You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Add explicit cache get, put, and exact-key delete APIs #217
Add explicit cache-only get, put, and exact-key delete operations. Applications should be able to read an entry without a loader, insert or replace it from an already available payload, and remove it by identity. The immediate use case is updating the cache after a successful source-of-truth write; prewarming and application-managed cache-aside flows also use the same primitives.
This is a purely additive feature. Existing cached, getOrLoad, scope, invalidation, key encoding, serializer, and Redis frame behavior must remain compatible. Reuse the existing storage layers and configuration provider. No new definition registry, cache handle, storage service, or background worker is required.
This issue records the proposed implementation contract. It does not claim the feature is implemented or validated. TypeScript examples below describe the new API.
Public API
Use the same options convention as getOrLoad(load, options): payload first, options last. Manual reads and writes need the same use-case configuration and serializer as automatic reads.
The type names and minimal result spelling above are proposed; the behavior below is the contract to preserve. SerializerOption illustrates reuse of the existing internal type and does not itself need to become a new public export.
GetOrLoadOptions<Value> should share the entry fields and retain its loader-specific fields: fallbackTimeoutMs, shadowComparator, and shouldAttemptStaleRecovery. Manual operations do not accept or execute a loader. Settings that only describe coalescing source work, shadow validation, or source-error recovery do not activate those mechanisms for a direct call, even when present in shared defaultConfig.
Identity and serializer
key retains the existing bare-ID or { id, args } form. Reuse normalization and key construction; the instance supplies its namespace.
A bare GetOrLoadOptions.key is not the complete cache identity. keyType, useCase, and trackForInvalidation are also needed to select the same stored entry.
delete requires identity only. It does not require a value type, serializer, or configuration. An existing full options object can still be passed directly through structural typing.
Preserve the existing TypeScript serializer rule: optional for JSON-compatible values, required otherwise. Use the default JSON serializer when omitted. Remote put encodes and remote get decodes with the supplied serializer; in-memory values retain the current reference/immutability contract.
All call sites sharing an entry must agree on its value meaning, tracking mode, and serializer. A custom serializer remains a trusted assertion, with the same guarantees and limitations as today.
Repeated manual operations do not register a use case uniquely. They can share a use case with an existing reader. Preserve the reserved "watermark" name and current identity validation.
Example
constuserOptions=(userId: string): CacheEntryOptions<User>=>({keyType: "user_id",useCase: "GetUser",key: userId,serializer: userSerializer,defaultConfig: newDialCacheKeyConfig({ttlSec: {[CacheLayer.REMOTE]: 60},}),});constoptions=userOptions(userId);// Commit the source first. No enable() wrapper is required for direct calls.constsaved=awaitdb.updateUser(userId,patch);constwrite=awaitdialcache.put(saved,options);if(write.status==="skipped"){// No entry was stored. The database change remains committed.}constentry=awaitdialcache.get<User>(options);if(entry.status==="hit"){useUser(entry.value);}awaitdialcache.delete(options);// Existing automatic behavior is unchanged and still requires enable().constuser=awaitdialcache.enable(()=>dialcache.getOrLoad(()=>db.getUser(userId),options),);
Explicit calls and enabled scopes
Calling get, put, or delete is explicit opt-in. These methods do not require an enabled context.
Operation
Enabled context required for caching?
Resolve use-case policy?
cached / getOrLoad
Yes, unchanged
Yes, when enabled, unchanged
Direct get / put
No
Yes
Direct delete
No
No; remove the exact entry regardless of rollout
Direct operations also work inside disable(...). The documented scope rule becomes: enable/disable controls automatic caching through cached/getOrLoad; direct calls express their own cache intent.
This avoids a helper's explicit cache update unexpectedly being skipped because it was called from a job, script, or disabled mutation region. The tradeoff is that disable(...) does not suppress explicit cache reads/writes. Document that distinction prominently. Existing automatic callers retain their exact behavior.
Bypassing the ambient enabled flag does not bypass runtime configuration. Direct get/put resolve one existing cacheConfigProvider snapshot, merge its sparse overrides with defaultConfig, and honor applicable layer selection, TTL, ramp, read timeout, serialization, and tracked-read rules. With no eligible layers, get misses and put reports skipped. Runtime policy changes retain their current meaning: they affect newly admitted work, not already admitted work or previously stored entry expiry.
Do not implement this by temporarily enabling the entire call chain: policy/serializer callbacks must not accidentally enable unrelated automatic readers.
Request-local lifetime
Use the current instance's live request holder when one exists and request-local policy participates, including inside a nested disabled region.
Outside a live request scope, skip request-local storage and use configured process-local/remote layers. Do not create an implicit request scope for each manual operation.
With request-local-only configuration and no live scope, get misses and put reports skipped.
Closed holders must not be read or repopulated. Closing a scope while a direct call is pending removes its request-local publication opportunity; it does not cancel an otherwise admitted shared-layer operation.
Other request scopes and other instances retain their own local state. No enumeration or global eviction of request holders is introduced.
Operation behavior
get(options)
Validate/build the existing identity and capture policy once.
Traverse eligible request-local, process-local, and remote layers in the existing order; return the first usable hit.
Preserve current age, TTL, primary-read, frame, deserialization, and tracked-watermark checks. A stale recovery candidate alone is not a hit: there is no failed source call authorizing recovery.
Preserve permitted warming of higher layers from validated lower-layer hits.
Never call a loader, start shadow/source-recovery work, or join a cached/getOrLoad flight. Such a flight can settle from the source rather than from cache. V1 does not need a new registry to coalesce manual calls.
Return a tagged miss when no usable cached value is obtained. Cached null, undefined, false, zero, and empty strings, where supported by the native API and serializer, must remain distinguishable from misses. Preserve existing cross-language value mappings rather than requiring every language to represent TypeScript undefined.
Proposed error behavior: preserve best-effort cache reads. Cache-plumbing failures use existing diagnostics and available healthy paths, ultimately returning a miss when no hit is available. Definition/input validation retains its explicit error behavior. Do not invoke a fake fallback to manufacture the miss; that could publish a sentinel or join source work.
put(value, options)
Validate/build identity and capture policy once. The caller owns obtaining the payload; DialCache never invokes the source.
Insert or replace the exact entry in eligible storage using the existing TTL, serialization, compression, and wire format rules. A successful overwrite renews the affected entry's lifetime as an ordinary new write.
Await attempted writes. Explicit mutation/serialization failures, provider failures, and structurally invalid configuration must surface to the caller; do not inherit the automatic loader path's suppression of cache-write failures. Preserve current per-layer validation of TTL/ramp leaves and their diagnostics; a provider exception must not be mislabeled as an intentional rollout exclusion.
Report skipped when no entry is stored, including disabled/no eligible layers and request-local-only calls without a live scope. A zero-capacity local layer must not produce a false written result. This outcome describes value storage, not a rollback or general no-side-effects guarantee.
Report written only after at least one storage write occurred and attempted writes completed without a reported failure. This acknowledges storage work, not global visibility, atomicity across layers, or a guarantee that a tracked frame clears a watermark. If any attempted write fails, surface that failure even if another layer changed successfully.
Writes must preserve the existing tracked-publication boundary. The tracked remote path participates when the key is tracked, a remote adapter is configured, and resolved policy admits the remote write. In that case, do not seed process-local storage directly with a payload that has not been validated by a tracked remote read. Evict a matching existing process-local entry when applying such a put, so a later validated read can warm it. A configured current request memo retains its explicitly scoped value semantics. Local-only and remote-policy-disabled paths retain their local lifetime limitations.
The exact storage-operation ordering should stay small and be covered by a deterministic partial-failure history; there is no transaction across request-local memory, process-local memory, and Redis. Do not turn partial failures into success or add a compensating transaction/retry system.
delete(identity)
Remove the exact current-request memo entry, this instance's process-local entry, and the corresponding remote value when a remote client is configured. Do this regardless of enabled state, current TTLs, or rollout policy; no configuration-provider lookup is necessary.
A missing entry is success. A local-only instance can delete its local entry without Redis being configured.
Remote deletion is exact-key deletion through the adapter. No scans or prefix matching are needed.
Do not delete or modify the entity watermark. Do not implement this as an alias for entity-wide invalidateRemote.
Surface deletion failures and unsupported remote-delete capability. Partial removal remains possible and must not be reported as complete success.
Leave other use cases, argument variants, entities, namespaces, other instances, and other request scopes untouched.
Deletion does not cancel or detach existing flights, revoke acquired snapshots, or prevent later work from repopulating the entry.
Consistency and invalidation boundaries
These operations support application-managed cache updates after source writes. They do not make database and cache writes atomic. A cache failure after source commit does not roll back the source; application retry/error handling must account for that.
Existing ordering remains visible:
Old source read starts
Database commits a newer value
Manual put stores the newer value
Old source read finishes and writes its older result
The last physical write can win. An in-flight load or shadow fill can likewise repopulate a deleted entry, and a read that already acquired a snapshot can still return it. A later automatic call can join an existing source flight rather than observe a newer manual put. This issue does not change those flight or source-version guarantees.
Other processes' local values can stay readable until their normal expiry. Use remote-only configuration when that local staleness is unacceptable. Skipped writes and policy-disabled layers are not invalidations; previously stored data can remain and become eligible again under a later policy.
Tracked reads continue to require createdAtMs > watermark, with the same frame timestamp, TTL cap, and primary/atomic-read rules. Manual writes must not clear, advance, or bypass the watermark, and must not forge a future timestamp to make a value readable. A direct put does not have an earlier read's observed watermark: keep the existing single native complete-frame SET; a later tracked read determines readability. A physically successful put can still be fenced, including after an invalidation with a future buffer. Do not automatically compose invalidateRemote followed by put or promise immediate readability from that sequence.
Adapter and compatibility changes
Reuse existing reads and complete-frame writes. Add only the exact-value deletion capability required by this feature.
Built-in adapters implement exact-key deletion using their native client and existing key routing.
Do not add a required method to existing public custom-adapter interfaces/traits/protocols. Use an optional capability or a backward-compatible default unsupported error, as appropriate for each language. Old adapters continue working for all previously supported operations.
Calling remote delete with an older adapter must clearly report that deletion is unsupported; never silently claim success or substitute entity invalidation.
Preserve existing root imports/exports, reader type inference, key bytes, frame bytes, watermarks, TTL constraints, and existing method semantics.
Reuse existing bounded logging/metrics where they describe the operation correctly. Direct calls must not emit fake source/fallback events. Do not put cache keys, IDs, or payloads in metric labels.
Apply the portable contract to TypeScript, Go, Rust, and Python. Current main includes Python; its existing aget(key, fallback) is a loader convenience and must not be renamed or repurposed into cache-only get. New native methods should be idiomatic while preserving existing calls and the same observations.
Implementation and validation
Define the new public operations in Quint first, reusing existing identity, layer, policy, expiry, scope-lifetime, and fence rules. Add only the profile/model support needed to express these operations and their consequential interactions.
Implement TypeScript as the executable reference using the existing cache boundaries. Extract shared options/read/write helpers where needed; avoid routing manual operations through a fake getOrLoad loader.
Implement equivalent operations and optional delete capability in every supported port and built-in adapter. Preserve compilation/behavior of a minimal pre-feature custom adapter.
Replay the same generated histories and deterministic regressions in TypeScript, Go, Rust, and Python. Keep native type/API and real-server tests for their own boundaries.
Update shared documentation and executable language examples, plus public exports/references. Explain explicit scope opt-in, serializer reuse, exact delete versus entity invalidation, skipped/failed writes, and consistency limits. Qualify broad existing statements such as “all caching requires enable” so they remain accurate for the new API.
Run the repository's required full behavior/model/replay validation and real-server checks before merge. Smoke-only evidence does not satisfy the conformance gate.
Required acceptance histories
Direct put/get round trip and overwrite, sharing entries with existing automatic readers using identical identity, policy, and serializer.
Direct calls outside a scope, inside enable, inside nested disable, and with a closed/pending-close scope; automatic disabled calls remain true pass-through.
Request-local-only, local-only, remote-only, and mixed eligible layers; no implicit request holder; zero-capacity local storage does not falsely acknowledge a write.
One policy snapshot per get/put; default/runtime overlay and deterministic ramp behavior; deletion ignores policy and does not invoke the provider.
Default/custom serializer round trips, supported native null/undefined-like values versus miss, preserved cross-language value mappings, encoding/decoding failures, and TypeScript compile-time serializer requirements.
Fresh/stale/expired/fenced remote reads, preserved wire/TTL behavior, and no source, shadow, recovery, or source-flight joining from direct get.
Exact deletion isolation across args, use cases, identities, namespaces, and tracking mode; missing key succeeds; watermark remains intact.
Tracked put cannot bypass the remote fence by publishing directly to process-local storage; storage acknowledgement does not claim readability; current-request snapshot behavior is explicit.
Awaited mutation errors, partial layer changes, unsupported delete capability, and old custom-adapter compatibility.
Deterministic old-load-after-put, late-fill-after-delete, and acquired-read-after-delete histories demonstrate the documented race boundaries rather than claiming stronger consistency.
Consequential generated witnesses and full replay in all four ports; real Redis/Valkey/Cluster deletion, routing, framing, and cross-language compatibility checks as applicable.
Problem and scope
Add explicit cache-only
get,put, and exact-keydeleteoperations. Applications should be able to read an entry without a loader, insert or replace it from an already available payload, and remove it by identity. The immediate use case is updating the cache after a successful source-of-truth write; prewarming and application-managed cache-aside flows also use the same primitives.This is a purely additive feature. Existing
cached,getOrLoad, scope, invalidation, key encoding, serializer, and Redis frame behavior must remain compatible. Reuse the existing storage layers and configuration provider. No new definition registry, cache handle, storage service, or background worker is required.This issue records the proposed implementation contract. It does not claim the feature is implemented or validated. TypeScript examples below describe the new API.
Public API
Use the same options convention as
getOrLoad(load, options): payload first, options last. Manual reads and writes need the same use-case configuration and serializer as automatic reads.Factor the existing options into shared pieces without changing existing call sites:
The type names and minimal result spelling above are proposed; the behavior below is the contract to preserve.
SerializerOptionillustrates reuse of the existing internal type and does not itself need to become a new public export.GetOrLoadOptions<Value>should share the entry fields and retain its loader-specific fields:fallbackTimeoutMs,shadowComparator, andshouldAttemptStaleRecovery. Manual operations do not accept or execute a loader. Settings that only describe coalescing source work, shadow validation, or source-error recovery do not activate those mechanisms for a direct call, even when present in shareddefaultConfig.Identity and serializer
keyretains the existing bare-ID or{ id, args }form. Reuse normalization and key construction; the instance supplies its namespace.GetOrLoadOptions.keyis not the complete cache identity.keyType,useCase, andtrackForInvalidationare also needed to select the same stored entry.deleterequires identity only. It does not require a value type, serializer, or configuration. An existing full options object can still be passed directly through structural typing.putencodes and remotegetdecodes with the supplied serializer; in-memory values retain the current reference/immutability contract."watermark"name and current identity validation.Example
Explicit calls and enabled scopes
Calling
get,put, ordeleteis explicit opt-in. These methods do not require an enabled context.cached/getOrLoadget/putdeleteDirect operations also work inside
disable(...). The documented scope rule becomes:enable/disablecontrols automatic caching throughcached/getOrLoad; direct calls express their own cache intent.This avoids a helper's explicit cache update unexpectedly being skipped because it was called from a job, script, or disabled mutation region. The tradeoff is that
disable(...)does not suppress explicit cache reads/writes. Document that distinction prominently. Existing automatic callers retain their exact behavior.Bypassing the ambient enabled flag does not bypass runtime configuration. Direct
get/putresolve one existingcacheConfigProvidersnapshot, merge its sparse overrides withdefaultConfig, and honor applicable layer selection, TTL, ramp, read timeout, serialization, and tracked-read rules. With no eligible layers,getmisses andputreports skipped. Runtime policy changes retain their current meaning: they affect newly admitted work, not already admitted work or previously stored entry expiry.Do not implement this by temporarily enabling the entire call chain: policy/serializer callbacks must not accidentally enable unrelated automatic readers.
Request-local lifetime
getmisses andputreports skipped.Operation behavior
get(options)cached/getOrLoadflight. Such a flight can settle from the source rather than from cache. V1 does not need a new registry to coalesce manual calls.null,undefined,false, zero, and empty strings, where supported by the native API and serializer, must remain distinguishable from misses. Preserve existing cross-language value mappings rather than requiring every language to represent TypeScriptundefined.Proposed error behavior: preserve best-effort cache reads. Cache-plumbing failures use existing diagnostics and available healthy paths, ultimately returning a miss when no hit is available. Definition/input validation retains its explicit error behavior. Do not invoke a fake fallback to manufacture the miss; that could publish a sentinel or join source work.
put(value, options)skippedwhen no entry is stored, including disabled/no eligible layers and request-local-only calls without a live scope. A zero-capacity local layer must not produce a falsewrittenresult. This outcome describes value storage, not a rollback or general no-side-effects guarantee.writtenonly after at least one storage write occurred and attempted writes completed without a reported failure. This acknowledges storage work, not global visibility, atomicity across layers, or a guarantee that a tracked frame clears a watermark. If any attempted write fails, surface that failure even if another layer changed successfully.Writes must preserve the existing tracked-publication boundary. The tracked remote path participates when the key is tracked, a remote adapter is configured, and resolved policy admits the remote write. In that case, do not seed process-local storage directly with a payload that has not been validated by a tracked remote read. Evict a matching existing process-local entry when applying such a put, so a later validated read can warm it. A configured current request memo retains its explicitly scoped value semantics. Local-only and remote-policy-disabled paths retain their local lifetime limitations.
The exact storage-operation ordering should stay small and be covered by a deterministic partial-failure history; there is no transaction across request-local memory, process-local memory, and Redis. Do not turn partial failures into success or add a compensating transaction/retry system.
delete(identity)invalidateRemote.Consistency and invalidation boundaries
These operations support application-managed cache updates after source writes. They do not make database and cache writes atomic. A cache failure after source commit does not roll back the source; application retry/error handling must account for that.
Existing ordering remains visible:
The last physical write can win. An in-flight load or shadow fill can likewise repopulate a deleted entry, and a read that already acquired a snapshot can still return it. A later automatic call can join an existing source flight rather than observe a newer manual put. This issue does not change those flight or source-version guarantees.
Other processes' local values can stay readable until their normal expiry. Use remote-only configuration when that local staleness is unacceptable. Skipped writes and policy-disabled layers are not invalidations; previously stored data can remain and become eligible again under a later policy.
Tracked reads continue to require
createdAtMs > watermark, with the same frame timestamp, TTL cap, and primary/atomic-read rules. Manual writes must not clear, advance, or bypass the watermark, and must not forge a future timestamp to make a value readable. A direct put does not have an earlier read's observed watermark: keep the existing single native complete-frameSET; a later tracked read determines readability. A physically successful put can still be fenced, including after an invalidation with a future buffer. Do not automatically composeinvalidateRemotefollowed byputor promise immediate readability from that sequence.Adapter and compatibility changes
Reuse existing reads and complete-frame writes. Add only the exact-value deletion capability required by this feature.
deletewith an older adapter must clearly report that deletion is unsupported; never silently claim success or substitute entity invalidation.Apply the portable contract to TypeScript, Go, Rust, and Python. Current
mainincludes Python; its existingaget(key, fallback)is a loader convenience and must not be renamed or repurposed into cache-only get. New native methods should be idiomatic while preserving existing calls and the same observations.Implementation and validation
getOrLoadloader.Required acceptance histories
Related work and source references
getOrLoadAPI and shared configuration/type contract.Source baseline: current
mainatd470b2f.— Levicus 🤖