@remnic/core 10.0.55 → 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.
- 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-4BUJICMI.js → chunk-CTNDFKEX.js} +2 -2
- package/dist/{chunk-2GU6XS4L.js → chunk-E2YAGEFR.js} +7 -7
- package/dist/{chunk-R3WZ74B2.js → chunk-KCTL5EY7.js} +5 -3
- package/dist/chunk-KCTL5EY7.js.map +1 -0
- package/dist/{chunk-PVG7TCUG.js → chunk-L5UB5BIY.js} +2 -2
- package/dist/{chunk-XVTB6BZS.js → chunk-P5FYZH5G.js} +253 -130
- package/dist/chunk-P5FYZH5G.js.map +1 -0
- package/dist/{chunk-Y56D5FSD.js → chunk-W2KH27MQ.js} +3 -3
- package/dist/{chunk-GGFQAGOT.js → chunk-W3R77WHT.js} +2 -2
- package/dist/{chunk-WGFEGBFB.js → chunk-W4QTA2H5.js} +2 -2
- package/dist/cli.js +6 -6
- package/dist/index.js +8 -8
- package/dist/maintenance/namespace-planner.d.ts.map +1 -1
- 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 +40 -28
- 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/maintenance/namespace-planner.test.ts +41 -1
- package/src/maintenance/namespace-planner.ts +8 -1
- 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 +566 -0
- package/src/namespaces/catalog.ts +155 -117
- package/src/orchestration/maintenance.test.ts +33 -1
- package/src/testing/subjects/catalog-read-repair.test.ts +124 -0
- package/dist/chunk-R3WZ74B2.js.map +0 -1
- package/dist/chunk-XVTB6BZS.js.map +0 -1
- /package/dist/{chunk-4BUJICMI.js.map → chunk-CTNDFKEX.js.map} +0 -0
- /package/dist/{chunk-2GU6XS4L.js.map → chunk-E2YAGEFR.js.map} +0 -0
- /package/dist/{chunk-PVG7TCUG.js.map → chunk-L5UB5BIY.js.map} +0 -0
- /package/dist/{chunk-Y56D5FSD.js.map → chunk-W2KH27MQ.js.map} +0 -0
- /package/dist/{chunk-GGFQAGOT.js.map → chunk-W3R77WHT.js.map} +0 -0
- /package/dist/{chunk-WGFEGBFB.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).
|
|
@@ -87,6 +94,31 @@ export interface NamespaceCatalogFilter {
|
|
|
87
94
|
writtenSince?: Date;
|
|
88
95
|
}
|
|
89
96
|
|
|
97
|
+
/** Out-parameter a catalog read fills in when it could not see the whole catalog. */
|
|
98
|
+
interface CatalogReadHealth {
|
|
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[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface NamespaceCatalogReadOptions {
|
|
114
|
+
/**
|
|
115
|
+
* Called when the read could not see the whole catalog (an unreadable file, or malformed rows
|
|
116
|
+
* that were skipped). The records that were readable are still returned. A catalog that was
|
|
117
|
+
* never written is normal and does not call this.
|
|
118
|
+
*/
|
|
119
|
+
onIncomplete?: (reason: string) => void;
|
|
120
|
+
}
|
|
121
|
+
|
|
90
122
|
export interface NamespaceTouchMetadata {
|
|
91
123
|
discoveredBy?: NamespaceDiscoverySource;
|
|
92
124
|
kind?: NamespaceKind;
|
|
@@ -332,7 +364,7 @@ function laterIso(a: string | undefined, b: string | undefined): string | undefi
|
|
|
332
364
|
* rebuilt record and left untouched — we only recover touch recency that a
|
|
333
365
|
* concurrent (possibly cross-process) writer recorded after our initial load.
|
|
334
366
|
*/
|
|
335
|
-
function mergeNewerTouchFields(base: NamespaceRecord, fresh: NamespaceRecord): NamespaceRecord {
|
|
367
|
+
export function mergeNewerTouchFields(base: NamespaceRecord, fresh: NamespaceRecord): NamespaceRecord {
|
|
336
368
|
const merged: NamespaceRecord = { ...base };
|
|
337
369
|
const lr = laterIso(base.lastReadAt, fresh.lastReadAt);
|
|
338
370
|
if (lr) merged.lastReadAt = lr;
|
|
@@ -412,6 +444,8 @@ interface CatalogFileIdentity {
|
|
|
412
444
|
interface CompactedCatalogCache {
|
|
413
445
|
identity: CatalogFileIdentity;
|
|
414
446
|
records: Map<string, NamespaceRecord>;
|
|
447
|
+
/** Why the read was incomplete (malformed rows), kept so a cache hit reports it too. */
|
|
448
|
+
incomplete?: string;
|
|
415
449
|
}
|
|
416
450
|
|
|
417
451
|
export class NamespaceCatalog {
|
|
@@ -480,6 +514,14 @@ export class NamespaceCatalog {
|
|
|
480
514
|
private readonly compactBytesLimit: number;
|
|
481
515
|
private readonly readCoalesceMs: number;
|
|
482
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
|
+
|
|
483
525
|
// Size after the most recent auto-compaction (0 = none yet). Hysteresis for
|
|
484
526
|
// maybeAutoCompact: a folded catalog that is itself above the limit (many
|
|
485
527
|
// distinct namespaces) would otherwise be re-folded + re-rewritten on every
|
|
@@ -522,129 +564,52 @@ export class NamespaceCatalog {
|
|
|
522
564
|
|
|
523
565
|
// ── Public enumeration API ──────────────────────────────────────────────
|
|
524
566
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
* — return `null` so the caller drops it. The disk SCAN and the hot touch
|
|
534
|
-
* path both reject such names with the SAME default-exempt `isSafeRouteNamespace`
|
|
535
|
-
* gate, so the read boundary MUST agree, or `listNamespaces()`/`getNamespaceRecord()`
|
|
536
|
-
* would expose a namespace those paths reject (note `isStorageDirForNamespace`
|
|
537
|
-
* can still build a tokenized root even for `../evil`, so storageDir sanitation
|
|
538
|
-
* alone does not catch it). The default namespace is exempt (it may be a
|
|
539
|
-
* non-route literal), matching every other validation site.
|
|
540
|
-
*
|
|
541
|
-
* 2. UNSAFE storageDir: for an otherwise-valid namespace, apply the SAME contract
|
|
542
|
-
* as the write path — full containment (`isContainedStorageDir`: lexical +
|
|
543
|
-
* symlink/realpath) AND namespace ownership (`isStorageDirForNamespace`). When
|
|
544
|
-
* a record fails EITHER check we substitute the trusted resolved-and-safe root
|
|
545
|
-
* for that namespace (rule 42: read and write stay symmetric).
|
|
546
|
-
*/
|
|
547
|
-
private async sanitizeRecordForRead(record: NamespaceRecord): Promise<NamespaceRecord | null> {
|
|
548
|
-
// Defense 1: drop an unsafe non-default namespace name entirely. Compare
|
|
549
|
-
// against the NORMALIZED default identity — record keys are trimmed, so a raw
|
|
550
|
-
// whitespace-padded config default would never match the default row (NH-FH).
|
|
551
|
-
if (record.namespace !== this.defaultNamespaceIdentity && !isSafeRouteNamespace(record.namespace)) {
|
|
552
|
-
return null;
|
|
553
|
-
}
|
|
554
|
-
// Defense 2: keep the record but substitute a safe storageDir when needed.
|
|
555
|
-
if (
|
|
556
|
-
(await this.isContainedStorageDir(record.storageDir)) &&
|
|
557
|
-
(await this.isStorageDirForNamespace(record.namespace, record.storageDir))
|
|
558
|
-
) {
|
|
559
|
-
return record;
|
|
560
|
-
}
|
|
561
|
-
const safe = await this.resolveSafeStorageDir(record.namespace);
|
|
562
|
-
return { ...record, storageDir: safe };
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
private storageRootOwnershipRank(
|
|
566
|
-
record: NamespaceRecord,
|
|
567
|
-
resolvedStorageDir: string,
|
|
568
|
-
configured: Set<string>,
|
|
569
|
-
): number {
|
|
570
|
-
if (resolvedStorageDir === path.resolve(this.memoryDir)) {
|
|
571
|
-
return record.namespace === this.defaultNamespaceIdentity ? 0 : 3;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
const leaf = path.basename(resolvedStorageDir);
|
|
575
|
-
const tokenOwnsRoot = namespaceIdentityToken(record.namespace) === leaf;
|
|
576
|
-
if (tokenOwnsRoot && configured.has(record.namespace)) {
|
|
577
|
-
return 0;
|
|
578
|
-
}
|
|
579
|
-
if (record.namespace === leaf) return 1;
|
|
580
|
-
if (tokenOwnsRoot) return 2;
|
|
581
|
-
return 3;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
private configuredNamespaceIdentities(): Set<string> {
|
|
585
|
-
return new Set(
|
|
586
|
-
[
|
|
587
|
-
this.config.defaultNamespace,
|
|
588
|
-
this.config.sharedNamespace,
|
|
589
|
-
...this.config.namespacePolicies.map((p) => p.name),
|
|
590
|
-
]
|
|
591
|
-
.map((n) => normalizeNamespaceIdentity(n))
|
|
592
|
-
.filter((n) => n.length > 0),
|
|
593
|
-
);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
private preferStorageRootOwner(
|
|
597
|
-
current: NamespaceRecord,
|
|
598
|
-
candidate: NamespaceRecord,
|
|
599
|
-
resolvedStorageDir: string,
|
|
600
|
-
configured: Set<string>,
|
|
601
|
-
): NamespaceRecord {
|
|
602
|
-
const currentRank = this.storageRootOwnershipRank(current, resolvedStorageDir, configured);
|
|
603
|
-
const candidateRank = this.storageRootOwnershipRank(candidate, resolvedStorageDir, configured);
|
|
604
|
-
if (candidateRank < currentRank) return candidate;
|
|
605
|
-
if (candidateRank > currentRank) return current;
|
|
606
|
-
|
|
607
|
-
const byName = candidate.namespace.localeCompare(current.namespace);
|
|
608
|
-
if (byName < 0) return candidate;
|
|
609
|
-
if (byName > 0) return current;
|
|
610
|
-
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
|
+
};
|
|
611
575
|
}
|
|
612
576
|
|
|
613
|
-
private
|
|
614
|
-
const byStorageDir = new Map<string, NamespaceRecord>();
|
|
615
|
-
const configured = this.configuredNamespaceIdentities();
|
|
616
|
-
for (const record of records) {
|
|
617
|
-
const resolvedStorageDir = path.resolve(record.storageDir);
|
|
618
|
-
const current = byStorageDir.get(resolvedStorageDir);
|
|
619
|
-
if (!current) {
|
|
620
|
-
byStorageDir.set(resolvedStorageDir, record);
|
|
621
|
-
continue;
|
|
622
|
-
}
|
|
623
|
-
const owner = this.preferStorageRootOwner(current, record, resolvedStorageDir, configured);
|
|
624
|
-
const alias = owner === current ? record : current;
|
|
625
|
-
byStorageDir.set(resolvedStorageDir, mergeNewerTouchFields(owner, alias));
|
|
626
|
-
}
|
|
627
|
-
return [...byStorageDir.values()];
|
|
628
|
-
}
|
|
629
|
-
private loadSanitizedRecords(): Promise<NamespaceRecord[]> {
|
|
577
|
+
private loadSanitizedRecords(health?: CatalogReadHealth): Promise<NamespaceRecord[]> {
|
|
630
578
|
return this.queueCritical(async () => {
|
|
631
|
-
const records = await this.loadCompacted();
|
|
579
|
+
const records = await this.loadCompacted(false, health);
|
|
580
|
+
const port = this.catalogReadBoundaryPort();
|
|
632
581
|
const sanitized = await Promise.all(
|
|
633
|
-
[...records.values()].map((r) =>
|
|
582
|
+
[...records.values()].map((r) => sanitizeRecordForRead(port, r)),
|
|
634
583
|
);
|
|
635
584
|
// Drop unsafe-namespace rows (sanitizer returned null) at the read boundary.
|
|
636
585
|
// Then collapse duplicate root aliases so maintenance/QMD see exactly one
|
|
637
586
|
// namespace owner for a physical storage root, matching rebuild ownership,
|
|
638
587
|
// while preserving touch recency from every alias row.
|
|
639
|
-
return
|
|
588
|
+
return dropDuplicateStorageRootAliases(
|
|
589
|
+
port,
|
|
640
590
|
sanitized.filter((r): r is NamespaceRecord => r !== null),
|
|
591
|
+
this.config,
|
|
641
592
|
);
|
|
642
593
|
});
|
|
643
594
|
}
|
|
644
595
|
|
|
645
|
-
async listNamespaces(
|
|
596
|
+
async listNamespaces(
|
|
597
|
+
filter?: NamespaceCatalogFilter,
|
|
598
|
+
options?: NamespaceCatalogReadOptions,
|
|
599
|
+
): Promise<NamespaceRecord[]> {
|
|
646
600
|
if (!this.enabled) return [];
|
|
647
|
-
|
|
601
|
+
const health: CatalogReadHealth = {};
|
|
602
|
+
let out = await this.loadSanitizedRecords(health);
|
|
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
|
+
}
|
|
648
613
|
if (filter?.kind) out = out.filter((r) => r.kind === filter.kind);
|
|
649
614
|
if (filter?.discoveredBy) out = out.filter((r) => r.discoveredBy === filter.discoveredBy);
|
|
650
615
|
if (filter?.writtenSince) {
|
|
@@ -1403,6 +1368,47 @@ export class NamespaceCatalog {
|
|
|
1403
1368
|
);
|
|
1404
1369
|
}
|
|
1405
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
|
+
|
|
1406
1412
|
/**
|
|
1407
1413
|
* Fold-and-rewrite the JSONL log when it exceeds `compactBytesLimit`
|
|
1408
1414
|
* (issue #1903). MUST be called only from inside `touch`'s held-lock +
|
|
@@ -2078,13 +2084,18 @@ export class NamespaceCatalog {
|
|
|
2078
2084
|
// ── Persistence ──────────────────────────────────────────────────────────
|
|
2079
2085
|
|
|
2080
2086
|
/** Load the JSONL log and fold it into current state (last-record-wins). */
|
|
2081
|
-
private async loadCompacted(
|
|
2087
|
+
private async loadCompacted(
|
|
2088
|
+
forceFresh = false,
|
|
2089
|
+
health?: CatalogReadHealth,
|
|
2090
|
+
): Promise<Map<string, NamespaceRecord>> {
|
|
2082
2091
|
const records = new Map<string, NamespaceRecord>();
|
|
2083
2092
|
let handle;
|
|
2084
2093
|
try {
|
|
2085
2094
|
handle = await open(this.catalogPath, "r");
|
|
2086
|
-
} catch {
|
|
2095
|
+
} catch (error) {
|
|
2087
2096
|
this.compactedCache = undefined;
|
|
2097
|
+
// A catalog that was never written is normal; any other open failure hid the whole catalog.
|
|
2098
|
+
if (health && !isNotFoundError(error)) health.incomplete = `catalog unreadable: ${String(error)}`;
|
|
2088
2099
|
return records;
|
|
2089
2100
|
}
|
|
2090
2101
|
|
|
@@ -2106,6 +2117,12 @@ export class NamespaceCatalog {
|
|
|
2106
2117
|
this.compactedCache.identity.ino === identity.ino
|
|
2107
2118
|
) {
|
|
2108
2119
|
await handle.close().catch(() => undefined);
|
|
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
|
+
}
|
|
2109
2126
|
return this.cloneCompactedRecords(this.compactedCache.records);
|
|
2110
2127
|
}
|
|
2111
2128
|
} catch {
|
|
@@ -2113,6 +2130,7 @@ export class NamespaceCatalog {
|
|
|
2113
2130
|
// do not cache the result without a trustworthy identity.
|
|
2114
2131
|
}
|
|
2115
2132
|
|
|
2133
|
+
let malformed = 0;
|
|
2116
2134
|
try {
|
|
2117
2135
|
this.onCatalogReadForTest?.();
|
|
2118
2136
|
// Streaming chunked parse (issue #1903): the log can reach 100+ MB in
|
|
@@ -2136,11 +2154,18 @@ export class NamespaceCatalog {
|
|
|
2136
2154
|
try {
|
|
2137
2155
|
parsed = JSON.parse(trimmed);
|
|
2138
2156
|
} catch {
|
|
2139
|
-
// Skip corrupt lines (CLAUDE.md rule #18 robustness)
|
|
2157
|
+
// Skip corrupt lines (CLAUDE.md rule #18 robustness), but count them so a caller that
|
|
2158
|
+
// needs the whole catalog can tell the read was incomplete (issue #3293).
|
|
2159
|
+
malformed += 1;
|
|
2160
|
+
health?.malformedLines?.push(trimmed);
|
|
2140
2161
|
return;
|
|
2141
2162
|
}
|
|
2142
2163
|
const record = coerceRecord(parsed);
|
|
2143
|
-
if (!record)
|
|
2164
|
+
if (!record) {
|
|
2165
|
+
malformed += 1;
|
|
2166
|
+
health?.malformedLines?.push(trimmed);
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2144
2169
|
const prior = records.get(record.namespace);
|
|
2145
2170
|
records.set(record.namespace, prior ? mergeNewerTouchFields(record, prior) : record);
|
|
2146
2171
|
};
|
|
@@ -2161,14 +2186,20 @@ export class NamespaceCatalog {
|
|
|
2161
2186
|
}
|
|
2162
2187
|
leftover += decoder.end();
|
|
2163
2188
|
foldLine(leftover);
|
|
2164
|
-
} catch {
|
|
2189
|
+
} catch (error) {
|
|
2165
2190
|
this.compactedCache = undefined;
|
|
2191
|
+
if (health) health.incomplete = `catalog unreadable: ${String(error)}`;
|
|
2166
2192
|
return new Map();
|
|
2167
2193
|
} finally {
|
|
2168
2194
|
await handle.close().catch(() => undefined);
|
|
2169
2195
|
}
|
|
2170
2196
|
|
|
2171
|
-
|
|
2197
|
+
const incomplete = malformed > 0 ? `${malformed} malformed catalog line(s) skipped` : undefined;
|
|
2198
|
+
if (health && incomplete) {
|
|
2199
|
+
health.incomplete = incomplete;
|
|
2200
|
+
health.malformed = true;
|
|
2201
|
+
}
|
|
2202
|
+
this.compactedCache = identity ? { identity, records, incomplete } : undefined;
|
|
2172
2203
|
return this.cloneCompactedRecords(records);
|
|
2173
2204
|
}
|
|
2174
2205
|
|
|
@@ -2233,8 +2264,15 @@ export class NamespaceCatalog {
|
|
|
2233
2264
|
const body = records.map((r) => serializeRecord(r)).join("\n") + (records.length > 0 ? "\n" : "");
|
|
2234
2265
|
await mkdir(this.stateDir, { recursive: true });
|
|
2235
2266
|
const tmp = `${this.catalogPath}.${process.pid}.${Date.now()}.tmp`;
|
|
2236
|
-
|
|
2237
|
-
|
|
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
|
+
}
|
|
2238
2276
|
await this.refreshCacheIdentity(new Map(records.map((record) => [record.namespace, record])));
|
|
2239
2277
|
}
|
|
2240
2278
|
|
|
@@ -43,7 +43,7 @@ import type {
|
|
|
43
43
|
NamespaceSearchRouter,
|
|
44
44
|
NamespaceUpdateResult,
|
|
45
45
|
} from "../namespaces/search.js";
|
|
46
|
-
import
|
|
46
|
+
import { NamespaceCatalog, type NamespaceRecord } from "../namespaces/catalog.js";
|
|
47
47
|
import { namespaceIdentityFromToken, namespaceIdentityToken } from "../namespaces/identity.js";
|
|
48
48
|
import { isSafeRouteNamespace } from "../routing/engine.js";
|
|
49
49
|
import { readNamespaceMaintenanceStatuses } from "../maintenance/namespace-planner.js";
|
|
@@ -3397,6 +3397,38 @@ test("requestQmdMaintenanceCompletion over namespaces: a failed catalog read lea
|
|
|
3397
3397
|
}
|
|
3398
3398
|
});
|
|
3399
3399
|
|
|
3400
|
+
test("requestQmdMaintenanceCompletion over namespaces: a malformed catalog record leaves the request owed (#3293)", async () => {
|
|
3401
|
+
// A real catalog: the malformed row is skipped (the rest is still maintained), but the pass did
|
|
3402
|
+
// not see the whole catalog, so the migration must keep its marker.
|
|
3403
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-qmd-completion-malformed-"));
|
|
3404
|
+
const config = fixtureConfig({
|
|
3405
|
+
memoryDir,
|
|
3406
|
+
namespacesEnabled: true,
|
|
3407
|
+
defaultNamespace: "default",
|
|
3408
|
+
sharedNamespace: "shared",
|
|
3409
|
+
namespacePolicies: [],
|
|
3410
|
+
maintenanceNamespaceLockStaleMs: 100,
|
|
3411
|
+
qmdAutoEmbedEnabled: false,
|
|
3412
|
+
});
|
|
3413
|
+
await mkdir(path.join(memoryDir, "state"), { recursive: true });
|
|
3414
|
+
await writeFile(path.join(memoryDir, "state", "namespaces.jsonl"), "{this is not json\n", "utf8");
|
|
3415
|
+
const router: NamespaceSearchRouter = Object.assign(Object.create(null), {
|
|
3416
|
+
async updateNamespacesDetailed(ns: string[]): Promise<NamespaceUpdateResult> {
|
|
3417
|
+
return { backendCount: ns.length, eligibleNamespaces: ns };
|
|
3418
|
+
},
|
|
3419
|
+
async embedNamespaces(): Promise<void> {},
|
|
3420
|
+
});
|
|
3421
|
+
const scheduler = buildScheduler({ config, catalog: new NamespaceCatalog(config), router });
|
|
3422
|
+
try {
|
|
3423
|
+
const served = scheduler.requestQmdMaintenanceCompletion();
|
|
3424
|
+
await scheduler.runQmdMaintenance();
|
|
3425
|
+
assert.equal(await served, false);
|
|
3426
|
+
} finally {
|
|
3427
|
+
await scheduler.dispose();
|
|
3428
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3429
|
+
}
|
|
3430
|
+
});
|
|
3431
|
+
|
|
3400
3432
|
test("requestQmdMaintenanceCompletion over namespaces: a permanent skip (branch_disabled) does not block serving", async () => {
|
|
3401
3433
|
// A cataloged branch namespace is never maintained by default; that is a standing configuration,
|
|
3402
3434
|
// not an unfinished pass, so the ran namespace must still serve the request.
|
|
@@ -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);
|