flatsql 3.4.0 → 3.5.0
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/docs/PARTITION-STORE-WASM.md +83 -7
- package/docs/PARTITION-STORE.md +271 -4
- package/package.json +1 -1
- package/wasm/flatsql-ps-threads.wasm +0 -0
- package/wasm/integrity.json +3 -3
- package/wasm/ps-node-guest.mjs +17 -0
- package/wasm/ps-node-host.d.mts +9 -0
- package/wasm/ps-node-host.mjs +46 -1
|
@@ -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
|
-
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
package/docs/PARTITION-STORE.md
CHANGED
|
@@ -8,7 +8,11 @@ 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.
|
|
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. The design is
|
|
12
16
|
the stack's `docs/architecture/flatsql-partition-store.md` (§22 overrides
|
|
13
17
|
§0–§21; §22.4 holds the owner rulings). This file records what was built, how
|
|
14
18
|
to run it, the measured acceptance, and every place the build departs from the
|
|
@@ -1459,11 +1463,11 @@ harness's arrivals check ran on a second reader instance the reader gate did
|
|
|
1459
1463
|
not cover, so a statement could lose a file to reclamation and end
|
|
1460
1464
|
SNAPSHOT_GONE (on 2f6d86e too: seed 9003, trial 48); the gate now covers both.
|
|
1461
1465
|
|
|
1462
|
-
|
|
1466
|
+
`quota_disk_full_resumes_without_operator_T3_6` could stay in its first
|
|
1463
1467
|
emergency past the test's minute on a loaded box (one episode, three
|
|
1464
|
-
segments evicted, the ballast never back). It
|
|
1468
|
+
segments evicted, the ballast never back). It predated this change: on
|
|
1465
1469
|
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.
|
|
1470
|
+
100 here; under ThreadSanitizer, 3 of 6 and 1 of 10. Root cause and fix: §38.
|
|
1467
1471
|
|
|
1468
1472
|
# Part V: reader memory
|
|
1469
1473
|
|
|
@@ -1574,3 +1578,266 @@ held across statements by the reader beyond the budgets above):
|
|
|
1574
1578
|
| 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
1579
|
| 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
1580
|
| writer: `Partition::segs`, `summary`, the file ledger | per segment | a few hundred bytes per segment | grows with segments |
|
|
1581
|
+
|
|
1582
|
+
# Part VI: memory safety and races
|
|
1583
|
+
|
|
1584
|
+
## 38. The ps-wasm trap, the sanitizers, the first emergency
|
|
1585
|
+
|
|
1586
|
+
Task flatsql-ps-memory-safety-races-20260929. Machines: the owner's Mac
|
|
1587
|
+
Studio (Darwin 25.3.0, arm64, 28 hardware threads) shared with other lanes
|
|
1588
|
+
(1-minute load 17–95 during these runs), and linux/arm64 in Docker Desktop on
|
|
1589
|
+
it (gcc 13 and clang 18, `--cpus 4` with `stress-ng` on one CPU unless noted).
|
|
1590
|
+
|
|
1591
|
+
**The ps-wasm trap is V8's shared-memory grow race, not an engine overrun**
|
|
1592
|
+
(PARTITION-STORE-WASM.md §4.3). CI's trap was a `memory.fill` right after an
|
|
1593
|
+
`operator new` on the type-merge helper, into memory another thread had just
|
|
1594
|
+
grown; V8 bounds-checks it against the thread's stale view of the memory's
|
|
1595
|
+
size. The wasm test main and `createPsNodeInstance` now grow the heap to the
|
|
1596
|
+
memory's maximum before any guest thread starts, and the Node host reports
|
|
1597
|
+
any growth after threads start. Natively, under ASan and UBSan, T2_1 found no
|
|
1598
|
+
memory error (loops below). UBSan found two defects:
|
|
1599
|
+
- A misaligned `uint64_t` reference: `Counters` is packed (on-disk).
|
|
1600
|
+
`Partition::counters` is now 8-aligned.
|
|
1601
|
+
- Null-pointer arithmetic in `EntryIter::next` (index_l0.cpp). That fix
|
|
1602
|
+
lands with flatsql-format2-query-gaps.
|
|
1603
|
+
|
|
1604
|
+
Under WasmEdge the same runs exposed a WasmEdge 0.16.4 AOT defect: atomic
|
|
1605
|
+
wait and notify dropped the memarg offset. SDN's static runtime carries the
|
|
1606
|
+
fix, patch `04-atomic-memarg-offset` (SDN 74db37e14). `sleepNs` passes its
|
|
1607
|
+
address through a volatile, so its wait carries no memarg offset.
|
|
1608
|
+
`memory_safety_sleep_and_notify_at_folded_offsets` fails on a runtime without
|
|
1609
|
+
the fix.
|
|
1610
|
+
|
|
1611
|
+
**ThreadSanitizer.** The whole default suite runs TSAN-clean (Docker, gcc 13
|
|
1612
|
+
`-fsanitize=thread`, `--cpus 6`) apart from the stage-1 races below. Each
|
|
1613
|
+
race found:
|
|
1614
|
+
|
|
1615
|
+
| Where | What | Verdict, change |
|
|
1616
|
+
|---|---|---|
|
|
1617
|
+
| 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. |
|
|
1618
|
+
| 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. |
|
|
1619
|
+
| 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`). |
|
|
1620
|
+
| 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. |
|
|
1621
|
+
| 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. |
|
|
1622
|
+
| 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. |
|
|
1623
|
+
|
|
1624
|
+
**T3 #6: the first emergency could stall for good** (§36). Traced under
|
|
1625
|
+
TSAN:
|
|
1626
|
+
- The eviction wave's partition had a full L0 directory (47 blocks) from
|
|
1627
|
+
ingest before the emergency, so its TOMB_RANGE needed a merge first.
|
|
1628
|
+
- Meanwhile the room the ballast gave back was spent by maintenance outside
|
|
1629
|
+
the eviction: the other partition's merges, and a compaction nobody
|
|
1630
|
+
requested. Its build failed NOSPACE and started over with a new intent
|
|
1631
|
+
batch in the meta log about ten times a second. Usage crept 8,960 B/s until
|
|
1632
|
+
the device was exactly full.
|
|
1633
|
+
- From then on no batch committed. The wave's merge could not write outputs
|
|
1634
|
+
and no TOMB step could stage, for the rest of the minute.
|
|
1635
|
+
|
|
1636
|
+
36. **In a space emergency, maintenance leaves the ballast's room to the
|
|
1637
|
+
eviction** (A13 assumes the ballast holds one segment's tombstones, and
|
|
1638
|
+
says nothing of what else may spend it). Only a partition with eviction
|
|
1639
|
+
work waiting merges (TOMB_RANGE steps, type-level kills, a requested
|
|
1640
|
+
compaction: `partitionWantsMerge`). A compaction the planner did not
|
|
1641
|
+
request does not start (`Writer::maintenance`); one in flight finishes.
|
|
1642
|
+
Ring entries stop `kL0ControlSlots` (4) L0 slots before the directory's
|
|
1643
|
+
cap, which mailbox kills and TOMB_RANGE steps may use, so a wave's first
|
|
1644
|
+
steps never wait for a merge.
|
|
1645
|
+
|
|
1646
|
+
Measured, in Docker:
|
|
1647
|
+
- **Before:** under TSAN, 4 of 12 runs, and 1 of 7 traced ones, stayed in
|
|
1648
|
+
the first emergency until the test's limit (70 s).
|
|
1649
|
+
- **After:** 50 of 50 TSAN runs recovered (whole test 19–43 s), and 340 of
|
|
1650
|
+
340 Debug runs (gcc `-O0`, 8–10 s each). The test also passes natively on
|
|
1651
|
+
the Mac and under the Node host.
|
|
1652
|
+
|
|
1653
|
+
**Loops** (Docker runs had a CPU hog on one of their CPUs; the Mac ran at
|
|
1654
|
+
load 17–95):
|
|
1655
|
+
|
|
1656
|
+
| Item | Loop | Runs | Result |
|
|
1657
|
+
|---|---|---|---|
|
|
1658
|
+
| 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). |
|
|
1659
|
+
| 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. |
|
|
1660
|
+
| 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. |
|
|
1661
|
+
| Seqlock, sync pool | `memory_safety_s*` under TSAN | 520 | 0 warnings, 0 failures |
|
|
1662
|
+
| 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. |
|
|
1663
|
+
| T3 #6 | TSAN; Debug | 50; 340 | 0 stalls |
|
|
1664
|
+
|
|
1665
|
+
**Writer memory that grows with the store.** §37's table lists the writer's
|
|
1666
|
+
per-store structures. Measured here with 200,000 OMM records in one partition
|
|
1667
|
+
at the defaults:
|
|
1668
|
+
- A warm partition's L1 accelerators (`SegmentInfo::runs`, loaded for every
|
|
1669
|
+
segment by `partitionWarm`, merge.cpp:172) hold 6.0 B per record of the
|
|
1670
|
+
partition's history. A partition written without a pause never cools
|
|
1671
|
+
(`idleCloseMs`).
|
|
1672
|
+
- A type's catalog accelerators (`TypeOwner::runs`, loaded by `typeWarm`,
|
|
1673
|
+
type_owner.cpp:580) hold 3.45 B per record of the type. A type owner never
|
|
1674
|
+
cools.
|
|
1675
|
+
- Every registered partition keeps a 5,952-byte `Partition` (writer.h:505)
|
|
1676
|
+
plus its ring descriptor, active or not.
|
|
1677
|
+
|
|
1678
|
+
At 10^9 records (a terabyte of OMM-sized records) the catalog alone is
|
|
1679
|
+
3.4 GB in one writer instance, over the wasm32 limit.
|
|
1680
|
+
|
|
1681
|
+
# Part VII: query gaps
|
|
1682
|
+
|
|
1683
|
+
## 39. Query gaps found on the host-02 fixture (T6)
|
|
1684
|
+
|
|
1685
|
+
T6 compared format 2 with format 1 on a host-02-sized migrated store (4.36 M
|
|
1686
|
+
records: OMM 1.70 M in one partition, MPE 1.69 M, CAT 141 k, IQC 419 k held by
|
|
1687
|
+
two producers). Five read shapes were slower than format 1, and store-migrate
|
|
1688
|
+
refused one record shape. Each is now an engine plan or check, held to a
|
|
1689
|
+
reference by `cpp/test/ps/query_gaps_test.cpp` (`query_gaps_*`) and to format 1
|
|
1690
|
+
by SDN's fixture comparison.
|
|
1691
|
+
|
|
1692
|
+
**Text CID order (A17).** `ORDER BY _cid [DESC]` with no other index
|
|
1693
|
+
condition is its own plan (`kAccCidOrder`). At type level it reads the cid
|
|
1694
|
+
catalog (`kIxTypeCid`), whose keys are the A17 sort key: one ordered pass
|
|
1695
|
+
gives the live CIDs in text order. A key's entries resolve per copy (the
|
|
1696
|
+
latest type commit wins; DEAD over the rest at an equal tcs, as `catalog()`
|
|
1697
|
+
does); the live FIRST or PROMOTED copy is the record, and only its row is
|
|
1698
|
+
read. An `OFFSET` with no other condition is pushed down and counted from
|
|
1699
|
+
catalog entries alone: no row, frame or posting of a skipped record is read.
|
|
1700
|
+
At partition level the plan reads the partition's CID postings. With other
|
|
1701
|
+
conditions the plan still gives the order; the conditions are checked per
|
|
1702
|
+
row.
|
|
1703
|
+
|
|
1704
|
+
**Per-object point profiles (A18).** Hidden input columns `_asof`,
|
|
1705
|
+
`_forward` and `_nearest` (epoch seconds, `=` only) select `kAccObjPoint`.
|
|
1706
|
+
Per object key it returns every live type-level row at the object's best
|
|
1707
|
+
second: `_asof` the latest second at or before T, `_forward` the earliest at
|
|
1708
|
+
or after, `_nearest` the closer of the two with a tie to the earlier. The
|
|
1709
|
+
plan reads the `OBJECT_EPOCH` postings `(object key, epoch)` of every
|
|
1710
|
+
partition, restricted by the manifests' epoch zone maps to the segments that
|
|
1711
|
+
can hold a candidate, merges the partitions by object key, and reads rows only
|
|
1712
|
+
for the candidates of each object's best seconds (from the closest outward,
|
|
1713
|
+
until one is live). Tag conditions and one `_epoch` range are applied before
|
|
1714
|
+
the choice; any other condition is refused (`SQLITE_CONSTRAINT`: SQLite would
|
|
1715
|
+
apply it after the choice, which is another query). `_object` projects the
|
|
1716
|
+
key: a u64 key in decimal, otherwise the (capped) string, NULL for a record
|
|
1717
|
+
without one. Records without an object key are read only from partitions whose
|
|
1718
|
+
`OBJECT_EPOCH` postings are fewer than their CID postings (the EPOCH index,
|
|
1719
|
+
their frames); each is its own entity. For OMM (NORAD, then OBJECT_ID) and MPE
|
|
1720
|
+
(ENTITY_ID) the object key is the legacy entity key, so SDN ranks the
|
|
1721
|
+
candidates exactly as format 1 did.
|
|
1722
|
+
|
|
1723
|
+
**The sandbox window (A18).** Untrusted SQL reads `<TYPE>`, `<TYPE>@<source>`
|
|
1724
|
+
and every other type-level shape except a CID or gseq lookup from the arrivals
|
|
1725
|
+
tail: the newest N entries (`hotWindow`, dead entries included), the
|
|
1726
|
+
statement's conditions applied per row. An alias or source statement no longer
|
|
1727
|
+
walks every posting of its source; its work is bounded by N whatever the
|
|
1728
|
+
store's size.
|
|
1729
|
+
|
|
1730
|
+
**Tag conditions on every copy (A2).** Type-level tag conditions (`_provider`,
|
|
1731
|
+
`_source_name`, `_batch`, `_peer_id`, `_source`, and the alias source) match a
|
|
1732
|
+
record when a live instance of any live copy (FIRST or REPEAT, any partition
|
|
1733
|
+
the plan reads) meets all of them, as the legacy tag table held every
|
|
1734
|
+
producer's tags per record. This extends §31.1 and T2 deviation 8.
|
|
1735
|
+
Row-driven plans check the FIRST copy's own tag first, then the other copies'
|
|
1736
|
+
own tags (listed by the cid catalog, only when the type has REPEAT postings),
|
|
1737
|
+
then RETAG instances. Posting-driven plans (tag, source, collected pages) let
|
|
1738
|
+
a live REPEAT copy's instance stand for its record: the record's FIRST row is
|
|
1739
|
+
emitted by the first matching copy in (FIRST, then REPEATs by pid, pseq)
|
|
1740
|
+
order, so each record comes once. `<TYPE>@<source>` is `<TYPE>` with that
|
|
1741
|
+
condition. The checks cost little per row: an instance's tuple is its lane's
|
|
1742
|
+
(`RecRow.laneId`, the partition's lane table read once per statement), a copy
|
|
1743
|
+
whose pseq is above the highest `max_pseq` of the lanes that meet the
|
|
1744
|
+
conditions cannot match (its RETAGs come after it), TAG_OF lookups read only
|
|
1745
|
+
the segments that can hold them and resume in the block the previous row's
|
|
1746
|
+
lookup read, and DEAD / TAG_DEAD lookups are skipped for a snapshot that holds
|
|
1747
|
+
no posting of the kind.
|
|
1748
|
+
|
|
1749
|
+
**Gseq-ordered tag pages (A16).** At type level, `ORDER BY _gseq` with tag
|
|
1750
|
+
conditions and a LIMIT (or a closed gseq range) is served in arrivals order
|
|
1751
|
+
with each row's tags checked, unless the lane counters say the tags are rare
|
|
1752
|
+
(at most 262,144 matching instances and under an eighth of the type's
|
|
1753
|
+
arrivals): then the tags' postings are collected (the most selective of batch,
|
|
1754
|
+
source, provider, peer), sorted by gseq and emitted in order. A datasync page
|
|
1755
|
+
by source no longer sorts every match of the source.
|
|
1756
|
+
|
|
1757
|
+
**Records stored with their own size prefix.** A record that carries its own
|
|
1758
|
+
size prefix (a `FinishSizePrefixed` buffer stored as is: SDN's
|
|
1759
|
+
dataset-publication PNMs and the local EPM) was refused with `kRejFid` (its
|
|
1760
|
+
identifier sits at frame offset 12), so store-migrate rejected 131 records of a
|
|
1761
|
+
dev node and refused to activate. `frameRootOffset()` (`extract.h`) finds the
|
|
1762
|
+
FlatBuffer after both prefixes (the record's u32 equals `len - 8` and the
|
|
1763
|
+
identifier is at 12) for the frame check, the verifier (at the record's own
|
|
1764
|
+
alignment origin), key extraction and column projection. The stored bytes and
|
|
1765
|
+
the CID (over the record, prefix included) are unchanged.
|
|
1766
|
+
|
|
1767
|
+
**Reads ahead.** Rows met in ascending pseq order (arrivals order, epoch and
|
|
1768
|
+
tag postings, pseq scans) are read 32 at a time, and a frame read covers the
|
|
1769
|
+
frames of the rows read ahead in its segment (up to 256 KiB).
|
|
1770
|
+
`rowsExamined` still counts the rows a statement uses.
|
|
1771
|
+
|
|
1772
|
+
**Measured** through SDN's storage API on the fixture (`TestZZBench`-style
|
|
1773
|
+
calls, each query twice in a fresh process: the first call pays the
|
|
1774
|
+
instance's warm-up and the page cache; format 1 is the same store before
|
|
1775
|
+
migration). Mac Studio (M3 Ultra, 28 cores), shared with other lanes, load
|
|
1776
|
+
47–55. Format 2 "before" is T6's run on the same fixture.
|
|
1777
|
+
|
|
1778
|
+
| Gap | Query | Format 1 (call 1 / 2) | Format 2 before | Format 2 after (call 1 / 2) |
|
|
1779
|
+
|---|---|---|---|---|
|
|
1780
|
+
| 1 | OMM CID order, LIMIT 500 OFFSET 1000 | 730 ms / 26 ms | 9.9–28.4 s | 2.65 s / 3.6 ms |
|
|
1781
|
+
| 1 | CAT CID order, LIMIT 200 OFFSET 60000 | 2.39 s / 67 ms | 430–537 ms | 589 ms / 11 ms |
|
|
1782
|
+
| 1 | IQC CID order, LIMIT 1000 OFFSET 100000 | 3.90 s / 153 ms | 5.0–5.5 s | 4.14 s / 35 ms |
|
|
1783
|
+
| 2 | OMM `nearest` (32,015 objects) | 99 s / 22.7 s | 56–79 s | 10.6 s / 2.3 s |
|
|
1784
|
+
| 2 | OMM `as_of`, one source | 36 s / 19.5 s | 9.7–13.1 s | 3.9 s / 4.2 s |
|
|
1785
|
+
| 2 | OMM `forward` | 14.6 s / 19.5 s | 8.4–82 s | 6.8 s / 6.1 s |
|
|
1786
|
+
| 2 | MPE `nearest`, 2 h delta | 50 s / 17.6 s | 29–35 s | 7.1 s / 6.1 s |
|
|
1787
|
+
| 3 | sandbox `SELECT _data FROM "CAT@celestrak-satcat"` (N = 10,000) | 4.4 / 3.9 ms¹ | `timeout` (work budget) | 55 / 11 ms |
|
|
1788
|
+
| 3 | `"CAT@celestrak-satcat-csv"` | 1.3 / 1.3 ms¹ | `timeout` | 3.2 / 4.5 ms |
|
|
1789
|
+
| 3 | `"IQC@IQEngine"` | 14.8 / 13.3 ms¹ | `timeout` | 67 / 32 ms |
|
|
1790
|
+
| 3 | `"MPE@celestrak-gp"` | 3.2 / 4.3 ms¹ | `timeout` | 68 / 7.3 ms |
|
|
1791
|
+
| 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) |
|
|
1792
|
+
| 5 | IQC datasync by source, 5 pages of 500 | 133 s / 190 s | about 3.8 s a page | 58 / 32 ms (all 5) |
|
|
1793
|
+
|
|
1794
|
+
¹ Format 1's hot window is an in-memory copy of the newest N records, filled
|
|
1795
|
+
by a hydration (17 s to 5.5 min on this fixture) and held in the arena whose
|
|
1796
|
+
growth is what format 2 replaces. Format 2 reads the window from disk: the
|
|
1797
|
+
engine's own cost for the CAT query is 2.4 ms natively (the rest is the wasm
|
|
1798
|
+
instance, the result ring and SDN's copy). Gap 3's acceptance (the statement
|
|
1799
|
+
answers within the work budget, bounded by N) is met; parity with the
|
|
1800
|
+
in-memory copy is not.
|
|
1801
|
+
|
|
1802
|
+
The first call of a fresh process pays the instance's warm-up and reads the
|
|
1803
|
+
cid catalog's blocks into the page cache (2.65 s for OMM against 730 ms for
|
|
1804
|
+
format 1's index); every later call is faster than format 1.
|
|
1805
|
+
`TestFormat2DaemonReadsEqualTheLegacyFixture` (SDN) passes with every section
|
|
1806
|
+
(windows, A17 exports, A16 syncs, epoch profiles, A18 relations) equal to
|
|
1807
|
+
format 1 on this fixture.
|
|
1808
|
+
|
|
1809
|
+
**store-migrate with size-prefixed records.** A copy of a dev node's store
|
|
1810
|
+
(9.2 GB control database, 163 PNMs in 37 partitions, 1 EPM): 0 copies
|
|
1811
|
+
rejected (131 before), partitions (58) and lanes (58) equal, 9 of 10 CID
|
|
1812
|
+
sequences equal. The tenth (IQC) differs because that store's index holds
|
|
1813
|
+
about 1.06 M IQC rows no producer table holds (orphans the legacy verifier's
|
|
1814
|
+
sequence includes; filed for SDN). `TestStoreMigrateActivatesWithSizePrefixedRecords`
|
|
1815
|
+
(SDN): a store with 12 PNMs in two partitions and an EPM migrates with 0
|
|
1816
|
+
rejects and 0 mismatches, activates, and serves the same bytes.
|
|
1817
|
+
|
|
1818
|
+
**What these cost at 1 TB** (per statement; P partitions of the type, S
|
|
1819
|
+
segments per partition, R type catalog runs):
|
|
1820
|
+
|
|
1821
|
+
- CID order: `(offset + limit) x copies per CID` catalog entries of about
|
|
1822
|
+
72 B (about 56 per 4 KiB block) plus one row read per emitted record. The
|
|
1823
|
+
skip is linear in the offset, as format 1's b-tree OFFSET is. Per-block live
|
|
1824
|
+
counts in the catalog's fences would make it logarithmic, but a CID's copies
|
|
1825
|
+
die in later type commits than their FIRST labels, so the type owner would
|
|
1826
|
+
have to post a CID-keyed marker when a CID's last copy dies (outside this
|
|
1827
|
+
change: `type_owner.cpp`).
|
|
1828
|
+
- Point profiles: the `OBJECT_EPOCH` postings (about 30 B each) of the
|
|
1829
|
+
segments whose epoch zone can hold a candidate: for `_forward` near the
|
|
1830
|
+
present, the newest segments; for `_asof` and `_nearest`, the segments
|
|
1831
|
+
before (or around) T. No payload is read to rank; rows are read for about one
|
|
1832
|
+
to two candidates per object. Enumerating the objects stays linear in the
|
|
1833
|
+
postings of those segments: a per-partition object directory would make it
|
|
1834
|
+
proportional to the objects.
|
|
1835
|
+
- Sandbox window: at most N arrivals entries (24 B) and N rows, whatever the
|
|
1836
|
+
store's size.
|
|
1837
|
+
- Tag conditions: per row an O(1) lane check, and a TAG_OF lookup only for a
|
|
1838
|
+
copy that a matching lane can still reach; a catalog lookup (R bloom-gated
|
|
1839
|
+
runs) only in a type with REPEAT copies.
|
|
1840
|
+
- Gseq tag pages: LIMIT / selectivity arrival entries and rows per page
|
|
1841
|
+
(arrivals mode), or the tags' matching instances (collected mode, at most
|
|
1842
|
+
262,144).
|
|
1843
|
+
- Size-prefixed records: nothing (a 4-byte comparison per record).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flatsql",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
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
|
package/wasm/integrity.json
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
"size": 2246620,
|
|
6
6
|
"files": {
|
|
7
7
|
"flatsql-ps-threads.wasm": {
|
|
8
|
-
"sha256": "
|
|
9
|
-
"sri": "sha384-
|
|
10
|
-
"size":
|
|
8
|
+
"sha256": "787adbcccf52a9e9fe2767d6679b6f2d94a4ead41985bf2a7bb0855f821abc72",
|
|
9
|
+
"sri": "sha384-qR37NE4y3sP60hLFJEmxsYz76i2vpTZf9ydfSO0dX1MSD15HKEgEIm6UnkGYS8lo",
|
|
10
|
+
"size": 2484927
|
|
11
11
|
},
|
|
12
12
|
"flatsql-wasi-noeh.wasm": {
|
|
13
13
|
"sha256": "8f11fd49ee2e6961b9c1c22dd891d5a6645d0faf481f815408c0f1f5d3a385ab",
|
package/wasm/ps-node-guest.mjs
CHANGED
|
@@ -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
|
}
|
package/wasm/ps-node-host.d.mts
CHANGED
|
@@ -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
|
}
|
package/wasm/ps-node-host.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|