@remnic/core 10.0.56 → 10.0.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/access-cli.js +8 -8
  2. package/dist/access-http-query.js +2 -2
  3. package/dist/access-http.js +3 -3
  4. package/dist/{chunk-FCAXGYDT.js → chunk-CTNDFKEX.js} +2 -2
  5. package/dist/{chunk-CC2ESKHP.js → chunk-E2YAGEFR.js} +7 -7
  6. package/dist/{chunk-CZS7UTXL.js → chunk-KCTL5EY7.js} +2 -2
  7. package/dist/{chunk-5C44AMRV.js → chunk-L5UB5BIY.js} +2 -2
  8. package/dist/{chunk-S3OI5DP5.js → chunk-P5FYZH5G.js} +235 -124
  9. package/dist/chunk-P5FYZH5G.js.map +1 -0
  10. package/dist/{chunk-OGR52N7A.js → chunk-W2KH27MQ.js} +3 -3
  11. package/dist/{chunk-GMRP3HN3.js → chunk-W3R77WHT.js} +2 -2
  12. package/dist/{chunk-VZ7VJGPL.js → chunk-W4QTA2H5.js} +2 -2
  13. package/dist/cli.js +6 -6
  14. package/dist/index.js +8 -8
  15. package/dist/namespaces/catalog-read-boundary.d.ts +42 -0
  16. package/dist/namespaces/catalog-read-boundary.d.ts.map +1 -0
  17. package/dist/namespaces/catalog-repair.d.ts +35 -0
  18. package/dist/namespaces/catalog-repair.d.ts.map +1 -0
  19. package/dist/namespaces/catalog.d.ts +31 -27
  20. package/dist/namespaces/catalog.d.ts.map +1 -1
  21. package/dist/operator-toolkit.js +3 -3
  22. package/dist/orchestration/maintenance.js +3 -3
  23. package/dist/orchestrator.js +8 -8
  24. package/package.json +2 -2
  25. package/src/namespaces/catalog-read-boundary.test.ts +121 -0
  26. package/src/namespaces/catalog-read-boundary.ts +159 -0
  27. package/src/namespaces/catalog-repair.ts +69 -0
  28. package/src/namespaces/catalog.test.ts +493 -10
  29. package/src/namespaces/catalog.ts +112 -110
  30. package/src/testing/subjects/catalog-read-repair.test.ts +124 -0
  31. package/dist/chunk-S3OI5DP5.js.map +0 -1
  32. /package/dist/{chunk-FCAXGYDT.js.map → chunk-CTNDFKEX.js.map} +0 -0
  33. /package/dist/{chunk-CC2ESKHP.js.map → chunk-E2YAGEFR.js.map} +0 -0
  34. /package/dist/{chunk-CZS7UTXL.js.map → chunk-KCTL5EY7.js.map} +0 -0
  35. /package/dist/{chunk-5C44AMRV.js.map → chunk-L5UB5BIY.js.map} +0 -0
  36. /package/dist/{chunk-OGR52N7A.js.map → chunk-W2KH27MQ.js.map} +0 -0
  37. /package/dist/{chunk-GMRP3HN3.js.map → chunk-W3R77WHT.js.map} +0 -0
  38. /package/dist/{chunk-VZ7VJGPL.js.map → chunk-W4QTA2H5.js.map} +0 -0
@@ -3,6 +3,7 @@ import {
3
3
  appendFile,
4
4
  mkdir,
5
5
  mkdtemp,
6
+ readdir,
6
7
  readFile,
7
8
  realpath,
8
9
  rm,
@@ -24,6 +25,35 @@ import {
24
25
  } from "./storage.js";
25
26
  import { withHeldFileLock } from "../utils/serialize-mutations.js";
26
27
 
28
+ /** Wait until a rewrite settles: the bad row is gone from the file, or 2 s pass. */
29
+ async function settleRewrite(catalogPath: string, badRow: string): Promise<void> {
30
+ const deadline = Date.now() + 2_000;
31
+ while ((await readFile(catalogPath, "utf8")).includes(badRow)) {
32
+ if (Date.now() >= deadline) return;
33
+ await new Promise((resolve) => setTimeout(resolve, 20));
34
+ }
35
+ // The rewrite may still be inside its rename/lock window when the last bad row disappears.
36
+ await new Promise((resolve) => setTimeout(resolve, 30));
37
+ }
38
+
39
+ /**
40
+ * Wait until the catalog state directory holds only stable files: no lock, no
41
+ * lock-release temp, no rewrite temp. withHeldFileLock renames the lock through
42
+ * a transient `.releasing.` file, and a recursive rm that walks the directory
43
+ * during that window fails with ENOTEMPTY (#3298).
44
+ */
45
+ async function settleStateDir(stateDir: string): Promise<void> {
46
+ const deadline = Date.now() + 2_000;
47
+ for (;;) {
48
+ const entries = await readdir(stateDir).catch((): string[] => []);
49
+ const busy = entries.some(
50
+ (e) => e.endsWith(".tmp") || e.includes(".releasing.") || e.endsWith(".rebuild.lock"),
51
+ );
52
+ if (!busy || Date.now() >= deadline) return;
53
+ await new Promise((resolve) => setTimeout(resolve, 20));
54
+ }
55
+ }
56
+
27
57
  function makeConfig(memoryDir: string, overrides: Partial<PluginConfig> = {}): PluginConfig {
28
58
  return {
29
59
  memoryDir,
@@ -518,6 +548,8 @@ test("catalog tolerates corrupt / non-object JSONL lines on read", async () => {
518
548
  const list = await catalog.listNamespaces();
519
549
  assert.equal(list.length, 1);
520
550
  assert.equal(list[0]?.namespace, "alpha");
551
+ // The read schedules the repair rewrite (#3297); settle it before the finally rm.
552
+ await settleRewrite(file, "not json at all");
521
553
  } finally {
522
554
  await rm(memoryDir, { recursive: true, force: true });
523
555
  }
@@ -610,6 +642,9 @@ test("catalog quarantines malformed JSONL record fields at the parse boundary",
610
642
  assert.equal(valid?.lastReadAt, undefined);
611
643
  assert.equal(valid?.lastWriteAt, undefined);
612
644
  assert.deepEqual(valid?.lastMaintenanceAt, { ok: now });
645
+ // The read reports quarantined rows as skipped, so the repair rewrite is scheduled (#3297);
646
+ // let it settle before the finally rm, which would otherwise race its lock release.
647
+ await settleRewrite(file, '"identityToken":"bad-token"');
613
648
  } finally {
614
649
  await rm(memoryDir, { recursive: true, force: true });
615
650
  }
@@ -4012,10 +4047,13 @@ test("listNamespaces reports an incomplete read for a malformed record but still
4012
4047
  assert.equal(reasons.length, 1);
4013
4048
  assert.match(reasons[0], /malformed/);
4014
4049
 
4015
- // A second read served from the warm cache must still say the catalog was incomplete.
4050
+ // The read schedules a rewrite that drops the malformed line (#3297), so a second read
4051
+ // sees a whole catalog and reports nothing.
4052
+ await new Promise((resolve) => setImmediate(resolve));
4053
+ await readFile(catalogPath, "utf8"); // ensure the rewrite settled before re-reading
4016
4054
  const again: string[] = [];
4017
4055
  await catalog.listNamespaces(undefined, { onIncomplete: (reason) => again.push(reason) });
4018
- assert.equal(again.length, 1, "the cached result keeps reporting the malformed line");
4056
+ assert.equal(again.length, 0, "the rewrite removed the malformed line, so the catalog is whole");
4019
4057
  } finally {
4020
4058
  await rm(memoryDir, { recursive: true, force: true });
4021
4059
  }
@@ -4062,20 +4100,465 @@ test("a catalog with malformed rows stays tolerant for callers that do not ask a
4062
4100
  // Valid JSON that is not a catalog record counts as malformed too.
4063
4101
  await appendFile(catalogPath, "{\"not\":\"a record\"}\n", "utf8");
4064
4102
 
4065
- // No options, or options without a callback: nothing to call, nothing may throw.
4103
+ // The FIRST read (no options) reports nothing to the caller, and its repair rewrite (#3297)
4104
+ // drops the bad row; later reads then see a whole catalog. The reporter must be on read #1.
4105
+ const reasons: string[] = [];
4106
+ const first = await catalog.listNamespaces(undefined, { onIncomplete: (reason) => reasons.push(reason) });
4107
+ assert.equal(reasons.length, 1, "the non-record row is reported to the first reader");
4108
+ assert.deepEqual(first.map((r) => r.namespace), ["project-origin-abc123"]);
4109
+ await settleRewrite(catalogPath, '"not":"a record"');
4110
+ assert.ok(!(await readFile(catalogPath, "utf8")).includes('"not":"a record"'), "the rewrite dropped the bad row");
4111
+
4112
+ // After the repair: no options, or options without a callback, nothing to call, nothing may throw.
4066
4113
  assert.deepEqual((await catalog.listNamespaces()).map((r) => r.namespace), ["project-origin-abc123"]);
4067
4114
  assert.deepEqual((await catalog.listNamespaces(undefined, {})).map((r) => r.namespace), ["project-origin-abc123"]);
4068
4115
 
4069
- const reasons: string[] = [];
4070
- await catalog.listNamespaces(undefined, { onIncomplete: (reason) => reasons.push(reason) });
4071
- assert.equal(reasons.length, 1, "the non-record row is reported");
4072
-
4073
- // The warm cache now remembers the problem; a reader without a reporter must still work.
4116
+ // After the repair, every reader sees a whole catalog.
4074
4117
  assert.equal((await catalog.getNamespaceRecord("project-origin-abc123"))?.namespace, "project-origin-abc123");
4075
-
4076
- // A cold read of a malformed catalog by a reader without a reporter must work too.
4077
4118
  const cold = new NamespaceCatalog(makeConfig(memoryDir));
4078
4119
  assert.equal((await cold.getNamespaceRecord("project-origin-abc123"))?.namespace, "project-origin-abc123");
4120
+ const reasons2: string[] = [];
4121
+ await cold.listNamespaces(undefined, { onIncomplete: (r) => reasons2.push(r) });
4122
+ assert.deepEqual(reasons2, [], "the rewrite made the catalog whole");
4123
+ } finally {
4124
+ await rm(memoryDir, { recursive: true, force: true });
4125
+ }
4126
+ });
4127
+
4128
+ test("a read that skipped malformed rows schedules a compaction rewrite that drops them (#3297)", async () => {
4129
+ const memoryDir = await mkMemoryDir();
4130
+ try {
4131
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
4132
+ await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
4133
+ const catalogPath = path.join(memoryDir, "state", "namespaces.jsonl");
4134
+ const before = await readFile(catalogPath, "utf8");
4135
+ await appendFile(catalogPath, "{this is not json\n", "utf8");
4136
+ assert.ok(before.length > 0, "the valid rows exist on disk");
4137
+
4138
+ // Read with the malformed row: the rewrite must produce a file without it.
4139
+ const reasons: string[] = [];
4140
+ const records = await catalog.listNamespaces(undefined, { onIncomplete: (r) => reasons.push(r) });
4141
+ assert.equal(reasons.length, 1);
4142
+ await settleRewrite(catalogPath, "this is not json");
4143
+ const after = await readFile(catalogPath, "utf8");
4144
+ assert.ok(after.length > 0, "the valid rows survive the rewrite");
4145
+ assert.ok(!after.includes("this is not json"), "the malformed row is gone after the rewrite");
4146
+ assert.deepEqual(records.map((r) => r.namespace), ["project-origin-abc123"]);
4147
+
4148
+ // The next read is clean: nothing to report, no further rewrite.
4149
+ const quiet: string[] = [];
4150
+ await catalog.listNamespaces(undefined, { onIncomplete: (r) => quiet.push(r) });
4151
+ await settleRewrite(catalogPath, "-no-such-row"); // one settle window for the queued rewrite, if any
4152
+ assert.deepEqual(quiet, []);
4153
+ assert.equal(await readFile(catalogPath, "utf8"), after, "no second rewrite happened");
4154
+ } finally {
4155
+ await rm(memoryDir, { recursive: true, force: true });
4156
+ }
4157
+ });
4158
+
4159
+ test("no rewrite is scheduled for a clean or missing catalog (#3297)", async () => {
4160
+ const memoryDir = await mkMemoryDir();
4161
+ try {
4162
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
4163
+ await catalog.listNamespaces();
4164
+ const stateDir = path.join(memoryDir, "state");
4165
+ // A fresh install has no state dir at all yet; that is the no-rewrite proof.
4166
+ // SAFETY: readdir of a fresh install fails with ENOENT, which is the proof itself; catch narrows to an empty list.
4167
+ const tmpFiles = await readdir(stateDir).then((entries) => entries.filter((e) => e.endsWith(".tmp"))).catch(() => [] as string[]);
4168
+ assert.deepEqual(tmpFiles, []);
4169
+ await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
4170
+ const before = await readFile(path.join(stateDir, "namespaces.jsonl"), "utf8");
4171
+ await catalog.listNamespaces();
4172
+ await new Promise((resolve) => setImmediate(resolve));
4173
+ assert.equal(await readFile(path.join(stateDir, "namespaces.jsonl"), "utf8"), before, "nothing rewrote a clean catalog");
4174
+ assert.equal((await readdir(stateDir)).filter((e) => e.endsWith(".tmp")).length, 0, "no temporary rewrite file is left behind");
4175
+ } finally {
4176
+ await rm(memoryDir, { recursive: true, force: true });
4177
+ }
4178
+ });
4179
+
4180
+ test("a read that skipped malformed rows does not rewrite while another process holds the catalog lock (#3297)", async () => {
4181
+ const memoryDir = await mkMemoryDir();
4182
+ try {
4183
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
4184
+ await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
4185
+ const catalogPath = path.join(memoryDir, "state", "namespaces.jsonl");
4186
+ await appendFile(catalogPath, "{this is not json\n", "utf8");
4187
+ const before = await readFile(catalogPath, "utf8");
4188
+
4189
+ // Another process holds the rebuild lock for the whole read+rewrite window.
4190
+ await withHeldFileLock(path.join(memoryDir, "state", "namespaces.rebuild.lock"), { staleMs: 30_000 }, async () => {
4191
+ const reasons: string[] = [];
4192
+ await catalog.listNamespaces(undefined, { onIncomplete: (r) => reasons.push(r) });
4193
+ assert.equal(reasons.length, 1, "the read still reports the malformed row");
4194
+ await new Promise((resolve) => setImmediate(resolve));
4195
+ assert.equal(await readFile(catalogPath, "utf8"), before, "no rewrite ran under another process's lock");
4196
+ });
4197
+ // The attempt's bounded lock wait runs inside queueCritical, so the holder above
4198
+ // cannot release until the attempt has settled (dropped): by this point nothing is
4199
+ // in flight and the cleanup below is safe.
4200
+ assert.equal(await readFile(catalogPath, "utf8"), before, "the dropped attempt changed nothing");
4201
+ } finally {
4202
+ await rm(memoryDir, { recursive: true, force: true });
4203
+ }
4204
+ });
4205
+
4206
+ test("a dropped repair backs off before the next attempt (#3298)", { timeout: 30_000 }, async () => {
4207
+ const memoryDir = await mkMemoryDir();
4208
+ try {
4209
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
4210
+ await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
4211
+ const catalogPath = path.join(memoryDir, "state", "namespaces.jsonl");
4212
+ await appendFile(catalogPath, "{this is not json\n", "utf8");
4213
+ const before = await readFile(catalogPath, "utf8");
4214
+ const lockPath = path.join(memoryDir, "state", "namespaces.rebuild.lock");
4215
+
4216
+ // Hold the lock past the repair's 5 s wait, so the first attempt is dropped.
4217
+ const { promise: released, resolve: release } = Promise.withResolvers<void>();
4218
+ // The holder resolves `held` from inside its callback, which runs only once
4219
+ // the on-disk lock is actually owned: awaiting it proves the lock is held
4220
+ // before the first read, so the repair can never slip in first.
4221
+ const { promise: held, resolve: lockHeld } = Promise.withResolvers<void>();
4222
+ const holder = withHeldFileLock(lockPath, { staleMs: 60_000 }, async () => {
4223
+ lockHeld();
4224
+ await released;
4225
+ });
4226
+ // Real delays remain where the platform clock is the thing under test: the
4227
+ // gate's backoff reads Date.now() and the repair's lock wait really polls.
4228
+ await held;
4229
+ await catalog.listNamespaces(undefined, { onIncomplete: () => undefined });
4230
+ // Past the repair's 5 s lock wait (the attempt drops at ~5.05 s) but still
4231
+ // inside the 250 ms backoff window that starts at the drop.
4232
+ { const { promise, resolve } = Promise.withResolvers<void>(); setTimeout(resolve, 5_100); await promise; }
4233
+ assert.equal(catalog.malformedRepairAttemptsForTest, 1, "one attempt was made and dropped");
4234
+
4235
+ // Still inside the 250 ms backoff window that began when the attempt dropped:
4236
+ // this read runs under the still-held lock and must not attempt again.
4237
+ await catalog.listNamespaces(undefined, { onIncomplete: () => undefined });
4238
+ assert.equal(catalog.malformedRepairAttemptsForTest, 1, "the backoff window suppresses a new attempt");
4239
+ release();
4240
+ await holder;
4241
+ assert.equal(await readFile(catalogPath, "utf8"), before, "the dropped attempt changed nothing");
4242
+
4243
+ // Past the window, the next malformed read retries and repairs.
4244
+ { const { promise, resolve } = Promise.withResolvers<void>(); setTimeout(resolve, 300); await promise; }
4245
+ await catalog.listNamespaces(undefined, { onIncomplete: () => undefined });
4246
+ await settleRewrite(catalogPath, "this is not json");
4247
+ assert.ok(!(await readFile(catalogPath, "utf8")).includes("this is not json"), "the retry repaired the catalog");
4248
+ assert.equal(catalog.malformedRepairAttemptsForTest, 2, "exactly one retry after the window");
4249
+ } finally {
4250
+ await rm(memoryDir, { recursive: true, force: true });
4251
+ }
4252
+ });
4253
+
4254
+ class FailingReadCatalog extends NamespaceCatalog {
4255
+ /** Stop injecting read failures. */
4256
+ stopFailing(): void {
4257
+ this.onCatalogReadForTest = undefined;
4258
+ }
4259
+
4260
+ /** The first full read succeeds; every later one throws (the repair's locked re-read fails). */
4261
+ failAfterFirstRead(): void {
4262
+ let reads = 0;
4263
+ this.onCatalogReadForTest = () => {
4264
+ reads += 1;
4265
+ if (reads > 1) throw new Error("EIO: simulated read failure on the second read");
4266
+ };
4267
+ }
4268
+
4269
+ /** Every full read of the catalog file throws, like a persistent EIO/EACCES on the file. */
4270
+ failEveryRead(): void {
4271
+ this.onCatalogReadForTest = () => {
4272
+ throw new Error("EIO: simulated read failure");
4273
+ };
4274
+ }
4275
+ }
4276
+
4277
+ async function stateEntries(memoryDir: string): Promise<string[]> {
4278
+ return readdir(path.join(memoryDir, "state")).catch((): string[] => []);
4279
+ }
4280
+
4281
+ test("a read error on a healthy catalog never rewrites it, and leaves no temp file (#3297)", async () => {
4282
+ const memoryDir = await mkMemoryDir();
4283
+ try {
4284
+ const writer = new NamespaceCatalog(makeConfig(memoryDir));
4285
+ await writer.markWrite("project-origin-abc123", { discoveredBy: "write" });
4286
+ await writer.markWrite("project-origin-def456", { discoveredBy: "write" });
4287
+ const catalogPath = path.join(memoryDir, "state", "namespaces.jsonl");
4288
+ const before = await readFile(catalogPath, "utf8");
4289
+ assert.ok(before.includes("abc123") && before.includes("def456"));
4290
+
4291
+ // A cold instance whose every read of the file fails: the catalog is healthy on disk.
4292
+ const reader = new FailingReadCatalog(makeConfig(memoryDir));
4293
+ reader.failEveryRead();
4294
+ const reasons: string[] = [];
4295
+ const records = await reader.listNamespaces(undefined, { onIncomplete: (r) => reasons.push(r) });
4296
+ assert.deepEqual(records, [], "the unreadable catalog yields no records");
4297
+ assert.equal(reasons.length, 1);
4298
+ assert.match(reasons[0], /unreadable/);
4299
+
4300
+ await new Promise((resolve) => setTimeout(resolve, 150)); // any scheduled rewrite would land here
4301
+ assert.equal(await readFile(catalogPath, "utf8"), before, "the healthy catalog bytes are untouched");
4302
+ assert.deepEqual((await stateEntries(memoryDir)).filter((e) => e.endsWith(".tmp")), [], "no temp file is left behind");
4303
+ } finally {
4304
+ await rm(memoryDir, { recursive: true, force: true });
4305
+ }
4306
+ });
4307
+
4308
+ test("an unreadable catalog path leaves no temp file and is not rewritten (#3297)", async () => {
4309
+ const memoryDir = await mkMemoryDir();
4310
+ try {
4311
+ await mkdir(path.join(memoryDir, "state", "namespaces.jsonl"), { recursive: true });
4312
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
4313
+ const reasons: string[] = [];
4314
+ await catalog.listNamespaces(undefined, { onIncomplete: (r) => reasons.push(r) });
4315
+ assert.equal(reasons.length, 1);
4316
+ await new Promise((resolve) => setTimeout(resolve, 150));
4317
+ assert.deepEqual(await stateEntries(memoryDir), ["namespaces.jsonl"], "nothing but the directory is in state");
4318
+ } finally {
4319
+ await rm(memoryDir, { recursive: true, force: true });
4320
+ }
4321
+ });
4322
+
4323
+ test("a catalog with no readable row is not rewritten to an empty file (#3297)", async () => {
4324
+ const memoryDir = await mkMemoryDir();
4325
+ try {
4326
+ const catalogPath = path.join(memoryDir, "state", "namespaces.jsonl");
4327
+ await mkdir(path.dirname(catalogPath), { recursive: true });
4328
+ const garbage = "not json at all\n{\"not\":\"a record\"}\n";
4329
+ await writeFile(catalogPath, garbage, "utf8");
4330
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
4331
+ const reasons: string[] = [];
4332
+ await catalog.listNamespaces(undefined, { onIncomplete: (r) => reasons.push(r) });
4333
+ assert.equal(reasons.length, 1, "the malformed rows are still reported");
4334
+ await new Promise((resolve) => setTimeout(resolve, 150));
4335
+ assert.equal(await readFile(catalogPath, "utf8"), garbage, "nothing readable to keep: the raw rows are left for a rebuild");
4336
+ } finally {
4337
+ await rm(memoryDir, { recursive: true, force: true });
4338
+ }
4339
+ });
4340
+
4341
+ test("a failed rewrite removes its temp file (#3297)", async () => {
4342
+ const memoryDir = await mkMemoryDir();
4343
+ try {
4344
+ await mkdir(path.join(memoryDir, "state", "namespaces.jsonl"), { recursive: true });
4345
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
4346
+ // The rename onto a directory fails; the temp file written before it must not leak.
4347
+ await assert.rejects(catalog["rewriteUnchained"]([]));
4348
+ assert.deepEqual((await stateEntries(memoryDir)).filter((e) => e.endsWith(".tmp")), []);
4349
+ } finally {
4350
+ await rm(memoryDir, { recursive: true, force: true });
4351
+ }
4352
+ });
4353
+
4354
+ test("concurrent malformed reads schedule a single repair (#3298)", async () => {
4355
+ const memoryDir = await mkMemoryDir();
4356
+ try {
4357
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
4358
+ await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
4359
+ const catalogPath = path.join(memoryDir, "state", "namespaces.jsonl");
4360
+ await appendFile(catalogPath, "{this is not json\n", "utf8");
4361
+
4362
+ // Eight reads of the same malformed catalog. Without a single-flight guard each
4363
+ // schedules its own repair, queueing serial lock waits ahead of touch().
4364
+ await Promise.all(
4365
+ Array.from({ length: 8 }, () => catalog.listNamespaces(undefined, { onIncomplete: () => undefined })),
4366
+ );
4367
+ assert.equal(catalog.malformedRepairAttemptsForTest, 1, "one repair attempt, not eight");
4368
+ await settleRewrite(catalogPath, "this is not json");
4369
+ await settleStateDir(path.join(memoryDir, "state"));
4370
+ const after = await readFile(catalogPath, "utf8");
4371
+ assert.ok(!after.includes("this is not json"), "the malformed row is gone after the repair");
4372
+
4373
+ const sidecars = (await readdir(path.join(memoryDir, "state"))).filter((e) =>
4374
+ /^namespaces\.jsonl\.malformed-[0-9a-f]{16}$/.test(e),
4375
+ );
4376
+ assert.equal(sidecars.length, 1, "exactly one quarantine sidecar");
4377
+ const sidecar = await readFile(path.join(memoryDir, "state", sidecars[0]!), "utf8");
4378
+ assert.ok(sidecar.includes("this is not json"), "the sidecar holds the dropped row");
4379
+ } finally {
4380
+ await rm(memoryDir, { recursive: true, force: true });
4381
+ }
4382
+ });
4383
+
4384
+ test("malformed reads during a pending repair do not queue another attempt (#3298)", async () => {
4385
+ const memoryDir = await mkMemoryDir();
4386
+ try {
4387
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
4388
+ await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
4389
+ const catalogPath = path.join(memoryDir, "state", "namespaces.jsonl");
4390
+ await appendFile(catalogPath, "{this is not json\n", "utf8");
4391
+ const before = await readFile(catalogPath, "utf8");
4392
+
4393
+ await withHeldFileLock(path.join(memoryDir, "state", "namespaces.rebuild.lock"), { staleMs: 30_000 }, async () => {
4394
+ await catalog.listNamespaces(undefined, { onIncomplete: () => undefined });
4395
+ { const { promise, resolve } = Promise.withResolvers<void>(); setImmediate(resolve); await promise; }
4396
+ assert.equal(catalog.malformedRepairAttemptsForTest, 1, "the first read starts the in-flight repair");
4397
+ // That attempt was dropped (another process holds the lock), so the backoff
4398
+ // window is on: further malformed reads must not queue another attempt.
4399
+ await catalog.listNamespaces(undefined, { onIncomplete: () => undefined });
4400
+ await catalog.listNamespaces(undefined, { onIncomplete: () => undefined });
4401
+ { const { promise, resolve } = Promise.withResolvers<void>(); setImmediate(resolve); await promise; }
4402
+ assert.equal(catalog.malformedRepairAttemptsForTest, 1, "the in-flight repair is the reason no second attempt is queued");
4403
+ assert.equal(await readFile(catalogPath, "utf8"), before, "no rewrite ran under another process's lock");
4404
+ });
4405
+ // The attempt's bounded lock wait runs inside queueCritical, so the holder above
4406
+ // cannot release until the attempt has settled (dropped): by this point nothing is
4407
+ // in flight and the cleanup below is safe.
4408
+ assert.equal(await readFile(catalogPath, "utf8"), before, "the dropped attempt changed nothing");
4409
+ } finally {
4410
+ await rm(memoryDir, { recursive: true, force: true });
4411
+ }
4412
+ });
4413
+
4414
+ test("the repair quarantines every dropped row in the sidecar before rewriting (#3298)", async () => {
4415
+ const memoryDir = await mkMemoryDir();
4416
+ try {
4417
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
4418
+ await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
4419
+ const catalogPath = path.join(memoryDir, "state", "namespaces.jsonl");
4420
+ await appendFile(catalogPath, "{first bad row\nnot json either\n", "utf8");
4421
+
4422
+ await catalog.listNamespaces(undefined, { onIncomplete: () => undefined });
4423
+ await settleRewrite(catalogPath, "first bad row");
4424
+ await settleStateDir(path.join(memoryDir, "state"));
4425
+ const after = await readFile(catalogPath, "utf8");
4426
+ assert.ok(!after.includes("first bad row") && !after.includes("not json either"), "both dropped rows are gone");
4427
+
4428
+ const entries = await readdir(path.join(memoryDir, "state"));
4429
+ const sidecars = entries.filter((e) => /^namespaces\.jsonl\.malformed-[0-9a-f]{16}$/.test(e));
4430
+ assert.equal(sidecars.length, 1, "one sidecar for the one repair");
4431
+ const sidecar = await readFile(path.join(memoryDir, "state", sidecars[0]!), "utf8");
4432
+ assert.ok(sidecar.includes("first bad row") && sidecar.includes("not json either"), "both dropped rows are kept");
4433
+ assert.ok(!entries.some((e) => e.endsWith(".tmp")), "no temp file is left behind");
4434
+ } finally {
4435
+ await rm(memoryDir, { recursive: true, force: true });
4436
+ }
4437
+ });
4438
+
4439
+ test("a repair that fails after the sidecar write does not pile up sidecars (#3298)", async () => {
4440
+ const memoryDir = await mkMemoryDir();
4441
+ try {
4442
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
4443
+ await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
4444
+ const catalogPath = path.join(memoryDir, "state", "namespaces.jsonl");
4445
+ await appendFile(catalogPath, "{this is not json\n", "utf8");
4446
+
4447
+ // The sidecar write succeeds, the rewrite after it fails. A failed attempt
4448
+ // does not back off (it holds no lock budget), so a second read retries —
4449
+ // and the hash-named sidecar must be reused, not duplicated.
4450
+ const original = catalog["rewriteUnchained"].bind(catalog);
4451
+ let failures = 0;
4452
+ // Each injected call signals its own completion, so the test awaits the
4453
+ // attempt it scheduled instead of polling the file for a needle that never
4454
+ // appears. The resolver is registered BEFORE the read that schedules the
4455
+ // attempt, so the signal can never be missed.
4456
+ const pendingResolves: Array<() => void> = [];
4457
+ const settlesOnNextAttempt = (): Promise<void> => {
4458
+ const { promise, resolve } = Promise.withResolvers<void>();
4459
+ pendingResolves.push(resolve);
4460
+ return promise;
4461
+ };
4462
+ catalog["rewriteUnchained"] = async (records: unknown[]) => {
4463
+ failures += 1;
4464
+ const signal = pendingResolves.shift();
4465
+ try {
4466
+ if (failures <= 2) throw new Error("simulated rewrite failure after the sidecar");
4467
+ // SAFETY: rewriteUnchained's parameter is NamespaceRecord[]; the override's
4468
+ // loose unknown[] only ever forwards what the repair itself passed.
4469
+ return await original(records as never);
4470
+ } finally {
4471
+ signal?.();
4472
+ }
4473
+ };
4474
+ let settled = settlesOnNextAttempt();
4475
+ await catalog.listNamespaces(undefined, { onIncomplete: () => undefined });
4476
+ await settled; // the first failing attempt
4477
+ settled = settlesOnNextAttempt();
4478
+ await catalog.listNamespaces(undefined, { onIncomplete: () => undefined });
4479
+ await settled; // the second failing attempt
4480
+ await catalog.listNamespaces(undefined, { onIncomplete: () => undefined });
4481
+ await settleRewrite(catalogPath, "this is not json"); // third attempt repairs
4482
+ await settleStateDir(path.join(memoryDir, "state"));
4483
+ assert.equal(catalog.malformedRepairAttemptsForTest, 3, "a failed attempt retries on the next read");
4484
+ assert.ok(!(await readFile(catalogPath, "utf8")).includes("this is not json"), "the third attempt repaired the catalog");
4485
+
4486
+ const sidecars = (await readdir(path.join(memoryDir, "state"))).filter((e) =>
4487
+ /^namespaces\.jsonl\.malformed-[0-9a-f]{16}$/.test(e),
4488
+ );
4489
+ assert.equal(sidecars.length, 1, "the same dropped rows reuse one hash-named sidecar");
4490
+ } finally {
4491
+ await rm(memoryDir, { recursive: true, force: true });
4492
+ }
4493
+ });
4494
+
4495
+ test("a repair whose locked re-read fails leaves the catalog untouched (#3297)", async () => {
4496
+ const memoryDir = await mkMemoryDir();
4497
+ try {
4498
+ const writer = new NamespaceCatalog(makeConfig(memoryDir));
4499
+ await writer.markWrite("project-origin-abc123", { discoveredBy: "write" });
4500
+ const catalogPath = path.join(memoryDir, "state", "namespaces.jsonl");
4501
+ await appendFile(catalogPath, "{this is not json\n", "utf8");
4502
+ const before = await readFile(catalogPath, "utf8");
4503
+
4504
+ // Read #1 (the caller's) succeeds and reports the malformed row; read #2 (the repair's, under
4505
+ // the lock) throws. An empty fold from the failed read must not replace the catalog.
4506
+ const reader = new FailingReadCatalog(makeConfig(memoryDir));
4507
+ reader.failAfterFirstRead();
4508
+ const reasons: string[] = [];
4509
+ const records = await reader.listNamespaces(undefined, { onIncomplete: (r) => reasons.push(r) });
4510
+ assert.deepEqual(records.map((r) => r.namespace), ["project-origin-abc123"]);
4511
+ assert.equal(reasons.length, 1);
4512
+ await new Promise((resolve) => setTimeout(resolve, 150));
4513
+ assert.equal(await readFile(catalogPath, "utf8"), before, "the failed second read did not wipe the catalog");
4514
+ assert.deepEqual((await stateEntries(memoryDir)).filter((e) => e.endsWith(".tmp")), []);
4515
+
4516
+ // The failure is transient: the next incomplete read retries the repair.
4517
+ reader.stopFailing();
4518
+ const again: string[] = [];
4519
+ await reader.listNamespaces(undefined, { onIncomplete: (r) => again.push(r) });
4520
+ assert.equal(again.length, 1, "the next read still reports the malformed row");
4521
+ await settleRewrite(catalogPath, "{this is not json");
4522
+ assert.ok(!(await readFile(catalogPath, "utf8")).includes("{this is not json"), "the retried repair dropped the row");
4523
+ assert.match(await readFile(catalogPath, "utf8"), /project-origin-abc123/, "the readable row survived");
4524
+ } finally {
4525
+ await rm(memoryDir, { recursive: true, force: true });
4526
+ }
4527
+ });
4528
+
4529
+ test("a repair dropped under lock contention is retried by a warm-cache read (#3297)", { timeout: 30_000 }, async () => {
4530
+ const memoryDir = await mkMemoryDir();
4531
+ try {
4532
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
4533
+ await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
4534
+ const catalogPath = path.join(memoryDir, "state", "namespaces.jsonl");
4535
+ await appendFile(catalogPath, "{this is not json\n", "utf8");
4536
+ const reader = new NamespaceCatalog(makeConfig(memoryDir));
4537
+ const lockPath = path.join(memoryDir, "state", "namespaces.rebuild.lock");
4538
+
4539
+ // Another process holds the catalog lock longer than the repair is willing to wait (5 s), so
4540
+ // the first repair is dropped. The read itself cached the catalog together with its problem.
4541
+ let release!: () => void;
4542
+ const released = new Promise<void>((resolve) => {
4543
+ release = resolve;
4544
+ });
4545
+ const holder = withHeldFileLock(lockPath, { staleMs: 60_000 }, async () => released);
4546
+ await new Promise((resolve) => setTimeout(resolve, 100));
4547
+ const first: string[] = [];
4548
+ await reader.listNamespaces(undefined, { onIncomplete: (r) => first.push(r) });
4549
+ assert.equal(first.length, 1);
4550
+ await new Promise((resolve) => setTimeout(resolve, 5_600)); // past the lock wait: repair dropped
4551
+ assert.ok((await readFile(catalogPath, "utf8")).includes("{this is not json"), "the dropped repair changed nothing");
4552
+ release();
4553
+ await holder;
4554
+
4555
+ // The lock is free now. This read is served from the warm cache, which still carries the
4556
+ // malformed flag, so it schedules the repair again.
4557
+ const again: string[] = [];
4558
+ await reader.listNamespaces(undefined, { onIncomplete: (r) => again.push(r) });
4559
+ assert.equal(again.length, 1);
4560
+ await settleRewrite(catalogPath, "{this is not json");
4561
+ assert.ok(!(await readFile(catalogPath, "utf8")).includes("{this is not json"), "the retry repaired the catalog");
4079
4562
  } finally {
4080
4563
  await rm(memoryDir, { recursive: true, force: true });
4081
4564
  }