|
private async resolveLocalLayerConfig( |
|
key: DialCacheKey, |
|
keyConfig: DialCacheKeyConfig | null, |
|
): Promise<LayerConfigResolution> { |
|
try { |
|
const result = await this.localCache.resolveLayerConfig(key, keyConfig); |
|
if (result.status === "disabled") { |
|
this.metrics?.disabled({ ...labelsFor(key, CacheLayer.LOCAL), reason: result.reason }); |
|
this.recordInvalidLeaf(key, CacheLayer.LOCAL, result.reason); |
|
} |
|
return result; |
|
} catch (error) { |
|
this.logger.error("Error resolving local cache config", error); |
|
this.recordError(key, CacheLayer.LOCAL, "config_resolution"); |
|
this.metrics?.disabled({ ...labelsFor(key, CacheLayer.LOCAL), reason: "config_error" }); |
|
return { status: "disabled", reason: "config_error" }; |
|
} |
|
} |
|
|
|
private readLocalWithResolvedConfig<T>(key: DialCacheKey, layerConfig: ResolvedLayerConfig): CacheGetResult<T> { |
|
const start = performance.now(); |
|
try { |
|
const result = this.localCache.getWithResolvedConfig<T>(key, layerConfig); |
|
this.metrics?.request(labelsFor(key, CacheLayer.LOCAL)); |
|
this.metrics?.observeGet(labelsFor(key, CacheLayer.LOCAL), elapsedSeconds(start)); |
|
if (result.status === "miss") { |
|
this.metrics?.miss(labelsFor(key, CacheLayer.LOCAL)); |
|
} |
|
return result; |
|
} catch (error) { |
|
this.logger.error("Error getting value from local cache", error); |
|
this.recordError(key, CacheLayer.LOCAL, "cache_read"); |
|
this.metrics?.disabled({ ...labelsFor(key, CacheLayer.LOCAL), reason: "config_error" }); |
|
return { status: "disabled", reason: "config_error" } as const; |
|
} |
|
} |
|
|
|
private async resolveRemoteLayerConfig(key: DialCacheKey, keyConfig: DialCacheKeyConfig | null) { |
|
try { |
|
const result = resolveLayerConfigResult({ |
|
config: keyConfig, |
|
key, |
|
layer: CacheLayer.REMOTE, |
|
}); |
|
if (result.status === "disabled") { |
|
this.metrics?.disabled({ ...labelsFor(key, CacheLayer.REMOTE), reason: result.reason }); |
|
this.recordInvalidLeaf(key, CacheLayer.REMOTE, result.reason); |
|
} |
|
return result; |
|
} catch (error) { |
|
this.logger.warn("Error resolving Redis cache config", error); |
|
this.recordError(key, CacheLayer.REMOTE, "config_resolution"); |
|
this.metrics?.disabled({ ...labelsFor(key, CacheLayer.REMOTE), reason: "config_error" }); |
|
return { status: "disabled", reason: "config_error", ...(key.trackForInvalidation ? { skipCacheWrite: true } : {}) } as const; |
|
} |
|
} |
|
|
|
private async readRemoteWithResolvedConfig<T>( |
|
redisCache: RedisCache, |
|
key: DialCacheKey, |
|
layerConfig: ResolvedLayerConfig, |
|
readTimeoutMs: number, |
|
): Promise<RemoteCacheGetResult<T>> { |
|
const start = performance.now(); |
|
this.metrics?.request(labelsFor(key, CacheLayer.REMOTE)); |
|
try { |
|
const result = await redisCache.getWithResolvedConfig<T>(key, layerConfig, readTimeoutMs); |
|
if (result.status === "miss") { |
|
this.metrics?.miss(labelsFor(key, CacheLayer.REMOTE)); |
|
} |
|
return result; |
|
} catch (error) { |
|
this.logger.warn("Error getting value from Redis cache", error); |
|
return { status: "error", operation: "read" }; |
|
} finally { |
|
this.metrics?.observeGet(labelsFor(key, CacheLayer.REMOTE), elapsedSeconds(start)); |
|
} |
|
} |
|
|
|
private async putLocalFailOpen<T>(key: DialCacheKey, value: T, config?: { readonly ttlSec: number }): Promise<void> { |
|
try { |
|
await this.localCache.put(key, value, config); |
|
} catch (error) { |
|
this.logger.warn("Error putting value in local cache", error); |
|
this.recordError(key, CacheLayer.LOCAL, "cache_write"); |
|
} |
|
} |
|
|
|
private async callFallback<T>(labels: CacheMetricLabels, fallback: () => Promise<T>): Promise<T> { |
|
const start = performance.now(); |
|
try { |
|
return await fallback(); |
|
} catch (error) { |
|
this.metrics?.error({ ...labels, error: "fallback", inFallback: true }); |
|
throw error; |
|
} finally { |
|
this.metrics?.observeFallback(labels, elapsedSeconds(start)); |
|
} |
|
} |
Priority
P3 — Low — benchmark-led hot-path work after the comparable harness exists; no public API change is required.
v0.14.0 grooming verification (2026-08-01)
The candidate costs remain, but the
v0.11.0timings below are now historical:v0.12.0–v0.14.0added shadow planning, detached validation/fill work, and observer wrappers. Reprofile exact current main through #35 before changing the hot path. Closed issue #103 is no longer separate work; its observer-isolation guarantee must simply be preserved. Keep P3, sequenced after correctness issues #101 and #97.Historical baseline and current candidates
At the
v0.11.0baseline, the following enabled-path costs were identified:() => nullprovider when no custom provider exists;Current evidence:
DialCache/src/dialcache.ts
Lines 302 to 364 in e06d833
DialCache/src/dialcache.ts
Lines 428 to 447 in e06d833
DialCache/src/dialcache.ts
Lines 450 to 513 in e06d833
DialCache/src/dialcache.ts
Lines 567 to 665 in e06d833
DialCache/src/dialcache.ts
Lines 697 to 713 in e06d833
DialCache/src/dialcache.ts
Lines 715 to 783 in e06d833
#39 remains consolidated here: calls without a custom provider still invoke and await a synthetic provider before request-local lookup.
Fresh directional profile
Node.js 22.22.0 repeated probes against exact
v0.11.0measured:performance.now()A deterministic no-op metrics adapter added approximately 1.5% to process-local hits and 3% to request-local hits. Real backend cost will depend on the adapter.
Tracked key construction measured approximately 420.2 ns/op versus 222.8 ns/op untracked. The tracked constructor currently builds an untracked prefix and then validates and encodes the same namespace/type/ID again for the Redis hash tag:
DialCache/src/key.ts
Lines 40 to 45 in e06d833
DialCache/src/key.ts
Lines 60 to 65 in e06d833
The internal cache layers also retain provider-owning compatibility paths that production does not call directly:
DialCache/src/internal/local-cache.ts
Lines 24 to 107 in e06d833
DialCache/src/internal/redis-cache.ts
Lines 27 to 88 in e06d833
DialCache/src/internal/redis-cache.ts
Lines 194 to 206 in e06d833
These figures are motivating evidence, not accepted budgets. Reproduce them through #35 before implementation decisions.
Candidate scope after #35
Profile first, then take only supported changes:
DialCache/runtime-config the sole policy owner and reducingLocalCache/RedisCacheto resolved storage/protocol operations.Any flight-order change must preserve synchronous leader registration before user fallback work and exact follower observability.
Out of scope
Acceptance criteria