Skip to content

Keep runtime configuration lookup cheap and bounded #39

Description

@lan17

Priority

P2 — Medium — remove built-in hot-path overhead without taking ownership of dynamic provider policy.

Current problem

Every enabled invocation awaits cacheConfigProvider before request-local lookup and before exact-key process coalescing. That remains true for the built-in provider that only returns null. Repeated request-local hits therefore still cross the provider abstraction.

Current evidence:

  • Invocation ordering:

    DialCache/src/dialcache.ts

    Lines 252 to 309 in 34a45fa

    const run = async (...args: Parameters<Fn>): Promise<CachedValue<Fn>> => {
    // `fn`'s awaited result is the cached value by construction; the generic `Fn` erases it to `unknown`.
    const rawFallback = async (): Promise<CachedValue<Fn>> => (await fn(...args)) as CachedValue<Fn>;
    const noLayerLabels = {
    cacheNamespace: this.namespace,
    useCase: options.useCase,
    keyType: options.keyType,
    layer: NO_CACHE_LAYER,
    } as const;
    if (!this.isEnabled()) {
    this.metrics?.disabled({ ...noLayerLabels, reason: "context" });
    return await rawFallback();
    }
    const fallback = (): Promise<CachedValue<Fn>> =>
    withFallbackTimeout(rawFallback, options.useCase, fallbackTimeoutMs);
    let key: DialCacheKey;
    try {
    key = this.buildKey(options, options.cacheKey(...args));
    } catch (error) {
    this.logger.error("Could not construct DialCache key", error);
    this.metrics?.error({
    ...noLayerLabels,
    error: "key_construction",
    inFallback: false,
    });
    return await this.callFallback(noLayerLabels, fallback);
    }
    let keyConfig: DialCacheKeyConfig | null;
    try {
    keyConfig = await fetchKeyConfig(this.configProvider, key);
    } catch (error) {
    // Provider failure: fail open and run uncached, mirroring the per-layer config_error path.
    this.logger.warn("Could not resolve DialCache key config", error);
    this.recordError(key, NO_CACHE_LAYER, "config_resolution");
    this.metrics?.disabled({ ...noLayerLabels, reason: "config_error" });
    return await this.callFallback(noLayerLabels, fallback);
    }
    // An unawaited child can inherit the async store after its outer enable()
    // callback settles. The closed holder turns that detached work back into
    // pass-through instead of allowing it to repopulate request state.
    if (!this.isEnabled()) {
    this.metrics?.disabled({ ...noLayerLabels, reason: "context" });
    return await this.callFallback(noLayerLabels, fallback);
    }
    if (keyConfig?.requestLocal === true) {
    const requestLocalCache = getOrCreateRequestLocalCache(this.context);
    if (requestLocalCache !== null) {
    return await this.getThroughRequestLocal(requestLocalCache, key, keyConfig, fallback);
    }
    }
    return await this.getThroughSharedLayers(key, keyConfig, fallback, CacheLayer.LOCAL);
  • Built-in provider:

    DialCache/src/dialcache.ts

    Lines 165 to 194 in 34a45fa

    const defaultConfigProvider: CacheConfigProvider = () => null;
    const defaultLogger: Logger = console;
    export class DialCache {
    private readonly context = new DialCacheContext();
    private readonly localCache: LocalCache;
    private readonly useCases = new Set<string>();
    private readonly configProvider: CacheConfigProvider;
    private readonly namespace: string;
    private readonly logger: Logger;
    private readonly rampSampler: CacheRampSampler;
    private readonly redisCache: RedisCache | null;
    private readonly metrics: DialCacheMetricsAdapter | null;
    private readonly processFlights = new Map<string, ProcessFlight>();
    private activeProcessFollowers = 0;
    constructor(config: DialCacheConfig = {}) {
    if (Object.hasOwn(config, "urnPrefix")) {
    throw new TypeError('DialCacheConfig.urnPrefix was renamed to "namespace"');
    }
    const namespace = config.namespace ?? "urn";
    assertValidNamespace(namespace);
    const localMaxSize = config.localMaxSize ?? DEFAULT_LOCAL_MAX_SIZE;
    if (!Number.isSafeInteger(localMaxSize) || localMaxSize < 0) {
    throw new RangeError("DialCache localMaxSize must be a nonnegative safe integer");
    }
    this.configProvider = config.cacheConfigProvider ?? defaultConfigProvider;
  • Per-invocation behavior is tested, including request-local reuse: https://github.com/lan17/DialCache/blob/34a45facfc4cb11ca7c08d5506e74b3dbcef4dd2/test/dialcache-request-local.test.ts

#69 confirms that provider settlement deadlines are application-owned; fallbackTimeoutMs does not cover provider work. Open #68 preserves one provider resolution per enabled invocation while changing how sparse results overlay defaults.

Recommended narrow scope — approval required

Acceptance criteria

  • No-custom-provider calls avoid the synthetic provider call/await.
  • Dynamic providers still resolve exactly once per enabled invocation.
  • No dynamic-provider freshness or failure semantics change.
  • Benchmark no provider, synchronous provider, and asynchronous provider.
  • Preserve fail-open config_error handling and the finite-settlement caller contract.
  • No public API change for the narrow optimization.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions