@hviana/sema 0.7.2 → 0.7.3
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/src/store.d.ts +33 -4
- package/dist/src/store.js +71 -14
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/store.ts +70 -16
- package/test/96-bytes-walk-termination.test.mjs +115 -0
package/dist/src/store.d.ts
CHANGED
|
@@ -570,9 +570,6 @@ export declare abstract class AbstractStore implements Store {
|
|
|
570
570
|
nodeCount(): number;
|
|
571
571
|
size(): Promise<number>;
|
|
572
572
|
get(id: NodeId): NodeRec | null;
|
|
573
|
-
/** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
|
|
574
|
-
* Iterative post-order on an explicit stack — the call stack never sees the
|
|
575
|
-
* tree depth, so even an adversarial chain of nodes stays safe. */
|
|
576
573
|
/** How many reads hit a MISSING node record this session (a dangling edge
|
|
577
574
|
* or kid id). Zero in a healthy store; a growing count means references
|
|
578
575
|
* outlive their records — the read degrades safely to empty bytes, this
|
|
@@ -582,6 +579,25 @@ export declare abstract class AbstractStore implements Store {
|
|
|
582
579
|
* nothing is profiling. Every read below bumps it through `?.`, so an
|
|
583
580
|
* unprofiled store pays one null check per read and allocates nothing. */
|
|
584
581
|
meter: Meter | null;
|
|
582
|
+
/** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
|
|
583
|
+
* Iterative post-order on an explicit stack — the call stack never sees the
|
|
584
|
+
* tree depth, so even an adversarial chain of nodes stays safe.
|
|
585
|
+
*
|
|
586
|
+
* TERMINATION. The walk memoizes into a LOCAL map, and `_bytesCache` is
|
|
587
|
+
* consulted only as a warm hint whose hit is immediately promoted into that
|
|
588
|
+
* map. It used to use `_bytesCache` itself as the memo, which is not a
|
|
589
|
+
* memo at all: it EVICTS, and its `"smallest"` policy prefers precisely the
|
|
590
|
+
* freshly-resolved small children that the pending parents on the stack are
|
|
591
|
+
* waiting for. A parent then finds them uncached again, re-pushes them,
|
|
592
|
+
* they are re-resolved, re-inserted, re-evicted — the loop makes no
|
|
593
|
+
* progress and never exits. Latent until the cache saturates, then
|
|
594
|
+
* unconditional: observed in the wild at 19.9M nodes with the 20 MB cache
|
|
595
|
+
* pinned at 19,999,962/20,000,000 bytes, spinning 8h45m on a node whose
|
|
596
|
+
* whole content was 124 bytes (5 kids, 2 of them perpetually re-evicted).
|
|
597
|
+
* Because the loop is synchronous, no timer could fire — the trainer's stall
|
|
598
|
+
* watchdog never got a turn either. A local map resolves each node at most
|
|
599
|
+
* once per call, so the walk terminates by construction and `_bytesCache`
|
|
600
|
+
* goes back to being a pure speed hint. */
|
|
585
601
|
bytes(id: NodeId): Uint8Array;
|
|
586
602
|
/** First `maxLen` bytes of a node. Walks only the leftmost branch,
|
|
587
603
|
* stopping at `maxLen` — so a 1 MB document root costs the same as a
|
|
@@ -665,7 +681,20 @@ export declare abstract class AbstractStore implements Store {
|
|
|
665
681
|
* common-prefix / common-suffix trim: whatever remains after both trims is
|
|
666
682
|
* the single differing span (substitution, insertion or deletion), and both
|
|
667
683
|
* remainders must fit the budget. Scattered differences leave a wide
|
|
668
|
-
* middle and are rejected.
|
|
684
|
+
* middle and are rejected.
|
|
685
|
+
*
|
|
686
|
+
* Every read here is CAPPED (§2.8). It used to open with
|
|
687
|
+
* `bytesPrefix(k, Number.MAX_SAFE_INTEGER)` — the ALL sentinel, i.e. the
|
|
688
|
+
* full materialising `bytes()` read — on the deposit hot path, and only
|
|
689
|
+
* then compare lengths. So a candidate the length test was about to reject
|
|
690
|
+
* had already been reconstructed byte for byte. The LENGTHS decide first
|
|
691
|
+
* instead, from the `contentLen` memo the interning order has already built
|
|
692
|
+
* bottom-up, and the target's length is itself read under a cap: a target
|
|
693
|
+
* longer than `la + W` is rejected without touching one of its bytes.
|
|
694
|
+
* Same semantics — the old capped `b` read would have produced
|
|
695
|
+
* `a.length + W + 1` here and failed the very same test — strictly fewer
|
|
696
|
+
* byte reads. The `+ 1` on each byte cap keeps `_prefix`'s
|
|
697
|
+
* "complete reconstruction" test true, so the results still cache. */
|
|
669
698
|
private differsByOneWindow;
|
|
670
699
|
putLeaf(bytes: Uint8Array, gist: Vec): Promise<NodeId>;
|
|
671
700
|
putBranch(kids: NodeId[], gist: Vec): Promise<NodeId>;
|
package/dist/src/store.js
CHANGED
|
@@ -591,9 +591,6 @@ export class AbstractStore {
|
|
|
591
591
|
this._recCache.set(id, rec);
|
|
592
592
|
return rec;
|
|
593
593
|
}
|
|
594
|
-
/** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
|
|
595
|
-
* Iterative post-order on an explicit stack — the call stack never sees the
|
|
596
|
-
* tree depth, so even an adversarial chain of nodes stays safe. */
|
|
597
594
|
/** How many reads hit a MISSING node record this session (a dangling edge
|
|
598
595
|
* or kid id). Zero in a healthy store; a growing count means references
|
|
599
596
|
* outlive their records — the read degrades safely to empty bytes, this
|
|
@@ -603,6 +600,25 @@ export class AbstractStore {
|
|
|
603
600
|
* nothing is profiling. Every read below bumps it through `?.`, so an
|
|
604
601
|
* unprofiled store pays one null check per read and allocates nothing. */
|
|
605
602
|
meter = null;
|
|
603
|
+
/** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
|
|
604
|
+
* Iterative post-order on an explicit stack — the call stack never sees the
|
|
605
|
+
* tree depth, so even an adversarial chain of nodes stays safe.
|
|
606
|
+
*
|
|
607
|
+
* TERMINATION. The walk memoizes into a LOCAL map, and `_bytesCache` is
|
|
608
|
+
* consulted only as a warm hint whose hit is immediately promoted into that
|
|
609
|
+
* map. It used to use `_bytesCache` itself as the memo, which is not a
|
|
610
|
+
* memo at all: it EVICTS, and its `"smallest"` policy prefers precisely the
|
|
611
|
+
* freshly-resolved small children that the pending parents on the stack are
|
|
612
|
+
* waiting for. A parent then finds them uncached again, re-pushes them,
|
|
613
|
+
* they are re-resolved, re-inserted, re-evicted — the loop makes no
|
|
614
|
+
* progress and never exits. Latent until the cache saturates, then
|
|
615
|
+
* unconditional: observed in the wild at 19.9M nodes with the 20 MB cache
|
|
616
|
+
* pinned at 19,999,962/20,000,000 bytes, spinning 8h45m on a node whose
|
|
617
|
+
* whole content was 124 bytes (5 kids, 2 of them perpetually re-evicted).
|
|
618
|
+
* Because the loop is synchronous, no timer could fire — the trainer's stall
|
|
619
|
+
* watchdog never got a turn either. A local map resolves each node at most
|
|
620
|
+
* once per call, so the walk terminates by construction and `_bytesCache`
|
|
621
|
+
* goes back to being a pure speed hint. */
|
|
606
622
|
bytes(id) {
|
|
607
623
|
if (this.meter) {
|
|
608
624
|
this.meter.byteReads++;
|
|
@@ -619,10 +635,22 @@ export class AbstractStore {
|
|
|
619
635
|
return hit;
|
|
620
636
|
const stack = [id];
|
|
621
637
|
const cache = this._bytesCache;
|
|
638
|
+
// The walk's own memo. Entries are the same shared arrays `_bytesCache`
|
|
639
|
+
// holds (no extra copy), and it lives exactly as long as this call.
|
|
640
|
+
const done = new Map();
|
|
622
641
|
while (stack.length > 0) {
|
|
623
642
|
const nid = stack[stack.length - 1]; // peek
|
|
624
|
-
// Already resolved by
|
|
625
|
-
|
|
643
|
+
// Already resolved by this walk — the ONLY authority the readiness test
|
|
644
|
+
// below trusts, because it cannot be evicted underneath us.
|
|
645
|
+
if (done.has(nid)) {
|
|
646
|
+
stack.pop();
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
// Warm hint: a hit is promoted into `done` in the same step, so from
|
|
650
|
+
// here on the entry is pinned for the rest of the walk.
|
|
651
|
+
const warm = cache.get(nid);
|
|
652
|
+
if (warm !== undefined) {
|
|
653
|
+
done.set(nid, warm);
|
|
626
654
|
stack.pop();
|
|
627
655
|
continue;
|
|
628
656
|
}
|
|
@@ -634,21 +662,26 @@ export class AbstractStore {
|
|
|
634
662
|
// The cache makes the empty read permanent for the session; the
|
|
635
663
|
// counter survives as the visible trace.
|
|
636
664
|
this.danglingReads++;
|
|
665
|
+
done.set(nid, _ZERO);
|
|
637
666
|
cache.set(nid, _ZERO);
|
|
638
667
|
stack.pop();
|
|
639
668
|
continue;
|
|
640
669
|
}
|
|
641
670
|
if (rec.leaf) {
|
|
642
|
-
|
|
671
|
+
// COPY before caching: rec.leaf is the node record's own buffer, and
|
|
672
|
+
// handing it out would let one mutating caller corrupt the record.
|
|
673
|
+
const leaf = new Uint8Array(rec.leaf);
|
|
674
|
+
done.set(nid, leaf);
|
|
675
|
+
cache.set(nid, leaf);
|
|
643
676
|
stack.pop();
|
|
644
677
|
continue;
|
|
645
678
|
}
|
|
646
|
-
// Branch — push any
|
|
647
|
-
// left-to-right). If every child is
|
|
679
|
+
// Branch — push any unresolved children (reverse order so they resolve
|
|
680
|
+
// left-to-right). If every child is resolved, concatenate now.
|
|
648
681
|
const kids = rec.kids ?? [];
|
|
649
682
|
let ready = true;
|
|
650
683
|
for (let i = kids.length - 1; i >= 0; i--) {
|
|
651
|
-
if (!
|
|
684
|
+
if (!done.has(kids[i])) {
|
|
652
685
|
stack.push(kids[i]);
|
|
653
686
|
ready = false;
|
|
654
687
|
}
|
|
@@ -656,10 +689,11 @@ export class AbstractStore {
|
|
|
656
689
|
if (!ready)
|
|
657
690
|
continue;
|
|
658
691
|
stack.pop();
|
|
659
|
-
const out = concat(kids.map((k) =>
|
|
692
|
+
const out = concat(kids.map((k) => done.get(k)));
|
|
693
|
+
done.set(nid, out);
|
|
660
694
|
cache.set(nid, out);
|
|
661
695
|
}
|
|
662
|
-
const out =
|
|
696
|
+
const out = done.get(id) ?? _ZERO;
|
|
663
697
|
if (this.meter)
|
|
664
698
|
this.meter.bytesRead += out.length;
|
|
665
699
|
return out;
|
|
@@ -1189,10 +1223,33 @@ export class AbstractStore {
|
|
|
1189
1223
|
* common-prefix / common-suffix trim: whatever remains after both trims is
|
|
1190
1224
|
* the single differing span (substitution, insertion or deletion), and both
|
|
1191
1225
|
* remainders must fit the budget. Scattered differences leave a wide
|
|
1192
|
-
* middle and are rejected.
|
|
1226
|
+
* middle and are rejected.
|
|
1227
|
+
*
|
|
1228
|
+
* Every read here is CAPPED (§2.8). It used to open with
|
|
1229
|
+
* `bytesPrefix(k, Number.MAX_SAFE_INTEGER)` — the ALL sentinel, i.e. the
|
|
1230
|
+
* full materialising `bytes()` read — on the deposit hot path, and only
|
|
1231
|
+
* then compare lengths. So a candidate the length test was about to reject
|
|
1232
|
+
* had already been reconstructed byte for byte. The LENGTHS decide first
|
|
1233
|
+
* instead, from the `contentLen` memo the interning order has already built
|
|
1234
|
+
* bottom-up, and the target's length is itself read under a cap: a target
|
|
1235
|
+
* longer than `la + W` is rejected without touching one of its bytes.
|
|
1236
|
+
* Same semantics — the old capped `b` read would have produced
|
|
1237
|
+
* `a.length + W + 1` here and failed the very same test — strictly fewer
|
|
1238
|
+
* byte reads. The `+ 1` on each byte cap keeps `_prefix`'s
|
|
1239
|
+
* "complete reconstruction" test true, so the results still cache. */
|
|
1193
1240
|
differsByOneWindow(kids, targetId, W) {
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1241
|
+
const lens = kids.map((k) => this.contentLen(k));
|
|
1242
|
+
let la = 0;
|
|
1243
|
+
for (const n of lens)
|
|
1244
|
+
la += n;
|
|
1245
|
+
const cap = la + W + 1;
|
|
1246
|
+
// `contentLen` under a cap returns a clamped LOWER BOUND once the partial
|
|
1247
|
+
// sum reaches it, so `>= cap` is exactly "longer than la + W".
|
|
1248
|
+
const lb = this.contentLen(targetId, cap);
|
|
1249
|
+
if (lb >= cap || Math.abs(la - lb) > W)
|
|
1250
|
+
return false;
|
|
1251
|
+
const a = concat(kids.map((k, i) => this.bytesPrefix(k, lens[i] + 1)));
|
|
1252
|
+
const b = this.bytesPrefix(targetId, lb + 1);
|
|
1196
1253
|
if (Math.abs(a.length - b.length) > W)
|
|
1197
1254
|
return false;
|
|
1198
1255
|
const n = Math.min(a.length, b.length);
|
package/jsr.json
CHANGED
package/package.json
CHANGED
package/src/store.ts
CHANGED
|
@@ -1133,9 +1133,6 @@ export abstract class AbstractStore implements Store {
|
|
|
1133
1133
|
return rec;
|
|
1134
1134
|
}
|
|
1135
1135
|
|
|
1136
|
-
/** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
|
|
1137
|
-
* Iterative post-order on an explicit stack — the call stack never sees the
|
|
1138
|
-
* tree depth, so even an adversarial chain of nodes stays safe. */
|
|
1139
1136
|
/** How many reads hit a MISSING node record this session (a dangling edge
|
|
1140
1137
|
* or kid id). Zero in a healthy store; a growing count means references
|
|
1141
1138
|
* outlive their records — the read degrades safely to empty bytes, this
|
|
@@ -1147,6 +1144,25 @@ export abstract class AbstractStore implements Store {
|
|
|
1147
1144
|
* unprofiled store pays one null check per read and allocates nothing. */
|
|
1148
1145
|
meter: Meter | null = null;
|
|
1149
1146
|
|
|
1147
|
+
/** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
|
|
1148
|
+
* Iterative post-order on an explicit stack — the call stack never sees the
|
|
1149
|
+
* tree depth, so even an adversarial chain of nodes stays safe.
|
|
1150
|
+
*
|
|
1151
|
+
* TERMINATION. The walk memoizes into a LOCAL map, and `_bytesCache` is
|
|
1152
|
+
* consulted only as a warm hint whose hit is immediately promoted into that
|
|
1153
|
+
* map. It used to use `_bytesCache` itself as the memo, which is not a
|
|
1154
|
+
* memo at all: it EVICTS, and its `"smallest"` policy prefers precisely the
|
|
1155
|
+
* freshly-resolved small children that the pending parents on the stack are
|
|
1156
|
+
* waiting for. A parent then finds them uncached again, re-pushes them,
|
|
1157
|
+
* they are re-resolved, re-inserted, re-evicted — the loop makes no
|
|
1158
|
+
* progress and never exits. Latent until the cache saturates, then
|
|
1159
|
+
* unconditional: observed in the wild at 19.9M nodes with the 20 MB cache
|
|
1160
|
+
* pinned at 19,999,962/20,000,000 bytes, spinning 8h45m on a node whose
|
|
1161
|
+
* whole content was 124 bytes (5 kids, 2 of them perpetually re-evicted).
|
|
1162
|
+
* Because the loop is synchronous, no timer could fire — the trainer's stall
|
|
1163
|
+
* watchdog never got a turn either. A local map resolves each node at most
|
|
1164
|
+
* once per call, so the walk terminates by construction and `_bytesCache`
|
|
1165
|
+
* goes back to being a pure speed hint. */
|
|
1150
1166
|
bytes(id: NodeId): Uint8Array {
|
|
1151
1167
|
if (this.meter) {
|
|
1152
1168
|
this.meter.byteReads++;
|
|
@@ -1162,12 +1178,25 @@ export abstract class AbstractStore implements Store {
|
|
|
1162
1178
|
|
|
1163
1179
|
const stack: NodeId[] = [id];
|
|
1164
1180
|
const cache = this._bytesCache;
|
|
1181
|
+
// The walk's own memo. Entries are the same shared arrays `_bytesCache`
|
|
1182
|
+
// holds (no extra copy), and it lives exactly as long as this call.
|
|
1183
|
+
const done = new Map<NodeId, Uint8Array>();
|
|
1165
1184
|
|
|
1166
1185
|
while (stack.length > 0) {
|
|
1167
1186
|
const nid = stack[stack.length - 1]; // peek
|
|
1168
1187
|
|
|
1169
|
-
// Already resolved by
|
|
1170
|
-
|
|
1188
|
+
// Already resolved by this walk — the ONLY authority the readiness test
|
|
1189
|
+
// below trusts, because it cannot be evicted underneath us.
|
|
1190
|
+
if (done.has(nid)) {
|
|
1191
|
+
stack.pop();
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Warm hint: a hit is promoted into `done` in the same step, so from
|
|
1196
|
+
// here on the entry is pinned for the rest of the walk.
|
|
1197
|
+
const warm = cache.get(nid);
|
|
1198
|
+
if (warm !== undefined) {
|
|
1199
|
+
done.set(nid, warm);
|
|
1171
1200
|
stack.pop();
|
|
1172
1201
|
continue;
|
|
1173
1202
|
}
|
|
@@ -1180,22 +1209,27 @@ export abstract class AbstractStore implements Store {
|
|
|
1180
1209
|
// The cache makes the empty read permanent for the session; the
|
|
1181
1210
|
// counter survives as the visible trace.
|
|
1182
1211
|
this.danglingReads++;
|
|
1212
|
+
done.set(nid, _ZERO);
|
|
1183
1213
|
cache.set(nid, _ZERO);
|
|
1184
1214
|
stack.pop();
|
|
1185
1215
|
continue;
|
|
1186
1216
|
}
|
|
1187
1217
|
if (rec.leaf) {
|
|
1188
|
-
|
|
1218
|
+
// COPY before caching: rec.leaf is the node record's own buffer, and
|
|
1219
|
+
// handing it out would let one mutating caller corrupt the record.
|
|
1220
|
+
const leaf = new Uint8Array(rec.leaf);
|
|
1221
|
+
done.set(nid, leaf);
|
|
1222
|
+
cache.set(nid, leaf);
|
|
1189
1223
|
stack.pop();
|
|
1190
1224
|
continue;
|
|
1191
1225
|
}
|
|
1192
1226
|
|
|
1193
|
-
// Branch — push any
|
|
1194
|
-
// left-to-right). If every child is
|
|
1227
|
+
// Branch — push any unresolved children (reverse order so they resolve
|
|
1228
|
+
// left-to-right). If every child is resolved, concatenate now.
|
|
1195
1229
|
const kids = rec.kids ?? [];
|
|
1196
1230
|
let ready = true;
|
|
1197
1231
|
for (let i = kids.length - 1; i >= 0; i--) {
|
|
1198
|
-
if (!
|
|
1232
|
+
if (!done.has(kids[i])) {
|
|
1199
1233
|
stack.push(kids[i]);
|
|
1200
1234
|
ready = false;
|
|
1201
1235
|
}
|
|
@@ -1203,11 +1237,12 @@ export abstract class AbstractStore implements Store {
|
|
|
1203
1237
|
if (!ready) continue;
|
|
1204
1238
|
|
|
1205
1239
|
stack.pop();
|
|
1206
|
-
const out = concat(kids.map((k) =>
|
|
1240
|
+
const out = concat(kids.map((k) => done.get(k)!));
|
|
1241
|
+
done.set(nid, out);
|
|
1207
1242
|
cache.set(nid, out);
|
|
1208
1243
|
}
|
|
1209
1244
|
|
|
1210
|
-
const out =
|
|
1245
|
+
const out = done.get(id) ?? _ZERO;
|
|
1211
1246
|
if (this.meter) this.meter.bytesRead += out.length;
|
|
1212
1247
|
return out;
|
|
1213
1248
|
}
|
|
@@ -1730,16 +1765,35 @@ export abstract class AbstractStore implements Store {
|
|
|
1730
1765
|
* common-prefix / common-suffix trim: whatever remains after both trims is
|
|
1731
1766
|
* the single differing span (substitution, insertion or deletion), and both
|
|
1732
1767
|
* remainders must fit the budget. Scattered differences leave a wide
|
|
1733
|
-
* middle and are rejected.
|
|
1768
|
+
* middle and are rejected.
|
|
1769
|
+
*
|
|
1770
|
+
* Every read here is CAPPED (§2.8). It used to open with
|
|
1771
|
+
* `bytesPrefix(k, Number.MAX_SAFE_INTEGER)` — the ALL sentinel, i.e. the
|
|
1772
|
+
* full materialising `bytes()` read — on the deposit hot path, and only
|
|
1773
|
+
* then compare lengths. So a candidate the length test was about to reject
|
|
1774
|
+
* had already been reconstructed byte for byte. The LENGTHS decide first
|
|
1775
|
+
* instead, from the `contentLen` memo the interning order has already built
|
|
1776
|
+
* bottom-up, and the target's length is itself read under a cap: a target
|
|
1777
|
+
* longer than `la + W` is rejected without touching one of its bytes.
|
|
1778
|
+
* Same semantics — the old capped `b` read would have produced
|
|
1779
|
+
* `a.length + W + 1` here and failed the very same test — strictly fewer
|
|
1780
|
+
* byte reads. The `+ 1` on each byte cap keeps `_prefix`'s
|
|
1781
|
+
* "complete reconstruction" test true, so the results still cache. */
|
|
1734
1782
|
private differsByOneWindow(
|
|
1735
1783
|
kids: NodeId[],
|
|
1736
1784
|
targetId: NodeId,
|
|
1737
1785
|
W: number,
|
|
1738
1786
|
): boolean {
|
|
1739
|
-
const
|
|
1740
|
-
|
|
1741
|
-
);
|
|
1742
|
-
const
|
|
1787
|
+
const lens = kids.map((k) => this.contentLen(k));
|
|
1788
|
+
let la = 0;
|
|
1789
|
+
for (const n of lens) la += n;
|
|
1790
|
+
const cap = la + W + 1;
|
|
1791
|
+
// `contentLen` under a cap returns a clamped LOWER BOUND once the partial
|
|
1792
|
+
// sum reaches it, so `>= cap` is exactly "longer than la + W".
|
|
1793
|
+
const lb = this.contentLen(targetId, cap);
|
|
1794
|
+
if (lb >= cap || Math.abs(la - lb) > W) return false;
|
|
1795
|
+
const a = concat(kids.map((k, i) => this.bytesPrefix(k, lens[i] + 1)));
|
|
1796
|
+
const b = this.bytesPrefix(targetId, lb + 1);
|
|
1743
1797
|
if (Math.abs(a.length - b.length) > W) return false;
|
|
1744
1798
|
const n = Math.min(a.length, b.length);
|
|
1745
1799
|
let i = 0;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// 96-bytes-walk-termination.test.mjs — `bytes()` must TERMINATE when its
|
|
2
|
+
// traversal memo cannot hold the reconstruction's working set.
|
|
3
|
+
//
|
|
4
|
+
// `bytes()` is an iterative post-order walk: it peeks the top of an explicit
|
|
5
|
+
// stack, pushes any child that is not yet resolved, and concatenates once every
|
|
6
|
+
// child is. Its termination argument needs a memo that only ever GROWS. It
|
|
7
|
+
// used `_bytesCache` — a byte-accounted BoundedMap that EVICTS, and whose
|
|
8
|
+
// `"smallest"` policy deliberately prefers the cheapest-to-rebuild entries,
|
|
9
|
+
// i.e. exactly the small, freshly-resolved children the parents still sitting
|
|
10
|
+
// on the stack are waiting for. The parent finds them unresolved again,
|
|
11
|
+
// re-pushes them, they are re-resolved, re-inserted and re-evicted. No
|
|
12
|
+
// progress. The loop never exits.
|
|
13
|
+
//
|
|
14
|
+
// It stayed hidden because it needs the cache to be SATURATED, which only a
|
|
15
|
+
// long run reaches. OBSERVED IN THE FIELD: a training run at 19.9M nodes with
|
|
16
|
+
// the 20 MB cache pinned at 19,999,962 bytes spun for 8h45m of 100% CPU on one
|
|
17
|
+
// node — whose entire content was 124 bytes, with 5 kids, 2 of them
|
|
18
|
+
// perpetually re-evicted. Sampled 45s apart through the V8 inspector, the
|
|
19
|
+
// walk's root id, the node's length and the cache's byte count were all
|
|
20
|
+
// identical; only the stack depth oscillated between 1 and 2.
|
|
21
|
+
//
|
|
22
|
+
// And the loop is SYNCHRONOUS, so nothing could observe it: the trainer's
|
|
23
|
+
// 15-minute stall watchdog is a timer, and a timer cannot fire while the
|
|
24
|
+
// microtask/JS stack is occupied. The run looked alive for 9 hours.
|
|
25
|
+
//
|
|
26
|
+
// The fix makes the walk memoize into a LOCAL map (`_bytesCache` demoted to a
|
|
27
|
+
// warm hint whose hit is promoted into that map immediately), so each node
|
|
28
|
+
// resolves at most once per call and termination is structural.
|
|
29
|
+
//
|
|
30
|
+
// This test does NOT depend on the eviction cursor's position — the field case
|
|
31
|
+
// reached the defect stochastically, via the cursor sweeping the map's
|
|
32
|
+
// recently-inserted tail. Here the subtree's children simply sum to more bytes
|
|
33
|
+
// than the ceiling, so no cursor position can save it: pre-fix this file hangs
|
|
34
|
+
// forever, and `node --test` reports it only as a timeout.
|
|
35
|
+
import { test } from "node:test";
|
|
36
|
+
import assert from "node:assert/strict";
|
|
37
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
38
|
+
|
|
39
|
+
const D = 64;
|
|
40
|
+
const gist = () => {
|
|
41
|
+
const v = new Float32Array(D);
|
|
42
|
+
v[0] = 1;
|
|
43
|
+
return v;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
test("bytes() terminates when its memo cannot hold the working set", async () => {
|
|
47
|
+
// A ceiling small enough to saturate in milliseconds. The mechanism is
|
|
48
|
+
// scale-free: this is the only thing scaled down from the field case.
|
|
49
|
+
const CEILING = 4096;
|
|
50
|
+
const store = new SQliteStore({
|
|
51
|
+
D,
|
|
52
|
+
bytesCacheMax: CEILING,
|
|
53
|
+
path: ":memory:",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// One ordinary 9,600-byte form: 300 distinct 32-byte children under a root.
|
|
57
|
+
const KIDS = 300, WIDTH = 32;
|
|
58
|
+
const kids = [];
|
|
59
|
+
for (let b = 0; b < KIDS; b++) {
|
|
60
|
+
const buf = new Uint8Array(WIDTH);
|
|
61
|
+
for (let i = 0; i < WIDTH; i++) buf[i] = (b * 131 + i * 17) & 0xff;
|
|
62
|
+
kids.push(await store.putLeaf(buf, gist()));
|
|
63
|
+
}
|
|
64
|
+
const root = await store.putBranch(kids, gist());
|
|
65
|
+
const expected = store.contentLen(root);
|
|
66
|
+
assert.equal(expected, KIDS * WIDTH);
|
|
67
|
+
|
|
68
|
+
// Churn the subtree out of the memo and leave it AT its ceiling — the steady
|
|
69
|
+
// state every long run reaches.
|
|
70
|
+
for (let k = 0; k < 4000; k++) {
|
|
71
|
+
const buf = new Uint8Array(48);
|
|
72
|
+
for (let i = 0; i < 48; i++) buf[i] = (k * 7919 + i * 251) & 0xff;
|
|
73
|
+
await store.putLeaf(buf, gist());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The premise the guard rests on: the memo genuinely cannot hold the working
|
|
77
|
+
// set. If a future change grows the ceiling or shrinks the fixture, this
|
|
78
|
+
// assertion fails LOUDLY rather than letting the test pass vacuously.
|
|
79
|
+
assert.ok(
|
|
80
|
+
KIDS * WIDTH > CEILING,
|
|
81
|
+
`fixture must exceed the memo ceiling (${KIDS * WIDTH} vs ${CEILING})`,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Pre-fix this call never returns. Post-fix it is sub-millisecond.
|
|
85
|
+
const out = store.bytes(root);
|
|
86
|
+
assert.equal(out.length, expected);
|
|
87
|
+
for (let b = 0; b < KIDS; b++) {
|
|
88
|
+
for (let i = 0; i < WIDTH; i++) {
|
|
89
|
+
assert.equal(out[b * WIDTH + i], (b * 131 + i * 17) & 0xff);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("differsByOneWindow's reads are capped — no ALL-sentinel read on deposit", async () => {
|
|
95
|
+
// §2.8: the near-dedup byte check used to open with
|
|
96
|
+
// `bytesPrefix(k, Number.MAX_SAFE_INTEGER)` — the ALL sentinel, which routes
|
|
97
|
+
// to the full materialising `bytes()` — and only THEN compare lengths. A
|
|
98
|
+
// candidate the length test was about to reject had already been rebuilt byte
|
|
99
|
+
// for byte, and that read is what dragged the deposit path into the walk
|
|
100
|
+
// above. Lengths now decide first, from the `contentLen` memo.
|
|
101
|
+
const src = await import("node:fs").then((fs) =>
|
|
102
|
+
fs.readFileSync(new URL("../src/store.ts", import.meta.url), "utf8")
|
|
103
|
+
);
|
|
104
|
+
const body = src.slice(src.indexOf("private differsByOneWindow"));
|
|
105
|
+
const end = body.indexOf("\n }\n");
|
|
106
|
+
const fn = body.slice(0, end);
|
|
107
|
+
assert.ok(
|
|
108
|
+
!fn.includes("MAX_SAFE_INTEGER"),
|
|
109
|
+
"differsByOneWindow must not read with the ALL sentinel",
|
|
110
|
+
);
|
|
111
|
+
assert.ok(
|
|
112
|
+
fn.includes("this.contentLen("),
|
|
113
|
+
"differsByOneWindow must decide on lengths before reading bytes",
|
|
114
|
+
);
|
|
115
|
+
});
|