Skip to content

Refactor cache config to group policy by layer #144

Description

@lan17

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:

  1. Breaking replacement in the next intentional config-schema break.
  2. Temporarily accept both forms, reject ambiguous mixed configuration, and normalize internally.
  3. Add the layer-first form first, deprecate the old ttlSec / ramp maps 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).

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