fix(consumer): skip config replay for enrichment batches
Browse files- .xtap-deployment.json +7 -7
- space/src/configuration-tail.ts +17 -0
- space/src/server.ts +2 -9
- space/tests/configuration-tail.test.ts +80 -0
.xtap-deployment.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
| 1 |
{
|
| 2 |
-
"source_revision": "
|
| 3 |
"enrichment_revision_handoff": {
|
| 4 |
"active_generation": 87,
|
| 5 |
"activation_sha256": "0d09e98a8073db48faf0ea56d7600fc581c1106ae98312d2ef8fd2273a48f404",
|
| 6 |
"run_id": "xtap-b0e01d0c7828c30def3948bcb4251e68",
|
| 7 |
"plan_sha256": "b0e01d0c7828c30def3948bcb4251e68ddc4151366cb31d3d96ea01ba86b2d85",
|
| 8 |
"plan_worker_revision": "6a3c4855dd21f469fab63705f77cd7b2f4095726",
|
| 9 |
-
"target_worker_revision": "
|
| 10 |
"contract_sha256": "2a2814ad4162457c0b8fe6de9cc07e0813f9fffadd3bde21ca46c731cc58de69",
|
| 11 |
"source_snapshot_revision": "5bcd681d22132a5009c5f3e72d33ffb6fb1ac1e1269a23dd3b84091f570a8dce",
|
| 12 |
-
"checkpoint_pointer_sha256": "
|
| 13 |
-
"checkpoint_sequence":
|
| 14 |
-
"checkpoint_key": "operations/enrichment/runs/xtap-b0e01d0c7828c30def3948bcb4251e68/checkpoints/sha256-
|
| 15 |
-
"checkpoint_sha256": "
|
| 16 |
-
"checkpoint_bytes":
|
| 17 |
}
|
| 18 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"source_revision": "cfd46b156aeec2c30225e63f9873c295fb247731",
|
| 3 |
"enrichment_revision_handoff": {
|
| 4 |
"active_generation": 87,
|
| 5 |
"activation_sha256": "0d09e98a8073db48faf0ea56d7600fc581c1106ae98312d2ef8fd2273a48f404",
|
| 6 |
"run_id": "xtap-b0e01d0c7828c30def3948bcb4251e68",
|
| 7 |
"plan_sha256": "b0e01d0c7828c30def3948bcb4251e68ddc4151366cb31d3d96ea01ba86b2d85",
|
| 8 |
"plan_worker_revision": "6a3c4855dd21f469fab63705f77cd7b2f4095726",
|
| 9 |
+
"target_worker_revision": "cfd46b156aeec2c30225e63f9873c295fb247731",
|
| 10 |
"contract_sha256": "2a2814ad4162457c0b8fe6de9cc07e0813f9fffadd3bde21ca46c731cc58de69",
|
| 11 |
"source_snapshot_revision": "5bcd681d22132a5009c5f3e72d33ffb6fb1ac1e1269a23dd3b84091f570a8dce",
|
| 12 |
+
"checkpoint_pointer_sha256": "55bfdde220bcf25eb008481e587d8292a13aa3ed956e9b92ebc51a6f32dab696",
|
| 13 |
+
"checkpoint_sequence": 237,
|
| 14 |
+
"checkpoint_key": "operations/enrichment/runs/xtap-b0e01d0c7828c30def3948bcb4251e68/checkpoints/sha256-30d20fbcf0ce6629c95fa0419e9e117cfcb7b4411a7414611b5df3dcf449d38c/checkpoint.hfjob",
|
| 15 |
+
"checkpoint_sha256": "30d20fbcf0ce6629c95fa0419e9e117cfcb7b4411a7414611b5df3dcf449d38c",
|
| 16 |
+
"checkpoint_bytes": 41668
|
| 17 |
}
|
| 18 |
}
|
space/src/configuration-tail.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { BucketLog, BucketSnapshot } from "./bucket-log.js";
|
| 2 |
+
|
| 3 |
+
/** Inspect only newly verified segments. Mixed append batches are not config writes. */
|
| 4 |
+
export async function hasConfigurationWrites(
|
| 5 |
+
log: Pick<BucketLog, "loadSegment">,
|
| 6 |
+
base: BucketSnapshot,
|
| 7 |
+
target: BucketSnapshot,
|
| 8 |
+
): Promise<boolean> {
|
| 9 |
+
const known = new Set(base.files.map((file) => file.key));
|
| 10 |
+
for (const file of target.files) {
|
| 11 |
+
if (known.has(file.key)) continue;
|
| 12 |
+
if (!file.key.includes("/config/") && !file.key.includes("/mixed/")) continue;
|
| 13 |
+
const segment = await log.loadSegment(file);
|
| 14 |
+
if (segment.operations.some((operation) => operation.mode === "write")) return true;
|
| 15 |
+
}
|
| 16 |
+
return false;
|
| 17 |
+
}
|
space/src/server.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { consumerFetch, withConsumerDeadline } from "./consumer-deadline.js";
|
|
| 12 |
import { createApp } from "./app.js";
|
| 13 |
import type { AppReadiness } from "./app.js";
|
| 14 |
import { loadConfig } from "./config.js";
|
|
|
|
| 15 |
import { BucketLog, createRawBucketClient } from "./bucket-log.js";
|
| 16 |
import type { BucketSnapshot } from "./bucket-log.js";
|
| 17 |
import type { StorageState } from "./storage-state.js";
|
|
@@ -263,14 +264,6 @@ async function primeStorageTextCache(snapshot: BucketSnapshot, stage: string): P
|
|
| 263 |
});
|
| 264 |
}
|
| 265 |
|
| 266 |
-
function hasConfigurationTail(base: BucketSnapshot, final: BucketSnapshot): boolean {
|
| 267 |
-
const baseKeys = new Set(base.files.map((file) => file.key));
|
| 268 |
-
return final.files.some(
|
| 269 |
-
(file) =>
|
| 270 |
-
!baseKeys.has(file.key) && (file.key.includes("/config/") || file.key.includes("/mixed/")),
|
| 271 |
-
);
|
| 272 |
-
}
|
| 273 |
-
|
| 274 |
function applyIndexStats(): void {
|
| 275 |
const stats = index.stats();
|
| 276 |
rebuilt = { files: stats.tweetFiles, tweets: stats.tweetRows };
|
|
@@ -304,7 +297,7 @@ async function refreshExternalEnrichment(): Promise<void> {
|
|
| 304 |
if (!storageCredentialOk(storageCredential)) return;
|
| 305 |
const baseSnapshot = index.sourceSnapshot();
|
| 306 |
const discovered = await log.discoverSnapshot(baseSnapshot.files);
|
| 307 |
-
const configChanged =
|
| 308 |
let nextTaxonomy = taxonomy;
|
| 309 |
if (configChanged) {
|
| 310 |
await primeStorageTextCache(discovered.snapshot, "configuration-refresh");
|
|
|
|
| 12 |
import { createApp } from "./app.js";
|
| 13 |
import type { AppReadiness } from "./app.js";
|
| 14 |
import { loadConfig } from "./config.js";
|
| 15 |
+
import { hasConfigurationWrites } from "./configuration-tail.js";
|
| 16 |
import { BucketLog, createRawBucketClient } from "./bucket-log.js";
|
| 17 |
import type { BucketSnapshot } from "./bucket-log.js";
|
| 18 |
import type { StorageState } from "./storage-state.js";
|
|
|
|
| 264 |
});
|
| 265 |
}
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
function applyIndexStats(): void {
|
| 268 |
const stats = index.stats();
|
| 269 |
rebuilt = { files: stats.tweetFiles, tweets: stats.tweetRows };
|
|
|
|
| 297 |
if (!storageCredentialOk(storageCredential)) return;
|
| 298 |
const baseSnapshot = index.sourceSnapshot();
|
| 299 |
const discovered = await log.discoverSnapshot(baseSnapshot.files);
|
| 300 |
+
const configChanged = await hasConfigurationWrites(log, baseSnapshot, discovered.snapshot);
|
| 301 |
let nextTaxonomy = taxonomy;
|
| 302 |
if (configChanged) {
|
| 303 |
await primeStorageTextCache(discovered.snapshot, "configuration-refresh");
|
space/tests/configuration-tail.test.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, vi } from "vitest";
|
| 2 |
+
import type {
|
| 3 |
+
BucketOperation,
|
| 4 |
+
BucketSegment,
|
| 5 |
+
BucketSnapshot,
|
| 6 |
+
BucketSnapshotFile,
|
| 7 |
+
} from "../src/bucket-log.js";
|
| 8 |
+
import { hasConfigurationWrites } from "../src/configuration-tail.js";
|
| 9 |
+
|
| 10 |
+
function file(kind: "config" | "mixed" | "tweet", n: number): BucketSnapshotFile {
|
| 11 |
+
const hash = String(n).padStart(64, "0");
|
| 12 |
+
return {
|
| 13 |
+
key: `v1/segments/${kind}/2026/09/08/1788868800000-00000000-0000-4000-8000-000000000000-${hash}.json.gz`,
|
| 14 |
+
oid: hash,
|
| 15 |
+
size: 100,
|
| 16 |
+
content_sha256: hash,
|
| 17 |
+
};
|
| 18 |
+
}
|
| 19 |
+
function snapshot(...files: BucketSnapshotFile[]): BucketSnapshot {
|
| 20 |
+
return {
|
| 21 |
+
schema_version: 1,
|
| 22 |
+
bucket: "owner/raw",
|
| 23 |
+
files: files.sort((a, b) => a.key.localeCompare(b.key)),
|
| 24 |
+
};
|
| 25 |
+
}
|
| 26 |
+
function reader(operations: BucketOperation[]) {
|
| 27 |
+
const segment: BucketSegment = {
|
| 28 |
+
schema_version: 1,
|
| 29 |
+
transaction_id: "00000000-0000-4000-8000-000000000000",
|
| 30 |
+
created_at: "2026-09-08T12:00:00.000Z",
|
| 31 |
+
operations,
|
| 32 |
+
};
|
| 33 |
+
return { loadSegment: vi.fn(() => Promise.resolve(segment)) };
|
| 34 |
+
}
|
| 35 |
+
const append: BucketOperation = {
|
| 36 |
+
mode: "append",
|
| 37 |
+
path: "enrichment/2026/09/enrichment-2026-09-08.jsonl",
|
| 38 |
+
lines: ["{}"],
|
| 39 |
+
};
|
| 40 |
+
const write: BucketOperation = { mode: "write", path: "config/labels.json", content: "{}" };
|
| 41 |
+
|
| 42 |
+
describe("configuration changes in verified source tails", () => {
|
| 43 |
+
it("does not reread old mixed/config segments or new tweet-only segments", async () => {
|
| 44 |
+
const base = snapshot(file("config", 1), file("mixed", 2));
|
| 45 |
+
const log = reader([write]);
|
| 46 |
+
expect(await hasConfigurationWrites(log, base, snapshot(...base.files, file("tweet", 3)))).toBe(
|
| 47 |
+
false,
|
| 48 |
+
);
|
| 49 |
+
expect(log.loadSegment).not.toHaveBeenCalled();
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
it("keeps mixed enrichment append batches out of the full configuration refresh", async () => {
|
| 53 |
+
const old = file("mixed", 1),
|
| 54 |
+
added = file("mixed", 2);
|
| 55 |
+
const log = reader([
|
| 56 |
+
append,
|
| 57 |
+
{ ...append, path: "enrichment/attempts/2026/09/attempts-2026-09-08.jsonl" },
|
| 58 |
+
]);
|
| 59 |
+
expect(await hasConfigurationWrites(log, snapshot(old), snapshot(old, added))).toBe(false);
|
| 60 |
+
expect(log.loadSegment).toHaveBeenCalledExactlyOnceWith(added);
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
it.each(["config", "mixed"] as const)(
|
| 64 |
+
"detects actual writes in new %s segments, including late arrivals",
|
| 65 |
+
async (kind) => {
|
| 66 |
+
const old = file(kind, 2),
|
| 67 |
+
added = file(kind, 1);
|
| 68 |
+
const log = reader(kind === "mixed" ? [append, write] : [write]);
|
| 69 |
+
expect(await hasConfigurationWrites(log, snapshot(old), snapshot(added, old))).toBe(true);
|
| 70 |
+
expect(log.loadSegment).toHaveBeenCalledExactlyOnceWith(added);
|
| 71 |
+
},
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
it("does not mistake an unreadable new segment for unchanged configuration", async () => {
|
| 75 |
+
const log = { loadSegment: vi.fn(() => Promise.reject(new Error("checksum mismatch"))) };
|
| 76 |
+
await expect(
|
| 77 |
+
hasConfigurationWrites(log, snapshot(), snapshot(file("mixed", 1))),
|
| 78 |
+
).rejects.toThrow("checksum mismatch");
|
| 79 |
+
});
|
| 80 |
+
});
|