@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.
Files changed (43) hide show
  1. package/dist/access-cli.js +8 -8
  2. package/dist/access-http-query.js +2 -2
  3. package/dist/access-http.js +3 -3
  4. package/dist/{chunk-4BUJICMI.js → chunk-CTNDFKEX.js} +2 -2
  5. package/dist/{chunk-2GU6XS4L.js → chunk-E2YAGEFR.js} +7 -7
  6. package/dist/{chunk-R3WZ74B2.js → chunk-KCTL5EY7.js} +5 -3
  7. package/dist/chunk-KCTL5EY7.js.map +1 -0
  8. package/dist/{chunk-PVG7TCUG.js → chunk-L5UB5BIY.js} +2 -2
  9. package/dist/{chunk-XVTB6BZS.js → chunk-P5FYZH5G.js} +253 -130
  10. package/dist/chunk-P5FYZH5G.js.map +1 -0
  11. package/dist/{chunk-Y56D5FSD.js → chunk-W2KH27MQ.js} +3 -3
  12. package/dist/{chunk-GGFQAGOT.js → chunk-W3R77WHT.js} +2 -2
  13. package/dist/{chunk-WGFEGBFB.js → chunk-W4QTA2H5.js} +2 -2
  14. package/dist/cli.js +6 -6
  15. package/dist/index.js +8 -8
  16. package/dist/maintenance/namespace-planner.d.ts.map +1 -1
  17. package/dist/namespaces/catalog-read-boundary.d.ts +42 -0
  18. package/dist/namespaces/catalog-read-boundary.d.ts.map +1 -0
  19. package/dist/namespaces/catalog-repair.d.ts +35 -0
  20. package/dist/namespaces/catalog-repair.d.ts.map +1 -0
  21. package/dist/namespaces/catalog.d.ts +40 -28
  22. package/dist/namespaces/catalog.d.ts.map +1 -1
  23. package/dist/operator-toolkit.js +3 -3
  24. package/dist/orchestration/maintenance.js +3 -3
  25. package/dist/orchestrator.js +8 -8
  26. package/package.json +2 -2
  27. package/src/maintenance/namespace-planner.test.ts +41 -1
  28. package/src/maintenance/namespace-planner.ts +8 -1
  29. package/src/namespaces/catalog-read-boundary.test.ts +121 -0
  30. package/src/namespaces/catalog-read-boundary.ts +159 -0
  31. package/src/namespaces/catalog-repair.ts +69 -0
  32. package/src/namespaces/catalog.test.ts +566 -0
  33. package/src/namespaces/catalog.ts +155 -117
  34. package/src/orchestration/maintenance.test.ts +33 -1
  35. package/src/testing/subjects/catalog-read-repair.test.ts +124 -0
  36. package/dist/chunk-R3WZ74B2.js.map +0 -1
  37. package/dist/chunk-XVTB6BZS.js.map +0 -1
  38. /package/dist/{chunk-4BUJICMI.js.map → chunk-CTNDFKEX.js.map} +0 -0
  39. /package/dist/{chunk-2GU6XS4L.js.map → chunk-E2YAGEFR.js.map} +0 -0
  40. /package/dist/{chunk-PVG7TCUG.js.map → chunk-L5UB5BIY.js.map} +0 -0
  41. /package/dist/{chunk-Y56D5FSD.js.map → chunk-W2KH27MQ.js.map} +0 -0
  42. /package/dist/{chunk-GGFQAGOT.js.map → chunk-W3R77WHT.js.map} +0 -0
  43. /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
- * Sanitize a record at the enumeration boundary (round 5, cursor Medium + codex
527
- * P2; round 6 — NDXHe). Reads return whatever is in `namespaces.jsonl` after
528
- * schema checks only, so a tampered or pre-fix row could surface unsafe data to
529
- * maintenance/QMD until a rewrite occurs. Two distinct defenses:
530
- *
531
- * 1. UNSAFE NAMESPACE NAME (NGZqr, codex P2): an unsafe non-default namespace
532
- * (e.g. `../evil`, a name with separators, or >64 chars) is REJECTED outright
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 dropDuplicateStorageRootAliases(records: NamespaceRecord[]): NamespaceRecord[] {
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) => this.sanitizeRecordForRead(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 this.dropDuplicateStorageRootAliases(
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(filter?: NamespaceCatalogFilter): Promise<NamespaceRecord[]> {
596
+ async listNamespaces(
597
+ filter?: NamespaceCatalogFilter,
598
+ options?: NamespaceCatalogReadOptions,
599
+ ): Promise<NamespaceRecord[]> {
646
600
  if (!this.enabled) return [];
647
- let out = await this.loadSanitizedRecords();
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(forceFresh = false): Promise<Map<string, NamespaceRecord>> {
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) return;
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
- this.compactedCache = identity ? { identity, records } : undefined;
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
- await writeFile(tmp, body, "utf8");
2237
- await rename(tmp, this.catalogPath);
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 type { NamespaceCatalog, NamespaceRecord } from "../namespaces/catalog.js";
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);