Skip to content

Commit 7046c2d

Browse files
committed
Finalize replay cache partitioning
1 parent 41164a2 commit 7046c2d

11 files changed

Lines changed: 674 additions & 106 deletions

File tree

docs/backend/replay.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ The normal startup path trusts cache metadata. It does not decompress every bloc
4848
to validate it; the file name and size are enough to publish availability, and
4949
decompressing the whole corpus on a cold boot would dominate startup time on
5050
modest hardware. A block is only read from disk when a viewer asks for it. If a
51-
cached block is missing or a viewer reports that it could not be decoded, the
52-
cache is corrected for that day and a diagnostic is recorded instead of leaving
53-
the stale coverage in place.
51+
cached block is missing, the cache is corrected for that day and a diagnostic is
52+
recorded instead of leaving the stale coverage in place. Browser-side decode
53+
failures stay browser-side: they raise diagnostics for the operator, but they do
54+
not ask the server to mutate cache state.
5455

5556
## Retention
5657

docs/getting-started/configuration.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,16 @@ behind the reverse proxy and let the proxy serve finalized artifacts directly:
113113
handle_path /api/replay/blocks/* {
114114
root * /var/lib/ident/replay/blocks
115115
116-
@zstd_block path *.zst
116+
@zstd_block {
117+
path *.zst
118+
file
119+
}
117120
header @zstd_block Content-Type application/octet-stream
118121
header @zstd_block Cache-Control "public, max-age=31536000, immutable"
119122
120123
@accepts_zstd {
121124
path *.zst
125+
file
122126
header Accept-Encoding *zstd*
123127
}
124128
header @accepts_zstd Content-Type application/json

ident/src/data/replay.test.ts

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,66 @@ describe("replay data loading", () => {
150150
expect(selectDisplayTrailsByHex(st).abc123).toHaveLength(2);
151151
});
152152

153+
it("expands compact replay aircraft into the display aircraft shape", async () => {
154+
const block = {
155+
version: 2,
156+
start: 120_000,
157+
end: 180_000,
158+
step_ms: 5_000,
159+
frames: [
160+
{
161+
ts: 130_000,
162+
aircraft: [
163+
{
164+
hex: "abc123",
165+
type: "adsb_icao",
166+
flight: "UAL123",
167+
r: "N12345",
168+
t: "B738",
169+
lat: 34,
170+
lon: -118,
171+
alt_baro: 12000,
172+
gs: 420,
173+
track: 90,
174+
seen_pos: 0.5,
175+
rssi: -12,
176+
},
177+
{
178+
hex: "ground1",
179+
type: "adsb_icao",
180+
alt_baro: "ground",
181+
},
182+
],
183+
},
184+
],
185+
};
186+
globalThis.fetch = vi.fn(async (url: string) =>
187+
url.includes("manifest") ? responseJson(manifest()) : responseJson(block),
188+
) as never;
189+
190+
await refreshReplayManifest();
191+
useIdentStore.getState().enterReplay(130_000);
192+
await ensureReplayRange(120_000, 180_000);
193+
194+
const aircraft = selectDisplayAircraftMap(useIdentStore.getState());
195+
expect(aircraft.get("abc123")).toMatchObject({
196+
hex: "abc123",
197+
idKind: "icao",
198+
source: "adsb_icao",
199+
flight: "UAL123",
200+
reg: "N12345",
201+
typeDesignator: "B738",
202+
altBaroFt: 12000,
203+
gsKt: 420,
204+
trackDeg: 90,
205+
seenPosSec: 0.5,
206+
rssiDbfs: -12,
207+
});
208+
expect(aircraft.get("ground1")).toMatchObject({
209+
onGround: true,
210+
});
211+
});
212+
153213
it("does not request blocks that are already loaded", async () => {
154214
const block = replayBlock();
155215
globalThis.fetch = vi.fn(async (url: string) =>
@@ -574,16 +634,10 @@ describe("replay data loading", () => {
574634
await refreshReplayManifest();
575635
await ensureReplayRange(120_000, 180_000, { background: true });
576636

577-
expect(globalThis.fetch).toHaveBeenCalledTimes(4);
578-
expect(globalThis.fetch).toHaveBeenCalledWith(
637+
expect(globalThis.fetch).toHaveBeenCalledTimes(3);
638+
expect(globalThis.fetch).not.toHaveBeenCalledWith(
579639
"/ident/api/replay/block-failure",
580-
expect.objectContaining({
581-
method: "POST",
582-
body: JSON.stringify({
583-
url: "/api/replay/blocks/120000-180000.json.zst",
584-
reason: "decode_failed",
585-
}),
586-
}),
640+
expect.anything(),
587641
);
588642
expect(warn).not.toHaveBeenCalledWith(
589643
"[ident replay] background block load failed",
@@ -600,6 +654,37 @@ describe("replay data loading", () => {
600654
);
601655
});
602656

657+
it("surfaces replay blocks whose frames normalize away", async () => {
658+
vi.spyOn(console, "warn").mockImplementation(() => {});
659+
globalThis.fetch = vi.fn(async (url: string) =>
660+
url.includes("manifest")
661+
? responseJson(manifest())
662+
: responseJson({
663+
version: 2,
664+
start: 120_000,
665+
end: 180_000,
666+
step_ms: 5_000,
667+
frames: [{ aircraft: [{ flight: "BROKEN" }] }],
668+
}),
669+
) as never;
670+
671+
await refreshReplayManifest();
672+
await ensureReplayRange(120_000, 180_000, { background: true });
673+
674+
expect(globalThis.fetch).not.toHaveBeenCalledWith(
675+
"/ident/api/replay/block-failure",
676+
expect.anything(),
677+
);
678+
expect(useIdentStore.getState().replay.error).toContain(
679+
"Invalid replay block",
680+
);
681+
expect(
682+
useIdentStore.getState().replay.cache[
683+
"/api/replay/blocks/120000-180000.json.zst"
684+
],
685+
).toBeUndefined();
686+
});
687+
603688
it("retries malformed replay blocks and clears the matching error after recovery", async () => {
604689
let malformed = true;
605690
globalThis.fetch = vi.fn(async (url: string) => {

ident/src/data/replay.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,11 @@ function loadReplayBlock(block: ReplayBlockIndex): Promise<void> | null {
203203
);
204204
if (controller.signal.aborted) throw new ReplayLoadCanceled();
205205
const body = decodeReplayBlockResponse(bytes) as ReplayBlockFile;
206-
if (body.version !== 2 || !Array.isArray(body.frames)) {
206+
if (
207+
body.version !== 2 ||
208+
!Array.isArray(body.frames) ||
209+
!hasReplayFrameSample(body.frames)
210+
) {
207211
throw new ReplayBlockFormatError(block.url);
208212
}
209213
useIdentStore.getState().setReplayBlock(block.url, body);
@@ -212,7 +216,6 @@ function loadReplayBlock(block: ReplayBlockIndex): Promise<void> | null {
212216
throw err;
213217
}
214218
if (err instanceof ReplayBlockFormatError) {
215-
void reportReplayBlockFailure(block.url, "decode_failed");
216219
emitFrontendDiagnostic({
217220
severity: "warning",
218221
channel: "frontend.replay",
@@ -222,7 +225,6 @@ function loadReplayBlock(block: ReplayBlockIndex): Promise<void> | null {
222225
throw err;
223226
}
224227
if (err instanceof ReplayBlockBodyError) {
225-
void reportReplayBlockFailure(block.url, "decode_failed");
226228
emitFrontendDiagnostic({
227229
severity: "warning",
228230
channel: "frontend.replay",
@@ -251,21 +253,14 @@ function loadReplayBlock(block: ReplayBlockIndex): Promise<void> | null {
251253
return load;
252254
}
253255

254-
async function reportReplayBlockFailure(
255-
url: string,
256-
reason: "decode_failed" | "missing",
257-
): Promise<void> {
258-
try {
259-
await fetch(appPath("api/replay/block-failure"), {
260-
method: "POST",
261-
headers: { "Content-Type": "application/json" },
262-
cache: "no-store",
263-
body: JSON.stringify({ url, reason }),
264-
});
265-
} catch {
266-
// Best-effort cache repair signal only; replay loading already reports
267-
// the user-visible diagnostic above.
268-
}
256+
function hasReplayFrameSample(frames: unknown[]): boolean {
257+
return frames.some(
258+
(frame) =>
259+
frame != null &&
260+
typeof frame === "object" &&
261+
typeof (frame as { ts?: unknown }).ts === "number" &&
262+
Number.isFinite((frame as { ts: number }).ts),
263+
);
269264
}
270265

271266
function abortStaleBlockLoads(

0 commit comments

Comments
 (0)