mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
0daddfde00
* fix: reconcile sticky update indicators with Anatomy preview Sidebar, Updates filter, and Fleet treated retained partial/failed scanner has_update as confirmed. Keep raw state for retention/notifications, project confirmed-only to APIs, show distinct incomplete indicators, and clear sticky rows only after an authoritative-negative preview. Closes #1685 * test: align sidebar truncate E2E with failed-over-retained precedence Purple update indicators are confirmed-only; hasUpdate with a failed check correctly shows the failed trailing icon. * fix: clear confirmed update rows on authoritative-negative preview Address audit SF-1/SF-2/SF-3: observation-watermark clears for older ok+has_update rows (DB + memory gens), Fleet checkability parity with backend not_checkable, and Updates chip confirmed-only regressions. * fix: tombstone equal-generation writers on preview clear Advance the per-stack write generation when clearing at the observation watermark so a scanner reserved before preview cannot recreate the row after an authoritative-negative reconcile. * fix: clear sticky updates with digest and tag preview parity Share detection across scanner and preview, keep GET read-only with POST reconcile, gate Apply to digest and rebuild updates, and invalidate the hub fleet cache on clear. * test: set digestUpdate on auto-update checkImage mocks Scheduler and execute routes now gate Compose on digest drift; fixtures that expect an apply need digestUpdate so they exercise the update path. * fix: clear unused lint errors on sticky update branch Drop unused partial helper and fleet invalidate import; keep the CacheService inflight self-ref as let with an eslint exception so tsc stays green. * fix: use inflight holder for CacheService prefer-const Keep generation-aware ownership without a let self-reference that fights ESLint and tsc.
295 lines
9.8 KiB
TypeScript
295 lines
9.8 KiB
TypeScript
/**
|
|
* CacheService: a single in-process cache for the entire backend.
|
|
*
|
|
* Design goals (see plans/caching-strategy-audit.md for full context):
|
|
* - TTL-based entries. Entries expire after a configured ms window.
|
|
* - Inflight deduplication: concurrent getOrFetch() calls for the same key
|
|
* share one in-flight Promise, preventing thundering-herd on cache miss.
|
|
* - Stale-on-error fallback: if fetcher() rejects while a stale value
|
|
* exists, return the stale value (and count it as a "stale" hit).
|
|
* - Namespaced stats: per-namespace hit / miss / stale counters for
|
|
* observability, surfaced via /api/system/cache-stats.
|
|
* - Key conventions: use "namespace:subkey" for per-entity caches
|
|
* (e.g. "stats:1" for nodeId=1). Stats are aggregated by namespace.
|
|
* - Safety cap: a hard limit on total entries as a defense-in-depth
|
|
* guard against unbounded growth. Default 1000 entries; each cache
|
|
* used in Sencho is bounded by construction (singleton or per-nodeId).
|
|
*
|
|
* Non-goals:
|
|
* - LRU eviction. All current callers have bounded keyspaces.
|
|
* - Persistence. Caches are rebuilt on process restart.
|
|
* - Cross-process sync. Sencho runs single-process per instance.
|
|
*/
|
|
|
|
interface CacheEntry<T> {
|
|
value: T;
|
|
expiresAt: number;
|
|
}
|
|
|
|
/**
|
|
* Observational outcome of a single getOrFetchWithMeta call. Truthful under
|
|
* concurrency: an `inflight` join is never reported as a `hit`.
|
|
*
|
|
* - hit: fresh entry returned without waiting
|
|
* - computed: this caller ran the fetcher and it succeeded
|
|
* - inflight: joined an existing in-flight promise (this caller did not run
|
|
* the fetcher)
|
|
* - stale: the fetcher this caller ran failed and a stale entry was
|
|
* returned instead
|
|
*/
|
|
export type CacheFetchOutcome = 'hit' | 'computed' | 'inflight' | 'stale';
|
|
|
|
export interface CacheFetchResult<T> {
|
|
value: T;
|
|
outcome: CacheFetchOutcome;
|
|
}
|
|
|
|
interface NamespaceStats {
|
|
hits: number;
|
|
misses: number;
|
|
stale: number;
|
|
size: number;
|
|
}
|
|
|
|
const MAX_ENTRIES = 1000;
|
|
|
|
/** Extract the namespace (part before first colon) from a key. */
|
|
function namespaceOf(key: string): string {
|
|
const idx = key.indexOf(':');
|
|
return idx === -1 ? key : key.slice(0, idx);
|
|
}
|
|
|
|
export class CacheService {
|
|
private static instance: CacheService;
|
|
|
|
private readonly store = new Map<string, CacheEntry<unknown>>();
|
|
private readonly inflight = new Map<string, Promise<unknown>>();
|
|
/** Per-key write generation. Bumped on invalidate so an older in-flight
|
|
* fetcher cannot commit after the key was intentionally cleared. */
|
|
private readonly generations = new Map<string, number>();
|
|
private readonly stats = new Map<string, NamespaceStats>();
|
|
|
|
public static getInstance(): CacheService {
|
|
if (!CacheService.instance) {
|
|
CacheService.instance = new CacheService();
|
|
}
|
|
return CacheService.instance;
|
|
}
|
|
|
|
/**
|
|
* Retrieve a cached value or compute it via `fetcher`. Concurrent callers
|
|
* for the same key await the same in-flight promise.
|
|
*
|
|
* On fetcher rejection: if a stale entry exists, return it (counted as
|
|
* `stale`); otherwise propagate the error.
|
|
*
|
|
* Thin wrapper over getOrFetchWithMeta that discards the outcome; all
|
|
* stats / TTL / inflight-dedup / stale-on-error semantics are identical.
|
|
*/
|
|
public async getOrFetch<T>(
|
|
key: string,
|
|
ttlMs: number,
|
|
fetcher: () => Promise<T>,
|
|
): Promise<T> {
|
|
const { value } = await this.getOrFetchWithMeta(key, ttlMs, fetcher);
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Same behaviour as getOrFetch, but also reports how the value was obtained
|
|
* (see CacheFetchOutcome). Intended for diagnostic logging that must not
|
|
* mislabel an in-flight join as a cache hit. The stats counters, TTL, cap,
|
|
* and stale-on-error fallback are recorded exactly as getOrFetch does: one
|
|
* miss per in-flight join, one stale per failed computation.
|
|
*/
|
|
public async getOrFetchWithMeta<T>(
|
|
key: string,
|
|
ttlMs: number,
|
|
fetcher: () => Promise<T>,
|
|
): Promise<CacheFetchResult<T>> {
|
|
const ns = namespaceOf(key);
|
|
const now = Date.now();
|
|
const existing = this.store.get(key) as CacheEntry<T> | undefined;
|
|
|
|
if (existing && existing.expiresAt > now) {
|
|
this.recordHit(ns);
|
|
return { value: existing.value, outcome: 'hit' };
|
|
}
|
|
|
|
this.recordMiss(ns);
|
|
|
|
const inflight = this.inflight.get(key) as Promise<T> | undefined;
|
|
if (inflight) {
|
|
const value = await inflight;
|
|
return { value, outcome: 'inflight' };
|
|
}
|
|
|
|
// Capture generation before the fetch so invalidate() during the wait can
|
|
// supersede this writer's store commit (and drop the inflight slot so a
|
|
// later caller starts a fresh computation).
|
|
const generation = this.currentGeneration(key);
|
|
|
|
// This caller owns the computation; the closure records whether it ended
|
|
// as a fresh compute or a stale fallback, read after the promise settles.
|
|
let outcome: CacheFetchOutcome = 'computed';
|
|
const inflightSelf: { promise: Promise<T> | null } = { promise: null };
|
|
const promise = (async () => {
|
|
try {
|
|
const value = await fetcher();
|
|
if (this.currentGeneration(key) === generation) {
|
|
this.set(key, value, ttlMs);
|
|
}
|
|
return value;
|
|
} catch (err) {
|
|
if (existing) {
|
|
this.recordStale(ns);
|
|
outcome = 'stale';
|
|
return existing.value;
|
|
}
|
|
throw err;
|
|
} finally {
|
|
// Only clear the inflight slot if we still own it. invalidate() may
|
|
// have already deleted this entry and allowed a newer owner.
|
|
if (this.inflight.get(key) === inflightSelf.promise) {
|
|
this.inflight.delete(key);
|
|
}
|
|
}
|
|
})();
|
|
inflightSelf.promise = promise;
|
|
|
|
this.inflight.set(key, promise);
|
|
const value = await promise;
|
|
return { value, outcome };
|
|
}
|
|
|
|
/**
|
|
* Synchronous get: returns undefined if the key is absent or expired.
|
|
* Updates hit/miss counters.
|
|
*/
|
|
public get<T>(key: string): T | undefined {
|
|
const ns = namespaceOf(key);
|
|
const entry = this.store.get(key) as CacheEntry<T> | undefined;
|
|
if (!entry || entry.expiresAt <= Date.now()) {
|
|
this.recordMiss(ns);
|
|
return undefined;
|
|
}
|
|
this.recordHit(ns);
|
|
return entry.value;
|
|
}
|
|
|
|
/**
|
|
* Set a value with a TTL. If the total number of entries exceeds
|
|
* MAX_ENTRIES, the oldest expired entries are purged first; if still
|
|
* over cap, the insertion is rejected with a warning (defense in depth).
|
|
*/
|
|
public set<T>(key: string, value: T, ttlMs: number): void {
|
|
if (this.store.size >= MAX_ENTRIES && !this.store.has(key)) {
|
|
this.purgeExpired();
|
|
if (this.store.size >= MAX_ENTRIES) {
|
|
console.warn(`[CacheService] Entry cap reached (${MAX_ENTRIES}); refusing to cache "${key}"`);
|
|
return;
|
|
}
|
|
}
|
|
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
}
|
|
|
|
/** Invalidate a single key and supersede any in-flight writer for it. */
|
|
public invalidate(key: string): void {
|
|
this.store.delete(key);
|
|
this.inflight.delete(key);
|
|
this.bumpGeneration(key);
|
|
}
|
|
|
|
/** Invalidate every key whose namespace matches `namespace`. */
|
|
public invalidateNamespace(namespace: string): void {
|
|
const prefix = `${namespace}:`;
|
|
const keys = new Set([...this.store.keys(), ...this.inflight.keys(), ...this.generations.keys()]);
|
|
for (const key of keys) {
|
|
if (key === namespace || key.startsWith(prefix)) {
|
|
this.store.delete(key);
|
|
this.inflight.delete(key);
|
|
this.bumpGeneration(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Reset all state. Intended for tests and admin "flush" actions. */
|
|
public flush(): void {
|
|
this.store.clear();
|
|
this.inflight.clear();
|
|
this.generations.clear();
|
|
this.stats.clear();
|
|
}
|
|
|
|
/**
|
|
* Per-namespace statistics snapshot. Size counts live (non-expired) entries
|
|
* currently in the store for each namespace at call time.
|
|
*/
|
|
public getStats(): Record<string, NamespaceStats> {
|
|
// Recompute live sizes at snapshot time; counters are kept incrementally.
|
|
const sizes = new Map<string, number>();
|
|
const now = Date.now();
|
|
for (const [key, entry] of this.store) {
|
|
if (entry.expiresAt <= now) continue;
|
|
const ns = namespaceOf(key);
|
|
sizes.set(ns, (sizes.get(ns) ?? 0) + 1);
|
|
}
|
|
|
|
const result: Record<string, NamespaceStats> = {};
|
|
const allNs = new Set<string>([...this.stats.keys(), ...sizes.keys()]);
|
|
for (const ns of allNs) {
|
|
const base = this.stats.get(ns) ?? { hits: 0, misses: 0, stale: 0, size: 0 };
|
|
result[ns] = {
|
|
hits: base.hits,
|
|
misses: base.misses,
|
|
stale: base.stale,
|
|
size: sizes.get(ns) ?? 0,
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ─── internals ────────────────────────────────────────────────────────
|
|
|
|
private recordHit(namespace: string): void {
|
|
const s = this.getOrCreateNs(namespace);
|
|
s.hits += 1;
|
|
}
|
|
|
|
private recordMiss(namespace: string): void {
|
|
const s = this.getOrCreateNs(namespace);
|
|
s.misses += 1;
|
|
}
|
|
|
|
private recordStale(namespace: string): void {
|
|
const s = this.getOrCreateNs(namespace);
|
|
s.stale += 1;
|
|
}
|
|
|
|
private getOrCreateNs(namespace: string): NamespaceStats {
|
|
let s = this.stats.get(namespace);
|
|
if (!s) {
|
|
s = { hits: 0, misses: 0, stale: 0, size: 0 };
|
|
this.stats.set(namespace, s);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
private purgeExpired(): void {
|
|
const now = Date.now();
|
|
for (const [key, entry] of this.store) {
|
|
if (entry.expiresAt <= now) this.store.delete(key);
|
|
}
|
|
}
|
|
|
|
private currentGeneration(key: string): number {
|
|
return this.generations.get(key) ?? 0;
|
|
}
|
|
|
|
private bumpGeneration(key: string): number {
|
|
const next = this.currentGeneration(key) + 1;
|
|
this.generations.set(key, next);
|
|
return next;
|
|
}
|
|
}
|