Repository navigation
Refactor cache config to group policy by layer #144
Description
Summary
DialCacheKeyConfig is starting to outgrow the current flat / property-first shape. staleOnErrorMaxAgeSec and remoteReadTimeoutMs are specifically Redis-layer behavior, while ttlSec and ramp are currently expressed as cross-layer maps.
As layer capabilities diverge, the public API would likely be easier to understand if policy were grouped by layer first, with remote-only behavior nested under remote.
This should be a follow-up to stale-on-error rather than part of PR #121.
Current shape
new DialCacheKeyConfig({
ttlSec: {
[CacheLayer.LOCAL]: 60,
[CacheLayer.REMOTE]: 300,
},
ramp: {
[CacheLayer.LOCAL]: 50,
[CacheLayer.REMOTE]: 100,
},
staleOnErrorMaxAgeSec: 3_600,
remoteReadTimeoutMs: 50,
requestLocal: true,
coalesce: true,
shadow: {
ramp: 1,
},
});This organizes config by property first and then layer. That works while layers are mostly symmetric, but the matrix is already diverging:
local remote
TTL ✓ ✓
ramp ✓ ✓
read timeout ✗ ✓
stale-on-error ✗ ✓
invalidation ✗ ✓
shadow validation ✗ ✓
Proposed direction
Prefer layer-owned policy objects:
new DialCacheKeyConfig({
local: {
ttlSec: 60,
ramp: 50,
},
remote: {
ttlSec: 300,
ramp: 100,
readTimeoutMs: 50,
staleOnError: {
maxAgeSec: 3_600,
},
shadow: {
ramp: 1,
},
},
requestLocal: true,
coalesce: true,
});Conceptually:
interface DialCacheKeyConfig {
local?: LocalCachePolicy;
remote?: RemoteCachePolicy;
requestLocal?: boolean;
coalesce?: boolean;
}
interface LocalCachePolicy {
ttlSec?: number;
ramp?: number;
}
interface RemoteCachePolicy {
ttlSec?: number;
ramp?: number;
readTimeoutMs?: number;
staleOnError?: {
maxAgeSec: number;
};
shadow?: {
ramp?: number;
logMismatches?: boolean;
};
}This makes related Redis behavior read together naturally:
remote: {
ttlSec: 300,
ramp: 100,
readTimeoutMs: 50,
staleOnError: {
maxAgeSec: 3_600,
},
}It also leaves room for future remote-only behavior without continuing to add top-level fields, for example:
remote: {
staleOnError: {
maxAgeSec: 3_600,
// possible future policy, if needed
strategy: "retain",
},
}or:
remote: {
staleOnError: {
maxAgeSec: 3_600,
ramp: 25,
},
}Keep application behavior separate from runtime cache policy
I would not move shouldAttemptStaleRecovery into DialCacheKeyConfig.
That option describes application semantics — which source errors are safe to degrade on — rather than operational rollout policy. It fits better alongside stable use-case behavior such as shadowComparator:
const getUser = dialcache.cached(fetchUser, {
keyType: "user_id",
useCase: "GetUser",
cacheKey: (id) => id,
shouldAttemptStaleRecovery: isTransientDatabaseError,
defaultConfig: new DialCacheKeyConfig({
remote: {
ttlSec: 300,
staleOnError: {
maxAgeSec: 3_600,
},
},
}),
});That gives a useful conceptual split:
use-case behavior
├── cacheKey
├── serializer
├── coalesce
├── shouldAttemptStaleRecovery
└── shadowComparator
runtime cache policy
├── requestLocal
├── local
│ ├── ttl
│ └── ramp
└── remote
├── ttl
├── ramp
├── readTimeout
├── staleOnError
└── shadow
Open question: where should shadow live?
shadow is not purely a Redis primitive; it is a higher-level validation mechanism that happens to operate against the remote layer. From a user mental-model perspective, though, it is remote-cache behavior.
Two reasonable options:
remote: {
shadow: { ... },
}or keep it top-level:
new DialCacheKeyConfig({
remote: { ... },
shadow: { ... },
});My preference is remote.shadow, but this is worth deciding explicitly.
Migration considerations
This is a public API refactor and should not be bundled into stale-on-error itself.
Potential migration paths:
- Breaking replacement in the next intentional config-schema break.
- Temporarily accept both forms, reject ambiguous mixed configuration, and normalize internally.
- Add the layer-first form first, deprecate the old
ttlSec/rampmaps and remote-specific top-level fields, then remove them in a later release.
If both forms coexist temporarily, configuration precedence should not be implicit. A config such as this should probably throw rather than guess:
new DialCacheKeyConfig({
ttlSec: { [CacheLayer.REMOTE]: 60 },
remote: { ttlSec: 300 },
});Motivation
The goal is not nesting for its own sake. The existing shape is optimized for symmetric layers, while DialCache's layers increasingly expose different capabilities. Grouping by layer should:
- make configuration more discoverable;
- keep remote-only features from accumulating at the top level;
- make validation and documentation easier to reason about;
- provide a cleaner home for future remote policy;
- make configs read in the same hierarchy users use to think about the cache chain.
Related: PR #121 (stale-on-error recovery).
Activity
lan17 commentedon Aug 30, 2026
Follow-up design decisions
A few decisions and additional constraints from the broader review:
Keep
coalescein runtime policy. Runtime control is intentional: operators may need to disable or re-enable sharing without redeploying. The documentation should make the behavioral cost explicit:coalesce: falserestores independent layer reads, source calls, fallback deadlines, writes, and thundering-herd exposure.Keep the layer-first direction.
localandremoteshould own their capabilities, withremote.shadow,remote.readTimeoutMs, andremote.staleOnErrornested under the remote layer.Prefer explicit disabled shapes over numeric sentinels where the schema permits it. For example:
Omission should continue to mean “inherit” for runtime overlays. Explicit
falseshould mean “disable inherited policy.” This is easier to read than overloading0, especially for nested features such as stale-on-error.Decide the future of
DialCacheKeyConfig.enabled(ttlSec)as part of this migration. The current helper enables both shared layers, so adding Redis to an instance can silently broaden an existing definition. Prefer either explicit layer-specific helpers such aslocalOnly()/remoteOnly(), or no broad helper. The root-surface decision is also tracked in Rationalize the root export surface and scope-method vocabulary #24.Normalize once into an internal immutable policy snapshot. Whether the public input remains a class or becomes a readonly structural object is secondary; the internal execution path should consume one normalized layer-first representation rather than repeatedly interpreting public sparse shapes. Runtime provider overlays should be normalized per invocation and static defaults should remain registration snapshots.
Reject ambiguous mixed schemas during migration. Supporting both old and new forms temporarily is reasonable, but any overlap for the same policy leaf should throw instead of applying precedence.
namespaceremains instance-level and is outside this issue. It is currently optional and defaults to"urn"; requiring it when Redis is configured would be a separate API decision. Scope aliases, root exports sourced frominternal, and the public hash-tag helper remain tracked by #24 rather than being added here.