domains / / ClInE/cLiNe
Dependency-Aware Caching
Caching is safe only when you (1) key by the full dependency set, (2) gate reads by a revision/stale check, (3) invalidate only the correct scope, and (4) handle async failures with rollback + bounded retries.
Caching is safe only when you (1) key by the full dependency set, (2) gate reads by a revision/stale check, (3) invalidate only the correct scope, and (4) handle async failures with rollback + bounded retries.
Standards
- Include all dependencies in the cache key
- If the cached value depends on inputs (tool id, arguments, account scope, workspace root, repo+branch, execution mode), those must be part of the key.
- For unordered structures (e.g., argument objects), use an order-insensitive representation.
- Add a revision/stale gate for correctness
- If the underlying definition can change (pin edited/removed, UI set refreshed, plugin registry state updated), store a revision and only serve cached results while the revision still matches.
- Scope your invalidation
- Invalidate/recompute only for the affected workspace/account/session/query. Do not let a single lookup failure poison shared availability caches.
- Protect caches with explicit budgets and eviction
- Use explicit TTL or idle eviction, and/or max-bytes (LRU or “refresh on read”).
- Keep caches session-scoped when the URI or data should not cross boundaries.
- Retry boundedly after failures—especially when detection is imperfect
- If a failure recovery cannot be detected by fingerprinting/key changes, retry on a bounded backoff (e.g., “every 30s max”), and don’t let repeated attempts stack.
- When updating stale state asynchronously, use rollback
- If you clear a “stale”/“needs rebuild” flag before kicking an async operation, restore it if the operation throws so retries behave correctly.
Practical pattern (keyed async lookup with TTL + backoff)
class KeyedLookup<T> {
private key?: string;
private value?: T;
private pending?: Promise<void>;
private failedAt?: number;
constructor(
private readonly now: () => number,
private readonly onLoaded: () => void,
private readonly retryMs: number,
) {}
async get(key: string, load: () => Promise<T>, refresh = false): Promise<T | undefined> {
this.start(key, load, refresh);
await this.pending;
return this.value;
}
private start(key: string, load: () => Promise<T>, refresh: boolean) {
if (this.key !== key || refresh) {
this.key = key;
this.value = undefined;
this.pending = undefined;
this.failedAt = undefined;
}
if (this.value !== undefined) return;
if (this.pending) return;
if (this.failedAt !== undefined && this.now() - this.failedAt < this.retryMs) return;
const pending = load().then(
(v) => {
if (this.pending !== pending) return;
this.pending = undefined;
this.value = v;
this.onLoaded();
},
() => {
if (this.pending !== pending) return;
this.pending = undefined;
this.failedAt = this.now();
}
);
this.pending = pending;
}
}
Checklist before landing a cache change
- What exact inputs determine the value? Are they in the cache key?
- Can those inputs change while an async operation is in-flight? Do you need a revision gate?
- What should happen if loading fails: invalidate, rollback a stale flag, or schedule bounded retry?
- What scope must stay isolated (account/workspace/session)?
- What eviction strategy/budget prevents unbounded memory growth?