flatsql 3.4.0 → 3.5.1

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.
@@ -26,11 +26,26 @@ departure from the design.
26
26
 
27
27
  - Create `env.memory` shared, with a maximum no larger than the import's
28
28
  (browser writer 256 MiB, reader 128 MiB, design §5.5).
29
- - Grow the heap before `flatsql_ps_start` when threads will allocate (alloc
30
- then free a large block through `flatsql_ps_alloc`/`flatsql_ps_free`): V8
31
- (Node 20–25, and browsers) refreshes each thread's view of a shared memory's
32
- size lazily, so a thread touching memory another thread has just grown can
33
- trap. WasmEdge is not affected.
29
+ - **V8 hosts (Node, browsers): grow the heap to the memory's maximum before
30
+ `flatsql_ps_start`, so it never grows while guest threads run.** Allocate
31
+ through `flatsql_ps_alloc` in steps from 1 GiB down to 1 MiB until each
32
+ fails, then free them all (the allocator keeps the blocks; wasm memory never
33
+ shrinks; the guest allocator grows at most 2 GiB per call). V8 bounds-checks
34
+ `memory.fill`/`memory.copy` and atomics against each thread's cached view of
35
+ a shared memory's size, and another thread's `memory.grow` updates that view
36
+ only at this thread's next stack check. A thread that fills a block carved
37
+ from memory another thread has just grown then traps "memory access out of
38
+ bounds" (§4.3). A partial pre-grow only moves the failure: the 1,536 MiB the
39
+ test main used to grow was passed at 1,651 MiB in CI. Stop 16 MiB short of
40
+ 4 GiB with a 4 GiB maximum: a heap segment ending at 2^32 wraps the
41
+ allocator's pointer arithmetic. `createPsNodeInstance` does this
42
+ (`growHeap: false` opts out, `heapBytes` caps it), and the Node host reports
43
+ `memoryGrownWhileThreadsRan` for every guest and warns when it is not 0.
44
+ - **WasmEdge hosts do not pre-grow** (and SDN recycles reader instances on
45
+ their page count). WasmEdge keeps one page count shared by every thread's
46
+ instance and grows in place in a fixed reservation, so a grow is visible to
47
+ every thread at once. WasmEdge 0.16.4's AOT compiler must carry SDN's
48
+ `04-atomic-memarg-offset` patch (§4.3).
34
49
  - Give every guest thread a distinct `tid` below 2^29 (wasi-libc keeps it in
35
50
  mutex words).
36
51
 
@@ -93,8 +108,8 @@ budget fails the crash instead of making it partial.
93
108
  Added tests (`PS_SLOW_TEST`, run only when named): `parity_canonical_dump`,
94
109
  `parity_deterministic_bytes` (`cpp/test/ps/parity_test.cpp`) and
95
110
  `host_crash_ingest` / `host_crash_verify` (`host_crash_test.cpp`). The wasm
96
- test main grows the heap by 1,536 MiB before any thread starts
97
- (`PS_WASM_HEAP_MB`).
111
+ test main grows the heap to the memory's maximum before any thread starts
112
+ (§1; `PS_WASM_HEAP_MB` caps it).
98
113
 
99
114
  ```
100
115
  npm run build:wasm:ps-tests # artifact + test commands
@@ -163,6 +178,67 @@ journal. A negative control (half a data segment cut off) fails the verifier.
163
178
  when `mergeL0Blocks` exceeded the directory cap, so labeling, partition
164
179
  merges and acks stopped (`dac8d89`).
165
180
 
181
+ ### 4.3 The shared-memory grow race (V8) and AOT wait/notify (WasmEdge)
182
+
183
+ **V8.** CI ps-wasm run 36603876540 (attempt 1) trapped "memory access out of
184
+ bounds" in `writeTypeMergeOutputs` on the type-merge helper thread during
185
+ `readers_under_saturating_writers_T2_1`, with memory at 1,651 MiB, past the
186
+ test main's 1,536 MiB pre-grow. A Docker build with the Linux wasi-sdk 30 at
187
+ the CI checkout path reproduces the CI test command exactly (its function
188
+ indices match the trace, and its `flatsql-ps-threads.wasm` is the committed
189
+ artifact's sha256). The trap offset `0x18d31a` is a `memory.fill`: the
190
+ zero-fill of `blocks[i].resize(de.l0Len)` right after its `operator new`.
191
+ Each thread's instance caches the memory size, and V8 bounds-checks
192
+ `memory.fill`/`memory.copy` and atomics against that cache. Another thread's
193
+ `memory.grow` updates the cache only at this thread's next stack check, so a
194
+ block the allocator carves from memory another thread has just grown fails
195
+ that check. Plain loads and stores are checked by guard pages and never trap.
196
+ Measured on the Mac, Node 25.4.0, load 35–65:
197
+
198
+ | Heap before threads start | T2_1 runs | Traps |
199
+ |---|---|---|
200
+ | none (`PS_WASM_HEAP_MB=0`) | 31 | 8, every one a `memory.fill`/`memory.copy` right after an allocation (vector push_back, string copy, the resize above) |
201
+ | 2,000 MiB (past the test's peak) | 30 | 0 |
202
+ | the memory's maximum (§1, now the default) | 500 | 0, and 0 runs grew memory after a thread started (1 run ended in a Node 25.4.0 SIGILL inside V8's JIT code, a host crash) |
203
+
204
+ Natively the same test under ASan+UBSan (Docker linux/arm64, 4 CPUs, a CPU
205
+ hog) found no memory error in 488 runs: the engine never overran.
206
+
207
+ **WasmEdge, the SDN host.** Every thread's instance imports one shared
208
+ `MemoryInstance`. On 64-bit hosts it is reserved in full up front (12 GiB,
209
+ `lib/system/allocator.cpp`) and `memory.grow` maps pages in place, so the
210
+ base never moves. The page count is one field that `memory.fill`/`copy`/
211
+ `init` and atomic wait/notify check live, and plain loads and stores are
212
+ checked by guard pages. The SDN C host I/O resolves every guest pointer per
213
+ call (`WasmEdge_MemoryInstanceGetPointer`), and the Go accessor re-reads the
214
+ page count on a miss. A grow is visible to every thread when the growing
215
+ thread's `memory.grow` returns, and the guest allocator's lock orders it
216
+ before any other thread can allocate from it. Measured: the same T2_1 with
217
+ no pre-grow on the patched WasmEdge 0.16.4, AOT, had 0 traps in 26 completed
218
+ runs.
219
+
220
+ **WasmEdge AOT wait/notify.** Those WasmEdge runs also hung at teardown: 4 of
221
+ 30 with no pre-grow, 5 of 30 with a 1,536 MiB pre-grow. WasmEdge 0.16.4's AOT
222
+ compiler passed `memory.atomic.notify` and `memory.atomic.wait32/64` the bare
223
+ address operand and dropped the instruction's memarg offset; the interpreter
224
+ adds it. wasi-libc addresses its thread-list lock as `i32.const 0;
225
+ memory.atomic.notify offset=<lock>`, so those wakeups went to address 0 and
226
+ thread exit and join hung. A plain spawn/join guest with no flatsql code, 50
227
+ rounds of 16 threads, hung 11 of 20 runs AOT, 0 of 30 interpreted and 0 of 20
228
+ under V8. `sleepNs` (a timed wait on a stack local whose frame offset the
229
+ compiler folds into the memarg) compared another stack word and returned at
230
+ once: 100 × 10 ms took 0.000 s AOT and 1.159 s interpreted. SDN's static
231
+ WasmEdge carries patch `04-atomic-memarg-offset` from SDN `74db37e14`, and
232
+ its substrate self-test checks it (tag `sdn3`, so threaded AOT artifacts
233
+ recompile). With the patch, the plain guest ran 40 of 40, and T2_1 AOT ran
234
+ 140 times with no hang. Two of those runs failed. One ran out of the 4 GiB
235
+ address space: the memio command keeps every file in guest memory, and
236
+ passing runs end near 2.7 GiB. The other run's output was not kept.
237
+ `memory_safety_sleep_and_notify_at_folded_offsets` passes. Without it, that test fails (20 of 20 folded-offset wakeups lost).
238
+ `sleepNs` now passes its address through a volatile, so its wait has a zero
239
+ memarg offset and sleeps even on an unpatched runtime. The artifact still has
240
+ 4 folded wait/notify sites, all in wasi-libc's thread code.
241
+
166
242
  ## 5. Design deviations
167
243
 
168
244
  1. **The Node host's thread spawn is its own pool, not the SDK's Node
@@ -8,7 +8,13 @@ fan-out merge, admission and results. Part III (§23–§30) is T3: compaction,
8
8
  reclamation, meta-segment retirement, disk accounting and quota. Part IV
9
9
  (§31–§35) is T3b: store-migrate gseqs, the entry-size setting, hot-partition
10
10
  splitting and arrivals compaction; §36 records the quota eviction race T3 #3
11
- met on Linux. Part V (§37) bounds a reader instance's memory. The design is
11
+ met on Linux. Part V (§37) bounds a reader instance's memory. Part VI (§38)
12
+ records the memory-safety and race work: the ps-wasm trap, the sanitizers and
13
+ the first disk-full emergency. Part VII (§39) closes the query gaps T6 found
14
+ on the host-02 fixture and accepts records stored with their own size
15
+ prefix. Part VIII (§40) makes a partition's bookkeeping O(1) or O(log S) per
16
+ commit (the terabyte audit's B4) and adds manifest version 3 (M3). The
17
+ design is
12
18
  the stack's `docs/architecture/flatsql-partition-store.md` (§22 overrides
13
19
  §0–§21; §22.4 holds the owner rulings). This file records what was built, how
14
20
  to run it, the measured acceptance, and every place the build departs from the
@@ -646,11 +652,11 @@ and idle-lane sub-cursors (§21 deviation 1).
646
652
 
647
653
  | Path | Contents |
648
654
  |---|---|
649
- | `ps/compaction.h`, `src/ps/compaction.cpp` | Manifest version 2, the compacted rows format, retire items; the compaction pipeline (plan, build, SWAP, apply, abort). |
650
- | `src/ps/reclaim.cpp` | The per-partition file ledger (`disk_bytes`), the RETIRE and UNLINKED records, reader-gated unlinking, meta-segment retirement (A9), open-time cleanup; the same for the type logs (meta log rotation and retirement, retired catalog runs and manifests, the type ledger). |
651
- | `ps/quota.h`, `src/ps/quota.cpp` | The quota planner, the space emergency, the ballast. |
655
+ | `ps/compaction.h`, `src/ps/compaction.cpp` | Manifest versions 2 and 3 (§40), the compacted rows format, retire items; the compaction pipeline (plan, build, SWAP, apply, abort). |
656
+ | `src/ps/reclaim.cpp` | The per-partition file ledger (`disk_bytes`; a hash table, §40), segment lookups by binary search, the RETIRE and UNLINKED records and the RETIRE valve, reader-gated unlinking, meta-segment retirement (A9), open-time cleanup; the same for the type logs (meta log rotation and retirement, retired catalog runs and manifests, the type ledger). |
657
+ | `ps/quota.h`, `src/ps/quota.cpp` | The quota planner, the space emergency, the ballast; the planner's per-segment summary and the compaction candidate index, kept from a change feed (§40). |
652
658
  | `src/ps/sources-t3.cmake` | Registers the T3 sources and tests on the targets `cpp/CMakeLists.txt` defines. |
653
- | `cpp/test/ps/{compaction,orphan,quota}_test.cpp` | T3 tests. `checkPartitionDir` and `checkTypeDir` (fixtures) walk a partition's or a type's directory against the files its head and manifest name. |
659
+ | `cpp/test/ps/{compaction,orphan,quota,bookkeeping_scale}_test.cpp` | T3 tests (the last: §40). `checkPartitionDir` and `checkTypeDir` (fixtures) walk a partition's or a type's directory against the files its head and manifest name. |
654
660
 
655
661
  ## 24. Compaction (§11, A11, minor 1)
656
662
 
@@ -693,7 +699,9 @@ in the manifest; rows' `seg` is rewritten); after a restart, when the
693
699
  partition's dead share is over the ratio and the per-segment counts are
694
700
  unknown, the oldest
695
701
  segment not yet surveyed (a build that would save under 10% is abandoned and
696
- its count remembered). `Engine::swapSegment` requests one (targeted, or the
702
+ its count remembered). The owner keeps these candidates in an index updated
703
+ from a change feed; a look does not walk the segments (§40).
704
+ `Engine::swapSegment` requests one (targeted, or the
697
705
  oldest-generation compactable segment).
698
706
 
699
707
  Kill bound and readers. A type-level reader's visibility is `V_p =
@@ -733,10 +741,12 @@ appended.
733
741
  133 files in 0.31 s at a 0.3 s grace). BUSY (a handle is open) skips to
734
742
  later items and retries after 50 ms; idle
735
743
  reader lanes close cached handles after 5 s. The next batch carries
736
- `UNLINKED` and the shrunken set; `disk_bytes` drops then. A set past 2,048
737
- items unlinks its oldest regardless of the gate (a statement older than
738
- hours of retirements gets the retryable SNAPSHOT_GONE instead of a writer
739
- waiting on it).
744
+ `UNLINKED` and the shrunken set; `disk_bytes` drops then. The set rides one
745
+ ctl record of one batch, so it is held under a soft cap (2,048 items as the
746
+ record encodes them, less with a large lane table): past it the oldest go at
747
+ once, regardless of the gate and the grace (deviation 37). A statement older
748
+ than them gets the retryable SNAPSHOT_GONE; an open handle still makes the
749
+ unlink BUSY.
740
750
  - **Open** unlinks every file of the persisted set (a file a surviving reader
741
751
  instance still holds stays retired), the outputs of an INTENT_COMPACT
742
752
  without SWAP, and r/a files a first merge created without MERGE_DONE, then
@@ -1459,11 +1469,11 @@ harness's arrivals check ran on a second reader instance the reader gate did
1459
1469
  not cover, so a statement could lose a file to reclamation and end
1460
1470
  SNAPSHOT_GONE (on 2f6d86e too: seed 9003, trial 48); the gate now covers both.
1461
1471
 
1462
- Open: `quota_disk_full_resumes_without_operator_T3_6` can stay in its first
1472
+ `quota_disk_full_resumes_without_operator_T3_6` could stay in its first
1463
1473
  emergency past the test's minute on a loaded box (one episode, three
1464
- segments evicted, the ballast never back). It predates this change: on
1474
+ segments evicted, the ballast never back). It predated this change: on
1465
1475
  linux/amd64 Debug at a host load near 40, 4 of 100 runs on 2f6d86e and 1 of
1466
- 100 here; under ThreadSanitizer, 3 of 6 and 1 of 10.
1476
+ 100 here; under ThreadSanitizer, 3 of 6 and 1 of 10. Root cause and fix: §38.
1467
1477
 
1468
1478
  # Part V: reader memory
1469
1479
 
@@ -1574,3 +1584,497 @@ held across statements by the reader beyond the budgets above):
1574
1584
  | writer: `L1Run::load` (`index_l1.cpp:168`) | every run's fences and all its blooms, held by `SegRun::run` for each warm partition's segments and each type's catalog runs | 1% of each run plus 1.25 B per entry | grows with the store: a terabyte store holds gigabytes in the writer instance; needs the reader's paging |
1575
1585
  | writer: `SegmentInfo::cdir` (`compaction.cpp:382`) | a compacted segment's presence directory, kept once loaded | 0.13 B per record | grows with the store |
1576
1586
  | writer: `Partition::segs`, `summary`, the file ledger | per segment | a few hundred bytes per segment | grows with segments |
1587
+
1588
+ # Part VI: memory safety and races
1589
+
1590
+ ## 38. The ps-wasm trap, the sanitizers, the first emergency
1591
+
1592
+ Task flatsql-ps-memory-safety-races-20260929. Machines: the owner's Mac
1593
+ Studio (Darwin 25.3.0, arm64, 28 hardware threads) shared with other lanes
1594
+ (1-minute load 17–95 during these runs), and linux/arm64 in Docker Desktop on
1595
+ it (gcc 13 and clang 18, `--cpus 4` with `stress-ng` on one CPU unless noted).
1596
+
1597
+ **The ps-wasm trap is V8's shared-memory grow race, not an engine overrun**
1598
+ (PARTITION-STORE-WASM.md §4.3). CI's trap was a `memory.fill` right after an
1599
+ `operator new` on the type-merge helper, into memory another thread had just
1600
+ grown; V8 bounds-checks it against the thread's stale view of the memory's
1601
+ size. The wasm test main and `createPsNodeInstance` now grow the heap to the
1602
+ memory's maximum before any guest thread starts, and the Node host reports
1603
+ any growth after threads start. Natively, under ASan and UBSan, T2_1 found no
1604
+ memory error (loops below). UBSan found two defects:
1605
+ - A misaligned `uint64_t` reference: `Counters` is packed (on-disk).
1606
+ `Partition::counters` is now 8-aligned.
1607
+ - Null-pointer arithmetic in `EntryIter::next` (index_l0.cpp). That fix
1608
+ lands with flatsql-format2-query-gaps.
1609
+
1610
+ Under WasmEdge the same runs exposed a WasmEdge 0.16.4 AOT defect: atomic
1611
+ wait and notify dropped the memarg offset. SDN's static runtime carries the
1612
+ fix, patch `04-atomic-memarg-offset` (SDN 74db37e14). `sleepNs` passes its
1613
+ address through a volatile, so its wait carries no memarg offset.
1614
+ `memory_safety_sleep_and_notify_at_folded_offsets` fails on a runtime without
1615
+ the fix.
1616
+
1617
+ **ThreadSanitizer.** The whole default suite runs TSAN-clean (Docker, gcc 13
1618
+ `-fsanitize=thread`, `--cpus 6`) apart from the stage-1 races below. Each
1619
+ race found:
1620
+
1621
+ | Where | What | Verdict, change |
1622
+ |---|---|---|
1623
+ | partition_log.cpp:2509 (`partitionPublish`) against type_owner.cpp:671 (`typeStage`) | The owner publishes a partition's commitSeq, pseqHi and L0 directory inside its seqlock; the type owner copies them and retries on a moved sequence. | Benign by design (a seqlock), but a plain copy on both sides is a data race in C++. Both sides now copy 8-byte words with relaxed atomic accesses (`publishPartition`, `readPublishedPartition`, writer.h). The sequence counter's fences order them. `PublishedPart::l0` is 8-aligned. `memory_safety_seqlock_publication_consistent`: readers racing a publishing owner only ever copy out whole states. |
1624
+ | writer_pool.cpp:230 (`SyncPool::runAll`) against `helpOne` | A pool thread reads a slot's `jobs` pointer while the writer publishes its next round; that stale pointer's claim then fails. | Benign by design. The pointer is `std::atomic` (relaxed); the round's `n` (release/acquire) orders the jobs. `memory_safety_sync_pool_rounds_run_each_job_once`: 6 writers × 400 rounds, each job runs exactly once with its own result, and the pool threads help. |
1625
+ | lane_arena.cpp:415 (`mxAlloc`) | Every lane's connection allocates SQLite's static mutexes again, which rewrote their shared wrappers. | Real (same values, rewritten under readers). The wrapper is filled once (`std::call_once`). |
1626
+ | lane.cpp:930 (`oldestActiveStart`) against `~ReaderInstance` | `Engine::setReaderGate` returned while a writer thread could still be inside the previous gate function, and the caller then destroyed the reader instance it reads. | Real: a use-after-free window. `setReaderGate` now clears the gate, waits for the gate calls in flight (a counter), then installs the new pair. |
1627
+ | merge.cpp:732 against open_test.cpp:296; reader_concurrency_test.cpp:216; compaction_test.cpp:565 | Tests read the owner's `nL0` from the main thread, or a latency series without its lock. | Test races. The tests read the published directory and take the lock. |
1628
+ | stage1.cpp:322 against stage1.cpp:499 (prep slots); ring.cpp:125 (a helper copies an entry the owner has consumed) | The owner republishes a slot it stole from a helper while the helper still reads it. | Benign by design (the helper's result is discarded by a failing CAS), and memory-safe (the copy stays inside the slab pool). Atomic slot fields and a racy-copy ring read belong to flatsql-ps-partition-bookkeeping-linear. |
1629
+
1630
+ **T3 #6: the first emergency could stall for good** (§36). Traced under
1631
+ TSAN:
1632
+ - The eviction wave's partition had a full L0 directory (47 blocks) from
1633
+ ingest before the emergency, so its TOMB_RANGE needed a merge first.
1634
+ - Meanwhile the room the ballast gave back was spent by maintenance outside
1635
+ the eviction: the other partition's merges, and a compaction nobody
1636
+ requested. Its build failed NOSPACE and started over with a new intent
1637
+ batch in the meta log about ten times a second. Usage crept 8,960 B/s until
1638
+ the device was exactly full.
1639
+ - From then on no batch committed. The wave's merge could not write outputs
1640
+ and no TOMB step could stage, for the rest of the minute.
1641
+
1642
+ 36. **In a space emergency, maintenance leaves the ballast's room to the
1643
+ eviction** (A13 assumes the ballast holds one segment's tombstones, and
1644
+ says nothing of what else may spend it). Only a partition with eviction
1645
+ work waiting merges (TOMB_RANGE steps, type-level kills, a requested
1646
+ compaction: `partitionWantsMerge`). A compaction the planner did not
1647
+ request does not start (`Writer::maintenance`); one in flight finishes.
1648
+ Ring entries stop `kL0ControlSlots` (4) L0 slots before the directory's
1649
+ cap, which mailbox kills and TOMB_RANGE steps may use, so a wave's first
1650
+ steps never wait for a merge.
1651
+
1652
+ Measured, in Docker:
1653
+ - **Before:** under TSAN, 4 of 12 runs, and 1 of 7 traced ones, stayed in
1654
+ the first emergency until the test's limit (70 s).
1655
+ - **After:** 50 of 50 TSAN runs recovered (whole test 19–43 s), and 340 of
1656
+ 340 Debug runs (gcc `-O0`, 8–10 s each). The test also passes natively on
1657
+ the Mac and under the Node host.
1658
+
1659
+ **Loops** (Docker runs had a CPU hog on one of their CPUs; the Mac ran at
1660
+ load 17–95):
1661
+
1662
+ | Item | Loop | Runs | Result |
1663
+ |---|---|---|---|
1664
+ | The trap | T2_1 under the Node host, heap grown to the maximum (Mac) | 500 | 0 traps. No run grew memory after a thread started. One run ended in a Node 25.4.0 SIGILL inside V8's JIT code (a host crash, not a guest trap). |
1665
+ | The trap | T2_1, native, ASan + UBSan | 488 before the T3 #6 change, then more on these sources | 0 ASan errors. UBSan reports only index_l0.cpp:319. |
1666
+ | The trap | T2_1 memio, WasmEdge 0.16.4 AOT with patch 04, no pre-grow (Mac) | 140 | 0 traps, 0 hangs. 2 failed: the memio command keeps every file in guest memory, and one run ran out of the 4 GiB address space (passing runs end near 2.7 GiB after about 500,000 records). The other run's output was not kept. |
1667
+ | Seqlock, sync pool | `memory_safety_s*` under TSAN | 520 | 0 warnings, 0 failures |
1668
+ | All races | The default suite under TSAN | 1 | Clean except the stage-1 races above. `arrivals_paging_while_writers_produce_T2_6` misses its 60 s label wait under TSAN, exactly as on 14a3075. `reader_point_memory_plateaus_under_commits` misses its idle-round bound under TSAN (0.155 MiB against 64 KiB). Both pass natively and under the Node host. |
1669
+ | T3 #6 | TSAN; Debug | 50; 340 | 0 stalls |
1670
+
1671
+ **Writer memory that grows with the store.** §37's table lists the writer's
1672
+ per-store structures. Measured here with 200,000 OMM records in one partition
1673
+ at the defaults:
1674
+ - A warm partition's L1 accelerators (`SegmentInfo::runs`, loaded for every
1675
+ segment by `partitionWarm`, merge.cpp:172) hold 6.0 B per record of the
1676
+ partition's history. A partition written without a pause never cools
1677
+ (`idleCloseMs`).
1678
+ - A type's catalog accelerators (`TypeOwner::runs`, loaded by `typeWarm`,
1679
+ type_owner.cpp:580) hold 3.45 B per record of the type. A type owner never
1680
+ cools.
1681
+ - Every registered partition keeps a 5,952-byte `Partition` (writer.h:505)
1682
+ plus its ring descriptor, active or not.
1683
+
1684
+ At 10^9 records (a terabyte of OMM-sized records) the catalog alone is
1685
+ 3.4 GB in one writer instance, over the wasm32 limit.
1686
+
1687
+ # Part VII: query gaps
1688
+
1689
+ ## 39. Query gaps found on the host-02 fixture (T6)
1690
+
1691
+ T6 compared format 2 with format 1 on a host-02-sized migrated store (4.36 M
1692
+ records: OMM 1.70 M in one partition, MPE 1.69 M, CAT 141 k, IQC 419 k held by
1693
+ two producers). Five read shapes were slower than format 1, and store-migrate
1694
+ refused one record shape. Each is now an engine plan or check, held to a
1695
+ reference by `cpp/test/ps/query_gaps_test.cpp` (`query_gaps_*`) and to format 1
1696
+ by SDN's fixture comparison.
1697
+
1698
+ **Text CID order (A17).** `ORDER BY _cid [DESC]` with no other index
1699
+ condition is its own plan (`kAccCidOrder`). At type level it reads the cid
1700
+ catalog (`kIxTypeCid`), whose keys are the A17 sort key: one ordered pass
1701
+ gives the live CIDs in text order. A key's entries resolve per copy (the
1702
+ latest type commit wins; DEAD over the rest at an equal tcs, as `catalog()`
1703
+ does); the live FIRST or PROMOTED copy is the record, and only its row is
1704
+ read. An `OFFSET` with no other condition is pushed down and counted from
1705
+ catalog entries alone: no row, frame or posting of a skipped record is read.
1706
+ At partition level the plan reads the partition's CID postings. With other
1707
+ conditions the plan still gives the order; the conditions are checked per
1708
+ row.
1709
+
1710
+ **Per-object point profiles (A18).** Hidden input columns `_asof`,
1711
+ `_forward` and `_nearest` (epoch seconds, `=` only) select `kAccObjPoint`.
1712
+ Per object key it returns every live type-level row at the object's best
1713
+ second: `_asof` the latest second at or before T, `_forward` the earliest at
1714
+ or after, `_nearest` the closer of the two with a tie to the earlier. The
1715
+ plan reads the `OBJECT_EPOCH` postings `(object key, epoch)` of every
1716
+ partition, restricted by the manifests' epoch zone maps to the segments that
1717
+ can hold a candidate, merges the partitions by object key, and reads rows only
1718
+ for the candidates of each object's best seconds (from the closest outward,
1719
+ until one is live). Tag conditions and one `_epoch` range are applied before
1720
+ the choice; any other condition is refused (`SQLITE_CONSTRAINT`: SQLite would
1721
+ apply it after the choice, which is another query). `_object` projects the
1722
+ key: a u64 key in decimal, otherwise the (capped) string, NULL for a record
1723
+ without one. Records without an object key are read only from partitions whose
1724
+ `OBJECT_EPOCH` postings are fewer than their CID postings (the EPOCH index,
1725
+ their frames); each is its own entity. For OMM (NORAD, then OBJECT_ID) and MPE
1726
+ (ENTITY_ID) the object key is the legacy entity key, so SDN ranks the
1727
+ candidates exactly as format 1 did.
1728
+
1729
+ **The sandbox window (A18).** Untrusted SQL reads `<TYPE>`, `<TYPE>@<source>`
1730
+ and every other type-level shape except a CID or gseq lookup from the arrivals
1731
+ tail: the newest N entries (`hotWindow`, dead entries included), the
1732
+ statement's conditions applied per row. An alias or source statement no longer
1733
+ walks every posting of its source; its work is bounded by N whatever the
1734
+ store's size.
1735
+
1736
+ **Tag conditions on every copy (A2).** Type-level tag conditions (`_provider`,
1737
+ `_source_name`, `_batch`, `_peer_id`, `_source`, and the alias source) match a
1738
+ record when a live instance of any live copy (FIRST or REPEAT, any partition
1739
+ the plan reads) meets all of them, as the legacy tag table held every
1740
+ producer's tags per record. This extends §31.1 and T2 deviation 8.
1741
+ Row-driven plans check the FIRST copy's own tag first, then the other copies'
1742
+ own tags (listed by the cid catalog, only when the type has REPEAT postings),
1743
+ then RETAG instances. Posting-driven plans (tag, source, collected pages) let
1744
+ a live REPEAT copy's instance stand for its record: the record's FIRST row is
1745
+ emitted by the first matching copy in (FIRST, then REPEATs by pid, pseq)
1746
+ order, so each record comes once. `<TYPE>@<source>` is `<TYPE>` with that
1747
+ condition. The checks cost little per row: an instance's tuple is its lane's
1748
+ (`RecRow.laneId`, the partition's lane table read once per statement), a copy
1749
+ whose pseq is above the highest `max_pseq` of the lanes that meet the
1750
+ conditions cannot match (its RETAGs come after it), TAG_OF lookups read only
1751
+ the segments that can hold them and resume in the block the previous row's
1752
+ lookup read, and DEAD / TAG_DEAD lookups are skipped for a snapshot that holds
1753
+ no posting of the kind.
1754
+
1755
+ **Gseq-ordered tag pages (A16).** At type level, `ORDER BY _gseq` with tag
1756
+ conditions and a LIMIT (or a closed gseq range) is served in arrivals order
1757
+ with each row's tags checked, unless the lane counters say the tags are rare
1758
+ (at most 262,144 matching instances and under an eighth of the type's
1759
+ arrivals): then the tags' postings are collected (the most selective of batch,
1760
+ source, provider, peer), sorted by gseq and emitted in order. A datasync page
1761
+ by source no longer sorts every match of the source.
1762
+
1763
+ **Records stored with their own size prefix.** A record that carries its own
1764
+ size prefix (a `FinishSizePrefixed` buffer stored as is: SDN's
1765
+ dataset-publication PNMs and the local EPM) was refused with `kRejFid` (its
1766
+ identifier sits at frame offset 12), so store-migrate rejected 131 records of a
1767
+ dev node and refused to activate. `frameRootOffset()` (`extract.h`) finds the
1768
+ FlatBuffer after both prefixes (the record's u32 equals `len - 8` and the
1769
+ identifier is at 12) for the frame check, the verifier (at the record's own
1770
+ alignment origin), key extraction and column projection. The stored bytes and
1771
+ the CID (over the record, prefix included) are unchanged.
1772
+
1773
+ **Reads ahead.** Rows met in ascending pseq order (arrivals order, epoch and
1774
+ tag postings, pseq scans) are read 32 at a time, and a frame read covers the
1775
+ frames of the rows read ahead in its segment (up to 256 KiB).
1776
+ `rowsExamined` still counts the rows a statement uses.
1777
+
1778
+ **Measured** through SDN's storage API on the fixture (`TestZZBench`-style
1779
+ calls, each query twice in a fresh process: the first call pays the
1780
+ instance's warm-up and the page cache; format 1 is the same store before
1781
+ migration). Mac Studio (M3 Ultra, 28 cores), shared with other lanes, load
1782
+ 47–55. Format 2 "before" is T6's run on the same fixture.
1783
+
1784
+ | Gap | Query | Format 1 (call 1 / 2) | Format 2 before | Format 2 after (call 1 / 2) |
1785
+ |---|---|---|---|---|
1786
+ | 1 | OMM CID order, LIMIT 500 OFFSET 1000 | 730 ms / 26 ms | 9.9–28.4 s | 2.65 s / 3.6 ms |
1787
+ | 1 | CAT CID order, LIMIT 200 OFFSET 60000 | 2.39 s / 67 ms | 430–537 ms | 589 ms / 11 ms |
1788
+ | 1 | IQC CID order, LIMIT 1000 OFFSET 100000 | 3.90 s / 153 ms | 5.0–5.5 s | 4.14 s / 35 ms |
1789
+ | 2 | OMM `nearest` (32,015 objects) | 99 s / 22.7 s | 56–79 s | 10.6 s / 2.3 s |
1790
+ | 2 | OMM `as_of`, one source | 36 s / 19.5 s | 9.7–13.1 s | 3.9 s / 4.2 s |
1791
+ | 2 | OMM `forward` | 14.6 s / 19.5 s | 8.4–82 s | 6.8 s / 6.1 s |
1792
+ | 2 | MPE `nearest`, 2 h delta | 50 s / 17.6 s | 29–35 s | 7.1 s / 6.1 s |
1793
+ | 3 | sandbox `SELECT _data FROM "CAT@celestrak-satcat"` (N = 10,000) | 4.4 / 3.9 ms¹ | `timeout` (work budget) | 55 / 11 ms |
1794
+ | 3 | `"CAT@celestrak-satcat-csv"` | 1.3 / 1.3 ms¹ | `timeout` | 3.2 / 4.5 ms |
1795
+ | 3 | `"IQC@IQEngine"` | 14.8 / 13.3 ms¹ | `timeout` | 67 / 32 ms |
1796
+ | 3 | `"MPE@celestrak-gp"` | 3.2 / 4.3 ms¹ | `timeout` | 68 / 7.3 ms |
1797
+ | 4 | IQC source window, LIMIT 1000 (two producers) | 12.7 s / 2.0 s | 111–121 ms (per partition, merged in SDN) | 183 / 30 ms (one statement) |
1798
+ | 5 | IQC datasync by source, 5 pages of 500 | 133 s / 190 s | about 3.8 s a page | 58 / 32 ms (all 5) |
1799
+
1800
+ ¹ Format 1's hot window is an in-memory copy of the newest N records, filled
1801
+ by a hydration (17 s to 5.5 min on this fixture) and held in the arena whose
1802
+ growth is what format 2 replaces. Format 2 reads the window from disk: the
1803
+ engine's own cost for the CAT query is 2.4 ms natively (the rest is the wasm
1804
+ instance, the result ring and SDN's copy). Gap 3's acceptance (the statement
1805
+ answers within the work budget, bounded by N) is met; parity with the
1806
+ in-memory copy is not.
1807
+
1808
+ The first call of a fresh process pays the instance's warm-up and reads the
1809
+ cid catalog's blocks into the page cache (2.65 s for OMM against 730 ms for
1810
+ format 1's index); every later call is faster than format 1.
1811
+ `TestFormat2DaemonReadsEqualTheLegacyFixture` (SDN) passes with every section
1812
+ (windows, A17 exports, A16 syncs, epoch profiles, A18 relations) equal to
1813
+ format 1 on this fixture.
1814
+
1815
+ **store-migrate with size-prefixed records.** A copy of a dev node's store
1816
+ (9.2 GB control database, 163 PNMs in 37 partitions, 1 EPM): 0 copies
1817
+ rejected (131 before), partitions (58) and lanes (58) equal, 9 of 10 CID
1818
+ sequences equal. The tenth (IQC) differs because that store's index holds
1819
+ about 1.06 M IQC rows no producer table holds (orphans the legacy verifier's
1820
+ sequence includes; filed for SDN). `TestStoreMigrateActivatesWithSizePrefixedRecords`
1821
+ (SDN): a store with 12 PNMs in two partitions and an EPM migrates with 0
1822
+ rejects and 0 mismatches, activates, and serves the same bytes.
1823
+
1824
+ **What these cost at 1 TB** (per statement; P partitions of the type, S
1825
+ segments per partition, R type catalog runs):
1826
+
1827
+ - CID order: `(offset + limit) x copies per CID` catalog entries of about
1828
+ 72 B (about 56 per 4 KiB block) plus one row read per emitted record. The
1829
+ skip is linear in the offset, as format 1's b-tree OFFSET is. Per-block live
1830
+ counts in the catalog's fences would make it logarithmic, but a CID's copies
1831
+ die in later type commits than their FIRST labels, so the type owner would
1832
+ have to post a CID-keyed marker when a CID's last copy dies (outside this
1833
+ change: `type_owner.cpp`).
1834
+ - Point profiles: the `OBJECT_EPOCH` postings (about 30 B each) of the
1835
+ segments whose epoch zone can hold a candidate: for `_forward` near the
1836
+ present, the newest segments; for `_asof` and `_nearest`, the segments
1837
+ before (or around) T. No payload is read to rank; rows are read for about one
1838
+ to two candidates per object. Enumerating the objects stays linear in the
1839
+ postings of those segments: a per-partition object directory would make it
1840
+ proportional to the objects.
1841
+ - Sandbox window: at most N arrivals entries (24 B) and N rows, whatever the
1842
+ store's size.
1843
+ - Tag conditions: per row an O(1) lane check, and a TAG_OF lookup only for a
1844
+ copy that a matching lane can still reach; a catalog lookup (R bloom-gated
1845
+ runs) only in a type with REPEAT copies.
1846
+ - Gseq tag pages: LIMIT / selectivity arrival entries and rows per page
1847
+ (arrivals mode), or the tags' matching instances (collected mode, at most
1848
+ 262,144).
1849
+ - Size-prefixed records: nothing (a 4-byte comparison per record).
1850
+
1851
+ # Part VIII: writer bookkeeping at scale
1852
+
1853
+ ## 40. Per-partition bookkeeping at scale (TB audit B4, M3)
1854
+
1855
+ The terabyte audit (stack `docs/architecture/flatsql-ps-terabyte.md`, B4)
1856
+ found a partition's bookkeeping growing as O(S²) in its segment count S. A
1857
+ single dominant partition (the GP archive import, a hot-split partition)
1858
+ reaches S = 1,000 at about 170 GB and S = 6,000 at about 1 TB. With 128 KiB
1859
+ seals, the rate fell from 116,829 rec/s at S = 235 to 465 rec/s at
1860
+ S = 5,188. Commit rounds reached a p99 of 100.7 ms and maintenance steps
1861
+ 100.7 ms. The causes:
1862
+
1863
+ - The ledger (§26) was a vector searched linearly.
1864
+ - `partitionPublishSummary` made three ledger scans per sealed segment on
1865
+ every commit that carried a MERGE_DONE, SEAL, SWAP, RETIRE or UNLINKED.
1866
+ - The compaction look walked every segment every 50 ms, with the same three
1867
+ scans and an L0 scan per segment.
1868
+ - Segment lookups were linear.
1869
+ - Open rebuilt the ledger in O(S²).
1870
+ - A MERGE_DONE and a SWAP recomputed the accelerator bytes by walking every
1871
+ run.
1872
+ - A split partition re-signed its run set on every commit by walking every
1873
+ run, and its helpers matched their previous view run by run in O(runs²).
1874
+
1875
+ What the owner keeps now (`PartitionLedger`, `ps/quota.h`):
1876
+
1877
+ - **The ledger** is an open-addressing table keyed by `(letter, seg, gen)`,
1878
+ with the same matching rules as before: manifests by generation, `d r a m`
1879
+ by segment, the rest by both. The byte total changes with every set and
1880
+ drop. A lookup, change or drop costs O(1).
1881
+ - **A change feed.** A ledger change to a segment's files, a new entry, a
1882
+ kill count, a survey and every input of a SWAP name the segments
1883
+ concerned. The planner's summary and the compaction candidate index update
1884
+ only those segments. The summary does this on the commits that publish
1885
+ it, the index on each look. Open rebuilds both in one walk.
1886
+ - **The compaction candidate index** applies §24's rules unchanged:
1887
+ - segments past the dead ratio, ordered by share (largest first, lowest
1888
+ id among equals);
1889
+ - starts of adjacent small pairs, where the first run of small segments
1890
+ begins at the lowest pair and grows within the coalescing limits;
1891
+ - segments not surveyed since a restart.
1892
+ A look costs O(log S), plus the output's neighbours.
1893
+ - **Segment lookups** binary-search the ascending entry list. Entries are
1894
+ created in order, and a pseq search steps over entries that hold no rows
1895
+ yet. Open walks the unmerged sealed segments alongside the entries.
1896
+ - **Accelerator bytes** change by the runs a MERGE_DONE or SWAP adds and
1897
+ removes.
1898
+ - **The split run set** is signed by the manifest generation, since every
1899
+ change to it commits a new one. A republish for new L0 blocks shares the
1900
+ run list instead of copying it. Helpers find reusable runs by
1901
+ `(seg, gen)`.
1902
+
1903
+ Stage-1 helpers (§32) no longer race the owner or producers (ThreadSanitizer):
1904
+ - A slot's `pos`, `len` and `kind` are relaxed atomics. The owner may take a
1905
+ CLAIMED slot and republish it for the next ordinal while the helper still
1906
+ reads them.
1907
+ - A helper copies an entry with `ringReadShared` (relaxed 8-byte word
1908
+ loads), never through a pointer into the ring. After a steal, the owner may
1909
+ consume and release the entry, and a producer may rewrite its bytes while
1910
+ the helper copies.
1911
+ - `ringWrite` stores relaxed words and edge bytes, which natively cost what
1912
+ plain stores do; the wasm hosts write slabs themselves.
1913
+ - In both cases the helper's CLAIMED to WRITING step fails and its result is
1914
+ dropped, as before. What it parsed is its own copy.
1915
+ - Slabs must be whole 8-byte words.
1916
+
1917
+ Deviations:
1918
+
1919
+ 37. **The RETIRE set is held under what one batch carries** (A12 said: past
1920
+ 2,048 items, the oldest go regardless of the gate once a grace has
1921
+ passed). The set rides one ctl record, whose length is a u16, inside the
1922
+ batch's 64 KiB ctl buffer, alongside UNLINKED (up to 1,024 items) and the
1923
+ lane table. A set that does not fit refuses every batch of the
1924
+ partition. A busy partition retires a manifest and folded runs per
1925
+ merge, and a coalescing SWAP retires a dozen files per input, so within
1926
+ the 60 s grace its set outgrew a batch. Ingest then stopped for the rest
1927
+ of the grace. The audit harness showed 843 rec/s for 50,000 records at a
1928
+ time, about every minute. The valve counts the set as its record
1929
+ encodes it (consecutive manifest generations collapse). The soft cap is
1930
+ 2,048 items, or less when a large lane table leaves less room. Past it,
1931
+ the oldest items are unlinked at once, without waiting for the reader
1932
+ gate or the grace:
1933
+ - A statement older than those items gets the retryable SNAPSHOT_GONE.
1934
+ - A file held open is BUSY and waits as before.
1935
+ - A meta segment still waits for a durable head (A9).
1936
+ - Crash safety never depended on the grace. The batch that retired a
1937
+ file is durable before its retirement counts, and open replays that
1938
+ batch.
1939
+ - While a compaction in flight pins the newest items and the set is past
1940
+ halfway from the soft cap to the hard cap, merges wait. Merges are the
1941
+ steady source of retirements, so the SWAP or abort can land first.
1942
+ 38. **Manifest version 3 (M3)** carries the segment count as a u32 in the
1943
+ header's last word (the u16 then reads 0xFFFF). It is written only when
1944
+ a partition's list outgrows the u16, so an engine that predates it still
1945
+ reads every store it can represent, and refuses a version 3 manifest
1946
+ instead of misreading it. Every version now decodes strictly: the
1947
+ records must end exactly at the CRC trailer. Before, a version 2
1948
+ manifest over 65,535 segments wrapped its count, passed the CRC and lost
1949
+ the rest.
1950
+
1951
+ Measured on the Mac (Darwin 25.3.0, arm64, 28 hardware threads, shared) with
1952
+ the in-memory fault host. One partition, 128 KiB seals, coalescing off (so S
1953
+ is the seal count), 400 B pad records, lockStats on:
1954
+ `bookkeeping_scale_S5k`. The base column is 0df6ef4 (its writer is
1955
+ 14a3075's) with the same test. Each row is the 50,000-record window that ends
1956
+ at that S.
1957
+
1958
+ | | S = 235, base | S = 235 | S = 5,188, base | S = 5,188 |
1959
+ |---|---|---|---|---|
1960
+ | commit round p99 | 0.328 ms | 0.229 ms | 100.7 ms | 0.115 ms |
1961
+ | maintenance step p99 | 0.082 ms | 0.049 ms | 100.7 ms | 0.328 ms |
1962
+ | summary refresh p99 (per commit that publishes it) | | 0.016 ms | | 0.004 ms |
1963
+ | candidate look p99 | | 0.033 ms | | 0.012 ms |
1964
+ | open (half, full: S = 2,594, 5,188) | | | 45.7 ms, 170 ms | 4.0 ms, 4.5 ms |
1965
+ | rec/s | 116,829 | 99,796 | 465 | 5,386 |
1966
+
1967
+ Load was 39–60 for the base run and 22–30 for the new one. The same test on
1968
+ this change over d566293, at load 26–37: commit p99 0.393 → 0.229 ms,
1969
+ maintenance p99 0.066 → 0.786 ms, summary refresh 0.012 → 0.020 ms,
1970
+ candidate look 0.049 → 0.016 ms, open 6.0 ms at S = 5,188, rate 77,556 →
1971
+ 3,363 rec/s.
1972
+
1973
+ Over the same 200,000 records (S = 0 to 940), the process used 5.9–6.9 s of
1974
+ CPU against 9.2–10.3 s on 14a3075 (six alternating runs). Near S = 235 the new
1975
+ build's wall rate was lower than base's by about 7% in alternating runs
1976
+ (82,405 against 94,237 rec/s, load 30–37), while at S ≈ 11 the two were equal.
1977
+ With shorter commit rounds, the writer commits smaller batches (about 940
1978
+ commits per 50,000 records against 700) and waits idle for the test's single
1979
+ producer. The time is lost in the handshake, not in work.
1980
+
1981
+ On ext4, in Docker's Linux VM on the same Mac, the audit's harness
1982
+ (`tb_segments_scaling`: fsync on, one partition, 128 KiB seals, 550,000
1983
+ records) was bound by the VM's shared virtual disk. Other lanes' containers
1984
+ were using it too, so both builds ran near 500 rec/s at S = 235:
1985
+
1986
+ | build | S = 235 | S = 1,886 | S = 2,594 | reopen at S = 2,594 | whole run |
1987
+ |---|---|---|---|---|---|
1988
+ | 14a3075 | 475 rec/s | 211 rec/s | 356 rec/s | 21.0 s | 31.3 min |
1989
+ | this build | 522 rec/s | 795 rec/s | 781 rec/s | 3.6 s | 19.0 min |
1990
+
1991
+ `bookkeeping_scale_S5k` itself on the same ext4 volume (`--dir`) is disk
1992
+ bound: commit rounds wait on fsync (p99 336 ms at S = 235, 470 ms at
1993
+ S = 5,188). The bookkeeping in them stays flat, though: the summary refresh
1994
+ p99 is 0.041 → 0.033 ms and the candidate look p99 0.066 → 0.029 ms, and
1995
+ open at S = 5,188 takes 134 ms. With `--dir` the test reports commit and
1996
+ maintenance p99 without gating them.
1997
+
1998
+ The rate that remains decays with S because of B5: every record's CID dedupe
1999
+ probes the bloom of every L1 run of every segment (`committedPostings`,
2000
+ `partition_log.cpp`). `sample` at S ≈ 4,900 puts 94% of the writer's samples
2001
+ in `cidState` and about 1% in the paths this section changed: maintenance,
2002
+ commit rounds and reclamation together account for 184 of 12,272 samples.
2003
+ Per-partition levelled lookup indexes (B5) are the terabyte design's.
2004
+
2005
+ What still costs O(S), and where it belongs:
2006
+ - A merge snapshots every segment into the manifest it rewrites (M3's delta
2007
+ or two-level manifest). The maintenance p99 above (0.05 → 0.33 ms) is
2008
+ that.
2009
+ - A SWAP writes a whole manifest and rebuilds the entry list.
2010
+ - A split partition's helpers reopen every run file per run set (B2's
2011
+ handle LRU).
2012
+
2013
+ Tests (`cpp/test/ps/bookkeeping_scale_test.cpp`):
2014
+ - `book_manifest_v3_codec`: exact round trips at 0, 1, 65,535, 65,536 and
2015
+ 70,001 segments; a wrapped v2 count, a stray byte, a bad v3 header and
2016
+ version 4 refused; version 1 read.
2017
+ - `book_retire_valve_keeps_ingest_flowing`: 60,000 records at the default
2018
+ 60 s grace. 16,767 files retired, 14,299 unlinked by the valve, the
2019
+ encoded set peaked at 2,113, 0 batches refused. With the valve off, the
2020
+ same test refused 4,397 batches and one 5,000-record chunk took 59.5 s.
2021
+ - The valve test also reports the writer's memory, and none of it grows
2022
+ with records. From 25,000 to 60,000 records:
2023
+ - slab pool: 3.9 → 4.1 MB;
2024
+ - ledger: 2,271 → 2,398 files (67 → 69 KB);
2025
+ - RETIRE set: 2,145 → 2,217 items (160 KB);
2026
+ - segment list: 54 → 68 entries (12 → 15 KB).
2027
+ The process does grow: 1.47 GB at 25,000 records and 3.55 GB at 60,000
2028
+ (3.4 GiB of wasm's 4 GiB). That is the in-memory test host, which keeps
2029
+ every file it ever wrote (7,212 files, 1.45 GB; then 17,281 files,
2030
+ 3.52 GB) so that data calls need no lock. The rest of the heap
2031
+ (13 → 25 MiB) grows by the host's 231 B per file. On wasm the test runs
2032
+ 25,000 records.
2033
+ - `book_equivalence_all_commit_kinds`: runs with `gBookCheck`, which checks
2034
+ every ledger operation against the linear ledger it replaced, and every
2035
+ segment lookup, summary refresh, candidate pick and split run-set
2036
+ signature against a walk. A mismatch aborts. The run covers seals,
2037
+ merges, SWAPs (dead-ratio, coalescing, requested, quota evictions),
2038
+ RETIRE and UNLINKED, meta-segment retirement, a hot split, a clean
2039
+ reopen and a kill -9.
2040
+ - `book_crash_every_op` (every 37th mutating call) and `_full` (every one:
2041
+ 9,416 crash points over seals, merges, SWAPs, retirement, unlinking and
2042
+ A9, all five crash modes, 0 failures): after each reopen, the rebuilt
2043
+ ledger names exactly the files on disk at `disk_bytes`, counters equal a
2044
+ recount, and the engine carries on under `gBookCheck`.
2045
+ - `book_check_fast_suite` (slow): every fast test again with `gBookCheck`
2046
+ (88 of 88 passed).
2047
+ - `bookkeeping_scale_S5k` (slow): the table above. It gates commit and
2048
+ maintenance p99 under 10 ms at S ≥ 5,000, and the summary refresh and
2049
+ candidate look within 2× (+50 µs) of their S = 235 cost. On the base
2050
+ commit it fails both p99 gates.
2051
+
2052
+ Sanitizers, on this build over d566293 (macOS, clang):
2053
+ - ThreadSanitizer reports nothing on eleven tests: the four book tests,
2054
+ `quota_arrival_order_heads_spared_T3_3`,
2055
+ `compaction_cat_supersede_plateau_T3_2`,
2056
+ `orphan_crash_points_during_compaction_T3_4`, and the four fast hot-split
2057
+ tests.
2058
+ - AddressSanitizer with UBSan reports nothing on the book tests,
2059
+ `compaction_removes_dead_rows_keeps_live`,
2060
+ `quota_arrival_order_heads_spared_T3_3`, the orphan harness,
2061
+ `hot_split_equals_unsplit_reference_T3_5` and `crash_faults_T1_1`. It had
2062
+ found one defect in this section, fixed: `std::min` bound a reference to a
2063
+ packed counter.
2064
+ - The stage-1 race, before and after, on Docker linux/arm64 with gcc 13 and
2065
+ on macOS:
2066
+ - 14a3075 reports it in 3 of 5 hot-split runs (`hot_split_throughput_T3_5`,
2067
+ and `hot_split_rebalance_stalled_helpers_A26` at 12 s, twice);
2068
+ - this change reports it in none of 6 runs.
2069
+ - A two-thread program (`ringWrite` against a helper's read of the same
2070
+ bytes) shows it on Linux with a plain reader, and still shows it with
2071
+ atomic loads against a plain `memcpy` write. It is silent only once both
2072
+ sides are atomic.
2073
+ - Under ASan the equivalence test needs more than its default 60 s to settle
2074
+ (it passes with `--eq-settle-s=600`).
2075
+
2076
+ Mutations the checks caught:
2077
+ - a missing kill touch;
2078
+ - a SWAP input not touched;
2079
+ - a ledger update dropped;
2080
+ - the valve off.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flatsql",
3
- "version": "3.4.0",
3
+ "version": "3.5.1",
4
4
  "description": "SQL queries over raw FlatBuffer storage: the SQLite-backed wasm engine and the multithreaded partition store (flatsql-ps-threads.wasm)",
5
5
  "type": "module",
6
6
  "main": "wasm/index.js",
Binary file
@@ -5,9 +5,9 @@
5
5
  "size": 2246620,
6
6
  "files": {
7
7
  "flatsql-ps-threads.wasm": {
8
- "sha256": "28f56226f97c16bc7e88c88c81003b0685a22a65f272e8cad7837ad380d0dd6d",
9
- "sri": "sha384-uEQxf2jUYcPEQxCngoWcGlrAp2Fudkk/uK8OytYA4WRK+dp6S3D0HHlFd2maSwps",
10
- "size": 2334491
8
+ "sha256": "3a215da45a53f3a7016257429c392720e728caf850d3a1213dc501bfb5844359",
9
+ "sri": "sha384-HJK5ECR2v2kAKnOHsyvXE+QgSywyuEUa2ipQQMhxR22dg7SZPAYPY985iyy2O7FP",
10
+ "size": 2485719
11
11
  },
12
12
  "flatsql-wasi-noeh.wasm": {
13
13
  "sha256": "8f11fd49ee2e6961b9c1c22dd891d5a6645d0faf481f815408c0f1f5d3a385ab",
@@ -144,6 +144,23 @@ if (mode === "pool") {
144
144
  reply(ptr);
145
145
  } else if (msg.op === "read") {
146
146
  reply(mem().slice(msg.ptr, msg.ptr + msg.len));
147
+ } else if (msg.op === "growHeap") {
148
+ // The heap to msg.limitBytes of memory before any guest thread starts
149
+ // (ps-node-host.mjs heapLimitBytes): allocate in steps from 1 GiB
150
+ // down to 1 MiB until each fails, then free them all. The guest
151
+ // allocator keeps the freed blocks; wasm memory never shrinks.
152
+ const { flatsql_ps_alloc: alloc, flatsql_ps_free: free } = instance.exports;
153
+ const held = [];
154
+ for (let step = 2 ** 30; step >= 2 ** 20 && held.length < 64; ) {
155
+ const p = memory.buffer.byteLength + step + 2 ** 20 <= msg.limitBytes ? alloc(step) : 0;
156
+ if (!p) {
157
+ step /= 2;
158
+ continue;
159
+ }
160
+ held.push(p);
161
+ }
162
+ for (const p of held) free(p);
163
+ reply(memory.buffer.byteLength);
147
164
  } else {
148
165
  throw new Error(`unknown op ${msg.op}`);
149
166
  }
@@ -11,6 +11,8 @@ export interface PsThreadReport {
11
11
  workerThreads: number;
12
12
  distinctWorkerThreadIds: number;
13
13
  memoryBytes: number;
14
+ /** Bytes the memory grew after the first guest thread ran (V8: a trap hazard). */
15
+ memoryGrownWhileThreadsRan: number;
14
16
  }
15
17
 
16
18
  export interface PsMount {
@@ -39,6 +41,13 @@ export interface PsHostOptions {
39
41
  deterministic?: { randomSeed?: bigint };
40
42
  initialPages?: number;
41
43
  maximumPages?: number;
44
+ /**
45
+ * createPsNodeInstance grows the guest heap to the memory's maximum before
46
+ * any guest thread starts (V8 can trap a thread touching memory another
47
+ * thread has just grown). false: no pre-grow. heapBytes caps the growth.
48
+ */
49
+ growHeap?: boolean;
50
+ heapBytes?: number;
42
51
  maxHandles?: number;
43
52
  instanceId?: number;
44
53
  }
@@ -165,7 +165,27 @@ function createMemory(bytes, options) {
165
165
  }
166
166
  const initial = Math.max(limits.initial, options.initialPages ?? 0);
167
167
  const maximum = Math.min(limits.maximum ?? 65536, options.maximumPages ?? 65536);
168
- return new WebAssembly.Memory({ initial, maximum, shared: true });
168
+ const memory = new WebAssembly.Memory({ initial, maximum, shared: true });
169
+ MAXIMUM_BYTES.set(memory, maximum * 65536);
170
+ return memory;
171
+ }
172
+
173
+ // The byte size each memory may grow to (createMemory).
174
+ const MAXIMUM_BYTES = new WeakMap();
175
+
176
+ // V8 bounds-checks memory.fill/copy and atomics against each thread's cached
177
+ // view of a shared memory's size, and another thread's memory.grow updates
178
+ // that view only at this thread's next stack check. A guest thread that
179
+ // fills a block carved from memory another thread has just grown traps
180
+ // ("memory access out of bounds"). So the heap grows to the memory's maximum
181
+ // before any guest thread starts, and never grows after (a partial pre-grow
182
+ // only moves the failure). The heap stops 16 MiB short of 4 GiB: a segment
183
+ // ending at 2^32 would wrap the guest allocator's pointer arithmetic.
184
+ // WasmEdge (the SDN host) keeps one shared size and is not affected.
185
+ function heapLimitBytes(memory, options) {
186
+ const max = MAXIMUM_BYTES.get(memory) ?? memory.buffer.byteLength;
187
+ const cap = options.heapBytes ?? Infinity;
188
+ return Math.min(max, 2 ** 32 - 16 * 2 ** 20, memory.buffer.byteLength + cap);
169
189
  }
170
190
 
171
191
  /**
@@ -185,6 +205,9 @@ class Supervisor {
185
205
  this.listeners = new Set();
186
206
  this.stopped = false;
187
207
  this.poolErrors = [];
208
+ // Memory size when the first guest thread was seen running (tick): any
209
+ // growth after it is the precondition of the V8 stale-view trap above.
210
+ this.memoryAtFirstThread = 0;
188
211
  const warm = Math.min(options.warmThreads ?? 16, this.cap);
189
212
  for (let i = 0; i < warm; i += 1) this.startPoolWorker();
190
213
  this.timer = setInterval(() => this.tick(), 2);
@@ -208,6 +231,9 @@ class Supervisor {
208
231
  if (this.stopped) return;
209
232
  const want = Atomics.exchange(this.pool, POOL_WANT, 0);
210
233
  for (let i = 0; i < want; i += 1) this.startPoolWorker();
234
+ if (!this.memoryAtFirstThread && Atomics.load(this.pool, POOL_SPAWNED) > 0) {
235
+ this.memoryAtFirstThread = this.memory.buffer.byteLength;
236
+ }
211
237
  for (const listener of this.listeners) listener();
212
238
  }
213
239
 
@@ -242,6 +268,11 @@ class Supervisor {
242
268
  workerThreads: Atomics.load(c, CONTROL_STARTED),
243
269
  distinctWorkerThreadIds: tids.size,
244
270
  memoryBytes: this.memory.buffer.byteLength,
271
+ // Bytes the memory grew after the first guest thread ran (sampled every
272
+ // 2 ms). Under V8 that growth can trap a thread (heapLimitBytes).
273
+ memoryGrownWhileThreadsRan: this.memoryAtFirstThread
274
+ ? Math.max(0, this.memory.buffer.byteLength - this.memoryAtFirstThread)
275
+ : 0,
245
276
  };
246
277
  }
247
278
 
@@ -372,6 +403,14 @@ export async function createPsNodeInstance(wasm, options = {}) {
372
403
  reject(error);
373
404
  });
374
405
  });
406
+ // Before any guest thread: the heap to the memory's maximum (heapLimitBytes).
407
+ if (options.growHeap !== false) {
408
+ await new Promise((resolve, reject) => {
409
+ const id = nextId++;
410
+ pending.set(id, { resolve, reject });
411
+ worker.postMessage({ op: "growHeap", limitBytes: heapLimitBytes(memory, options), id });
412
+ });
413
+ }
375
414
  const rpc = (msg) => {
376
415
  if (failure) return Promise.reject(failure);
377
416
  const id = nextId++;
@@ -427,5 +466,11 @@ if (invokedDirectly) {
427
466
  `${t.spawnsDeclined ? `, ${t.spawnsDeclined} spawns declined` : ""}; memory ${(t.memoryBytes / 2 ** 20).toFixed(0)} MiB; ` +
428
467
  `${(r.elapsedMs / 1000).toFixed(1)} s\n`,
429
468
  );
469
+ if (t.memoryGrownWhileThreadsRan) {
470
+ process.stderr.write(
471
+ `ps-node-host: WARNING memory grew ${(t.memoryGrownWhileThreadsRan / 2 ** 20).toFixed(0)} MiB while guest ` +
472
+ `threads ran (V8 can trap a thread on memory another thread grew; grow the heap before threads start)\n`,
473
+ );
474
+ }
430
475
  process.exit(r.exitCode);
431
476
  }