Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@

## Project overview

DialCache is a TypeScript caching library with explicit request-scoped enablement, local and Redis layers, runtime rollout controls, request coalescing, targeted invalidation, and adapter-based observability.
DialCache is a TypeScript caching library with explicit request-scoped enablement, local and Redis layers, runtime rollout controls, request coalescing, detached Redis shadow validation and bootstrap, targeted invalidation, and adapter-based observability.

## Structure

```text
README.md # Adoption guide, safety model, and reference routing
docs/ # Focused user-facing configuration and operations guides
src/
dialcache.ts # Main DialCache API and cached-function wrapper
config.ts # Public configuration and rollout types
context.ts # AsyncLocalStorage-based enabled context
key.ts # Structured cache keys and Redis hash tags
metrics.ts # Backend-neutral metrics adapter contract
prometheus.ts # Optional Prometheus adapter
datadog.ts # Optional Datadog adapter
redis-client.ts # Client-independent semantic Redis interface
node-redis.ts # node-redis adapter and script registration
valkey-glide.ts # Valkey GLIDE adapter and script registration
redis-protocol.ts # Public Lua protocol exports
serializer.ts # Serializer contract and JSON implementation
internal/ # Cache layers, runtime config, and Lua scripts
Expand All @@ -28,16 +32,28 @@ test/ # Unit and Redis integration tests
- Disabled calls are true pass-through and must not build keys, resolve config, or coalesce work.
- Active same-key work is coalesced before the first active cache layer, using
request scope for request-local caching and process scope for shared layers.
- Shadow validation is opt-in, detached, tracked-key-only work that never
supplies or delays the caller; it has separate sampling, capacity, deadline,
invalidation, and observability contracts.
- Shadow policy is grouped under `DialCacheKeyConfig.shadow`. Mismatch logging
is default-off diagnostic output; its size limits are not redaction.
- Cache plumbing fails open; explicit maintenance operations surface mutation failures.
- `invalidateRemote()` requires a configured Redis client and rejects when the
client is absent or the watermark mutation fails.
- Tracked Redis values and invalidation watermarks share a Redis Cluster hash tag.
- Tracked reads run on primaries so replica lag cannot hide invalidation.
- Local entries are process-local and are not synchronously invalidated across instances.

## Conventions

- Preserve strict TypeScript settings and public abstraction boundaries.
- Keep the README focused on evaluation and adoption. Put complete operational
contracts in a focused `docs/` guide and link it from the relevant README
summary.
- Keep Redis client-specific behavior in adapters; core code depends on `DialCacheRedisClient`.
- Public exports belong in the root or an explicit integration entry point such as `src/node-redis.ts`, `src/prometheus.ts`, or `src/redis-protocol.ts`.
- Public exports belong in the root or an explicit integration entry point such
as `src/node-redis.ts`, `src/valkey-glide.ts`, `src/prometheus.ts`,
`src/datadog.ts`, or `src/redis-protocol.ts`.
- Use `corepack pnpm` for project commands.

## Validation
Expand Down
1,106 changes: 365 additions & 741 deletions README.md

Large diffs are not rendered by default.

271 changes: 271 additions & 0 deletions docs/coalescing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
# Coalescing and fallback liveness

[Back to the README](../README.md)

DialCache shares same-key in-flight work within the lifetime of the first active
cache layer. It applies a finite deadline to each active remote read and a
separate default deadline once an initially enabled invocation begins its
fallback loader.

These mechanisms reduce duplicate source work. Their deadlines help flights
settle, but eventual cleanup still requires finite application-owned budgets
for every injected operation. They do not replace cross-process coordination,
source-native cancellation, admission control, or backpressure.

Detached [shadow work](shadow-validation.md) has a separate instance-level
registry and capacity limit. It is not another coalescing scope.

## Request coalescing

DialCache has two sharing scopes.

### Request-local scope

When request-local caching is active, callers with the same key in one outermost
`enable()` scope share in-flight work before the request-local lookup.

The resolved value is memoized for later sequential calls in that scope. A
different outer request has a different request-local flight registry.

### Process scope

When process-local or remote caching is active, same-key callers share work
within one `DialCache` instance before the first active process-local or remote
layer.

This is reported as `scope="process"`, but it is instance-scoped:

- separate requests using the same `DialCache` instance can share;
- separate `DialCache` instances in one process do not share; and
- separate processes or hosts do not share.

```ts
await dialcache.enable(async () => {
// Same cold key and active process-local or remote layer:
// one fallback execution, one shared result.
const [first, second] = await Promise.all([
getUser("456"),
getUser("456"),
]);
});
```

With a remote layer configured, an instance-scoped leader that misses
process-local cache performs one bounded Redis read. Followers share that read
and its remaining deadline. On a remote miss, the leader runs the fallback and
cache write; followers await that result.

For a process-local-only miss, followers share the leader's fallback and local
write. This mitigates a thundering herd on one hot key within the instance.

## When calls do not coalesce

Coalescing applies only when at least one cache layer is active:

- calls that start outside `enable()` are true pass-through;
- initially enabled calls with every layer disabled are uncached and
uncoalesced; and
- process-scoped work is never shared across `DialCache` instances.

An initially enabled all-disabled call still receives the fallback deadline
described below.

Because coalescing is keyed by the full constructed cache key, concurrent calls
with the same identity share the leader's execution. Every function argument
or captured value omitted from the selected or direct key must be safe to share
this way.

Include locale, auth context, cancellation behavior, or any other input in the
key when it can change:

- the returned value;
- whether the underlying function should run independently; or
- whether two callers may safely share one result.

### Shadow work does not enable caller coalescing

Shadow admission does not make an otherwise all-disabled caller path
coalesced.

When the remote layer is the only configured serving layer and its ramp
excludes a key, concurrent calls each run their own source fallback. Same-key
shadow jobs are deduplicated by admitting one and reporting the others as
`dropped`; callers do not join or await that job.

A serving Redis hit reached through a process-scoped leader schedules at most
one shadow job for its coalesced followers. `shadowMaxInFlight` limits scheduled
or running shadow jobs across the instance, independently of request-local and
process-scoped flights. See
[Shadow validation and Redis bootstrap](shadow-validation.md) for the full
admission and lifecycle contract.

## Fallback deadlines

Once an initially enabled invocation begins its wrapped fallback, DialCache
applies a 60-second monotonic deadline by default.

Set `fallbackTimeoutMs` on a cached wrapper or `getOrLoad()` invocation to
choose a positive integer deadline in milliseconds, up to 2,147,483,647. Set
it to `null` only when the application intentionally accepts an unbounded
fallback:

```ts
import { FallbackTimeoutError } from "dialcache";

const getUser = dialcache.cached(
(userId: string) => db.fetchUser(userId),
{
keyType: "user_id",
useCase: "GetUserWithDeadline",
cacheKey: (userId) => userId,
defaultConfig: DialCacheKeyConfig.enabled(60),
fallbackTimeoutMs: 2_000,
},
);

try {
await dialcache.enable(() => getUser("123"));
} catch (error) {
if (error instanceof FallbackTimeoutError) {
logger.warn("source lookup exceeded its DialCache budget", {
useCase: error.useCase,
timeoutMs: error.timeoutMs,
});
}
throw error;
}
```

### When the timer runs

The timer starts only when the fallback begins:

- same-key followers share the request-local or process leader's remaining
budget and receive its `FallbackTimeoutError`;
- a remote read failure or timeout starts the fallback timer only when the
source loader begins;
- enabled pass-through invocations where every layer is disabled have
independent timers;
- cache hits create no fallback timer; and
- calls that began outside an enabled context remain true pass-through and are
not timed out, even when the operation has `fallbackTimeoutMs`.

The fallback deadline does not cover work that happens before fallback. An
active remote read has its own resolved
[remote-read deadline](redis.md#remote-read-deadlines-and-async-liveness), while
a pending config provider or serializer load does not. Serialization and a
Redis write after fallback also remain outside it. Give every injected
operation its own finite, resource-native budget.

### Event-loop behavior

Deadline delivery requires the JavaScript event loop to make progress. It
cannot preempt a synchronous fallback prefix or other event-loop blocking.
Rejection can therefore arrive later than the configured duration.

When control returns, DialCache checks the monotonic deadline before accepting
the result. The timer remains referenced until the fallback settles or times
out. An abandoned enabled fallback can keep an otherwise idle short-lived
process alive until the deadline.

Shutdown code should await outstanding caller-path DialCache promises rather
than discard them. This does not drain detached shadow jobs; see
[Redis lifecycle ownership](redis.md#lifecycle-ownership) for dependency
shutdown requirements.

### Timeout does not cancel the source

Timing out:

1. rejects the DialCache chain;
2. clears its tracked flight normally;
3. ignores a later fallback resolution; and
4. prevents that invocation from proceeding to serializer, Redis, or local
publication.

The underlying loader is not canceled and may continue its own I/O or side
effects. Give the source operation a native timeout or `AbortSignal` whenever
possible.

`fallbackTimeoutMs: null` disables the guard and makes finite fallback
settlement entirely application-owned. Use that escape hatch only after
intentionally accepting the liveness risk.

Timeout failures retain the bounded metrics classification
`error="fallback"` with `in_fallback="true"`. The typed error carries timeout
details without adding high-cardinality labels.

A shared remote-read timeout emits one `cache_read_timeout` error for the
leader, not one per follower.

### Shadow deadlines are separate

A finite `fallbackTimeoutMs` also supplies the whole-job deadline for detached
shadow work. Setting it to `null` removes the caller fallback deadline, but
shadow work still uses the 60-second default.

For a served Redis hit, the shadow clock starts when detached validation
begins. For a remote-ramped-down call, it starts before the caller's source
operation, so synchronous source work consumes the same budget. Shadow work
never delays or rejects the caller.

The shadow scheduler and deadline timer are unreferenced. A deadline prevents
later serialization or write dispatch, but cannot cancel an already-started
source call, serializer, raw Redis read, or dispatched Redis write.

Underlying shadow-owned work that has already started can retain a capacity
slot until it settles, even after the bounded outcome is reported. A
caller-owned source promise reused by a ramped-down shadow path is the
exception: by itself, it stops retaining that slot at the shadow deadline.

## Inspecting process-scoped flights

`getCoalescingState()` returns a point-in-time copy of caller-path
process-scoped flights owned by one `DialCache` instance:

```ts
const state = dialcache.getCoalescingState();

state.process.activeLeaders;
state.process.activeFollowers;
state.process.oldestLeaderAgeMs; // null when idle
```

A leader is one exact cache key currently tracked by the instance-scoped
coalescer. A follower is each later invocation that joined that pending leader;
the initiating invocation is not counted as a follower.

Followers remain counted until their leader's DialCache promise settles,
including by deadline rejection. The underlying source operation may continue
after that point.

Request-local flights are deliberately excluded because their lifecycle is
bounded by the outer `enable()` scope. Shadow jobs are also excluded; they use
their own capacity registry and outcome metrics.
`oldestLeaderAgeMs` uses a monotonic clock and is computed when the snapshot is
requested.

## Admission control remains application-owned

There is no library-wide cap or age-based replacement for caller-path
request-local or process-scoped flights. `shadowMaxInFlight` bounds only
detached shadow jobs.

A registry cap would bound only DialCache metadata. Overflow or eviction could
still create unbounded source work and unsafe duplicate publication.
DialCache's remote-read and fallback deadlines cover only those phases;
provider, serializer, and Redis-write settlement remains application-owned.
Admission control and backpressure remain responsible for bounding
simultaneous distinct-key work.

Monitor:

- active leader count;
- active follower count;
- oldest leader age;
- remote-read timeout errors;
- fallback deadline errors; and
- source concurrency and saturation.

Use those signals to verify that application budgets and admission control hold
under production load.
Loading
Loading