@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
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
readdir,
|
|
10
10
|
realpath,
|
|
11
11
|
rename,
|
|
12
|
+
rm,
|
|
12
13
|
stat,
|
|
13
14
|
writeFile,
|
|
14
15
|
} from "node:fs/promises";
|
|
@@ -23,6 +24,12 @@ import { namespaceIdentityFromToken, namespaceIdentityLegacyToken, namespaceIden
|
|
|
23
24
|
import { resolveDefaultNamespaceRoot, resolveNamespaceStorageRoot } from "./storage.js";
|
|
24
25
|
import { ALL_CATEGORY_DIRS } from "../utils/category-dir.js";
|
|
25
26
|
import { isNotFoundError } from "../utils/errno.js";
|
|
27
|
+
import {
|
|
28
|
+
dropDuplicateStorageRootAliases,
|
|
29
|
+
sanitizeRecordForRead,
|
|
30
|
+
type CatalogReadBoundaryPort,
|
|
31
|
+
} from "./catalog-read-boundary.js";
|
|
32
|
+
import { MalformedCatalogRepairGate, malformedSidecarPath } from "./catalog-repair.js";
|
|
26
33
|
|
|
27
34
|
/**
|
|
28
35
|
* Rebuildable namespace catalog (issue #1499).
|
|
@@ -90,6 +97,17 @@ export interface NamespaceCatalogFilter {
|
|
|
90
97
|
/** Out-parameter a catalog read fills in when it could not see the whole catalog. */
|
|
91
98
|
interface CatalogReadHealth {
|
|
92
99
|
incomplete?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Set ONLY when the file was read to the end and malformed rows were skipped. An unreadable
|
|
102
|
+
* catalog also sets `incomplete` but never `malformed`: its empty result is not the catalog's
|
|
103
|
+
* content, so nothing may be rewritten from it.
|
|
104
|
+
*/
|
|
105
|
+
malformed?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* The raw malformed lines the read skipped, in file order. Only filled when `malformed` is
|
|
108
|
+
* set; the repair quarantines exactly these into its sidecar before rewriting.
|
|
109
|
+
*/
|
|
110
|
+
malformedLines?: string[];
|
|
93
111
|
}
|
|
94
112
|
|
|
95
113
|
export interface NamespaceCatalogReadOptions {
|
|
@@ -346,7 +364,7 @@ function laterIso(a: string | undefined, b: string | undefined): string | undefi
|
|
|
346
364
|
* rebuilt record and left untouched — we only recover touch recency that a
|
|
347
365
|
* concurrent (possibly cross-process) writer recorded after our initial load.
|
|
348
366
|
*/
|
|
349
|
-
function mergeNewerTouchFields(base: NamespaceRecord, fresh: NamespaceRecord): NamespaceRecord {
|
|
367
|
+
export function mergeNewerTouchFields(base: NamespaceRecord, fresh: NamespaceRecord): NamespaceRecord {
|
|
350
368
|
const merged: NamespaceRecord = { ...base };
|
|
351
369
|
const lr = laterIso(base.lastReadAt, fresh.lastReadAt);
|
|
352
370
|
if (lr) merged.lastReadAt = lr;
|
|
@@ -496,6 +514,14 @@ export class NamespaceCatalog {
|
|
|
496
514
|
private readonly compactBytesLimit: number;
|
|
497
515
|
private readonly readCoalesceMs: number;
|
|
498
516
|
private readonly writeCoalesceMs: number;
|
|
517
|
+
// #3298 review: single-flight + backoff gate for the malformed-catalog repair.
|
|
518
|
+
private readonly malformedRepairGate = new MalformedCatalogRepairGate();
|
|
519
|
+
|
|
520
|
+
/** Test-only observability: how many repair attempts actually entered the gate. */
|
|
521
|
+
get malformedRepairAttemptsForTest(): number {
|
|
522
|
+
return this.malformedRepairGate.attemptCount;
|
|
523
|
+
}
|
|
524
|
+
|
|
499
525
|
// Size after the most recent auto-compaction (0 = none yet). Hysteresis for
|
|
500
526
|
// maybeAutoCompact: a folded catalog that is itself above the limit (many
|
|
501
527
|
// distinct namespaces) would otherwise be re-folded + re-rewritten on every
|
|
@@ -538,122 +564,31 @@ export class NamespaceCatalog {
|
|
|
538
564
|
|
|
539
565
|
// ── Public enumeration API ──────────────────────────────────────────────
|
|
540
566
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
* — return `null` so the caller drops it. The disk SCAN and the hot touch
|
|
550
|
-
* path both reject such names with the SAME default-exempt `isSafeRouteNamespace`
|
|
551
|
-
* gate, so the read boundary MUST agree, or `listNamespaces()`/`getNamespaceRecord()`
|
|
552
|
-
* would expose a namespace those paths reject (note `isStorageDirForNamespace`
|
|
553
|
-
* can still build a tokenized root even for `../evil`, so storageDir sanitation
|
|
554
|
-
* alone does not catch it). The default namespace is exempt (it may be a
|
|
555
|
-
* non-route literal), matching every other validation site.
|
|
556
|
-
*
|
|
557
|
-
* 2. UNSAFE storageDir: for an otherwise-valid namespace, apply the SAME contract
|
|
558
|
-
* as the write path — full containment (`isContainedStorageDir`: lexical +
|
|
559
|
-
* symlink/realpath) AND namespace ownership (`isStorageDirForNamespace`). When
|
|
560
|
-
* a record fails EITHER check we substitute the trusted resolved-and-safe root
|
|
561
|
-
* for that namespace (rule 42: read and write stay symmetric).
|
|
562
|
-
*/
|
|
563
|
-
private async sanitizeRecordForRead(record: NamespaceRecord): Promise<NamespaceRecord | null> {
|
|
564
|
-
// Defense 1: drop an unsafe non-default namespace name entirely. Compare
|
|
565
|
-
// against the NORMALIZED default identity — record keys are trimmed, so a raw
|
|
566
|
-
// whitespace-padded config default would never match the default row (NH-FH).
|
|
567
|
-
if (record.namespace !== this.defaultNamespaceIdentity && !isSafeRouteNamespace(record.namespace)) {
|
|
568
|
-
return null;
|
|
569
|
-
}
|
|
570
|
-
// Defense 2: keep the record but substitute a safe storageDir when needed.
|
|
571
|
-
if (
|
|
572
|
-
(await this.isContainedStorageDir(record.storageDir)) &&
|
|
573
|
-
(await this.isStorageDirForNamespace(record.namespace, record.storageDir))
|
|
574
|
-
) {
|
|
575
|
-
return record;
|
|
576
|
-
}
|
|
577
|
-
const safe = await this.resolveSafeStorageDir(record.namespace);
|
|
578
|
-
return { ...record, storageDir: safe };
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
private storageRootOwnershipRank(
|
|
582
|
-
record: NamespaceRecord,
|
|
583
|
-
resolvedStorageDir: string,
|
|
584
|
-
configured: Set<string>,
|
|
585
|
-
): number {
|
|
586
|
-
if (resolvedStorageDir === path.resolve(this.memoryDir)) {
|
|
587
|
-
return record.namespace === this.defaultNamespaceIdentity ? 0 : 3;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
const leaf = path.basename(resolvedStorageDir);
|
|
591
|
-
const tokenOwnsRoot = namespaceIdentityToken(record.namespace) === leaf;
|
|
592
|
-
if (tokenOwnsRoot && configured.has(record.namespace)) {
|
|
593
|
-
return 0;
|
|
594
|
-
}
|
|
595
|
-
if (record.namespace === leaf) return 1;
|
|
596
|
-
if (tokenOwnsRoot) return 2;
|
|
597
|
-
return 3;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
private configuredNamespaceIdentities(): Set<string> {
|
|
601
|
-
return new Set(
|
|
602
|
-
[
|
|
603
|
-
this.config.defaultNamespace,
|
|
604
|
-
this.config.sharedNamespace,
|
|
605
|
-
...this.config.namespacePolicies.map((p) => p.name),
|
|
606
|
-
]
|
|
607
|
-
.map((n) => normalizeNamespaceIdentity(n))
|
|
608
|
-
.filter((n) => n.length > 0),
|
|
609
|
-
);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
private preferStorageRootOwner(
|
|
613
|
-
current: NamespaceRecord,
|
|
614
|
-
candidate: NamespaceRecord,
|
|
615
|
-
resolvedStorageDir: string,
|
|
616
|
-
configured: Set<string>,
|
|
617
|
-
): NamespaceRecord {
|
|
618
|
-
const currentRank = this.storageRootOwnershipRank(current, resolvedStorageDir, configured);
|
|
619
|
-
const candidateRank = this.storageRootOwnershipRank(candidate, resolvedStorageDir, configured);
|
|
620
|
-
if (candidateRank < currentRank) return candidate;
|
|
621
|
-
if (candidateRank > currentRank) return current;
|
|
622
|
-
|
|
623
|
-
const byName = candidate.namespace.localeCompare(current.namespace);
|
|
624
|
-
if (byName < 0) return candidate;
|
|
625
|
-
if (byName > 0) return current;
|
|
626
|
-
return candidate.identityToken.localeCompare(current.identityToken) < 0 ? candidate : current;
|
|
567
|
+
private catalogReadBoundaryPort(): CatalogReadBoundaryPort {
|
|
568
|
+
return {
|
|
569
|
+
defaultNamespaceIdentity: this.defaultNamespaceIdentity,
|
|
570
|
+
memoryDir: this.memoryDir,
|
|
571
|
+
isContainedStorageDir: (candidate) => this.isContainedStorageDir(candidate),
|
|
572
|
+
isStorageDirForNamespace: (namespace, candidate) => this.isStorageDirForNamespace(namespace, candidate),
|
|
573
|
+
resolveSafeStorageDir: (namespace) => this.resolveSafeStorageDir(namespace),
|
|
574
|
+
};
|
|
627
575
|
}
|
|
628
576
|
|
|
629
|
-
private dropDuplicateStorageRootAliases(records: NamespaceRecord[]): NamespaceRecord[] {
|
|
630
|
-
const byStorageDir = new Map<string, NamespaceRecord>();
|
|
631
|
-
const configured = this.configuredNamespaceIdentities();
|
|
632
|
-
for (const record of records) {
|
|
633
|
-
const resolvedStorageDir = path.resolve(record.storageDir);
|
|
634
|
-
const current = byStorageDir.get(resolvedStorageDir);
|
|
635
|
-
if (!current) {
|
|
636
|
-
byStorageDir.set(resolvedStorageDir, record);
|
|
637
|
-
continue;
|
|
638
|
-
}
|
|
639
|
-
const owner = this.preferStorageRootOwner(current, record, resolvedStorageDir, configured);
|
|
640
|
-
const alias = owner === current ? record : current;
|
|
641
|
-
byStorageDir.set(resolvedStorageDir, mergeNewerTouchFields(owner, alias));
|
|
642
|
-
}
|
|
643
|
-
return [...byStorageDir.values()];
|
|
644
|
-
}
|
|
645
577
|
private loadSanitizedRecords(health?: CatalogReadHealth): Promise<NamespaceRecord[]> {
|
|
646
578
|
return this.queueCritical(async () => {
|
|
647
579
|
const records = await this.loadCompacted(false, health);
|
|
580
|
+
const port = this.catalogReadBoundaryPort();
|
|
648
581
|
const sanitized = await Promise.all(
|
|
649
|
-
[...records.values()].map((r) =>
|
|
582
|
+
[...records.values()].map((r) => sanitizeRecordForRead(port, r)),
|
|
650
583
|
);
|
|
651
584
|
// Drop unsafe-namespace rows (sanitizer returned null) at the read boundary.
|
|
652
585
|
// Then collapse duplicate root aliases so maintenance/QMD see exactly one
|
|
653
586
|
// namespace owner for a physical storage root, matching rebuild ownership,
|
|
654
587
|
// while preserving touch recency from every alias row.
|
|
655
|
-
return
|
|
588
|
+
return dropDuplicateStorageRootAliases(
|
|
589
|
+
port,
|
|
656
590
|
sanitized.filter((r): r is NamespaceRecord => r !== null),
|
|
591
|
+
this.config,
|
|
657
592
|
);
|
|
658
593
|
});
|
|
659
594
|
}
|
|
@@ -665,7 +600,16 @@ export class NamespaceCatalog {
|
|
|
665
600
|
if (!this.enabled) return [];
|
|
666
601
|
const health: CatalogReadHealth = {};
|
|
667
602
|
let out = await this.loadSanitizedRecords(health);
|
|
668
|
-
if (health.incomplete !== undefined)
|
|
603
|
+
if (health.incomplete !== undefined) {
|
|
604
|
+
options?.onIncomplete?.(health.incomplete);
|
|
605
|
+
}
|
|
606
|
+
if (health.malformed === true) {
|
|
607
|
+
// A permanently malformed row keeps every completion request owed until the catalog is
|
|
608
|
+
// rewritten, so repair it now: fold the readable rows and rewrite the log without the bad
|
|
609
|
+
// ones. Best-effort and fail-open like the size-triggered compaction. Only a read that
|
|
610
|
+
// reached the end of the file may trigger it (see CatalogReadHealth.malformed).
|
|
611
|
+
void this.malformedRepairGate.run(() => this.rewriteAfterIncompleteRead()).catch(() => undefined);
|
|
612
|
+
}
|
|
669
613
|
if (filter?.kind) out = out.filter((r) => r.kind === filter.kind);
|
|
670
614
|
if (filter?.discoveredBy) out = out.filter((r) => r.discoveredBy === filter.discoveredBy);
|
|
671
615
|
if (filter?.writtenSince) {
|
|
@@ -1424,6 +1368,47 @@ export class NamespaceCatalog {
|
|
|
1424
1368
|
);
|
|
1425
1369
|
}
|
|
1426
1370
|
|
|
1371
|
+
/**
|
|
1372
|
+
* Rewrite the catalog after a read skipped malformed rows (#3297). Same fold-and-rewrite as the
|
|
1373
|
+
* size-triggered compaction — the readable rows survive, the malformed ones do not — so a stuck
|
|
1374
|
+
* bad row stops keeping every completion request owed. Best-effort and fail-open (rule #40): a
|
|
1375
|
+
* failed rewrite leaves the catalog unchanged and the next read reports and retries.
|
|
1376
|
+
*
|
|
1377
|
+
* Locking: the fold runs inside `queueCritical` (in-process serialization, like every other
|
|
1378
|
+
* read), and the rewrite's load→rename window runs under the held catalog file lock, so it can
|
|
1379
|
+
* never interleave with another process's touch append or rebuild. If another process already
|
|
1380
|
+
* holds the lock, the rewrite is dropped (not queued): that process's own rewrite or rebuild is
|
|
1381
|
+
* the likeliest repair, and the next incomplete read retries anyway.
|
|
1382
|
+
*
|
|
1383
|
+
* Data safety: the locked re-read must itself reach the end of the file and still find
|
|
1384
|
+
* malformed rows, and must leave at least one readable row. A failed re-read folds to an empty
|
|
1385
|
+
* map, and a catalog with nothing readable is left for `rebuildFromDisk`: rewriting either
|
|
1386
|
+
* would replace a healthy or recoverable catalog with an empty file.
|
|
1387
|
+
*/
|
|
1388
|
+
private async rewriteAfterIncompleteRead(): Promise<"repaired" | "dropped" | "failed"> {
|
|
1389
|
+
return this.queueCritical(async () =>
|
|
1390
|
+
this.withHeldCatalogLock(async (acquired) => {
|
|
1391
|
+
if (!acquired) return "dropped";
|
|
1392
|
+
const health: CatalogReadHealth = { malformedLines: [] };
|
|
1393
|
+
const folded = await this.loadCompacted(true, health);
|
|
1394
|
+
if (health.malformed !== true || folded.size === 0) return "failed";
|
|
1395
|
+
// Quarantine the rows the rewrite drops before the rename, so nothing is lost
|
|
1396
|
+
// silently. Best-effort like the repair itself: a failed sidecar write must not
|
|
1397
|
+
// leave a stuck malformed catalog unrepaired.
|
|
1398
|
+
const dropped = health.malformedLines ?? [];
|
|
1399
|
+
if (dropped.length > 0) {
|
|
1400
|
+
// Hash-named (#3298 review): repeated attempts over the same bad rows
|
|
1401
|
+
// reuse one sidecar instead of piling one per failed attempt.
|
|
1402
|
+
const sidecar = malformedSidecarPath(this.catalogPath, dropped);
|
|
1403
|
+
await writeFile(sidecar, dropped.join("\n") + "\n", "utf8").catch(() => undefined);
|
|
1404
|
+
}
|
|
1405
|
+
await this.rewriteUnchained([...folded.values()]);
|
|
1406
|
+
return "repaired";
|
|
1407
|
+
}),
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
|
|
1427
1412
|
/**
|
|
1428
1413
|
* Fold-and-rewrite the JSONL log when it exceeds `compactBytesLimit`
|
|
1429
1414
|
* (issue #1903). MUST be called only from inside `touch`'s held-lock +
|
|
@@ -2132,7 +2117,12 @@ export class NamespaceCatalog {
|
|
|
2132
2117
|
this.compactedCache.identity.ino === identity.ino
|
|
2133
2118
|
) {
|
|
2134
2119
|
await handle.close().catch(() => undefined);
|
|
2135
|
-
if (health && this.compactedCache.incomplete)
|
|
2120
|
+
if (health && this.compactedCache.incomplete) {
|
|
2121
|
+
health.incomplete = this.compactedCache.incomplete;
|
|
2122
|
+
// Invariant: cache.incomplete is set ONLY by the malformed branch below, so
|
|
2123
|
+
// deriving `malformed` here cannot misfire for an unreadable catalog.
|
|
2124
|
+
health.malformed = true;
|
|
2125
|
+
}
|
|
2136
2126
|
return this.cloneCompactedRecords(this.compactedCache.records);
|
|
2137
2127
|
}
|
|
2138
2128
|
} catch {
|
|
@@ -2167,11 +2157,13 @@ export class NamespaceCatalog {
|
|
|
2167
2157
|
// Skip corrupt lines (CLAUDE.md rule #18 robustness), but count them so a caller that
|
|
2168
2158
|
// needs the whole catalog can tell the read was incomplete (issue #3293).
|
|
2169
2159
|
malformed += 1;
|
|
2160
|
+
health?.malformedLines?.push(trimmed);
|
|
2170
2161
|
return;
|
|
2171
2162
|
}
|
|
2172
2163
|
const record = coerceRecord(parsed);
|
|
2173
2164
|
if (!record) {
|
|
2174
2165
|
malformed += 1;
|
|
2166
|
+
health?.malformedLines?.push(trimmed);
|
|
2175
2167
|
return;
|
|
2176
2168
|
}
|
|
2177
2169
|
const prior = records.get(record.namespace);
|
|
@@ -2203,7 +2195,10 @@ export class NamespaceCatalog {
|
|
|
2203
2195
|
}
|
|
2204
2196
|
|
|
2205
2197
|
const incomplete = malformed > 0 ? `${malformed} malformed catalog line(s) skipped` : undefined;
|
|
2206
|
-
if (health && incomplete)
|
|
2198
|
+
if (health && incomplete) {
|
|
2199
|
+
health.incomplete = incomplete;
|
|
2200
|
+
health.malformed = true;
|
|
2201
|
+
}
|
|
2207
2202
|
this.compactedCache = identity ? { identity, records, incomplete } : undefined;
|
|
2208
2203
|
return this.cloneCompactedRecords(records);
|
|
2209
2204
|
}
|
|
@@ -2269,8 +2264,15 @@ export class NamespaceCatalog {
|
|
|
2269
2264
|
const body = records.map((r) => serializeRecord(r)).join("\n") + (records.length > 0 ? "\n" : "");
|
|
2270
2265
|
await mkdir(this.stateDir, { recursive: true });
|
|
2271
2266
|
const tmp = `${this.catalogPath}.${process.pid}.${Date.now()}.tmp`;
|
|
2272
|
-
|
|
2273
|
-
|
|
2267
|
+
try {
|
|
2268
|
+
await writeFile(tmp, body, "utf8");
|
|
2269
|
+
await rename(tmp, this.catalogPath);
|
|
2270
|
+
} catch (error) {
|
|
2271
|
+
// A failed write or rename (for example the catalog path is a directory) must not leave
|
|
2272
|
+
// the temp file behind; the original error still surfaces to the caller.
|
|
2273
|
+
await rm(tmp, { force: true }).catch(() => undefined);
|
|
2274
|
+
throw error;
|
|
2275
|
+
}
|
|
2274
2276
|
await this.refreshCacheIdentity(new Map(records.map((record) => [record.namespace, record])));
|
|
2275
2277
|
}
|
|
2276
2278
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle matrix subject for the namespace catalog's repair after an incomplete read (#3297).
|
|
3
|
+
* A catalog with a malformed row is read through the public `listNamespaces` API: the read
|
|
4
|
+
* reports the incomplete catalog and schedules a rewrite that drops the bad row, so a fresh
|
|
5
|
+
* instance over the persisted file and a repeated read both see a whole catalog.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { appendFile, readFile, rm } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import { NamespaceCatalog } from "../../namespaces/catalog.js";
|
|
12
|
+
import type { PluginConfig } from "../../types.js";
|
|
13
|
+
import { type LifecycleSubject, type MatrixRow, runLifecycleMatrix } from "../lifecycle-matrix.js";
|
|
14
|
+
import { mkTempMemoryDir } from "../orchestrator-lite.js";
|
|
15
|
+
|
|
16
|
+
const BAD_ROW = "{this is not json";
|
|
17
|
+
|
|
18
|
+
interface CatalogReadRepairState {
|
|
19
|
+
dir: string;
|
|
20
|
+
catalogPath: string;
|
|
21
|
+
catalog: NamespaceCatalog;
|
|
22
|
+
firstReasons: string[];
|
|
23
|
+
firstNamespaces: string[];
|
|
24
|
+
secondReasons?: string[];
|
|
25
|
+
afterRepair?: string;
|
|
26
|
+
afterSecondRead?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Fixture config: the catalog reads only memoryDir, the namespace names and the enable flags. */
|
|
30
|
+
function catalogConfig(memoryDir: string): PluginConfig {
|
|
31
|
+
return Object.assign(Object.create(null), {
|
|
32
|
+
memoryDir,
|
|
33
|
+
namespacesEnabled: true,
|
|
34
|
+
namespaceCatalogEnabled: true,
|
|
35
|
+
defaultNamespace: "default",
|
|
36
|
+
sharedNamespace: "shared",
|
|
37
|
+
namespacePolicies: [],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Wait until the scheduled rewrite has removed the bad row (the read does not await it). */
|
|
42
|
+
async function waitForRepair(catalogPath: string): Promise<string> {
|
|
43
|
+
const deadline = Date.now() + 2_000;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const text = await readFile(catalogPath, "utf8");
|
|
46
|
+
if (!text.includes(BAD_ROW) || Date.now() >= deadline) {
|
|
47
|
+
// Let the rewrite finish its rename and release the catalog lock before anyone reads again.
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
49
|
+
return readFile(catalogPath, "utf8");
|
|
50
|
+
}
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const subject: LifecycleSubject<CatalogReadRepairState> = {
|
|
56
|
+
appliesTo(row: MatrixRow): boolean | string {
|
|
57
|
+
if (row.id === "restart-reload-recovery") return true;
|
|
58
|
+
if (row.id === "dedupe-replay") return true;
|
|
59
|
+
return "catalog repair applies to persisted-state reload and repeated reads only";
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async setup(row: MatrixRow): Promise<CatalogReadRepairState> {
|
|
63
|
+
const dir = await mkTempMemoryDir(`catalog-read-repair-${row.id}`);
|
|
64
|
+
try {
|
|
65
|
+
const catalog = new NamespaceCatalog(catalogConfig(dir));
|
|
66
|
+
await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
|
|
67
|
+
const catalogPath = path.join(dir, "state", "namespaces.jsonl");
|
|
68
|
+
await appendFile(catalogPath, `${BAD_ROW}\n`, "utf8");
|
|
69
|
+
return { dir, catalogPath, catalog, firstReasons: [], firstNamespaces: [] };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
await rm(dir, { recursive: true, force: true });
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async exercise(state: CatalogReadRepairState, row: MatrixRow): Promise<void> {
|
|
77
|
+
const first = await state.catalog.listNamespaces(undefined, {
|
|
78
|
+
onIncomplete: (reason) => state.firstReasons.push(reason),
|
|
79
|
+
});
|
|
80
|
+
state.firstNamespaces = first.map((record) => record.namespace);
|
|
81
|
+
state.afterRepair = await waitForRepair(state.catalogPath);
|
|
82
|
+
|
|
83
|
+
if (row.dimensions.restart) {
|
|
84
|
+
// A fresh instance over the persisted (repaired) file.
|
|
85
|
+
const restarted = new NamespaceCatalog(catalogConfig(state.dir));
|
|
86
|
+
state.secondReasons = [];
|
|
87
|
+
const reasons = state.secondReasons;
|
|
88
|
+
await restarted.listNamespaces(undefined, { onIncomplete: (reason) => reasons.push(reason) });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (row.dimensions.dedupeOrReplay) {
|
|
92
|
+
// The same instance reads again: nothing to report and no second rewrite.
|
|
93
|
+
state.secondReasons = [];
|
|
94
|
+
const reasons = state.secondReasons;
|
|
95
|
+
await state.catalog.listNamespaces(undefined, { onIncomplete: (reason) => reasons.push(reason) });
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
97
|
+
state.afterSecondRead = await readFile(state.catalogPath, "utf8");
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async invariants(state: CatalogReadRepairState, row: MatrixRow): Promise<void> {
|
|
102
|
+
assert.equal(state.firstReasons.length, 1, `${row.id}: the first read reports the malformed row`);
|
|
103
|
+
assert.deepEqual(
|
|
104
|
+
state.firstNamespaces,
|
|
105
|
+
["project-origin-abc123"],
|
|
106
|
+
`${row.id}: the readable record is still returned`,
|
|
107
|
+
);
|
|
108
|
+
assert.ok(
|
|
109
|
+
!(state.afterRepair ?? "").includes(BAD_ROW),
|
|
110
|
+
`${row.id}: the scheduled rewrite dropped the malformed row`,
|
|
111
|
+
);
|
|
112
|
+
assert.match(state.afterRepair ?? "", /project-origin-abc123/, `${row.id}: the readable row survived the rewrite`);
|
|
113
|
+
assert.deepEqual(state.secondReasons, [], `${row.id}: after the repair the catalog reads whole`);
|
|
114
|
+
if (row.dimensions.dedupeOrReplay) {
|
|
115
|
+
assert.equal(state.afterSecondRead, state.afterRepair, `${row.id}: a clean read does not rewrite again`);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async teardown(state: CatalogReadRepairState): Promise<void> {
|
|
120
|
+
await rm(state.dir, { recursive: true, force: true });
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
runLifecycleMatrix("catalog-read-repair", subject);
|