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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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