fix: reconcile sticky update indicators with Anatomy preview (#1698)

* 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.
This commit is contained in:
Anso
2026-07-25 15:42:19 -04:00
committed by GitHub
parent 8b5407fcff
commit 0daddfde00
43 changed files with 2529 additions and 390 deletions
+36 -4
View File
@@ -64,6 +64,9 @@ export class 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 {
@@ -121,13 +124,21 @@ export class CacheService {
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();
this.set(key, value, ttlMs);
if (this.currentGeneration(key) === generation) {
this.set(key, value, ttlMs);
}
return value;
} catch (err) {
if (existing) {
@@ -137,9 +148,14 @@ export class CacheService {
}
throw err;
} finally {
this.inflight.delete(key);
// 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;
@@ -177,17 +193,22 @@ export class CacheService {
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
/** Invalidate a single key. */
/** 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}:`;
for (const key of this.store.keys()) {
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);
}
}
}
@@ -196,6 +217,7 @@ export class CacheService {
public flush(): void {
this.store.clear();
this.inflight.clear();
this.generations.clear();
this.stats.clear();
}
@@ -259,4 +281,14 @@ export class CacheService {
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;
}
}