mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-11 04:01:32 +00:00
fix(explorer): dedupe temporal snapshot requests and apply latest-wins The temporal snapshot effect fetched /api/temporal/snapshot with no idempotency or ordering guards. Upstream churn (timeline recreation while bounds settle, play ticks resetting the playhead, drag events) could re-request the same `at` repeatedly, and with variable network latency an older position's response could land after a newer one's, overwriting the active-node count, so the chip visibly lagged the scrubber. Add a small stateful guard module (temporalSnapshotGuards.ts) built around a per-position cache, keyed by the debounced timestamp's primitive millisecond value rather than the Date object, so upstream object-identity churn cannot defeat the dedup on its own: - at most one in-flight request per scrubber position, so identical `at` values arriving while a request is pending are dropped instead of firing a fresh fetch, breaking the idle/play polling loop; - successful snapshots are cached per position and re-applied when the scrubber returns to it (play wrap-around, back-scrubbing) without a network round trip; - a response is applied only while the scrubber is still on the position it was requested for, so an out-of-order response can never clobber a newer position's count; - failed, cancelled, or superseded requests release their position so it can be fetched again the next time it's visited, rather than stalling it permanently; - reset() drops all cached and in-flight state when the underlying graph summary changes (reload/retry), since snapshots cached against the previous graph no longer describe anything real. Keyed on the summary query's data identity, which react-query keeps stable (staleTime: Infinity plus structural sharing) unless the graph data itself was replaced, so reset fires exactly on a real reload and not on cosmetic re-renders. The snapshot effect is wired through the guards end to end: begin() returns either a fresh sequence number to fetch under or a cached snapshot to reapply directly; the same shouldApply()/apply() gate handles both the network and cached-reapply paths so they can't drift apart; finish() runs from both the fetch's failure branch and its cleanup function, so a cancelled or failed request is always retryable on the next visit instead of leaving its position stuck in-flight. 16 unit tests cover dedup, independent positions, revisit re-apply, play wrap-around, failure retry, stale-sequence protection (a late response or a late release from a superseded request cannot act on a newer request's position), reset-on-reload, and cache-bound eviction. Closes #1128
114 lines
4.0 KiB
TypeScript
114 lines
4.0 KiB
TypeScript
/**
|
|
* Guards for the temporal snapshot fetch/apply lifecycle.
|
|
*
|
|
* The snapshot effect previously fetched /api/temporal/snapshot with no
|
|
* idempotency or ordering protection. Upstream churn (timeline recreation
|
|
* while bounds settle, play ticks resetting the playhead, drag events) could
|
|
* re-request the same `at` repeatedly, and responses could arrive after the
|
|
* scrubber had moved on.
|
|
*
|
|
* The guards enforce:
|
|
* - at most one in-flight request per scrubber position (identical `at`
|
|
* values are deduplicated while a request is pending, breaking the
|
|
* idle/play polling loop);
|
|
* - successful snapshots are cached per position and re-applied when the
|
|
* scrubber returns (play wrap-around, back-scrubbing) without a refetch;
|
|
* - a response is applied only while the scrubber is still on its position,
|
|
* so out-of-order responses cannot clobber a newer position's count;
|
|
* - failed, cancelled, or superseded requests release their position so it
|
|
* can be fetched again on the next visit;
|
|
* - `reset()` drops all state when the underlying graph data is replaced
|
|
* (reload/retry), because cached snapshots describe the previous graph.
|
|
*
|
|
* `createTemporalSnapshotGuards()` is stateful by design.
|
|
*/
|
|
|
|
export interface TemporalSnapshotResponse {
|
|
active_node_ids: string[];
|
|
active_node_count: number;
|
|
}
|
|
|
|
export interface TemporalSnapshotRequest {
|
|
/** null when the request was deduplicated because one is already in flight. */
|
|
seq: number | null;
|
|
/** The snapshot previously applied for this position, when revisiting it. */
|
|
cached: TemporalSnapshotResponse | null;
|
|
}
|
|
|
|
export interface TemporalSnapshotGuards {
|
|
/** Begin (or dedupe) a request for `atMs`; marks it as the current position. */
|
|
begin(atMs: number): TemporalSnapshotRequest;
|
|
/** True when the response for `atMs`/`seq` may be applied (scrubber still on `atMs`). */
|
|
shouldApply(atMs: number, seq: number): boolean;
|
|
/** Record a successful application and cache its snapshot for revisits. */
|
|
apply(atMs: number, seq: number, data: TemporalSnapshotResponse): void;
|
|
/** Release a position whose request failed, was cancelled, or was superseded. */
|
|
finish(atMs: number, seq: number): void;
|
|
/** Drop all state; call when the underlying graph data is replaced (reload). */
|
|
reset(): void;
|
|
}
|
|
|
|
interface SnapshotEntry {
|
|
seq: number;
|
|
/** null while the request is in flight (or before the first success). */
|
|
data: TemporalSnapshotResponse | null;
|
|
}
|
|
|
|
/** Upper bound on cached positions so long scrubbing sessions stay bounded. */
|
|
const MAX_CACHED_POSITIONS = 256;
|
|
|
|
export function createTemporalSnapshotGuards(): TemporalSnapshotGuards {
|
|
const entries = new Map<number, SnapshotEntry>();
|
|
let latestRequestSeq = 0;
|
|
let currentAtMs: number | null = null;
|
|
|
|
const evictOldest = () => {
|
|
while (entries.size > MAX_CACHED_POSITIONS) {
|
|
const oldestAtMs = entries.keys().next().value;
|
|
if (oldestAtMs === undefined) return;
|
|
entries.delete(oldestAtMs);
|
|
}
|
|
};
|
|
|
|
return {
|
|
begin(atMs) {
|
|
const existing = entries.get(atMs);
|
|
if (existing && existing.data === null) {
|
|
// Identical request already in flight: dedupe, but the scrubber is here now.
|
|
currentAtMs = atMs;
|
|
return { seq: null, cached: null };
|
|
}
|
|
latestRequestSeq += 1;
|
|
const seq = latestRequestSeq;
|
|
entries.set(atMs, { seq, data: existing?.data ?? null });
|
|
currentAtMs = atMs;
|
|
evictOldest();
|
|
return { seq, cached: existing?.data ?? null };
|
|
},
|
|
|
|
shouldApply(atMs, seq) {
|
|
return atMs === currentAtMs && entries.get(atMs)?.seq === seq;
|
|
},
|
|
|
|
apply(atMs, seq, data) {
|
|
const entry = entries.get(atMs);
|
|
if (entry && entry.seq === seq) {
|
|
entry.data = data;
|
|
}
|
|
},
|
|
|
|
finish(atMs, seq) {
|
|
const entry = entries.get(atMs);
|
|
if (entry && entry.seq === seq && entry.data === null) {
|
|
entries.delete(atMs);
|
|
}
|
|
},
|
|
|
|
reset() {
|
|
entries.clear();
|
|
latestRequestSeq = 0;
|
|
currentAtMs = null;
|
|
},
|
|
};
|
|
}
|