@remnic/core 10.0.56 → 10.0.58
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.
- package/dist/access-cli.js +8 -8
- package/dist/access-http-query.js +2 -2
- package/dist/access-http.js +3 -3
- package/dist/{chunk-FCAXGYDT.js → chunk-CTNDFKEX.js} +2 -2
- package/dist/{chunk-CC2ESKHP.js → chunk-E2YAGEFR.js} +7 -7
- package/dist/{chunk-CZS7UTXL.js → chunk-KCTL5EY7.js} +2 -2
- package/dist/{chunk-5C44AMRV.js → chunk-L5UB5BIY.js} +2 -2
- package/dist/{chunk-S3OI5DP5.js → chunk-P5FYZH5G.js} +235 -124
- package/dist/chunk-P5FYZH5G.js.map +1 -0
- package/dist/{chunk-OGR52N7A.js → chunk-W2KH27MQ.js} +3 -3
- package/dist/{chunk-GMRP3HN3.js → chunk-W3R77WHT.js} +2 -2
- package/dist/{chunk-VZ7VJGPL.js → chunk-W4QTA2H5.js} +2 -2
- package/dist/cli.js +6 -6
- package/dist/index.js +8 -8
- package/dist/namespaces/catalog-read-boundary.d.ts +42 -0
- package/dist/namespaces/catalog-read-boundary.d.ts.map +1 -0
- package/dist/namespaces/catalog-repair.d.ts +35 -0
- package/dist/namespaces/catalog-repair.d.ts.map +1 -0
- package/dist/namespaces/catalog.d.ts +31 -27
- package/dist/namespaces/catalog.d.ts.map +1 -1
- package/dist/operator-toolkit.js +3 -3
- package/dist/orchestration/maintenance.js +3 -3
- package/dist/orchestrator.js +8 -8
- package/package.json +2 -2
- package/src/namespaces/catalog-read-boundary.test.ts +121 -0
- package/src/namespaces/catalog-read-boundary.ts +159 -0
- package/src/namespaces/catalog-repair.ts +69 -0
- package/src/namespaces/catalog.test.ts +493 -10
- package/src/namespaces/catalog.ts +112 -110
- package/src/testing/subjects/catalog-read-repair.test.ts +124 -0
- package/dist/chunk-S3OI5DP5.js.map +0 -1
- /package/dist/{chunk-FCAXGYDT.js.map → chunk-CTNDFKEX.js.map} +0 -0
- /package/dist/{chunk-CC2ESKHP.js.map → chunk-E2YAGEFR.js.map} +0 -0
- /package/dist/{chunk-CZS7UTXL.js.map → chunk-KCTL5EY7.js.map} +0 -0
- /package/dist/{chunk-5C44AMRV.js.map → chunk-L5UB5BIY.js.map} +0 -0
- /package/dist/{chunk-OGR52N7A.js.map → chunk-W2KH27MQ.js.map} +0 -0
- /package/dist/{chunk-GMRP3HN3.js.map → chunk-W3R77WHT.js.map} +0 -0
- /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
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
}
|