flatsql 3.11.1 → 3.12.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.
Files changed (181) hide show
  1. package/README.md +61 -795
  2. package/bin/flatsql.mjs +76 -0
  3. package/cpp/README.md +18 -0
  4. package/cpp/include/flatsql/flatsql.h +162 -0
  5. package/cpp/include/flatsql/flatsql_io.h +159 -0
  6. package/docs/API-3.12.md +126 -0
  7. package/package.json +28 -123
  8. package/wasm/flatsql.wasm +0 -0
  9. package/wasm/index.d.ts +61 -548
  10. package/wasm/index.js +280 -1281
  11. package/wasm/integrity.json +6 -26
  12. package/wasm/runtime/LICENSE.sdk.txt +190 -0
  13. package/wasm/runtime/NOTICE.txt +2 -0
  14. package/wasm/runtime/browser-guest.mjs +1 -0
  15. package/wasm/runtime/browser-host.mjs +1 -0
  16. package/wasm/runtime/node-guest.mjs +2 -0
  17. package/wasm/runtime/node-host.mjs +5 -0
  18. package/wasm/runtime/opfsIoWorker.mjs +1 -0
  19. package/wasm/runtime/wasiThreadBrowserWorker.mjs +1 -0
  20. package/dist/artifacts/index.d.ts +0 -3
  21. package/dist/artifacts/index.d.ts.map +0 -1
  22. package/dist/artifacts/index.js +0 -3
  23. package/dist/artifacts/index.js.map +0 -1
  24. package/dist/artifacts/transport.d.ts +0 -5
  25. package/dist/artifacts/transport.d.ts.map +0 -1
  26. package/dist/artifacts/transport.js +0 -45
  27. package/dist/artifacts/transport.js.map +0 -1
  28. package/dist/artifacts/types.d.ts +0 -34
  29. package/dist/artifacts/types.d.ts.map +0 -1
  30. package/dist/artifacts/types.js +0 -2
  31. package/dist/artifacts/types.js.map +0 -1
  32. package/dist/btree/btree.d.ts +0 -34
  33. package/dist/btree/btree.d.ts.map +0 -1
  34. package/dist/btree/btree.js +0 -234
  35. package/dist/btree/btree.js.map +0 -1
  36. package/dist/btree/index.d.ts +0 -3
  37. package/dist/btree/index.d.ts.map +0 -1
  38. package/dist/btree/index.js +0 -3
  39. package/dist/btree/index.js.map +0 -1
  40. package/dist/btree/types.d.ts +0 -36
  41. package/dist/btree/types.d.ts.map +0 -1
  42. package/dist/btree/types.js +0 -55
  43. package/dist/btree/types.js.map +0 -1
  44. package/dist/cluster/index.d.ts +0 -11
  45. package/dist/cluster/index.d.ts.map +0 -1
  46. package/dist/cluster/index.js +0 -59
  47. package/dist/cluster/index.js.map +0 -1
  48. package/dist/response/artifact.d.ts +0 -4
  49. package/dist/response/artifact.d.ts.map +0 -1
  50. package/dist/response/artifact.js +0 -102
  51. package/dist/response/artifact.js.map +0 -1
  52. package/dist/response/cache-key.d.ts +0 -6
  53. package/dist/response/cache-key.d.ts.map +0 -1
  54. package/dist/response/cache-key.js +0 -114
  55. package/dist/response/cache-key.js.map +0 -1
  56. package/dist/response/index.d.ts +0 -5
  57. package/dist/response/index.d.ts.map +0 -1
  58. package/dist/response/index.js +0 -5
  59. package/dist/response/index.js.map +0 -1
  60. package/dist/response/memory-cache.d.ts +0 -17
  61. package/dist/response/memory-cache.d.ts.map +0 -1
  62. package/dist/response/memory-cache.js +0 -76
  63. package/dist/response/memory-cache.js.map +0 -1
  64. package/dist/response/types.d.ts +0 -57
  65. package/dist/response/types.d.ts.map +0 -1
  66. package/dist/response/types.js +0 -2
  67. package/dist/response/types.js.map +0 -1
  68. package/dist/schema/index.d.ts +0 -3
  69. package/dist/schema/index.d.ts.map +0 -1
  70. package/dist/schema/index.js +0 -3
  71. package/dist/schema/index.js.map +0 -1
  72. package/dist/schema/parser.d.ts +0 -25
  73. package/dist/schema/parser.d.ts.map +0 -1
  74. package/dist/schema/parser.js +0 -474
  75. package/dist/schema/parser.js.map +0 -1
  76. package/dist/schema/types.d.ts +0 -33
  77. package/dist/schema/types.d.ts.map +0 -1
  78. package/dist/schema/types.js +0 -29
  79. package/dist/schema/types.js.map +0 -1
  80. package/dist/standalone/index.d.ts +0 -3
  81. package/dist/standalone/index.d.ts.map +0 -1
  82. package/dist/standalone/index.js +0 -3
  83. package/dist/standalone/index.js.map +0 -1
  84. package/dist/standalone/process-client.d.ts +0 -85
  85. package/dist/standalone/process-client.d.ts.map +0 -1
  86. package/dist/standalone/process-client.js +0 -537
  87. package/dist/standalone/process-client.js.map +0 -1
  88. package/dist/standalone/wasmedge-runner.d.ts +0 -19
  89. package/dist/standalone/wasmedge-runner.d.ts.map +0 -1
  90. package/dist/standalone/wasmedge-runner.js +0 -108
  91. package/dist/standalone/wasmedge-runner.js.map +0 -1
  92. package/dist/storage/index.d.ts +0 -3
  93. package/dist/storage/index.d.ts.map +0 -1
  94. package/dist/storage/index.js +0 -3
  95. package/dist/storage/index.js.map +0 -1
  96. package/dist/storage/stacked-flatbuffers.d.ts +0 -38
  97. package/dist/storage/stacked-flatbuffers.d.ts.map +0 -1
  98. package/dist/storage/stacked-flatbuffers.js +0 -253
  99. package/dist/storage/stacked-flatbuffers.js.map +0 -1
  100. package/dist/storage/types.d.ts +0 -23
  101. package/dist/storage/types.d.ts.map +0 -1
  102. package/dist/storage/types.js +0 -27
  103. package/dist/storage/types.js.map +0 -1
  104. package/dist/stress/aggregate.d.ts +0 -3
  105. package/dist/stress/aggregate.d.ts.map +0 -1
  106. package/dist/stress/aggregate.js +0 -61
  107. package/dist/stress/aggregate.js.map +0 -1
  108. package/dist/stress/docker-compose.d.ts +0 -17
  109. package/dist/stress/docker-compose.d.ts.map +0 -1
  110. package/dist/stress/docker-compose.js +0 -156
  111. package/dist/stress/docker-compose.js.map +0 -1
  112. package/dist/stress/index.d.ts +0 -10
  113. package/dist/stress/index.d.ts.map +0 -1
  114. package/dist/stress/index.js +0 -10
  115. package/dist/stress/index.js.map +0 -1
  116. package/dist/stress/metrics.d.ts +0 -3
  117. package/dist/stress/metrics.d.ts.map +0 -1
  118. package/dist/stress/metrics.js +0 -83
  119. package/dist/stress/metrics.js.map +0 -1
  120. package/dist/stress/native-publish-event-workload.d.ts +0 -3
  121. package/dist/stress/native-publish-event-workload.d.ts.map +0 -1
  122. package/dist/stress/native-publish-event-workload.js +0 -465
  123. package/dist/stress/native-publish-event-workload.js.map +0 -1
  124. package/dist/stress/production-gate.d.ts +0 -4
  125. package/dist/stress/production-gate.d.ts.map +0 -1
  126. package/dist/stress/production-gate.js +0 -12
  127. package/dist/stress/production-gate.js.map +0 -1
  128. package/dist/stress/report.d.ts +0 -3
  129. package/dist/stress/report.d.ts.map +0 -1
  130. package/dist/stress/report.js +0 -174
  131. package/dist/stress/report.js.map +0 -1
  132. package/dist/stress/sds-discovery.d.ts +0 -4
  133. package/dist/stress/sds-discovery.d.ts.map +0 -1
  134. package/dist/stress/sds-discovery.js +0 -40
  135. package/dist/stress/sds-discovery.js.map +0 -1
  136. package/dist/stress/smoke-runner.d.ts +0 -3
  137. package/dist/stress/smoke-runner.d.ts.map +0 -1
  138. package/dist/stress/smoke-runner.js +0 -64
  139. package/dist/stress/smoke-runner.js.map +0 -1
  140. package/dist/stress/types.d.ts +0 -219
  141. package/dist/stress/types.d.ts.map +0 -1
  142. package/dist/stress/types.js +0 -2
  143. package/dist/stress/types.js.map +0 -1
  144. package/dist/stress/workload.d.ts +0 -6
  145. package/dist/stress/workload.d.ts.map +0 -1
  146. package/dist/stress/workload.js +0 -144
  147. package/dist/stress/workload.js.map +0 -1
  148. package/docs/PARTITION-STORE-WASM.md +0 -338
  149. package/docs/PARTITION-STORE.md +0 -2380
  150. package/docs/agents/response-artifacts.md +0 -65
  151. package/docs/response-artifacts.md +0 -86
  152. package/scripts/stress-docker.sh +0 -279
  153. package/sdm/flatsql-spatial.wasm +0 -0
  154. package/sdm/index.d.ts +0 -88
  155. package/sdm/index.js +0 -253
  156. package/sdm/manifest.json +0 -402
  157. package/stress/docker/Dockerfile +0 -11
  158. package/stress/docker/docker-compose.yml +0 -51
  159. package/stress/sds/aggregate.mjs +0 -97
  160. package/stress/sds/run.mjs +0 -105
  161. package/stress/sds/write-compose.mjs +0 -96
  162. package/wasm/flatsql-client.js +0 -280
  163. package/wasm/flatsql-io.d.ts +0 -97
  164. package/wasm/flatsql-io.js +0 -625
  165. package/wasm/flatsql-p4-threads.wasm +0 -0
  166. package/wasm/flatsql-ps-threads.wasm +0 -0
  167. package/wasm/flatsql-wasi-noeh.wasm +0 -0
  168. package/wasm/flatsql-wasi.wasm +0 -0
  169. package/wasm/flatsql.js +0 -2
  170. package/wasm/flatsql.worker.js +0 -209
  171. package/wasm/native/flatsql_wasmedge_runner.cpp +0 -858
  172. package/wasm/ps-node-fault.d.mts +0 -23
  173. package/wasm/ps-node-fault.mjs +0 -381
  174. package/wasm/ps-node-guest.mjs +0 -173
  175. package/wasm/ps-node-host.d.mts +0 -95
  176. package/wasm/ps-node-host.mjs +0 -476
  177. package/wasm/ps-node-imports.mjs +0 -847
  178. package/wasm/ps.d.ts +0 -21
  179. package/wasm/ps.js +0 -75
  180. package/wasm/wasi.d.ts +0 -11
  181. package/wasm/wasi.js +0 -51
@@ -1,2380 +0,0 @@
1
- # FlatSQL partition store (format 2): native engine
2
-
3
- The engine of the partition store program. Part I (§1–§11) is T1: append-only
4
- partition logs, one writer per partition on a pinned pool, durable acks,
5
- per-commit indexes, type owners, and durable-tail open. Part II (§12–§21) is
6
- T2: reader instances and lanes, the snapshot protocol, the SQL surface, the
7
- fan-out merge, admission and results. Part III (§23–§30) is T3: compaction,
8
- reclamation, meta-segment retirement, disk accounting and quota. Part IV
9
- (§31–§35) is T3b: store-migrate gseqs, the entry-size setting, hot-partition
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. 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). Part IX
17
- (§41) is TB03: format levels with a ratchet, and paged lane checkpoints that
18
- lift the 1,170-lane cap (level 3). The
19
- design is
20
- the stack's `docs/architecture/flatsql-partition-store.md` (§22 overrides
21
- §0–§21; §22.4 holds the owner rulings). This file records what was built, how
22
- to run it, the measured acceptance, and every place the build departs from the
23
- design.
24
-
25
- The wasm artifact is T4; SDN and browser integration are T6/T10.
26
-
27
- ## 1. Code map
28
-
29
- | Path | Contents |
30
- |---|---|
31
- | `cpp/include/flatsql/flatsql_io.h`, `cpp/src/flatsql_io_native.cpp` | Seven-import host contract; flags `CREATE_PARENTS` 0x0100, `UNLINK_IF_UNUSED` 0x0200, `OPEN_DEFERRED` 0x0400, status `BUSY` −7 (the module-sdk 0.8.22 values). Native host: lock-free handle table, `F_FULLFSYNC` on darwin, `fdatasync` on Linux, parent-directory fsyncs. |
32
- | `cpp/include/flatsql/ps/format.h`, `src/ps/format.cpp` | On-disk structs, magics, key encodings, A17 CID sort key, head slots, paths. |
33
- | `ps/index.h`, `src/ps/index_l0.cpp`, `index_l1.cpp` | Per-commit L0 blocks, L1 runs (4 KiB blocks, fences, blooms, TOC), streaming k-way merge. |
34
- | `ps/ring.h`, `src/ps/ring.cpp` | 64 KiB slab pool, SPSC rings, page map-ahead, rejects, owner word. |
35
- | `ps/extract.h`, `src/ps/extract.cpp` | Per-type config (BFBS + rule text), frame checks, key extraction (A19), producer token (A3), sealed envelopes. |
36
- | `ps/registry.h`, `src/ps/registry.cpp` | Registry frames and head (A10). |
37
- | `ps/writer.h`, `src/ps/writer_pool.cpp` | Engine, writers, commit rounds, sync pool, mailbox, maintenance, producers. |
38
- | `src/ps/partition_log.cpp` | Staging, dedupe, RETAG, supersede, TOMB, RECONCILE, TOMB_RANGE, transactions, publish. |
39
- | `src/ps/merge.cpp` | Warm/cool, merge planning and outputs (A11), manifests. |
40
- | `src/ps/type_owner.cpp` | Labels, arrivals (A15), cid catalog, REPEAT, REHOME (A14), type-level deletes, notices (A25). |
41
- | `src/ps/journal.cpp` | Per-writer commit journal (A8 fallback). |
42
- | `src/ps/open.cpp` | Open, journal replay, durable-tail adoption (A4), registration. |
43
- | `ps/flatsql_ps.h`, `src/ps/capi_ps.cpp` | C ABI (i32/f64 only). |
44
- | `schemas/flatsql_attr.fbs` | `RecordAttr` / `SourceTag` (generated with the published flatc-wasm). |
45
- | `cpp/test/ps/`, `cpp/test/io_fault.cpp` | Tests, fault-injecting host, bench driver, A19 vectors. |
46
-
47
- ## 2. On-disk layout
48
-
49
- ```
50
- <root>/fsql2/
51
- STORE MIGRATED registry.fsl registry.fsh
52
- STORE.tmp only while a format ratchet runs (§41)
53
- j/<writer:02x>-{a,b}.fsj commit journal (only with commitJournal)
54
- p/<pid:08x>/
55
- h.fsh head, A/B 4 KiB slots
56
- m-<seg:06x>.fsl meta log, one per data segment (A9)
57
- d-<seg:06x>.fsd frames, [u32 size][FlatBuffer]
58
- r-<seg>.fsr a-<seg>.fsa dense rows / attributes (merged ranges)
59
- x-<seg>-<gen:04x>.fsx L1 runs
60
- mf-<gen:06x>.fsm segment manifest
61
- l.fsl lane table (tuples)
62
- lk-<gen:06x>.fsl level 3: paged lane checkpoint (§41)
63
- t/<fid hex8>/
64
- h.fsh m-<seg>.fsl type head and log (A10 batches)
65
- g-<seg:06x>.fsg arrivals, 24 B entries (A15 segments)
66
- g.fsf arrivals gseq fence index (A15)
67
- x-<gen:06x>.fsx mf-<gen>.fsm cid catalog runs and manifest
68
- s-<fp:016x>.fsc type config (BFBS + rules)
69
- ```
70
-
71
- Byte layouts are the structs in `format.h` (all little-endian, CRC32C).
72
-
73
- ## 3. Write path
74
-
75
- 1. A producer reserves credits (`min(ring cap − used, pool free − reserve)`),
76
- copies one entry into its ring and rings the owner's doorbell.
77
- 2. The owning writer stages each partition with backlog: verify (size, fid,
78
- BFBS), CID check, extraction, dedupe (writer cid table, blooms, L0/L1),
79
- RETAG for a new tag tuple (A2), supersede, lane deltas, the L0 block.
80
- 3. A commit round writes every staged batch and makes it durable:
81
- - **file mode** (default, §6.4 as amended by A8): pwrite `d`/`l`/`g`/fence,
82
- one concurrent sync round; pwrite `m` batches, a second sync round. Two
83
- sync rounds per iteration whatever the number of dirty partitions.
84
- - **journal mode** (`commitJournal`, §22.4 ruling 5): the same pwrites plus
85
- one journal record holding their bytes; the journal fsync is the round's
86
- only sync. A checkpoint thread later syncs the touched files and heads
87
- (paced, see deviation 13) and truncates the retired journal file.
88
- 4. Publish: state, seqlock publication, head slot pwrite (checkpoint heads are
89
- synced one round later in file mode, by the journal checkpoint in journal
90
- mode), acks, notices to the type owners (A25: a full queue drops the
91
- notice; the owner compares `labeled_through` with each partition's HWM).
92
- 5. Maintenance, bounded per iteration: merge planning (INTENT_MERGE first,
93
- A11), MERGE_DONE once a helper has written and synced the outputs, seal by
94
- size/records/age, next-segment pre-creation, idle reclaim, journal
95
- checkpoints.
96
-
97
- Mailbox commands (never ring entries, A24): adopt/handoff, rebalance, type
98
- registration, type-level kills (TOMB_CID fan-out, A14) and `TOMB_RANGE{seg,
99
- epoch < t}` for the quota planner. TOMB_RANGE runs in bounded steps, spares
100
- supersede-lane heads and control kinds, and completes when its TOMBs are
101
- durable.
102
-
103
- Ownership (A26): owner word `{epoch u32, writer u8, state}`. A rebalance waits
104
- for any in-flight merge helper, sets HANDOFF, and the target CASes it to
105
- OWNED with epoch + 1. A helper re-validates `{epoch, OWNED}` before every file
106
- mutation.
107
-
108
- ## 4. Open and recovery
109
-
110
- 1. `STORE` and `MIGRATED` (refused without the marker). A level above the
111
- engine's `kFormatMax` is refused before any file is touched; a lower one
112
- ratchets once the registry is non-empty (§41).
113
- 2. Registry: frames scanned past the head, truncated at the first bad CRC;
114
- incarnation = max seen + 1, recorded durably before any batch.
115
- 3. Journal replay (A8): both files of every writer id from 0 until the first
116
- id without journals; records in `seq` order; parts pwritten into their
117
- files; the files fsynced; the journals truncated.
118
- 4. Types: head slot, label checkpoint (A10, > 128 pids), tail batches (commit
119
- chain, incarnation, arrivals CRC, A15 segment switch; a journaled batch only
120
- if replayed), fence index validation, fsync before adoption.
121
- 5. Partitions: head slot, tail batches across SEAL continuations (same rules),
122
- fsync `d`/`m`, A11 intent cleanup (`UNLINK_IF_UNUSED`), DURABLE_CKPT head.
123
- At level 3 a head that names a LaneRef folds the lane table from it, and
124
- open removes a lane checkpoint no durable head names (§41).
125
- 6. Type heads for adopted type tails, written after the partitions are
126
- attached (their inline labels come from them).
127
-
128
- Open never reads a `d-*` byte and never parses a frame
129
- (`openDataBytes = framesParsedAtOpen = 0` in every crash trial).
130
-
131
- ## 5. Extraction rules (A19)
132
-
133
- A type's extraction is engine data: `TypeConfig` = schema name, fid, BFBS,
134
- rule text, limits, flags. The rule language is documented in `extract.h`
135
- (`epoch`, `col`, `epoch_day`, `object`, `require`, `supersede`).
136
-
137
- The production rule texts for OMM, MPE, OEM, CAT, PNM and RFB are
138
- `cpp/test/ps/vectors/<TYPE>.rules`. `format_extraction_golden_vectors_A19`
139
- checks them against rows produced by sdn-server's `extractIndexedFields`,
140
- `recordIndexArgs` and `recordSupersedeKey` (copied verbatim into
141
- `vectors/gen/goref.go`, built against the published SDS Go bindings 1.226.0)
142
- over 420 frames built from the published SDS 1.226.0 schemas with the
143
- published flatc-wasm: 310 OMM from a Space-Track GP archive day plus edge
144
- cases (fallbacks, time zones, invalid dates, pre-1970), 44 MPE, 10 OEM, 48 CAT
145
- (every enum value, out-of-range values, all supersede branches), 4 PNM,
146
- 4 RFB. All 420 match.
147
-
148
- ## 6. C ABI
149
-
150
- `flatsql_ps_init(role, cfg, len)`, `flatsql_ps_start()`,
151
- `flatsql_ps_layout(out)`, `flatsql_ps_wake(addr, n)`,
152
- `flatsql_ps_pump(budget_us)`, `flatsql_ps_stop(deadline_ms)`,
153
- `flatsql_ps_stats(out, len)`, `flatsql_ps_register_type(cfg, len)`,
154
- `flatsql_ps_register_partition(peer, len, fid)`, `flatsql_ps_ring(pid)`.
155
- Config TLV tags are listed in `flatsql_ps.h`.
156
-
157
- ## 7. Configuration (`EngineConfig`)
158
-
159
- | Field | Default | Meaning |
160
- |---|---|---|
161
- | `writers` | 1 | writer threads (production: clamp(cores − 2, 1, 16)) |
162
- | `syncThreads` | 0 = clamp(writers, 4, 8) | concurrent sync pool, fair per writer |
163
- | `poolBytes` / `slabBytes` | 192 MiB / 64 KiB | ring slab pool |
164
- | `defaultRingCap` | 4 MiB | per partition (type config may override) |
165
- | `commitBytes` / `commitFrames` | 4 MiB / 1024 | per partition per commit |
166
- | `sealBytes` / `sealRecords` / `sealAgeMs` | 64 MiB / 1 M / 1 h | segment seal |
167
- | `ckptIntervalMs` / `ckptMetaBytes` | 250 / 64 KiB | head checkpoints (file mode) |
168
- | `mergeL0Blocks` / `mergeL0Bytes` / `mergeMinL0Bytes` | 16 / 1 MiB / 64 KiB | merge triggers |
169
- | `mergeHelpers` | 1 | merge builder threads (0: build on the writer) |
170
- | `zeroFillStep` | 1 MiB | A8 zero-fill ahead |
171
- | `arrivalsSegBytes` | 64 MiB | A15 arrivals segment |
172
- | `commitJournal` | false | A8 fallback: per-writer commit journal |
173
- | `journalCkptBytes` / `journalCkptMs` | 8 MiB / 1000 | journal checkpoint triggers |
174
- | `noticeQueue` | 1024 | A25 notice queue per type |
175
- | `typeCommitRows` / `reconcileStep` | 8192 / 4096 | bounded steps |
176
-
177
- ## 8. Tests and benchmarks
178
-
179
- ```
180
- cmake -S cpp -B cpp/build -DCMAKE_BUILD_TYPE=RelWithDebInfo
181
- cmake --build cpp/build -j 8 --target flatsql_ps_test flatsql_ps_bench
182
- cpp/build/flatsql_ps_test # default suite, T1 + T2 (~2 min)
183
- cpp/build/flatsql_ps_test --test=<name> # one test, slow ones included
184
- cpp/build/flatsql_ps_test --crash-trials=N --crash-seed=S --test=crash_faults_T1_1
185
- cpp/build/flatsql_ps_bench --mode=scaling|host02|dirty|soak [--journal=1] [--io=mem|fs] [--dir=D]
186
- ```
187
-
188
- Slow tests: `crash_faults_T1_1_full` (10,000 trials, 64 types, 256
189
- partitions), `writer_single_writer_under_rebalance_T1_4_full` (10 min),
190
- `type_notices_never_block_A25_full` (10 min),
191
- `open_clean_1M_records_256_partitions_T1_2_full` and `_real_fs`.
192
-
193
- The crash harness (FaultFs) injects: drop all unsynced writes, drop a random
194
- subset, tear the last write at a 512 B boundary, reorder unsynced writes, and
195
- kill -9 keeping the page cache; every other trial adds the A4 sequence
196
- (reopen, resend, publish, second crash dropping everything). Half the stores
197
- run in journal mode; seals, merges (helper and inline), arrivals seals,
198
- RECONCILE, TOMB_CID, TOMB_RANGE, transactions and registrations run under the
199
- crashes. A stalled ack counts as a failure.
200
-
201
- ## 9. Acceptance (§18 T1 as amended)
202
-
203
- Machines: **Mac** = owner Mac Studio (Darwin 25.3.0, arm64, 28 cores,
204
- APFS, `F_FULLFSYNC`), shared with other lanes (load average 13–42 during
205
- these runs). **Docker** = Linux 6.12 linuxkit arm64 in Docker Desktop on that
206
- Mac (ext4 on a virtual disk), `--cpus`/`--memory` as noted. Neither is
207
- host-02 or a Linux-8 box; §11 lists what needs one. All numbers come from
208
- `MEASURED` lines of the tests and `flatsql_ps_bench`, run on task-branch builds from 16a4c63 to 7f941bc.
209
-
210
- | # | Acceptance | Result | Where |
211
- |---|---|---|---|
212
- | 1 | 10,000 injected crashes, 256 partitions, 64 types (OMM/MPE/CAT/IQC shapes); acked bytes identical, gap-free pseqs, counters = recount, no gseq reassigned, 0 `d-*` bytes read by open; A4 crash → reopen → resend → second drop-all crash; A10 type/registry crash points; A11 merge crash points | **Pass.** Final build (7f941bc), Mac: 10,000 trials over the five modes (about 2,000 each: drop all unsynced, drop a subset, tear the last write at 512 B, reorder, kill -9) plus an A4 second drop-all crash every other trial; 3,437,331 acked records verified; 526,105 merges, 1,087 seals, 69,126 arrivals seals, 11,107 TOMB_RANGE commands, 983,753 journal records replayed (half the stores journaled); 243 s. Linux (Docker): 2 × 1,000 trials on the final build, 5 × 1,000 on earlier ones. | Mac `crash_faults_T1_1_full`; Docker `--crash-trials=1000` |
213
- | 2 | Open, 1 M records in 256 partitions: clean ≤ 150 ms and ≤ 1 MiB I/O; after drop-unsynced ≤ 64 KiB `m` per active partition; 0 frames parsed | **Pass.** Real fs: Docker ext4 39.6 ms, 476 KB read; Mac APFS 34.1 ms, 574 KB read, 1 fsync (the registry incarnation). In-memory host: 4.5 ms, 577 KB. After drop-unsynced: 24 KB of `m` per partition (64 partitions). `framesParsedAtOpen = 0`, `openDataBytes = 0`. | `open_clean_1M_records_256_partitions_T1_2_real_fs`, `_full`, `open_after_drop_unsynced_reads_bounded_meta` |
214
- | 3 | Resend the last 10 k acked after a crash: 0 new rows, 0 `d-*` bytes read | **Pass.** 0 rows, 0 bytes. | `open_resend_after_crash_dedupes_T1_3` |
215
- | 4 | Rebalance every 50 ms for 10 min: pseq strictly increasing and gap-free, one writer thread per ownership interval, no overlap; A26: rebalances during merges with helpers stalled 100 ms, 0 non-owner writes | **Pass.** 600 s, 8 writers, 64 partitions, producers paced to ~6,400 records/s: 10,623 moves, 761,217 audited commits, 7,624 ownership intervals, 0 violations (gap, order, overlap, second thread in an interval); 33,511 merges, 5,181 injected 100 ms helper stalls, 5,448 handoffs that waited for an in-flight helper, 0 non-owner helper writes; every sent record present after reopen. | `writer_single_writer_under_rebalance_T1_4_full` (Mac) |
216
- | 5 | One partition flooded: ring ≤ cap, others' p99 changes ≤ 20%, pool within budget, credits reach 0 and recover, 0 frames lost | **Pass.** Ring max 4,193,680 of 4,194,304 B; others' ack p99 34.5 → 30.4 ms (ratio 0.88; 28 hardware threads); pool peak 111 slabs (≤ total − reserve); 101,094 zero-credit samples and 14,006 credit waits, credits > 0 after; every sent frame present after reopen. The test enforces the ring, pool, credit and frame checks on every machine. The 20% ack-p99 ratio is a Linux-8 acceptance: it is enforced only on machines with ≥ 8 hardware threads and otherwise printed with the thread count (a 3-vCPU CI runner measured 91.6 → 154.9 ms once, then passed on rerun). | `writer_backpressure_flood_T1_5` (Mac) |
217
- | 6 | Linux-8: N=8/N=1 ≥ 5× in memory, ≥ 3× with real fdatasync; 2 fsyncs per partition commit | **Pass on the proxy.** Docker `--cpus 8`: in memory 154,230 → 829,233 records/s (5.38×); real fs 80,883 → 456,263 records/s (5.64×); `d` 1.000 + `m` 1.000 fsyncs per framed partition commit. Journal mode: 1 journal fsync per committing iteration. | `flatsql_ps_bench --mode=scaling --io=mem|fs --writers=8` |
218
- | 7 | host-02: N=1 ≥ 5,000 OMM records/s durable over 50 partitions; ack p99 ≤ 40 ms at 2,500/s | **Needs host-02.** Sustained rate passes everywhere measured (Docker 4 vCPU 38,912–100,598 records/s; 1 vCPU 35,788–85,149; Mac 19,920–26,936). Ack p99 at 2,500/s follows the disk's fsync tail and the shared box's load, not the engine: the same Docker 4 vCPU profile gave file mode p50 17–20 ms / p99 153–214 ms and journal mode p50 1.1–4.0 ms / p99 120 ms at load 15–20 (that disk's `fio` fdatasync p50 0.66 ms, p99 27 ms, p99.9 142 ms), and p99 0.4–4.4 s in both modes (an A/B run of the build at da0ad51 gave the same), when the box ran at load 12–42 with other lanes' I/O. Mac APFS (`F_FULLFSYNC`, load 14–22): file p50 757 / p99 998 ms, journal p50 343 / p99 567 ms. A8 at 1/10/50 dirty partitions (Docker 4 vCPU, load 15–20): file p99 23 / 95 / 185 ms, journal 17 / 93 / 170 ms. | `--mode=host02`, `--mode=dirty`, `--journal=0|1` |
219
- | 8 | 30-min soak: every shared lock max hold ≤ 50 ms, p99.9 ≤ 1 ms; hot-path `malloc` per record = 0 | **Pass.** Mac, 1,800 s, 3,600,000 records over 50 partitions, 2 writers, real fs: hot-path `malloc` 0 per record; partition seqlock writer sections (144,399) max 0.027 ms, p99.9 ≤ 0.001 ms; registration lock (116 holds) max 0.025 ms, p99.9 bucket ≤ 0.033 ms. The other locks are queue mutexes held for one push or pop. `writer_hot_path_zero_malloc_T1_8`: 0 allocations over 4,000 records in file and in journal mode. (Ack p99 in that run was 6.5 s: the Mac's `F_FULLFSYNC` at 2 per partition commit, see row 7.) | `flatsql_ps_bench --mode=soak` (Mac); `writer_hot_path_zero_malloc_T1_8` |
220
- | 9 | 2,048 partitions, 64 active: writer committed memory ≤ 288 MiB; idle partitions hold 0 slabs | **Pass.** 80.6 MiB committed (pool 25.8 MiB touched of 192 MiB, descriptors 11.9 MiB); 0 idle partitions with slabs. | `writer_memory_2048_partitions_64_active_T1_9` |
221
-
222
- §22 amendments naming T1:
223
-
224
- | Amendment | Status |
225
- |---|---|
226
- | A2 TAG rows, RETAG, RECONCILE | Built; B1/B2 sequence gives 0 TOMBs and 0 new arrivals, B3 tombstones exactly the removed record (`writer_dedupe_retag_reconcile_A2`); RECONCILE and TAG_TOMB under crashes. |
227
- | A4 durable tails | Built (fsync before adoption, DURABLE_CKPT head, quarantine on fsync EIO, dedupe acks at durable commit); the crash harness's A4 mode. |
228
- | A8 sync rounds, zero-fill, journal fallback | Built: 2 sync rounds per iteration (file mode) or 1 (journal mode), zero-fill ahead, A8 ack p99 reported at 1/10/50 dirty partitions. |
229
- | A9 segmented meta log | Built (`m-<seg>` per data segment). Retirement is T3 (deviation 3). |
230
- | A10 type-log and registry durability | Built (type batch format, > 128 pids label checkpoints, registry truncation, EXCL heads, pid never reused). |
231
- | A11 merge intents | Built (INTENT_MERGE, idempotent `r`/`a`, fresh `x`/`mf`, open unlinks orphans). |
232
- | A14 kills and promotion | Built (TOMB_CID fan-out, REHOME keeps gseq). |
233
- | A15 arrivals | Segments and fence index built; compaction is T3 (deviation 4). MaxRowID = published `gseq_hi`. |
234
- | A19 extraction parity, sealed frames | Built; 420/420 golden rows (§5); sealed envelopes checked by magic and size only. |
235
- | A25 notices never block | Built. 10-minute run (Mac, 2 writers, crossed type ownership, notice queue of 1): 253,777 notices dropped, `labeled_through` lag p99 0.001 ms and max 222 ms, longest producer ack wait 414 ms (0 stalls ≥ 1 s). Under a load that saturates the type owners' labeling, the lag is throughput-bound, not bounded by the commit window. |
236
- | A26 ownership epochs | Built except stage-1 prep states (deviation 2). |
237
- | A34 published deps | `flatsql_attr_generated.h` and the A19 vectors come from the published flatc-wasm, SDS npm 1.226.0 and SDS Go 1.226.0; no sibling checkout is read at build time except the flatbuffers sources CMake already used. |
238
- | A35 host-02 budget | Memory measured against the host-02 profile (row 9); the ops receipt (`nproc`, `free -m`, `df`, `fio`) needs the hosts (§11). |
239
-
240
- ## 10. Design deviations
241
-
242
- Each entry: what the design says, what was built, why, and the evidence.
243
-
244
- 1. **Merge outputs are built and synced by a maintenance helper thread**
245
- (design §4.5/§11: the owner merges between commits; helpers only for split
246
- hot partitions, §12). The owner plans the merge (INTENT_MERGE), a helper
247
- writes `r`/`a` at plan-derived offsets and fresh `x`/`mf` files, syncs them,
248
- and the owner commits MERGE_DONE. Why: building a run on the writer stalled
249
- every partition it owns (maintenance step max at 50 dirty partitions, Linux
250
- 1 vCPU profile: 325.8 ms with merges on the writer, 125.7 ms with the helper
251
- sharing that one CPU); the owner law puts
252
- compaction "per partition, off the hot path". The helper writes only new
253
- files or regions no reader can see before MERGE_DONE, re-validates the owner
254
- epoch before every mutation (A26), and a HANDOFF waits for it
255
- (`writer_single_writer_under_rebalance_T1_4`: helpers stalled 100 ms, 0
256
- non-owner writes). Its syncs no longer ride the commit's first sync round.
257
- 2. **No stage-1 helpers; ring entries carry no prep state** (§5.2, A26 "FREE →
258
- CLAIMED(epoch) → PREPARED"). The owning writer verifies, extracts and
259
- checks CIDs itself. Scaling came from partitions spread over writers
260
- (T1 #6 met without them, see §9), so the extra protocol bought
261
- nothing measurable. A26's other clauses (owner word, epoch re-validation,
262
- slab grace) are built.
263
- 3. **A torn-both-heads rebuild replays the meta log from segment 0** (A9:
264
- "scans only the active m segment plus the manifest"). Sealed `m` segments
265
- are retired by T3 (A12 lifecycle), which also records `first_live_m_seg`;
266
- until then segment 0 onward is the only complete source.
267
- 4. **A15 arrivals compaction is not built.** Arrivals are segmented with a
268
- gseq fence index (`g-<seg>.fsg`, `g.fsf`); dropping dead entries and
269
- folding DEAD entries out of catalog runs belong with T3's compaction and
270
- quota planner, which trigger them. MaxRowID is the published `gseq_hi`.
271
- 5. **The A8 commit journal is a configuration mode, off by default.** §22.4
272
- ruling 5 makes it the fallback "if a host misses T1 #7"; host-02 has not
273
- been measured (no ssh from this lane). Both modes pass the crash harness
274
- (half its stores run journaled) and are benchmarked in §9.
275
- 6. **A19 golden vectors come from a local archive day, not the host-02
276
- inventory sample** (no host access). The frames are real Space-Track GP
277
- records (`/opt/data/sdn-archive/spacetrack/gp/2026/2026-09-27.json.gz`)
278
- plus constructed edge cases; the expected rows come from the Go functions
279
- themselves.
280
- 7. **New rule directive `require <table path>`**: OEM's Go extraction returns
281
- no keys at all when the first block has no OBJECT; the rule language had no
282
- way to say that.
283
- 8. **New index kind `TAG_DEAD`** (A2 lists only TAG_TOMB rows). A PUT row is
284
- also its record's first tag instance; posting its TAG_TOMB under `DEAD`
285
- would have killed the record. `TAG_DEAD` keys a dead tag instance
286
- separately from a dead record.
287
- 9. **A full type L0 directory (48 blocks) makes the type owner wait for its
288
- merge** instead of committing more label batches; the head lists at most
289
- 48 unmerged blocks.
290
- 10. **The registration lock holds no I/O.** A registry frame reserves its
291
- offset under a short mutex, is pwritten outside every lock, and returns
292
- once all earlier frames are written (so its fsync covers them); a failed
293
- pwrite breaks the registry for the run and open truncates at the hole.
294
- The hint head is encoded under the lock and written after it. A second
295
- registrant of the same key waits for the first. With the fsyncs inside,
296
- the 30-minute Mac soak measured a 92 ms hold; with only the pwrite inside,
297
- 1.8 ms (a pwrite queued behind another registrant's `F_FULLFSYNC`).
298
- 11. **In journal mode heads carry no DURABLE_CKPT meaning.** Heads are written
299
- every commit and made durable by the journal checkpoint; open trusts
300
- `max(head, replayed journal)` and never adopts a journaled batch its
301
- journal did not replay.
302
- 12. **Backpressure acceptance "credits go to 0"** is measured as samples with
303
- less than one entry of credit (< 1 KiB) and producer credit waits during
304
- the flood, then credits > 0 after it.
305
- 13. **Journal checkpoints run on their own thread and are paced** (a 50% duty
306
- cycle per file sync). Back-to-back checkpoint fsyncs queued ahead of the
307
- journal fsyncs acks wait on, and a long checkpoint on the merge helper
308
- delayed merges until L0 directories filled.
309
-
310
- ## 11. Needs a Linux host or ops
311
-
312
- Commands for the rows this lane could not measure (run from a flatsql
313
- checkout at the landed commit, `cmake --build cpp/build -j 8 --target
314
- flatsql_ps_bench flatsql_ps_test` first):
315
-
316
- - **T1 #7 on host-02** (4 vCPU / 7.9 GiB), on the store disk, both modes:
317
- `cpp/build/flatsql_ps_bench --mode=host02 --dir=<store disk>/ps-bench --journal=0` and
318
- `--journal=1`; `cpp/build/flatsql_ps_bench --mode=dirty --dir=<store disk>/ps-bench --journal=0|1`.
319
- If file mode misses p99 ≤ 40 ms and journal mode meets it, §22.4 ruling 5
320
- selects `commitJournal` for that host.
321
- - **A8/A35 ops receipt** on host-01 and host-02: `nproc; free -m; df -h <store disk>;
322
- fio --name=fds --rw=write --bs=16k --size=256m --fdatasync=1 --directory=<store disk>`
323
- (p50/p99 of `sync`).
324
- - **T1 #6 on a real Linux-8 box**: `cpp/build/flatsql_ps_bench --mode=scaling --io=mem --writers=8`
325
- and `--io=fs --dir=<ext4 dir>`.
326
- - **LazyFS** power-loss runs (the crash harness emulates the loss modes in
327
- memory; a filesystem-level run needs Linux with FUSE):
328
- `lazyfs` mounted at `<dir>`, then `flatsql_ps_test --test=open_clean_1M_records_256_partitions_T1_2_real_fs --dir=<dir>`
329
- with `lazyfs::clear-cache` between ingest and open.
330
-
331
- # Part II: readers and SQL (T2)
332
-
333
- ## 12. Code map
334
-
335
- | Path | Contents |
336
- |---|---|
337
- | `ps/lane.h`, `src/ps/lane.cpp` | Reader instances, lanes, the mailbox ABI (request slots, result rings, parking, cancellation), the native client, `CounterReader` (counters without a lane, A28). |
338
- | `ps/lane_arena.h`, `src/ps/lane_arena.cpp` | Per-lane TLSF arenas behind `SQLITE_CONFIG_MALLOC`, instrumented SQLite mutexes, the null VFS. |
339
- | `ps/snapshot.h`, `src/ps/snapshot.cpp` | The reader's view of committed state: registry view, partition and type snapshots, rows, frames, attributes, posting scans and lookups, lazily read L1 runs, the instance's shared index cache, arrivals, GONE counting. |
340
- | `ps/vtab.h`, `src/ps/vtab_partition.cpp` | Record virtual tables: module, plans (`xBestIndex`), columns, partition-level row sources. |
341
- | `src/ps/vtab_fanout.cpp` | Type level: pruning, the k-way merge, arrivals order with REHOME/GONE joins, the cid catalog, `<TYPE>_current`, offset paging. |
342
- | `src/ps/vtab_meta.cpp` | `flatsql_partitions`, `flatsql_lanes`, `flatsql_licences`, `flatsql_arrivals`, `flatsql_types`. |
343
- | `src/ps/admission.cpp` | Plan admission (bounded bit), the sandbox authorizer. |
344
- | `ps/result_block.h`, `src/ps/result_block.cpp` | RB1 encoder/decoder, raw-stream framing. |
345
- | `cpp/test/ps/{lane,fanout_merge,int64_exact,lane_isolation,snapshot,arrivals_paging,reader_concurrency}_test.cpp` | T2 tests; `reader_fixtures.*` helpers; `vectors/rb1_int64.hex` (the RB1 golden vector for T6's Go decoder). |
346
-
347
- ## 13. Reader instances and lanes
348
-
349
- - A `ReaderInstance` runs L lane threads of one class: **interactive**
350
- (index-bounded plans only), **bulk** (any plan; `setpriority` nice +10 on
351
- Linux, QoS utility on darwin) or **sandbox** (untrusted SQL under a work
352
- budget, A28). Instances share nothing with a writer: they read files only.
353
- - Each lane owns a SQLite connection (`:memory:`, `SQLITE_OPEN_NOMUTEX`, the
354
- `flatsql_ps_null` VFS that refuses every file open, `temp_store=MEMORY`), a
355
- TLSF arena (8 MiB interactive and sandbox, 128 MiB bulk) installed through
356
- `SQLITE_CONFIG_MALLOC` (thread-bound dispatch), lookaside carved from that
357
- arena, its own file handles (LRU of host handles) and caches.
358
- - SQLite is built separately for the readers (`sqlite3_ps`):
359
- `THREADSAFE=2`, `DEFAULT_MEMSTATUS=0`, `OMIT_WAL`, `TEMP_STORE=3`.
360
- - Lane threads get explicit stacks (2 MiB default, ≥ 1 MiB enforced) with a
361
- guard page and a canary word checked every loop iteration (A30).
362
- - **Mailbox ABI** (what the Go router programs against): a pool of request
363
- slots in instance memory. A slot is a `SlotHeader` (state, cancel word,
364
- doorbells, budgets, status, statistics) followed by a request area (SQL,
365
- then RB1-encoded parameters) and an SPSC result ring. Clients claim a free
366
- slot by CAS, write the request and queue its index on an MPMC queue; an
367
- idle lane is woken through its doorbell word.
368
- - **Parking (A28).** A lane never blocks on output: when a slot's ring is
369
- full the statement parks (its `sqlite3_stmt` stays resumable) and the lane
370
- serves other slots, up to 8 parked statements per lane. A client read that
371
- frees space rings the owning lane.
372
- - **Cancellation** is the slot's cancel word, polled by the progress handler
373
- every 4,096 VM steps and by every vtab loop every 4,096 entries (A21, A28).
374
- The instance's stop word is polled the same way. No deadline, no abandon.
375
- - **A12 announcements.** Each lane publishes the start time of its oldest
376
- running (or parked) statement; `ReaderInstance::oldestActiveStart()` is the
377
- minimum. The reclaimer (T3) may unlink files a SWAP retired at time t once
378
- that minimum is later than t, checked twice a grace apart.
379
-
380
- ## 14. Snapshot protocol (§8)
381
-
382
- 1. Each statement re-reads the registry head and parses new frames only
383
- (copy-on-write view; a changed incarnation rebuilds the view).
384
- 2. A partition is snapshotted once per statement at first touch: the valid
385
- head slot with the highest gen (8 KiB, one pread), then its manifest
386
- (immutable, cached per lane).
387
- 3. Type-level statements snapshot the type head first (labels inline, or the
388
- A10 checkpoint plus deltas for > 128 partitions, cached incrementally),
389
- then each partition. **V_p = min(pseq_hi, labeled_through[p]).** A
390
- partition head older than `labeled_through` (the writer pwrites the head
391
- right after the commit its type owner may already have labeled) is re-read,
392
- bounded (4 ms), so V_p never hides a promotion.
393
- 4. Rows come from `r-<seg>` (merged range, per the manifest) or the meta
394
- batch the head's L0 directory names; payloads from `d-<seg>` at
395
- `(off, len)`, CRC-checked against the row's `dataCrc`.
396
- 5. Liveness: a DEAD posting with killer ≤ V_p. Type level: a row is FIRST when
397
- it has no REPEAT posting (REPEAT is a lookup kind with a bloom; a REPEAT
398
- label always leaves one), otherwise its latest LABEL decides (A14
399
- promotions). Readers take no lock shared with a writer.
400
- 6. A file named by the snapshot that fails with ENOENT before the first row
401
- re-snapshots once; after a row it returns `FLATSQL_SNAPSHOT_GONE`
402
- (retryable). The announcement protocol makes that happen only after a
403
- restart.
404
-
405
- ## 15. SQL surface (§9, A17, A18)
406
-
407
- Virtual tables are created lazily in the lane's temp schema the first time a
408
- statement names them:
409
-
410
- | Table | Rows |
411
- |---|---|
412
- | `sds_p_<producer>__<TYPE>` | every live PUT of one partition (partition-level visibility) |
413
- | `<TYPE>` | the fan-out: one row per live CID, FIRST copies only |
414
- | `<TYPE>@<source>` | `<TYPE>` with a live tag instance of that source |
415
- | `<TYPE>_current` | latest live record per stored supersede key, else per object key (OBJECT_EPOCH) |
416
- | `flatsql_partitions`, `flatsql_lanes`, `flatsql_licences`, `flatsql_arrivals`, `flatsql_types` | meta (never public) |
417
-
418
- Record vtabs expose the root table's fields (BFBS reflection, vtable-slot
419
- order; sealed rows project NULL) and hidden columns `_pseq _cid _cid_bin
420
- _epoch _arrival _gseq _producer _source _source_name _provider _batch _peer_id
421
- _signature _data _offset _len _kind _rowid _pid`. `_source` is
422
- `'<TYPE>@<source_name>'`; `_rowid` is `_pseq` on a partition and `_gseq` at
423
- type level.
424
-
425
- Plans (`xBestIndex`), each recording a bounded bit in its `idxStr`:
426
-
427
- | Access | Claims | Order |
428
- |---|---|---|
429
- | CID | `_cid` / `_cid_bin` EQ (type level: the cid catalog) | – |
430
- | pseq | `_pseq` / `_rowid` EQ or range (partition) | pseq ± |
431
- | gseq | `_gseq` / `_rowid` EQ or range (type: arrivals), OFFSET pushdown | gseq ± |
432
- | COL | indexed column EQ (`col <n> u64pos|str:FIELD` rules) | – |
433
- | tag | `_provider` / `_batch` / `_peer_id` EQ | – |
434
- | source | `_source` / `_source_name` EQ (+ `_epoch` range; the alias) | epoch ± |
435
- | epoch | `_epoch` range or `ORDER BY _epoch` (EPOCH index) | epoch ± |
436
- | default | none of the above at type level (EPOCH_CID index) | seconds DESC, CID ASC |
437
- | full | anything else | unbounded |
438
-
439
- A plan is bounded when its index range is closed or a LIMIT applies to an
440
- index order the vtab emits. The fan-out prunes by `_producer` EQ, merges
441
- index-ordered sub-cursors on (key, CID, pid, pseq) with a heap, drops
442
- adjacent duplicate CIDs, and stops at LIMIT.
443
-
444
- **Admission.** Interactive lanes read the prepared statement's own program
445
- (`sqlite3_stmt_explain`): any `VFilter` whose plan is unbounded returns
446
- `FLATSQL_NEEDS_BULK` before a row is examined. Every statement reports
447
- rows examined, bytes read, index entries and fence reads.
448
-
449
- **Untrusted SQL (A28, §22.4 ruling 7).** Sandbox requests keep the SandboxCaps
450
- contract: one read-only SELECT, the authorizer allow-list mapped onto the
451
- record vtab names (meta tables never public), `<TYPE>` bounded to the newest N
452
- arrivals (A18; `OMM` 400,000, other types 10,000), and a work budget — rows
453
- examined, bytes read, VM steps — that is an admission limit returning the
454
- existing `timeout` code. Sandbox SQL never runs on the shared bulk lane: it is
455
- refused `needs-bulk` there, admitted by plan on interactive lanes, and runs
456
- any plan on a dedicated sandbox lane under the budget.
457
-
458
- ## 16. Results (RB1, raw stream)
459
-
460
- `ps/result_block.h` has the byte layout. Integers are exact 8-byte int64
461
- (no float64 rounding above 2^53, §2); REAL, TEXT and BLOB cells are typed per
462
- cell; an end record carries the status and the work counters. Raw-stream mode
463
- emits `[u32le size][bytes]` per BLOB cell, so `SELECT _data …` streams the
464
- stored frames verbatim. `cpp/test/ps/vectors/rb1_int64.hex` is the golden
465
- vector for T6's Go decoder.
466
-
467
- ## 17. Index access and caches
468
-
469
- - **L0 blocks** are read whole once (CRC-checked) and kept as a kind
470
- directory; each kind's section (entries, offsets, bloom) is cached separately
471
- and binary-searched. A statement resolves each snapshot's sections once per
472
- kind.
473
- - **L1 runs** open lazily: footer and TOC, the metadata CRC checked once per
474
- lane, then a kind's fences a page (128 fences, 5 KiB) at a time and its
475
- bloom while it fits (§37).
476
- - **Ascending scans prime runs lazily.** A run joins a posting merge with a
477
- lower bound of its first entry (the scan's `lo`, or its first block's fence
478
- prefix) and reads its first 4 KiB block only when it reaches the top of the
479
- merge. The default order (`EPOCH_CID`, newest first) and gseq order are
480
- ascending, so a LIMIT window reads blocks only from the runs it draws rows
481
- from, not one block from every run of every partition. Descending scans
482
- prime eagerly (a fence gives no upper bound).
483
- - **The instance's shared cache** holds these immutable pieces (L0
484
- directories and sections, fence pages, blooms) for every lane: one shard
485
- per MiB of budget (1 to 64), each only ever try-locked, so a busy shard is
486
- a miss and a lane never waits on another lane (writers never touch it). A
487
- lane-private front, an LRU by bytes, serves repeated hits without any lock.
488
- - Default budgets: 16 MiB shared per instance, 4 MiB per lane for each of
489
- its private cache, its resident runs and its front, 4,096 host handles per
490
- lane (the host's handles are virtual; T5 bounds real fds with its LRU).
491
- Every cache is bounded by bytes (§37).
492
-
493
- ## 18. Arrivals, offset paging, dead history (A14, A15)
494
-
495
- - Arrivals rows merge-join two type postings by gseq: **REHOME** (the latest
496
- promotion keeps the gseq, A14) and **GONE** (a gseq whose last live copy
497
- died; new, posted by the type owner). No per-entry lookup.
498
- - **Offset paging.** `ORDER BY _gseq|_rowid [DESC] LIMIT n OFFSET k` pushes the
499
- OFFSET into the vtab. Live entries in positions [a, b) = (b − a) − GONE
500
- gseqs in [g_a, g_b), counted from L1 fences (whole blocks by their counts,
501
- the two edge blocks read) and the unmerged L0 sections; a binary search over
502
- positions finds the k-th live entry in O(log n) probes.
503
- - **Dead runs.** A GONE entry starts a gallop over the same counts to the next
504
- live position; short runs fall back to per-entry checks.
505
- - `flatsql_types` exposes MaxRowID (`gseq_hi`), TotalCount
506
- (`first_live_count`) and the SnapshotID inputs (A16).
507
-
508
- ## 19. Writer-side changes made by T2
509
-
510
- T2 owns the whole flatsql component; these changes to T1's writer serve the
511
- readers and are covered by T1's suites as well as T2's.
512
-
513
- | Change | Why |
514
- |---|---|
515
- | Index kind `EPOCH_CID` (19): complemented `floor(epoch_ms/1000)` + the A17 CID key → PUT pseq | A19's default order (seconds DESC, text CID ASC) as one ascending index scan, with copies of a CID adjacent for the merge. |
516
- | Type kind `GONE` (0x204): a gseq whose last live copy died | Dead arrivals counted by fences: offset paging (T2 #7), dead-run skipping (T2 #8), merge-joined arrivals. |
517
- | `REPEAT` (0x203) is a lookup kind (bloom) | The type-level FIRST test is a bloom probe for most rows; LABEL is read only for REPEAT copies. |
518
- | SWAP seam: `Engine::swapSegment`, ctl `SWAP{seg, gen, oldCgen}`, manifest segment `cgen`, `c-<seg>-<gen>.{fsd,fsr,fsa}`; the owner reads compacted files, open replays SWAP | The smallest seam T3's compaction plugs into (§20 #5 uses a verbatim copier through it). |
519
- | Catalog precedence at equal tcs: DEAD > PROMOTED > REPEAT > FIRST (writer `upsertCopy`, reader `catalog`/`labelOf`; REHOME candidates of one tcs resolved by liveness) | **T1 defect** found by the fan-out oracle: a batch that promoted a REPEAT and then killed it left two entries with one tcs; value order made the copy read as live, and a returning copy was labeled REPEAT instead of FIRST. |
520
- | A cid's catalog is gathered, resolved per copy and loaded without its dead copies | **T1 defect** found by the A14 reader test: a cid killed and re-added over 1,000 times overflowed the per-batch copy table, dropped its live REPEAT, and died without an heir (63 of 64 promotions at round 1,027). |
521
- | Writer threads tag themselves for the lock report | Lock-set disjointness instrumentation (T2 #1). |
522
- | Test fixture `buildRecord` passes a NUL-terminated file identifier | `FinishSizePrefixed` takes a C string; the fixture passed the type's four `fid` bytes, and Debug builds (CI) asserted when the next byte was not zero (T2's type variants). |
523
-
524
- ## 20. Acceptance (§18 T2 as amended)
525
-
526
- Machines: **Mac** = the owner's Mac Studio (Darwin 25.3.0, arm64, 28 hardware
527
- threads), shared with other lanes; the 1-minute load average is recorded per
528
- run (20–46 during these). **Mac mem** runs use the in-memory test host (no
529
- fsync), **Mac APFS** the native host on APFS (`F_FULLFSYNC`). **Docker** =
530
- Linux 6.12 arm64 in Docker Desktop on that Mac (a 16-vCPU VM shared with the
531
- Mac's load), either `--cpuset-cpus 0-7` (8 pinned vCPUs, "Docker-8") or
532
- `--cpus 8` (a CFS quota over 16 vCPUs, whose throttling periods show up as
533
- 100 ms+ tails; used only for correctness and disjointness); ext4 on the VM's
534
- disk with `fdatasync`. None is a quiet Linux-8 box: latency bounds are enforced
535
- by the tests only on one (a release build, 8+ usable hardware threads, load
536
- below a quarter of them) and reported everywhere else. Numbers come from
537
- `MEASURED` lines of `flatsql_ps_test` and `flatsql_ps_bench --mode=test` on
538
- task-branch builds of 2026-09-28; rows 1, 4 and A28 are from the final build
539
- (lazy run priming, §17).
540
-
541
- | # | Acceptance | Result | Where |
542
- |---|---|---|---|
543
- | 1 | 8 writers saturate 256 partitions, 16 lanes, 10 min: `flatsql_partitions` p99 ≤ 1 ms; window LIMIT 1000 p99 ≤ 10 ms; reader and writer lock sets disjoint; max lane lock wait ≤ 1 ms | **Disjointness and lock wait pass everywhere; the latency bounds are not met on any box measured (all of them CPU-saturated by the test and by other work: needs a quiet Linux-8 box).** Every SQLite mutex and the shared index cache are acquired by lanes only (writer acquisitions 0 in every run); max lane lock wait 0.000–0.022 ms (the cache is only ever try-locked). The engine's own cost, writers stopped and one client (100 statements, 1–4 L0 blocks per partition after merges, 4.7–18 M records): Mac window p50 3.0–8.1 ms, p99 6.3–48 ms following the Mac's load, `flatsql_partitions` p99 0.95–1.3 ms; Docker-8 in-memory window p50 6.3 / p99 60 ms, ext4 p50 14.6 / p99 154 ms. Under saturation the lanes share the CPUs with 8 producer threads, the writers and their merges: Mac mem 40 s at load 46 (15.4 M records): `flatsql_partitions` p99 6.5 ms, window p50 14.5 / p99 91 ms, source window p99 77 ms, counters without a lane (A28) p50 0.42 / p99 2.0 ms. Docker-8 in-memory 30 s at VM load 12 (8.6 M records): `flatsql_partitions` p99 15.7 ms, window p50 75 / p99 259 ms. Docker-8 ext4 60 s at VM load 19 (8.1 M records): `flatsql_partitions` p99 24 ms, window p50 278 / p99 762 ms (an earlier build's run at VM load 18: p50 3.6 / p99 347 ms; host contention decides). Mac APFS 10 min at load 16 (earlier build, 14.8 M records, fsync-bound): 0 errors, window p50 70 / p99 370 ms. | `readers_under_saturating_writers_T2_1[_full]` (`--dir` for a real file system) |
544
- | 2 | 10,000 randomized workloads (a CID across up to 8 producers, supersede, tombstones, epoch-less types) equal a brute-force reference: one row per CID, FIRST semantics, order; rows examined ≤ LIMIT × (partitions + 1). A14: readers during FIRST deaths never miss a live CID | **Pass.** Mac mem: 10,000 workloads (49,833 promotions, 10,064 type deletes, 22,043 supersedes, 13,119 RECONCILEs), 229 s; every Q1–Q5 check (default order, LIMIT prefix and its examined bound, CID lookups, gseq order, partition counts) against the Inspector's reference. A14: 10 min, 2,979 rounds of FIRST deaths, 190,656 promotions, 5,179 reader statements, 0 missing CIDs, 0 gseq changes. Two T1 defects were found on the way (§19). Docker (`--cpus 8`): 500 workloads pass. | `fanout_randomized_vs_bruteforce_T2_2_full`, `fanout_readers_during_first_deaths_A14_full` |
545
- | 3 | −2^63, 2^53+1, 2^63−1 round-trip exactly through RB1 (C++ encoder; Go decoder vectors for T6) | **Pass.** Encoder/decoder in any byte split, the golden vector `vectors/rb1_int64.hex`, and parameters and literals through a lane. Docker: pass. | `int64_rb1_encoder_decoder_exact_T2_3`, `int64_exact_through_sql_lane_T2_3` |
546
- | 4 | Bulk statement past its 128 MiB arena → `SQLITE_NOMEM`, interactive p99 within ±10%, nothing poisoned; unbounded plan on an interactive lane → `FLATSQL_NEEDS_BULK` in ≤ 1 ms with 0 rows examined | **NOMEM, no poisoning and NEEDS_BULK pass; the ±10% ratio holds on Docker-8 and is noise-bound on the Mac.** Every hog returns `kRsNoMem`; the same lane then answers, its arena passes a full consistency check and holds 160 KiB. NEEDS_BULK: lane time p50 0.012–0.016 / p99 0.051–0.099 / max 0.17 ms, 0 rows examined, over 200 unbounded shapes (Mac and Docker). Ratio (p99 with the hog / without, four alternating rounds): Docker-8 at VM load 2.0 and 3.5: 1.030 and 1.087 (rounds 0.95–1.09 and 1.02–1.43); Docker `--cpus 8`: 0.947; Mac control runs (no hog) 1.02–1.05, with the hog 0.94–2.3 at load 20–47 (one busy core streaming 300 MB beside the lanes; no shared lock, see #1). | `lane_isolation_bulk_nomem_T2_4`, `lane_needs_bulk_under_1ms_zero_rows_T2_4` |
547
- | 5 | 1,000 compaction SWAPs under load: statements started before a SWAP complete with pre-SWAP rows. A12: 10-min bulk export under continuous compaction, 0 SNAPSHOT_GONE | **Pass.** Mac APFS: 1,000 SWAPs while ingest runs, 2,406 partition scans verified row by row against the frames sent, 987 of them spanning a SWAP (up to 28 SWAPs during one statement), 0 errors, every retired file set unlinked once announcements allowed. A12, Mac APFS 10 min: 2,769 type-level exports (84.5 M frames), 8,153 SWAPs, 8,090 retired sets unlinked, 0 SNAPSHOT_GONE. Negative control: unlinking without the announcement check gives SNAPSHOT_GONE. Docker (`--cpus 8`, in-memory): 1,000 SWAPs, 56 partition scans all spanning SWAPs (up to 257 during one), 0 errors; A12 10 min: 18,891 exports (296.5 M frames) across 300 SWAPs (the in-memory cap), 0 SNAPSHOT_GONE. | `snapshot_swaps_under_load_T2_5`, `snapshot_bulk_export_under_compaction_A12_full --dir=…`, `snapshot_unlink_without_announce_is_snapshot_gone` |
548
- | 6 | Arrivals paging while 8 writers produce: gseq strictly increasing within and across pages; no gseq ≤ the snapshot head appears after the snapshot; union of pages = the FIRST-live set at the snapshot. A14: a CID's gseq never changes while a copy lives | **Pass.** Mac mem: 62 syncs (18,716 pages of 500) against 1.44 M records from 8 producers into 32 partitions; every sync equals the oracle (final arrivals ≤ its MaxRowID), 0 violations, 0 gseq changes. Docker: 46 syncs, 9,648 pages, 921,888 records, 0 violations. | `arrivals_paging_while_writers_produce_T2_6` |
549
- | 7 | Page 10,000 of 100 rows on a 3 M-record type examines ≤ 2 × 100 rows plus fence reads | **Pass.** Mac mem, 3,000,000 records, 10% scattered deaths: OFFSET 999,900 in gseq order examined 100 rows and 132 arrival entries with 268 fence reads, 2.5 ms; pages equal a full scan's (60 random offsets, ascending and descending, 30% deaths). Docker: 100 rows, 150 entries, 196 fence reads, 7.3 ms. | `offset_paging_page_10000_T2_7_full`, `offset_paging_matches_reference_with_deaths` |
550
- | 8 | A full sync of a 99%-dead history examines ≤ 2 × (live rows + fence reads) | **Pass.** 100,000-entry history, 1,000 live. Deaths clustered: 1,000 rows and 1,008–1,009 arrival entries examined, 724–1,052 fence reads. Deaths scattered (1 in 100 live): 1,000 rows and 2,001 entries examined, 57,388–67,740 fence reads (deviation 4). Docker: clustered 1,000 rows / 1,008 entries / 707 fence reads; scattered 1,000 / 2,001 / 57,492. | `dead_history_full_sync_T2_8` |
551
- | A30 | A maximum-depth SQL expression on a lane | **Pass.** A depth-1000 expression (`SQLITE_MAX_EXPR_DEPTH`) runs; depth 1001 is refused; the lane serves on, canary intact. | `lane_max_depth_expression_A30` |
552
- | A28 | 8 clients reading at 10 KB/s: counter p99 ≤ 10 ms, window p99 ≤ 50 ms. A sandbox cartesian join returns `timeout` while the bulk lane stays free | **Counter bound met on the Mac and Docker-8 in-memory; window bound met only at Mac load 20; the sandbox contract passes.** 8 slow clients park their lanes while others read. Mac mem 40 s: counter p99 1.1 / 4.1 / 2.4 ms and window p99 31.9 / 58 / 64 ms at load 20 (earlier build) / 31 / 46. Docker-8: counter p99 6.4 (in-memory) and 14.1 ms (ext4), window p99 148 and 224 ms at VM load 12 and 19. The join returns `timeout` from the work budget while a bulk statement completes in 1.5–2.3 ms; meta tables are not public; sandbox SQL is refused on the shared bulk lane. | `readers_under_saturating_writers_T2_1_full`, `lane_sandbox_contract_A28` |
553
-
554
- A12's announcement protocol, A14's promotion reads, A15's page fill and
555
- MaxRowID, A17's CID order in the default merge, A18's `_source` and bounded
556
- `<TYPE>`, A21's cancellation polls, A28 and A30 are covered by the rows above
557
- and `lane_test.cpp`.
558
-
559
- ## 21. Design deviations (T2)
560
-
561
- Each entry: what the design says, what was built, why, and the evidence.
562
-
563
- 1. **Fan-out sub-cursors run inline; no idle-lane borrowing** (§5.3: "borrows
564
- idle lanes … as sub-cursor producers … when no lane is idle, the
565
- sub-cursors run inline"). Only the inline form is built. A partition
566
- sub-cursor costs its sections' binary searches and one block per L1 run;
567
- the measured window cost (§20 #1) is dominated by per-row label, liveness
568
- and row reads that a producer lane would not remove. Borrowing is an
569
- optimization T6 can add behind the same `RowSource` interface.
570
- 2. **Type-level FIRST test per row, not a REPEAT merge-join** (§8.6). Index
571
- order is not pseq order, so a merge-join with the REPEAT run applies only to
572
- pseq scans. Rows test the REPEAT bloom (REPEAT became a lookup kind); only
573
- rows with a REPEAT posting read their latest LABEL. Arrivals order does
574
- merge-join (GONE, REHOME).
575
- 3. **Offset paging by GONE counts in arrivals order, not per-block live-FIRST
576
- counts in L1 fences** (§9, A17). T1's merges write each block's entry count
577
- as its live count, and liveness changes after a merge; a new type kind,
578
- GONE, is counted by fences instead (§18). Offset paging is pushed down for
579
- `_gseq`/`_rowid` order (the data explorer's order); the default and CID
580
- orders page through SQLite (O(offset)).
581
- 4. **A15 #8 without arrivals compaction.** Arrivals compaction (dropping dead
582
- entries) is T3's. Dead runs are skipped by galloping over GONE fence counts;
583
- the bound holds for rows and for arrival entries examined, but a history
584
- whose deaths are scattered one-in-a-hundred costs about 60 fence probes per
585
- live row (§20 #8) until T3 compacts it.
586
- 5. **RB1 cells are typed per cell** (§9: a type vector per block). SQLite
587
- values are dynamically typed per row.
588
- 6. **One result ring per request slot** (256 KiB default), parked per
589
- statement, rather than one 1 MiB ring per lane (A28 parking is per
590
- statement either way).
591
- 7. **Sandbox work budget adds VM steps** to rows examined and bytes read, so a
592
- statement that reads nothing (a recursive CTE) is bounded too; the A18
593
- window counts the newest N arrivals entries (dead included), not N
594
- FIRST-live records (one entry read instead of a count).
595
- 8. **`<TYPE>@<source>` matches live tag instances of the FIRST copy's PUT** in
596
- its partition. An untagged record never matches a source (the legacy
597
- `_source` falls back to the producer token for untagged rows); T6's
598
- equivalence run on a migrated copy decides whether that case exists.
599
- 9. **Type-level visibility is labeled rows only**; the A20 read-your-writes
600
- scan of the unlabeled gap belongs to `GetRecord` (T6), which can use the
601
- partition vtab (partition-level visibility is the acked HWM).
602
- 10. **Interactive lanes at nice −5 on ≤ 2 vCPU** (A28) needs privileges the
603
- engine does not have natively; bulk lanes do take nice +10 on Linux. The
604
- host (T5/T6) sets both.
605
- 11. **Shared index cache, try-locked.** The design budgets a 16 MiB block cache
606
- per reader instance; the built cache holds parsed index pieces (L0 sections,
607
- fences, blooms) for all lanes of an instance and is only ever try-locked, so
608
- lanes never wait on each other. Per-lane budgets (4 MiB) hold manifests and
609
- run views; 4,096 host handles per lane (virtual handles; T5 bounds real fds).
610
- 12. **SWAP seam built in the writer** (T3's scope). `Engine::swapSegment` copies
611
- a fully merged sealed segment verbatim to `c-<seg>-<gen>.*`, commits SWAP
612
- with a new manifest, and reports the retired files; it has no
613
- INTENT_COMPACT (T3 adds it with crash handling). Rows stay dense by pseq.
614
- 13. **Lock disjointness natively covers SQLite's mutexes and the shared
615
- cache**; the host handle table is measured under WasmEdge (A29, T5/T6).
616
- 14. **CounterReader reads heads** (O(partitions) preads), the A28 path without
617
- a lane; the Go snapshot refreshed on every acked commit is T6's.
618
-
619
- ## 22. Running the T2 tests; what needs a Linux-8 box
620
-
621
- ```
622
- cmake --build cpp/build -j 8 --target flatsql_ps_test flatsql_ps_bench
623
- cpp/build/flatsql_ps_test --test=lane_ # lanes, parking, sandbox, isolation, A30, C ABI
624
- cpp/build/flatsql_ps_test --test=fanout_randomized_vs_bruteforce_T2_2_full # 10,000 workloads
625
- cpp/build/flatsql_ps_test --test=fanout_readers_during_first_deaths_A14_full # 10 min
626
- cpp/build/flatsql_ps_test --test=snapshot_ [--dir=<real fs dir>]
627
- cpp/build/flatsql_ps_test --test=snapshot_bulk_export_under_compaction_A12_full --dir=<dir> # 10 min
628
- cpp/build/flatsql_ps_test --test=offset_paging_page_10000_T2_7_full # 3 M records
629
- cpp/build/flatsql_ps_bench --mode=test --test=readers_under_saturating_writers_T2_1_full [--dir=<dir>]
630
- ```
631
-
632
- The default suite runs shortened forms of every slow test. Long runs that
633
- write a lot use `--dir` (the in-memory test host keeps every file it held).
634
-
635
- **T2 #1 and #4 on a quiet Linux-8 box** (8 cores, ext4, nothing else
636
- running), from a checkout at the landed commit:
637
-
638
- ```
639
- cpp/build/flatsql_ps_bench --mode=test --test=readers_under_saturating_writers_T2_1_full --dir=<ext4 dir>
640
- cpp/build/flatsql_ps_test --test=lane_isolation_bulk_nomem_T2_4
641
- ```
642
-
643
- Both enforce their bounds on such a box (a release build, 8+ usable hardware
644
- threads, 1-minute load below a quarter of them; the load is sampled at the end
645
- of the run, so the test's own writers count). The disjointness claim itself is
646
- measured under WasmEdge with the C host module in T5/T6 (A29).
647
-
648
- What to expect there: the window's own cost is 3–8 ms at p50 (§20 #1), so the
649
- 10 ms p99 bound leaves little room for waiting on a CPU. The test runs 8
650
- producer threads, the writers and their merges and 16 lanes on 8 cores; when
651
- they saturate the CPUs, the window p99 is decided by the scheduler, not by the
652
- engine. If the bound fails there, the next steps are the design's own levers:
653
- fewer writer threads than cores (design §5.1: `clamp(cores−2, 1, 16)`), interactive
654
- lanes at a higher priority than writers (design A28 gives nice −5 only on ≤ 2 vCPU),
655
- and idle-lane sub-cursors (§21 deviation 1).
656
-
657
- # Part III: compaction, reclamation and quota (T3)
658
-
659
- ## 23. Code map
660
-
661
- | Path | Contents |
662
- |---|---|
663
- | `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). |
664
- | `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). |
665
- | `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). |
666
- | `src/ps/sources-t3.cmake` | Registers the T3 sources and tests on the targets `cpp/CMakeLists.txt` defines. |
667
- | `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. |
668
-
669
- ## 24. Compaction (§11, A11, minor 1)
670
-
671
- A compaction rewrites a run of sealed, fully merged segments `[seg, segEnd]`
672
- into one new file set, `c-<seg>-<gen>.{fsd,fsr,fsa}` plus
673
- `x-<seg>-<gen>.fsx` and `mf-<gen>.fsm`:
674
-
675
- 1. The owner plans. `INTENT_COMPACT{seg, segEnd, gen}` rides the next batch; no
676
- output exists before it is durable. The head carries the intent.
677
- 2. A compaction thread builds. It reads the DEAD and TAG_DEAD postings over the
678
- inputs' pseq range from every run and L0 block at or after the inputs, keeps
679
- a killer only when it is at or below the plan's kill bound (the type owner's
680
- `labeled_through`), and decides per row:
681
- - PUT, LICENCE and CTL rows with a killer go;
682
- - RETAG rows go when their instance is TAG_DEAD, or their PUT is dead or gone;
683
- - TOMB, CTL_TOMB and TAG_TOMB rows go only when an earlier SWAP removed their
684
- target (minor 1): outside the inputs, or VOID within them.
685
-
686
- It copies the surviving frames, attributes and rows in 4 MiB slices, filters
687
- the inputs' L1 runs down to the surviving rows' postings (every partition
688
- posting's value is the pseq of the row that emitted it), fsyncs everything,
689
- and re-checks the owner word and an abort flag before every mutation (A26).
690
- 3. The owner, once no merge is in flight, writes the manifest (its fsync joins
691
- the next round's first sync phase) and queues `SWAP{seg, gen, oldCgen,
692
- segEnd}`; the SWAP, the RETIRE set and the counters' adjustment ride one
693
- batch. Publish attaches the new files.
694
-
695
- Rows keep their pseqs. `c-*.fsr` holds the surviving rows (dense by rank) and a
696
- presence directory (a bit per pseq, a rank per 512); a pseq the directory
697
- lacks reads as a `VOID` row (kind 8, never stored, never live). A segment with
698
- no survivor is `EMPTY` in the manifest and has no files.
699
-
700
- Candidates (maintenance, every 50 ms per warm partition, and at once after a
701
- SWAP): the sealed segment with the largest dead share at or over
702
- `compactDeadRatio` (15%; dead frame bytes counted as kills commit), joined by
703
- its adjacent segments past the ratio too (one output per wave, within the
704
- coalescing limits); adjacent sealed segments under 8 MiB, coalesced up to 16
705
- at a time and 64 MiB of output into one segment named by the first (`lastSeg`
706
- in the manifest; rows' `seg` is rewritten); after a restart, when the
707
- partition's dead share is over the ratio and the per-segment counts are
708
- unknown, the oldest
709
- segment not yet surveyed (a build that would save under 10% is abandoned and
710
- its count remembered). The owner keeps these candidates in an index updated
711
- from a change feed; a look does not walk the segments (§40).
712
- `Engine::swapSegment` requests one (targeted, or the
713
- oldest-generation compactable segment).
714
-
715
- Kill bound and readers. A type-level reader's visibility is `V_p =
716
- min(pseq_hi, labeled_through[p])`. A compacted segment records `killThrough`
717
- (the largest killer behind a removed row, the inputs' own, and the bound of
718
- the SWAP that removed a dropped tombstone's target) and `prevGen` (the manifest
719
- before its SWAP). A type-level snapshot whose `V_p` is below a segment's
720
- `killThrough` reads that segment through `prevGen`, repeatedly for a segment
721
- compacted more than once; the reader gate keeps those files for any statement
722
- older than the SWAP. Partition-level readers see `pseq_hi`, which is never
723
- below a SWAP's bound.
724
-
725
- Counters: `total_count`, `total_bytes` and `tomb_count` count the rows the
726
- files hold (a SWAP subtracts what it removed), so they equal a recount;
727
- `min_epoch`, `max_epoch` and `latest_arrival` stay bounds over everything ever
728
- appended.
729
-
730
- ## 25. Reclamation (A12) and meta segments (A9)
731
-
732
- - **Retire.** A MERGE_DONE retires the folded runs and the manifest it
733
- replaces; a SWAP retires the inputs' files, their runs and the manifest. The
734
- whole outstanding set is a `RETIRE` ctl record in the same batch (the head
735
- points at it: `retireSeg/retireOff/retireN`); consecutive manifests collapse
736
- into one range item.
737
- - **Unlink.** Maintenance unlinks a retired file with `UNLINK_IF_UNUSED` once
738
- the reader gate (the start of the oldest running reader statement) is past
739
- its retirement time, checked twice a grace apart (60 s by
740
- default). The retirement time is that of the first head write after the
741
- batch commits (the head that stops naming the file): a statement that
742
- starts earlier may read the previous head. In journal mode also once every
743
- journal record older than the
744
- retirement is checkpointed; a meta segment once a DURABLE_CKPT head names a
745
- later `first_live_m_seg` (a quiet partition writes one; later items do not
746
- wait for it, and a partition with retired files never goes cold). Each pass
747
- takes the first check of every file past the gate, so a backlog goes about
748
- one grace after its statement ends (`reclaim_backlog_goes_in_one_grace`:
749
- 133 files in 0.31 s at a 0.3 s grace). BUSY (a handle is open) skips to
750
- later items and retries after 50 ms; idle
751
- reader lanes close cached handles after 5 s. The next batch carries
752
- `UNLINKED` and the shrunken set; `disk_bytes` drops then. The set rides one
753
- ctl record of one batch, so it is held under a soft cap (2,048 items as the
754
- record encodes them, less with a large lane table): past it the oldest go at
755
- once, regardless of the gate and the grace (deviation 37). A statement older
756
- than them gets the retryable SNAPSHOT_GONE; an open handle still makes the
757
- unlink BUSY.
758
- - **Open** unlinks every file of the persisted set (a file a surviving reader
759
- instance still holds stays retired), the outputs of an INTENT_COMPACT
760
- without SWAP, and r/a files a first merge created without MERGE_DONE, then
761
- writes a durable head.
762
- - **Meta segments (A9).** A sealed `m-<seg>` whose batches are all merged is
763
- retired by a batch that also re-emits the full lane table (LANE_CKPT; at
764
- level 3 past 32 lanes, a lane checkpoint must cover the segment first, §41)
765
- and the RETIRE set, whose header carries the manifest generation, `merged_through`,
766
- `first_live_m_seg` and `next_gen`; `first_live_m_seg` advances when that
767
- batch commits. A rebuild of a head whose both slots are torn probes for the
768
- first existing meta segment and replays from its first batch.
769
- - **Type logs.** A type's MERGE_DONE retires the catalog runs it folds and
770
- the manifest it replaces. The new manifest persists the whole outstanding
771
- set (these and any still waiting) in an appendix after its CRC trailer,
772
- `[magic FSTR][n][n x RetireItem][crc][pad]`, which readers that predate it
773
- never read. Unlinking follows the partition rules (reader gate twice a
774
- grace apart, journal safe time, BUSY retried after 50 ms). Open unlinks the
775
- appendix's files, the outputs of a merge a crash cut short (it sweeps the
776
- generations after the manifest's to `next_gen + 64`, and 16 past the last
777
- leftover it finds, since a head's `next_gen` can lag), and anything below
778
- `first_live_m_seg`. A failed merge unlinks its outputs at once (durably;
779
- one that cannot go yet is retired like the rest) and retries with the same
780
- generation. At stop a merge in flight closes its handles; an unfinished
781
- build's outputs go, a finished one's are left to open (a MERGE_DONE already
782
- in the log may name them).
783
- - **Type meta segments.** The type meta log starts `m-<seg+1>` once it passes
784
- `typeMetaSegBytes` (64 MiB): the sealed segment is cut at the end of its
785
- last batch and synced, the new one created, and a durable head names it.
786
- Open follows the batch chain from a segment's end into the next one, so a
787
- stale head slot loses nothing. With more than 128 partitions the new
788
- segment's first batch is a FULL_LABELS checkpoint, and readers read label
789
- deltas across segments. The oldest sealed segment retires once no unmerged
790
- L0 block, merge input or label checkpoint lives in it: `first_live_m_seg`
791
- (a type head field) is written to both head slots and synced, then the file
792
- waits for readers like any retired file.
793
- - **Dead copies leave the catalog (A15, catalog half).** A type merge drops the
794
- CID, LABEL and REPEAT entries of every copy killed within its inputs whose
795
- first posting (FIRST or REPEAT) is also among them: a copy never leaves
796
- DEAD, and no older run holds any of its postings. A full fold drops every
797
- dead copy. GONE and REHOME entries stay: they are keyed by gseq for
798
- arrivals order, and leave with their arrival entries (T3b, §33).
799
- - **Reader gate in production.** The writer is its own wasm instance: the host
800
- reads every reader instance's lane announcements
801
- (`FlatsqlPsReaderLayout.laneAnnounce`) and passes the minimum through
802
- `flatsql_ps_reader_gate` (T6 wires it). Natively, `Engine::setReaderGate`.
803
-
804
- ## 26. Disk bytes (§13)
805
-
806
- `disk_bytes` is the exact size of the files a partition names: a ledger of the
807
- stable files (sealed segments' d and m, r/a, c-*, runs, manifests, the next
808
- segment's pre-created files, retired files until UNLINKED) plus the extents of
809
- the files still growing (h, l, the active d and m, zero-fill included). Open
810
- rebuilds it from the head, the manifest and file sizes; the owner maintains it
811
- on every append, merge, seal, SWAP and unlink. After every reopen in the crash
812
- tests, a walk of each partition directory finds exactly the named files and a
813
- total equal to `disk_bytes`. Head files are written as whole slots until they
814
- hold both, so a head write never grows the file again.
815
-
816
- A type's disk bytes are kept the same way: its head, meta segments from
817
- `first_live_m_seg`, arrivals segments and fence, catalog runs, manifest, a
818
- built merge's outputs until MERGE_DONE, and retired files until they are
819
- unlinked, maintained on every write, seal, rotation, merge and unlink and
820
- rebuilt at open from file sizes. Type configs (`s-*.fsc`) are registration
821
- and not counted. The crash tests check the type directory the same way.
822
-
823
- ## 27. Quota and the full disk (§13, A13, §22.4-3)
824
-
825
- - **Planner** (writer 0's maintenance, every 100 ms): usage = Σ partition
826
- `disk_bytes` + Σ type log bytes, less retired files waiting only for
827
- readers. Over the cap
828
- (`EngineConfig::quotaBytes`, `Engine::setQuota`, `flatsql_ps_set_quota`), it
829
- evicts whole sealed segments in arrival order across partitions (their
830
- `minArrival` zone) down to 0.85 of the cap: a `TOMB_RANGE{seg, all}` on the
831
- owner that names the segment's rows by pseq (deviation 34; bounded 512-row
832
- steps; supersede-lane heads and control kinds are spared; each step also
833
- stops after 4 ms of CPU), then, once the tombstones are durable, a
834
- compaction of the segment, or of the output it has been coalesced into
835
- since. One wave at a time.
836
- - **ENOSPC.** A commit that fails NOSPACE starts an emergency: record entries
837
- stop being consumed (producers see zero credits; nothing is acked), the
838
- ballast file (`fsql2/ballast`, `ballastBytes`) is released, and waves evict one
839
- segment at a time down to 0.85 of the usage at the failure, and at least the
840
- ballast's size, but never more than half the store in one episode. Once
841
- usage is under that cap the ballast is recreated and ingest resumes. NOSPACE
842
- in a merge, a compaction, a head write or a segment creation is transient;
843
- a merge that failed retries after 100 ms (a type merge with the same
844
- generation when it left nothing behind). A partition whose L0 directory is
845
- full stages no rows at all (entries, mailbox kills, TOMB_RANGE steps) until
846
- a merge frees a slot.
847
-
848
- ## 28. Acceptance (§18 T3 as amended)
849
-
850
- Machines: **Mac** = the owner's Mac Studio (Darwin 25.3.0, arm64, 28 hardware
851
- threads), shared with other lanes (1-minute load recorded per run). **Mac mem**
852
- runs use the in-memory fault host (no fsync). Numbers come from `MEASURED`
853
- lines of `flatsql_ps_test` (RelWithDebInfo) on the task-branch build landed on
854
- 2026-09-28, run one after another at load 28–38.
855
-
856
- | # | Acceptance | Result | Where |
857
- |---|---|---|---|
858
- | 1 | A partition with 50% dead bytes compacts to ≤ 55% of its prior disk bytes; owner commit p99 and interactive lane p99 ≤ 1.5× baseline during compaction; 1,000 pre-SWAP statements complete correctly | **Size and statements pass; the latency ratio is not met on the Mac (needs a quiet Linux-8 box; T3b's Linux runs: §34).** Mac mem, load 31–35: 4 partitions × 20,000 OMM records, half killed: 106.4 MB → 51.9 MB (0.488×); 1,003 bulk statements that started before a SWAP returned exactly the live rows with their bytes, 0 errors (2,177 SWAPs). Latency over the whole SWAP period (the compaction plus the re-compactions that produced those statements; 16,093 samples): ack p99 0.133 → 18.5 ms, interactive lane p99 0.339 → 3.75 ms, commit round p99 1.05 ms. | `compaction_half_dead_T3_1_full` |
859
- | 2 | CAT supersede, 20% changed per cycle, 100 cycles: disk plateaus ≤ 1.3× live bytes; A9: `m` bytes ≤ one active plus one sealed segment | **Pass with the 15% trigger (deviation 24), "live bytes" read as the live set's compacted footprint.** Mac mem, load 31–33: 20,000 CAT objects, 100 cycles (each updates a random 20%), the store measured at rest after each cycle: disk peaked at 1.120× the footprint of the same live set fully compacted (19.5 MB); 346 compactions, 520 meta segments retired, 5,291 files unlinked; at most 1 meta segment on disk (A9). With §11's 25% trigger it peaks at 1.31–1.38× (six runs). Against the records' own bytes (Σ len − 4 of live PUTs, 4.9 MB) the disk is 4.5×: a fully compacted CAT record costs about 4× its frame in rows, attributes and postings, which no compaction removes | `compaction_cat_supersede_plateau_T3_2_full` |
860
- | 3 | Quota at 80% of current bytes: oldest records evicted first; no supersede-lane head evicted; disk ≤ cap within 3 passes; each eviction step ≤ 10 ms | **Pass (arrival order, §22.4-3).** Mac mem, load 33–35: the store with its type logs 14.1 MB, cap 11.3 MB: under the cap after 1 pass (14 segments), 0 of 256 CAT heads evicted, in each partition the evicted records are exactly its oldest (0 holes); step max 3.4 ms over 15 steps. Across partitions eviction is by whole segment, so a survivor can be older than an evicted record of another partition (288 of 6,367 records here). The 10 ms bound is enforced on a quiet box (the wasm suite at load 41 measured past it). | `quota_arrival_order_heads_spared_T3_3` |
861
- | 4 | 1,000 crash points during compaction: exactly one of the old or new set is live, 0 orphans, 0 acked records lost; A12: crash points inside the grace window, Σ on-disk sizes = Σ `disk_bytes` after each reopen | **Pass.** Mac mem, load 28–38: 3,224 trials, 1,000 with a compaction planned but not applied at the crash, 1,744 with retired files waiting; all five crash modes (664 drop-all, 637 drop-subset, 658 torn, 623 reordered, 642 kill -9); 41,825 compactions, 291,864 files unlinked, 24,719 meta segments retired (partitions and the type's), 723,969 dead catalog entries folded out; after every reopen each partition directory and the type directory held exactly the named files with sizes equal to the engine's disk bytes, every acked live record live with its bytes, every acked kill dead, counters equal to a recount; once labels caught up, `first_live_count` equal to the live records and 16 catalog lookups right. 507 s. The T1 crash harness (merges, A9, reclamation; compaction off: its oracle keeps dead bytes) checks the partition directories after every reopen too; the wasm build runs it ×1000. | `orphan_crash_points_during_compaction_T3_4_full`, `crash_faults_T1_1` |
862
- | 5 | Hot split at 4× single-writer capacity with 4 helpers | **Built in T3b** (§32); ratio and correctness in §34–§35. | `hot_split_*`, `flatsql_ps_bench --mode=hotsplit` |
863
- | 6 | Fill the disk to ENOSPC under ingest: ingest resumes with 0 acked losses and no operator action | **Pass.** Mac mem, load 34–35, a 16 MiB device, 1 MiB ballast, two OMM producers for 4 s: 17 episodes, the ballast released 17 times and restored 16, 41,112 records acked after a recovery, 6,318 alive at the end; in each partition the surviving acked records are exactly its newest (0 holes); after a reopen (before the engine starts) the partition and type directories hold exactly the named files at the engine's disk bytes. Before type-log reclamation the type logs filled 16 of the device's 17 MB and the run ended in the emergency with no survivor. | `quota_disk_full_resumes_without_operator_T3_6` |
864
-
865
- §22 amendments naming T3:
866
-
867
- | Amendment | Status |
868
- |---|---|
869
- | A9 meta-segment retirement | Built (§25). |
870
- | A11 intents | INTENT_COMPACT built; merge and compaction intents coexist in one batch. |
871
- | A12 reclamation lifecycle | Built (§25): RETIRE/UNLINKED, reader gate twice a grace apart, open replays the set. |
872
- | A13 ENOSPC, ballast, arrival order | Built with deviations 12–13. |
873
- | A15 arrivals compaction | Type-log half built here (§25–§27); the arrivals half in T3b (§33). |
874
- | A26 stage-1 prep states | Built in T3b (§32, deviations 27–28, 31). |
875
-
876
- ## 29. Design deviations (T3)
877
-
878
- 1. **Compaction builds on its own threads** (§11: the owner in 4 MiB / 10 ms
879
- slices; helpers only for split partitions). As T1 deviation 1 did for
880
- merges: a build on the writer stalls every partition it owns. The builder
881
- re-checks the owner word and an abort flag before every mutation, and a
882
- HANDOFF waits for it to stop (one slice).
883
- 2. **The owner writes the manifest at SWAP time**, synced in the next round's
884
- first phase, instead of the builder: merges of the active segment continue
885
- while a build runs; only the SWAP waits for no merge in flight.
886
- 3. **Kill bound and `prevGen` fallback** (not in the design): a row is removed
887
- only when its killer is labeled, and a type-level snapshot below a
888
- segment's bound reads the previous file set. Without it, a type-level
889
- statement whose type snapshot predates a death could miss a live copy
890
- (A14).
891
- 4. **Counters are physical** (§24): recounts equal them after compaction.
892
- 5. **Coalescing** adjacent small segments (§11 "adjacent sealed segments under
893
- 8 MiB each") into one output named by the first segment.
894
- 6. **Minor 1 extended**: a tombstone whose target is VOID in the same inputs
895
- (an earlier SWAP of that segment removed it) goes too. Without it the
896
- supersede tombstones of a partition compacted in one piece never went
897
- (measured: the CAT plateau grew by 77 KB a cycle).
898
- 7. **Tombstone-only segments seal by age** (T1 sealed only segments with
899
- frames), so a partition that stops ingesting can drop its tombstones.
900
- 8. **Reader gate by statement start time**, not by announced manifest
901
- generations (A12 text): T2 built announcements as start times
902
- (`oldestActiveStart`); the host passes the minimum
903
- (`flatsql_ps_reader_gate`).
904
- 9. **RETIRE is the whole set** per changing batch, not incremental records, so
905
- open reads one record (head pointer) plus the tail.
906
- 10. **UNLINK_IF_UNUSED BUSY skips** to later items (A12: "retry later");
907
- reader lanes close idle handles after 5 s.
908
- 11. **Disk bytes include zero-fill and pre-created files** (the file sizes);
909
- open stats the files it names.
910
- 12. **ENOSPC evicts with tombstones, one segment per wave** (A13: "first unlink
911
- whole eligible sealed segments, oldest first ... needs no new space").
912
- Dropping a segment without per-record tombstones would leave the type
913
- owner unaware of the deaths (A14: FIRST copies must be labeled DEAD and
914
- REPEATs promoted), and those catalog updates need space too. The ballast
915
- must hold one segment's tombstones: about 128 B of row plus postings per
916
- record, twice until the tombstones' own segment is compacted.
917
- 13. **Ballast is off in the engine default**; hosts set it (servers 256 MiB,
918
- browsers one seal plus one commit), so tests and benchmarks are unaffected.
919
- 14. **Quota counts partitions and type logs**, not the registry (a few KB)
920
- or type configs (registration).
921
- 15. **A rebalance of a partition already handed off is ignored** (a T1 race
922
- the compaction timing exposed: a second release before the target adopted
923
- could leave two owners).
924
- 16. **RecordAttr bytes**: `buildRecordAttr` created its strings inside one
925
- argument list, whose evaluation order C++ leaves unspecified; GCC on x86_64
926
- built different bytes from clang. Each string is now created in field order;
927
- `format_record_attr_golden_bytes` checks `vectors/record_attr.hex`
928
- (22.3a-6).
929
- 17. **T3 #2 "live bytes" is the live set's compacted footprint**: the same
930
- records fully compacted, rows, attributes and postings included. Every
931
- record carries about four times its frame in index structure, so a ratio
932
- to frame bytes alone would measure the index, not garbage; both ratios are
933
- reported (§28).
934
- 18. **Type logs persist their retired set in the manifest** (not in ctl
935
- records): a type has no ctl-record log, and the manifest is the one file a
936
- MERGE_DONE names. The appendix sits after the CRC trailer, so readers and
937
- opens that predate it parse the manifest unchanged.
938
- 19. **A type meta segment retires with two synced head writes** instead of a
939
- LANE_CKPT batch and a later DURABLE_CKPT head (A9): a type head has no
940
- lane table, and after the second write neither slot names the segment,
941
- whichever one a crash leaves torn.
942
- 20. **A compaction plan pins its files**: anything retired after the plan was
943
- made stays until the SWAP or the abort (a build reads the runs and meta
944
- segments current at planning).
945
- 21. **A failed build never quarantines**: the inputs are untouched, the
946
- partition carries on, and requests end with an error (NOSPACE starts the
947
- emergency). A quarantined partition ends its compaction requests at once.
948
- 22. **TOMB_RANGE steps are also bounded in time** (4 ms of CPU, besides 512
949
- rows): a kill looks up the row's tag instances in every run, so rows cost
950
- more as a partition grows, and more under wasm (T3 #3: ≤ 10 ms a step).
951
- 23. **Dead catalog copies are dropped by merges** (A15 says "type-log
952
- compaction"): the type merge already reads every entry it could drop, so
953
- no separate pass rewrites the runs; a copy goes once its first posting and
954
- its DEAD posting meet in one merge (at the latest, the next full fold).
955
- 24. **The dead-share trigger is 15%, not 25%** (§11). At rest a segment can
956
- hold up to the trigger's share of dead rows, and a dead row costs as much
957
- as a live one in rows, attributes and postings, so a store at rest sits
958
- at up to 1/(1 − trigger) of its compacted footprint: 1.33× at 25%, above
959
- T3 #2's 1.3. Measured on the T3 #2 run (20,000 CAT objects, 20% updated
960
- per cycle, 100 cycles; Mac mem, load 6–26): 25%: 1.31–1.38 over six
961
- runs; 20%: 1.35, 1.38; 15%: 1.115, 1.120; 10%: 1.19, with about the
962
- same number of compactions (334–361). The price is write
963
- amplification: a compaction at 15% rewrites 5.7 bytes per byte it
964
- reclaims (3 at 25%). `EngineConfig::compactDeadRatio` sets it.
965
- 25. **A compaction wave is one output**: segments next to the chosen one that
966
- are past the trigger too join it, and the next candidate is looked for
967
- right after a SWAP (not 50 ms later).
968
-
969
- ## 30. Not built yet; running the T3 tests
970
-
971
- The T3 scope left open at 697a177 (#5 hot split, A15's arrivals half, A26,
972
- and the #1 latency ratio on a quiet Linux-8 box) went to T3b: §32–§35.
973
-
974
- ```
975
- cpp/build/flatsql_ps_test --test=compaction_ # basic, T3 #1 and #2 (short forms)
976
- cpp/build/flatsql_ps_test --test=compaction_half_dead_T3_1_full [--dir=<dir>]
977
- cpp/build/flatsql_ps_test --test=compaction_cat_supersede_plateau_T3_2_full
978
- cpp/build/flatsql_ps_test --test=orphan_crash_points_during_compaction_T3_4_full # 1,000 crash points
979
- cpp/build/flatsql_ps_test --test=quota_ # T3 #3, #6
980
- cpp/build/flatsql_ps_test --test=type_logs_reclaimed_under_readers
981
- ```
982
-
983
- **T3 #1 on a quiet Linux-8 box** (8 cores, ext4, nothing else running), from a
984
- checkout at the landed commit; the test enforces the 1.5× bounds on such a box
985
- (what it enforces since T3b: §34):
986
-
987
- ```
988
- cpp/build/flatsql_ps_test --test=compaction_half_dead_T3_1_full --dir=<ext4 dir>
989
- ```
990
-
991
- # Part IV: T3b
992
-
993
- ## 31. store-migrate gseqs, the entry-size setting, tag conditions
994
-
995
- store-migrate (T6, design §16.1-5) gives every migrated FIRST copy gseq = its
996
- legacy `sdn_record_index.rowid`, so per-schema MaxRowID and the FTS rowids
997
- carry over.
998
-
999
- - **Input.** `RecordAttr.migrated_gseq` (`ulong`, field 6, appended to
1000
- `schemas/flatsql_attr.fbs`; absent = 0, so older attributes are
1001
- unchanged byte for byte). `buildRecordAttr(..., migratedGseq)` writes it.
1002
- - **Row.** A new PUT whose attribute carries one gets row flag
1003
- `MIGRATED_GSEQ` (0x80). The gseq stays in the attribute (in the same meta
1004
- batch, `ATTR_IN_M`): the RecRow has no spare u64 (offsets 108–127 hold
1005
- `supersedeHash`, `tagHash` and `aux`), and the attribute is as durable as
1006
- the row. Deviation from the T6 proposal "(b) the RecRow reserved u64 at
1007
- offset 116", which T1's layout had already given to `tagHash`.
1008
- - **Labeling.** The type owner reads the flagged rows' attributes in windows
1009
- over the chunk it labels (one read per chunk of up to 4,096 rows when the
1010
- attributes fit in 1 MiB). A flagged copy labeled FIRST keeps its gseq when it
1011
- is above the type's committed `gseq_hi` and no arrival of the same type
1012
- commit holds it; the engine's gseq counter is then raised to at least gseq
1013
- + 1. Otherwise it gets an allocated gseq and counts a fallback; a flagged
1014
- copy labeled REPEAT counts a fallback too. A type commit's new arrivals are
1015
- sorted by gseq before they are appended, so arrivals stay strictly
1016
- increasing whatever order partitions are labeled in.
1017
- - **Counters.** `EngineStats::migratedGseqs` and `migratedGseqFallbacks`,
1018
- appended to `flatsql_ps_stats` (entries 24 and 25). store-migrate's
1019
- verification (§16.1-7) needs fallbacks = 0; they are counted per labeling
1020
- decision, so a type commit that fails and is relabeled counts again.
1021
- - **Crash.** The gseq is read from durable rows and attributes, so a crash
1022
- that loses the type commit relabels the same copies with the same gseqs.
1023
- An allocated fallback gseq that was never published can be reassigned
1024
- (as for every allocated gseq).
1025
- - **What T6 must do** (its (d)): feed each type's FIRST copies in rowid
1026
- order and wait for `labeled_through` before crossing partitions, and run
1027
- with fallbacks = 0. gseqs are unique per type; across types a fallback could
1028
- reuse a legacy rowid another type migrates later, which is one more reason
1029
- a fallback fails the migration.
1030
- - **Entry size.** Writer TLV 19 = `maxEntryBytes` (u64; default 1 MiB +
1031
- 4 KiB, clamped to [64 KiB, 1 GiB]): the largest ring entry (header,
1032
- RecordAttr and frame). Each writer's arena grows to at least 4 ×
1033
- (maxEntryBytes + 4 KiB), so the frames half holds two entries. Frames
1034
- larger than a segment (A27 jumbo segments) are still not built.
1035
-
1036
- Tests: `type_migrated_gseq_preserved_fallback_and_stats` (kept, fallback
1037
- below `gseq_hi`, REPEAT copy, ordinary allocation continues above, flags on
1038
- disk, reopen), `type_migrated_gseq_one_commit_sorted_and_crash_reopen` (two
1039
- partitions' interleaved gseqs in one type commit come out sorted; a crash
1040
- before and after the type commit gives the same arrivals),
1041
- `writer_max_entry_bytes_configurable` (a 9 MiB frame refused by default,
1042
- stored and read back at 10 MiB), `writer_capi_tlv19_max_entry_and_stats`
1043
- (tag 19 through `flatsql_ps_init`, the 26-entry stats array).
1044
-
1045
- ### 31.1 Tag conditions match any live tag instance (A2)
1046
-
1047
- `_provider`, `_batch`, `_peer_id`, `_source` and `_source_name` conditions
1048
- have the legacy tag table's ANY-row semantics (`indexedRecordWindowSQL`: a
1049
- CID matches when one of its tag rows satisfies every requested condition).
1050
- In format 2 a record's tag instances are its PUT's own tag and its RETAG rows
1051
- (A2). As T2 built it, the vtab scanned tag postings (which name every
1052
- instance, RETAGs included) but left each constraint for SQLite to re-check
1053
- against the projected column, which shows only the PUT's own tag, so every
1054
- record matched through a RETAG was dropped (T6 #7 on a migrated copy: a
1055
- provider window of 200 read as 100; a source + batch window lost the
1056
- re-tagged records).
1057
-
1058
- Now `xBestIndex` consumes every tag condition (`omit = 1`) and the vtab
1059
- evaluates them all on one instance:
1060
-
1061
- - A tag or source posting scan (the plan's key) checks the remaining
1062
- conditions on the instance the posting names (its RecordAttr), then emits
1063
- the instance's record. A single condition that is the scanned key needs no
1064
- attribute read unless the key was capped (over 512 bytes).
1065
- - Any other plan (CID, column index, epoch, arrivals order, full scan)
1066
- keeps a record when one of its live tag instances matches every condition
1067
- (`RowFilter::hasLiveTag`, the TAG_OF postings), as the alias table's source
1068
- filter already did.
1069
- - An untagged record matches no tag condition (as the index-driven plans
1070
- already behaved, T2 deviation 8), and conditions that contradict each other
1071
- (two sources) return no rows.
1072
- - `_source = '<TYPE>@<name>'` is compared as the column projects it: the type
1073
- prefix is case-sensitive (a lower-case prefix returned no rows before
1074
- either, after SQLite's re-check).
1075
- - `_peer_id` conditions match the tag's producer peer (the TAG_PEER postings
1076
- the T2 plan scanned); the `_peer_id` column still projects the storing
1077
- call's `RecordAttr.peer_id`. Before, SQLite compared that BLOB column with
1078
- the argument, so a text argument never matched.
1079
- - `<TYPE>_current` groups first and filters after, as SQL says: its tag
1080
- conditions stay SQLite's (the current version's own tag).
1081
- - Projected columns are unchanged: they show the PUT's own tag.
1082
-
1083
- Test: `lane_tag_conditions_match_any_instance_A2` (two partitions, PUT tags,
1084
- RETAGs and REPEAT copies; eleven condition sets at type and partition level
1085
- against an ANY-instance oracle, the full `_source` form, the alias, an
1086
- epoch-ordered window, column and CID plans). With `omit = 0` restored it
1087
- fails 18 checks.
1088
-
1089
- ## 32. Hot-partition split: stage-1 helpers and prep states (§12, A26)
1090
-
1091
- A partition keeps one appender, its owner, split or not. Splitting is a
1092
- runtime state of the owner: nothing on disk changes, so open, readers and the
1093
- crash rules are those of an unsplit partition.
1094
-
1095
- **Code.** `ps/stage1.h` (slot protocol), `src/ps/stage1.cpp` (publish, take,
1096
- stage-1 computation, helpers), `src/ps/hot_split.cpp` (trigger, split, merge
1097
- back). `partition_log.cpp` stages from a prepared result when there is one.
1098
-
1099
- **Trigger (§12).** A partition is split when its backlog stays above
1100
- `hotSplitBacklogPct` (50) of its ring cap for `hotSplitAfterMs` (30 s) while no
1101
- other partition of its owner has a backlog, on an engine with 2 writers or
1102
- more. It merges back when the backlog stays under `hotUnsplitPct` (10) for
1103
- `hotUnsplitAfterMs` (30 s). `Engine::setHotSplit(pid, on)` forces either
1104
- (tests, hosts); a forced split stays until it is forced off. A quarantined
1105
- partition merges back. Up to 16 split partitions are offered to helpers; a
1106
- 17th is split but its owner prepares everything itself.
1107
-
1108
- **Window.** The first split allocates the partition's prep window (2,048
1109
- slots, 1.7 MB, heap, never freed while the engine runs). The owner publishes
1110
- the upcoming ring entries (position, length, kind) in ordinal order, up to
1111
- 2,048 ahead of the entry it will stage next, and tops the window up when fewer
1112
- than half are ahead. A split partition's ring pages are mapped for the whole
1113
- ring (the producer runs a full ring ahead of the owner, so helpers always have
1114
- entries), and it commits up to `hotCommitFrames` (4,096) frames per round
1115
- instead of `commitFrames` (1,024).
1116
-
1117
- **Slot states (A26).** Each slot's state word is `{ordinal << 3 | state}` and
1118
- moves by CAS:
1119
-
1120
- | From | To | By |
1121
- |---|---|---|
1122
- | (taken) | FREE(k) | the owner publishes ordinal k |
1123
- | FREE(k) | CLAIMED(k) | a helper, after taking k from the claim counter |
1124
- | CLAIMED(k) | WRITING(k) | the helper, its result computed |
1125
- | WRITING(k) | READY(k) | the helper, its result copied into the slot |
1126
- | FREE(k) or CLAIMED(k) | OWNER(k) | the owner, staging k itself |
1127
-
1128
- The owner takes ordinals in ring order. READY: it stages from the result.
1129
- FREE: it takes the entry. CLAIMED: it waits up to `prepClaimWaitUs` (20 µs)
1130
- for the helper, then takes the entry (counted as stolen). WRITING: it waits
1131
- for one result copy. Ordinals never repeat, so a helper whose entry was taken
1132
- fails its CLAIMED → WRITING CAS and discards what it computed (counted as
1133
- wasted), even if the ring pages it read were recycled meanwhile.
1134
-
1135
- **Helpers.** An idle writer (no work of its own) prepares up to
1136
- `prepHelpBudget` (256) entries per iteration; a publish wakes up to
1137
- `hotHelpers` (4) writers. A helper writes nothing but its claimed slot: no
1138
- file, no ring slab, no partition state. Per record entry it runs what
1139
- `stageRecord` does before it touches partition state: the frame check, the CID
1140
- check or computation (sha256), the attribute check, the tag and lane hashes,
1141
- the CRC32C of the stored bytes, and the key extraction (kept when its strings
1142
- fit in 480 bytes). It also looks the CID up among the owner's committed
1143
- postings (the dedupe hint): the owner publishes its L1 run set (by version)
1144
- and the CID entries and blooms of its unmerged L0 blocks (immutable copies),
1145
- and a helper searches those with its own file handles. The owner then
1146
- searches only the L0 blocks committed after the hint and the entries it has
1147
- staged. Dedupe, supersede, pseq assignment, rows, postings and the commit stay
1148
- with the owner.
1149
-
1150
- **Rebalance and stop.** A HANDOFF of a split partition moves it split: the
1151
- window's positions belong to the partition, and the new owner continues from
1152
- them. A helper counts
1153
- itself into the window before it reads the split flag; the owner leaves the
1154
- window only when it is unsplit and no helper is inside. `Engine::stop` drops
1155
- the helpers' views before it closes files.
1156
-
1157
- **Counters.** `EngineStats` splits, unsplits, prepPrepared, prepUsed,
1158
- prepStolen, prepWasted, prepHinted, l0FullStalls; `flatsql_ps_stats` entries
1159
- 26–33.
1160
-
1161
- **A fix to T1's L0 accelerator** found by the crash harness with splits: when
1162
- the partition's accelerator memory could not hold a block's bloom, the loader
1163
- stopped at that kind and the kinds after it (TAG_OF among them) read as having
1164
- no postings in the block, so a kill missed the record's tag instances and its
1165
- lane counters went wrong. The loader now lists every kind and leaves out only
1166
- the blooms it cannot hold. Split partitions map their whole ring and made the
1167
- memory run out in the harness; any partition could.
1168
-
1169
- Deviations from §12 and A26:
1170
-
1171
- 26. **No SPLIT frame and no time boundary.** Sealed segments' merges and
1172
- compactions already build off the owner (T1 deviation 1, T3 deviation 1),
1173
- so §12's re-homing needs nothing new; what a split adds is stage 1. A
1174
- split therefore writes nothing and survives nothing: after a restart the
1175
- trigger decides again.
1176
- 27. **Entries are claimed one at a time from a published window**, not in
1177
- ranges: a helper that stalls holds one entry, which the owner takes after
1178
- 20 µs.
1179
- 28. **Prep states carry an ordinal, not an epoch** (A26: FREE →
1180
- CLAIMED(epoch) → PREPARED): helpers write no file and no slab, so the
1181
- owner's epoch guards nothing they do; the ordinal is what makes a stale
1182
- claim fail. WRITING and OWNER are added so the owner can take an entry
1183
- without waiting on a stalled helper.
1184
- 29. **Merge back** (not in §12) at under 10% for 30 s, and a forced split
1185
- (`setHotSplit`).
1186
- 30. **Dedupe hints** (not in §12): after stage 1 the owner's largest per-entry
1187
- cost was the CID lookup in its runs.
1188
- 31. **A26's "helpers SIGSTOPped for 100 ms"** is a 100 ms sleep inside every
1189
- Nth claim (`testPrepStallNs`, `testPrepStallEvery`): a single thread
1190
- cannot be SIGSTOPped.
1191
-
1192
- Tests:
1193
-
1194
- - `hot_split_equals_unsplit_reference_T3_5`: the same CAT workload (puts,
1195
- supersedes, re-tags, duplicates, kills) through a split and an unsplit
1196
- engine with an injected clock: rows, frames, attributes and SQL results
1197
- equal; one appending thread per ownership interval; pseqs gap-free.
1198
- - `hot_split_rebalance_stalled_helpers_A26` (6 s; `_full` 600 s): a split
1199
- partition flooded while 5 others trickle in a 48 MiB slab pool (slabs
1200
- recycle across partitions), rebalances every 50 ms, a 100 ms helper stall
1201
- every 97 claims: 0 non-owner writes, one (writer, thread) per
1202
- ownership epoch, contiguous pseqs, and after a reopen every stored frame
1203
- hashes to its row's CID and CRC with every sent CID present.
1204
- - `hot_split_trigger_splits_and_merges_back`: the §12 trigger and the merge
1205
- back with an injected clock.
1206
- - `hot_split_throughput_T3_5` (`_full`): the §18 ratio, enforced on a quiet
1207
- box only.
1208
- - The T1 crash harness splits up to 16 partitions in half its stores, at
1209
- setup and after every reopen.
1210
-
1211
- ## 33. Arrivals compaction (A15, arrivals half)
1212
-
1213
- T3 built the type-log half of A15 (§25). The arrivals half removes the
1214
- arrival entries of dead gseqs, with their GONE postings.
1215
-
1216
- **Rule.** A type merge that folds GONE postings looks at every sealed arrivals
1217
- segment (its fence entry, fixed when the merge is planned). A segment whose
1218
- entries those GONE postings cover by at least `arrivalsCompactRatio` (0.25) is
1219
- rewritten without them into `ga-<gen>.fsg`, `<gen>` being the merge's output
1220
- generation, and the merge's run leaves out those GONE postings and the REHOME
1221
- postings of the same gseqs. Entries and their GONE postings leave in the same
1222
- MERGE_DONE, so live = entries − GONE holds in every snapshot and offset paging
1223
- (T2) is unchanged. GONE postings of other runs, and of the active segment,
1224
- stay with their entries. `arrivalsCompactRatio = 0` turns the rewrite off.
1225
-
1226
- **Manifest.** The type manifest carries an arrivals table (magic FSAC) after
1227
- its retire set (T3 deviation 18): per rewritten segment {seg, gen, offset,
1228
- count, first gseq, last gseq}, 40 bytes. Readers and open take a segment's
1229
- entries from its `ga` file at that offset and its count from the table; the
1230
- fence file is unchanged. Manifests without the table read as before.
1231
-
1232
- **Files.** A rewritten segment's `g-<seg>` and a superseded `ga` file are
1233
- retired through the type's retire set (A12, letters `g` and `G`). When a
1234
- merge rewrites segments, a `ga` file that holds entries for less than half of
1235
- its bytes is written again, so old `ga` files do not pin bytes. Open sweeps
1236
- `ga` files that no manifest names, and counts in the disk bytes the `ga`
1237
- files the manifest names instead of the originals they replace; a merge in
1238
- flight counts its outputs.
1239
-
1240
- **Quota.** The quota planner triggers the rewrite through its evictions: an
1241
- evicted record's GONE posting reaches the next type merge. The planner does
1242
- not request type merges itself (deviation 32).
1243
-
1244
- **Counters.** `EngineStats` arrivalSegsCompacted and arrivalEntriesDropped;
1245
- `flatsql_ps_stats` entries 34–35.
1246
-
1247
- Deviation:
1248
-
1249
- 32. **The quota planner does not start type merges** (A15: "can be triggered
1250
- by the quota planner"). Its evictions produce the GONE postings, and the
1251
- type owner merges on its own cadence (L0 fill); an eviction wave that
1252
- frees little arrivals space frees it at the next merge.
1253
-
1254
- Tests:
1255
-
1256
- - `type_arrivals_compaction_A15`: 3 partitions, 150 records each per round,
1257
- 70% of the live records killed per round, 3 s, with offset paging over
1258
- arrivals (`ORDER BY _gseq LIMIT 40 OFFSET n`) and `count(*)` checked against
1259
- the live records' gseqs every 10 rounds, then a reopen; the same run with
1260
- the rewrite off for comparison. Mac mem, load 10: off, 191,228 entries
1261
- appended and 191,228 kept, 4,589,472 bytes of arrivals (24 per appended
1262
- entry); on, 151,188 appended and 425 kept (211 records live), 10,200 bytes
1263
- (0.067 per appended entry), 7,838 segments rewritten, 150,763 entries
1264
- dropped, 204 paging checks, 0 wrong; the type directory holds exactly the
1265
- named files at the engine's type disk bytes, before and after the reopen.
1266
- - `orphan_crash_points_during_compaction_T3_4` (`_full`): half the stores
1267
- seal arrivals segments every 48 entries; after each reopen, arrivals strictly
1268
- increasing, no dead gseq's entry visible past its segment's fence, offset
1269
- pages equal to the live gseqs. Mac mem, load 7–12, `_full`: 2,787 trials,
1270
- 1,000 crash points during compaction, 1,649 in the grace window, 7,592
1271
- arrivals segments rewritten, 76,499 entries dropped, all five crash modes
1272
- (567 drop-all, 583 drop-subset, 538 torn, 545 reordered, 554 kill -9),
1273
- 394 s.
1274
- - The T1 crash harness keeps every published gseq's entry (its oracle), so it
1275
- runs with the rewrite off.
1276
-
1277
- ## 34. T3 #1 and #5 on Linux-8 (T3b item 3)
1278
-
1279
- **Machine.** The Linux-8 of T2: Docker Engine 29.2.0 (Docker Desktop's
1280
- linux/arm64 VM, kernel 6.12.67-linuxkit, 16 vCPUs) on the owner's Mac Studio
1281
- (Apple M3 Ultra, 28 cores, 256 GB), an `ubuntu:22.04` container limited to 8
1282
- CPUs with `--cpus 8` (CFS quota 800000/100000) or pinned with
1283
- `--cpuset-cpus 0-7`, `--memory 24g`, g++ 11.4 RelWithDebInfo, ext4 on the VM's
1284
- virtual disk (a file on the Mac's APFS). It is not quiet: the Mac ran other
1285
- lanes throughout (1-minute load 6–16 on 28 cores), and the VM's load average,
1286
- recorded before each run, includes the benchmarks' own threads. Source: a `git
1287
- archive` of the landed tree's `cpp/`.
1288
-
1289
- ```
1290
- docker run --rm --platform linux/arm64 --cpus 8 --memory 24g \
1291
- -v <checkout>:/src:ro -v <flatbuffers c72a8bed>:/fb:ro ubuntu:22.04 bash -c '
1292
- apt-get update && apt-get install -y cmake g++ make
1293
- cmake -S /src/cpp -B /tmp/b -DCMAKE_BUILD_TYPE=RelWithDebInfo -DFLATBUFFERS_DIR=/fb
1294
- cmake --build /tmp/b -j 8 --target flatsql_ps_test flatsql_ps_bench
1295
- /tmp/b/flatsql_ps_bench --mode=hotsplit --records=2000000 # x3
1296
- /tmp/b/flatsql_ps_bench --mode=hotsplit --io=fs --dir=/tmp/hs --records=1000000 # x2
1297
- /tmp/b/flatsql_ps_test --test=compaction_half_dead_T3_1_full --dir=/tmp/c1 # ext4
1298
- /tmp/b/flatsql_ps_test --test=compaction_half_dead_T3_1_full' # in memory
1299
- ```
1300
-
1301
- ### T3 #5: hot split throughput
1302
-
1303
- One partition, one closed-loop producer (the ring stays full: the offered load
1304
- is whatever the partition takes), 5 writers, the same engine unsplit and then
1305
- split with 4 helpers. `mem` is the in-memory host; `ext4` commits with
1306
- fdatasync.
1307
-
1308
- | Run | Unsplit rec/s | Split rec/s | Ratio |
1309
- |---|---|---|---|
1310
- | mem, `--cpus 8`, VM load 2.0 | 185,205 | 450,587 | 2.43 |
1311
- | mem, `--cpus 8`, 3.8 | 179,775 | 467,213 | 2.60 |
1312
- | mem, `--cpus 8`, 5.7 | 182,512 | 472,698 | 2.59 |
1313
- | mem, `--cpuset-cpus 0-7`, 5.1 | 176,825 | 479,294 | 2.71 |
1314
- | mem, `--cpuset-cpus 0-7`, 5.4 | 185,216 | 479,830 | 2.59 |
1315
- | mem, `--cpuset-cpus 0-7`, 5.6 | 176,934 | 476,050 | 2.69 |
1316
- | ext4, `--cpus 8`, 6.5 | 142,799 | 320,235 | 2.24 |
1317
- | ext4, `--cpus 8`, 6.9 | 157,696 | 326,783 | 2.07 |
1318
- | ext4, `--cpuset-cpus 0-7`, 5.5 | 156,950 | 324,930 | 2.07 |
1319
- | ext4, `--cpuset-cpus 0-7`, 5.1 | 150,486 | 333,085 | 2.21 |
1320
-
1321
- In memory: 2.43–2.71×, median 2.60, at least 2.5 in 5 of 6 runs. The build
1322
- before the accelerator fix (49d4a1d, the same split path) measured 2.61, 2.63,
1323
- 2.56 (`--cpus 8`) and 2.51, 2.59, 2.56 (`--cpuset-cpus`) in memory, 1.64–2.30
1324
- on ext4.
1325
-
1326
- The split partition's stage-1 results: about 1.86 M of 2 M entries prepared
1327
- with a dedupe hint the owner used, 57–151 taken back from a helper after
1328
- 20 µs. With fdatasync every commit, the owner's syncs do not shrink with
1329
- helpers, so the ext4 ratio is lower (the owner's CPU is no longer the whole
1330
- cost).
1331
-
1332
- ### T3 #1: latency during compaction
1333
-
1334
- `compaction_half_dead_T3_1_full`: 4 partitions × 20,000 OMM records, every
1335
- other one killed; a paced producer on a fifth partition (ack latency), an
1336
- interactive lane asking 100-row windows, 3 bulk lanes reading whole
1337
- partitions; a 3 s baseline, then the compaction **pass** (every sealed
1338
- segment of the four compacted once), then the **storm**: forced
1339
- re-compactions until 1,000 bulk statements started before a SWAP. Ratios are
1340
- p99 during / p99 at baseline; commit round and maintenance step p99 come from
1341
- the engine's histograms (quarter-octave buckets), ack and lane p99 from every
1342
- sample.
1343
-
1344
- | Run | Pass s | Commit round | Lane | Ack | Storm: commit / lane / ack |
1345
- |---|---|---|---|---|---|
1346
- | ext4 a, `--cpus 8`, VM load 2.0 | 0.64 | 1.57 / 2.62 ms = 0.60 | 0.49 / 0.81 ms = 0.61 | 26.5 / 8.8 ms = 3.01 | 2.8 / 0.76 / 3.9 |
1347
- | ext4 b, `--cpus 8`, 5.7 | 2.97 | 41.9 / 21.0 = 2.00 | 0.45 / 0.83 = 0.55 | 102.9 / 77.9 = 1.32 | 2.8 / 0.51 / 1.8 |
1348
- | ext4 c¹, `--cpus 8`, 2.8 | 0.71 | 1.00 | 1.34 / 0.91 = 1.47 | 27.6 / 14.6 = 1.89 | – |
1349
- | ext4 d¹, `--cpus 8`, 8.9 | 3.90 | 1.67 | 1.62 / 0.85 = 1.90 | 263.4 / 56.6 = 4.66 | – |
1350
- | ext4 e¹, `--cpus 8`, 10.2 | 1.89 | 3.50 | 0.53 / 0.57 = 0.92 | 176.9 / 43.0 = 4.11 | – |
1351
- | ext4 f, `--cpus 8`, 6.8 | 0.80 | 4.19 / 4.19 = 1.00 | 0.56 / 0.86 = 0.65 | 51.4 / 21.0 = 2.45 | 1.0 / 0.62 / 2.9 |
1352
- | ext4 g, `--cpuset-cpus 0-7`, 5.5 | 0.69 | 3.67 / 2.10 = 1.75 | 0.85 / 0.61 = 1.39 | 31.9 / 11.8 = 2.70 | 1.5 / 0.85 / 4.0 |
1353
- | mem, `--cpus 8`, 9.6 | 0.16 | 0.082 / 0.066 = 1.25 | 0.44 / 0.68 = 0.65 | 0.139 / 0.234 = 0.60 | 40 / 7.1 / 50 |
1354
- | mem, `--cpus 8`, 7.2 | 0.17 | 0.066 / 0.066 = 1.00 | 0.39 / 0.66 = 0.60 | 0.143 / 0.249 = 0.57 | 32 / 7.0 / 46 |
1355
- | mem, `--cpuset-cpus 0-7`, 5.7 | 0.16 | 0.066 / 0.057 = 1.14 | 0.42 / 0.73 = 0.57 | 0.138 / 0.216 = 0.64 | 46 / 6.4 / 51 |
1356
-
1357
- ¹ The "once at the end" half of the A/B runs in the notes below (the landed
1358
- behavior); their log kept the pass ratios and the ack and lane baselines
1359
- only. Rows a, b and the first in-memory row ran the working tree just before
1360
- the landed commit (the same engine and test). The in-memory passes gave
1361
- 81–85 lane samples, under the 100 the test needs to enforce.
1362
-
1363
- - **In memory** the pass stays within 1.5× on all three.
1364
- - **On ext4** the lane p99 stays within 1.5× in 6 of 7 runs, the commit round
1365
- p99 in 3 of 7, the ack p99 in 1 of 7. The baseline itself moves by 9× from
1366
- run to run (ack p99 8.8–78 ms): the virtual disk shares the Mac's SSD with
1367
- the other lanes. The builder syncing its outputs every 1 MiB instead of
1368
- once at the end (three runs of each, interleaved) gave ack ratios 1.07,
1369
- 1.80, 6.67 against 1.89, 4.66, 4.11 and commit round ratios 2.5, 1.67, 19.2
1370
- against 1.0, 1.67, 3.5: nothing the variance does not cover, so it is not
1371
- kept.
1372
- - **The storm** (5,000–7,000 forced SWAPs) raises ack p99 1.8–4× on ext4 and
1373
- 46–51× in memory, and the in-memory lane p99 6–7×. Its SWAPs are the test's
1374
- device for collecting pre-SWAP statements, not compactions the trigger
1375
- would start.
1376
-
1377
- So **T3 #1's latency ratio is not shown on this machine**: the pass meets it
1378
- in memory, the ext4 runs are dominated by the virtual disk. It needs a quiet
1379
- Linux box with its own disk; the test enforces the bound there (deviation
1380
- 33).
1381
-
1382
- Deviation:
1383
-
1384
- 33. **T3 #1 is measured and enforced on the compaction pass** with the
1385
- design's two metrics (owner commit round p99, interactive lane p99), when
1386
- the pass gives 100 samples of each; the storm and the ack p99 are
1387
- reported. T3 enforced ack and lane p99 over the storm, whose thousands of
1388
- back-to-back SWAPs are not "the compaction" of §18.
1389
-
1390
- ## 35. T3b acceptance, what remains
1391
-
1392
- | Item | Result |
1393
- |---|---|
1394
- | 0: store-migrate gseqs, TLV 19, tag conditions | **Done** (ad07b80, §31); released as 3.2.0 by T6. |
1395
- | 1: T3 #5 hot split, A26 | **Built** (§32). Correctness: split = unsplit reference (rows, frames, attributes, SQL), gap-free pseqs, one appending thread per ownership interval; A26 (6 s, Mac mem, load 5–48): 108 rebalances, 163 helper stalls of 100 ms, 44 ownership intervals, 0 non-owner writes, after a reopen 1,094,610 rows checked with 0 corrupt frames. Throughput on the Docker Linux-8 in memory: 2.43–2.71×, median 2.60, ≥ 2.5 in 5 of 6 runs; on ext4 with fdatasync 2.07–2.24× (§34). |
1396
- | 2: A15 arrivals half | **Built** (§33): 0.067 bytes of arrivals per appended entry under churn against 24 without it, offset paging exact, disk bytes = the files, 1,000 crash points with arrivals rewrites. The planner triggers it through its evictions only (deviation 32). |
1397
- | 3: ratios on quiet Linux-8 | Measured on the Docker Linux-8 (§34): #5 as above; **#1 not shown** there (ext4 variance), met in memory. |
1398
- | 4: artifact, CI, gauntlet | `wasm/flatsql-ps-threads.wasm` rebuilt with the Linux wasi-sdk 30 (exports unchanged). |
1399
-
1400
- Remaining:
1401
-
1402
- - **T3 #1 on a quiet Linux-8 box with a local disk**: `cpp/build/flatsql_ps_test
1403
- --test=compaction_half_dead_T3_1_full --dir=<ext4 dir>` (it enforces the
1404
- bound when the load is under a quarter of the cores); and the hot split's
1405
- ext4 ratio there (`flatsql_ps_bench --mode=hotsplit --io=fs --dir=<dir>`).
1406
- - The quota planner requesting type merges (deviation 32).
1407
-
1408
- ```
1409
- cpp/build/flatsql_ps_test --test=hot_split_ # T3 #5, A26, trigger
1410
- cpp/build/flatsql_ps_test --test=hot_split_rebalance_stalled_helpers_A26_full # 10 min
1411
- cpp/build/flatsql_ps_test --test=type_arrivals_compaction_A15 [--seconds=N]
1412
- cpp/build/flatsql_ps_test --test=orphan_crash_points_during_compaction_T3_4_full
1413
- cpp/build/flatsql_ps_bench --mode=hotsplit [--helpers=4] [--io=mem|fs] [--dir=D] [--records=N]
1414
- ```
1415
-
1416
- ## 36. Eviction ranges name rows, not segments (T3 #3 on Linux)
1417
-
1418
- `quota_arrival_order_heads_spared_T3_3` failed now and then on the ubuntu CI
1419
- job (holes 585 on 71065d3; 616, then 471 on 49e7ac7). It reproduced in Docker
1420
- (Debug, the test looped): 3 of 100 runs on linux/arm64 with 4 CPUs, 1 of 150
1421
- on linux/amd64 with 4 CPUs and stress-ng on 2. Each failure had the same
1422
- interleaving (traced on arm64, 4 of 100):
1423
-
1424
- 1. The planner chose partition p's oldest segments from its summary, A
1425
- (`[0-1]`), B (`2`), C and later, and queued a TOMB_RANGE for each by
1426
- segment id.
1427
- 2. A's range started. Its first steps put A past the dead-share trigger, and
1428
- maintenance compacted A, which left it small.
1429
- 3. Maintenance coalesced the shrunken A with its small neighbour B into one
1430
- output named A (`[0-2]`).
1431
- 4. B's range reached the front of the queue and looked for segment 2. There
1432
- was none, so it examined nothing and completed.
1433
-
1434
- B's records outlived C's and every later evicted record, and stayed after the
1435
- wave had settled: an engine bug, not the test's view.
1436
-
1437
- 34. **An eviction's TOMB_RANGE names the segment's rows** (§13:
1438
- `TOMB_RANGE{seg, epoch < t}`). The summary carries each sealed segment's
1439
- `[firstPseq, endPseq)`, which compaction and coalescing never renumber,
1440
- and the planner's range examines exactly those rows wherever they live
1441
- now (`Engine::tombRange(pid, seg, bound, ticket, firstPseq, endPseq)`;
1442
- without a range, `seg` is still resolved when the range starts). A
1443
- compaction request for a segment coalesced since compacts the output
1444
- that holds it. Before, it failed NOENT, the wave's space came back only
1445
- through maintenance, and the next pass could evict below the low-water
1446
- mark.
1447
- 35. **A compaction reads the kills in the active segment's runs.** It
1448
- collected the runs of the segments that end after its inputs. The active
1449
- segment has no end until it seals, so the kills a merge had folded into
1450
- its runs were missed and their rows kept (an eviction's own compaction
1451
- met this whenever its tombstones merged before the SWAP was planned).
1452
-
1453
- `quota_tomb_range_follows_rows_coalesced_into_older_segment` replays both:
1454
- A and B coalesce, B's range runs from the pseqs taken before that, one L0
1455
- block per step so that some of the tombstones merge into the active segment,
1456
- then B's compaction request. Before the fix, 0 of B's 157 records die and
1457
- the request returns NOENT. With deviation 34 but not 35, the compaction
1458
- drops 29 of the 157.
1459
-
1460
- The quota tests read the files under the engine's reader gate, the way a
1461
- statement does (reclamation runs at a 1 ms grace there), and a partition
1462
- they cannot read fails the test instead of reading as an empty live set.
1463
- The planner's counters are atomic (ThreadSanitizer: quotaStats() read them
1464
- while writer 0 counted).
1465
-
1466
- After the fix (Docker Linux, Debug, the test alone, looped):
1467
-
1468
- | Commit | Machine | Load | Runs | Failures |
1469
- |---|---|---|---|---|
1470
- | bedfe06 (deviation 34) | linux/arm64 | 4 CPUs, stress-ng on 2 | 520 | 0 |
1471
- | 5f4af36 (34, 35) | linux/amd64 | 4 CPUs, stress-ng on 2 | 520 | 0 |
1472
- | a416695 | linux/arm64 | 3 CPUs, stress-ng on 3 | 520 | 0 |
1473
-
1474
- The quota tests also pass under ThreadSanitizer (linux/arm64). The native
1475
- suite (Mac 76 tests; Linux ctest 14 of 14), `crash_faults_T1_1_full` (10,000
1476
- trials) and `orphan_crash_points_during_compaction_T3_4_full` pass. The T3 #4
1477
- harness's arrivals check ran on a second reader instance the reader gate did
1478
- not cover, so a statement could lose a file to reclamation and end
1479
- SNAPSHOT_GONE (on 2f6d86e too: seed 9003, trial 48); the gate now covers both.
1480
-
1481
- `quota_disk_full_resumes_without_operator_T3_6` could stay in its first
1482
- emergency past the test's minute on a loaded box (one episode, three
1483
- segments evicted, the ballast never back). It predated this change: on
1484
- linux/amd64 Debug at a host load near 40, 4 of 100 runs on 2f6d86e and 1 of
1485
- 100 here; under ThreadSanitizer, 3 of 6 and 1 of 10. Root cause and fix: §38.
1486
-
1487
- # Part V: reader memory
1488
-
1489
- ## 37. Reader memory is bounded by bytes, not by the store
1490
-
1491
- A point reader instance grew while a writer committed: about 5 MB per 20,000
1492
- statements in SDN's probe, a guest `bad_alloc` at 28 minutes in T6's daemon
1493
- soak, and flat once ingest stopped. Nothing leaked per statement (SQLite
1494
- allocates from the lane's fixed arena, every statement is finalized). A cache
1495
- had no byte bound:
1496
-
1497
- - **The lane-private front** of the shared cache (`LaneStore::front_`) was
1498
- bounded by 65,536 entries only. Every shared-cache hit and put entered it,
1499
- so it kept every L0 directory and section, fence vector and bloom the lane
1500
- had used after the 16 MiB shared cache evicted them. Every commit and merge
1501
- adds keys (type L0 sections are keyed by block offset), so under ingest it
1502
- only grew; the entry cap lay gigabytes away.
1503
- - A resident run pinned its fences and blooms without charging them to the
1504
- lane's run budget.
1505
- - The shared cache inserted an object larger than its shard's share of the
1506
- budget after emptying the shard (up to 64 x the largest object over
1507
- budget); the private cache did the same with an object over its budget.
1508
- - The set of runs a lane had verified grew by one entry per run ever opened.
1509
- - A run's fences were read whole per kind: 40 bytes per 4 KiB block, about
1510
- 1% of the run, so a lookup in a large type run held its fences whole.
1511
-
1512
- Built:
1513
-
1514
- - The front is an LRU by bytes (`LaneStoreConfig::frontBytes`, set to
1515
- `ReaderConfig::laneCacheBytes`) and by entries.
1516
- - `ReaderCache` never caches an object larger than a shard's share, and has
1517
- one shard per MiB of budget (1 to 64: 16 at the default 16 MiB, so a shard
1518
- holds the largest L0 sections). The private cache never caches an object
1519
- larger than its budget. Such an object is read again by the next statement
1520
- that needs it. A manifest is charged its memory (about 200 bytes per
1521
- segment), not its file size.
1522
- - A resident run is charged its blooms.
1523
- - The verified runs are a 1,024-entry LRU of runs evicted from the lane's
1524
- run cache (a resident run needs no entry).
1525
- - **Fence pages.** `FenceView` reads a kind's fences a page at a time
1526
- (`ReaderConfig::fencePageEntries`, 128 fences, 5 KiB) through the shared
1527
- cache: lookups binary-search across pages, scans walk them, GONE counts sum
1528
- them. A lookup holds one page whatever the run's size.
1529
-
1530
- **The bound.** Per reader instance, index state is at most
1531
-
1532
- cacheBytes + lanes x (3 x laneCacheBytes + maxHandlesPerLane x 256 B + 1,024 x 128 B)
1533
-
1534
- (the instance cache; per lane its private cache, resident runs and front,
1535
- its handle table and verified runs): 42.25 MiB with the defaults (16 MiB,
1536
- 4 MiB, 2 lanes, 4,096 handles), on top of the fixed arenas and slots. It does
1537
- not depend on the store's size, the statement count or the commit rate.
1538
-
1539
- **Tests.** `ps_test_main.cpp` gives every test allocation a 16-byte header
1540
- and counts the live bytes allocated on reader lane threads, the reader's
1541
- state beyond its fixed arenas, wherever they are freed.
1542
-
1543
- - `reader_point_memory_plateaus_under_commits`: a 2-lane point instance with
1544
- a 1 MiB instance cache and 256 KiB lane caches runs 164,000 statements
1545
- (GetRecord by CID and a CID IN at type level, a partition point read) from
1546
- two clients while producers commit and merges run, then idle rounds. It
1547
- checks the bound, the lanes' budgets after every round, a flat second half
1548
- and flat idle rounds. Mac, native: 2.7-3.2 MiB against the 3.9 MiB bound;
1549
- the engine before this change reaches 7.9 MiB in the same run (linear, 1.1
1550
- MiB per 20,000 statements) and fails. Under the Node wasm host: 2.72-2.76
1551
- MiB.
1552
- - `reader_lookups_and_scans_across_fence_pages`: two fences per page against
1553
- the default and the records written: type and partition point lookups,
1554
- index range scans both ways, offset paging after deaths (17,986 fence pages
1555
- past the first read in one run). `fanout_randomized_vs_bruteforce_small_fence_pages`
1556
- and `offset_paging_matches_reference_small_fence_pages` rerun those
1557
- suites with two per page; `--fence-page=N` sets it for every reader the
1558
- fixtures build (the default suite passes with 2).
1559
- - `reader_memory_soak_writer` and `reader_memory_soak_reader` (slow, named):
1560
- two processes on one directory. The writer grows the store past
1561
- `--store-mib` (16 partitions, 1.8 KB records, 12 MiB/s) and appends acked
1562
- CIDs to a file; the reader (native, or the wasm command under the Node host)
1563
- looks them up in rounds of 20,000 and reports its lane live bytes and its
1564
- process memory.
1565
-
1566
- Soak, store 65 MiB to 1,200 MiB, Mac Studio (Node 25 for wasm, heap
1567
- pre-grown to `PS_WASM_HEAP_MB`):
1568
-
1569
- | Reader | Caches | Statements | Lane live bytes | Process |
1570
- |---|---|---|---|---|
1571
- | native, before | 2 MiB / 512 KiB | 860,000 | 3.1 -> 57.2 MiB, linear | RSS 21.0 -> 90.2 MiB |
1572
- | native, after | 2 MiB / 512 KiB | 820,000 | 2.4 -> 3.4 MiB (bound 9.25) | RSS 19.6 -> 28.3 MiB |
1573
- | native, after | defaults | 840,000 | 2.7 -> 15.1 MiB (bound 44.25), filling its caches | RSS 20.8 -> 42.9 MiB |
1574
- | wasm, before | 2 MiB / 512 KiB | 800,000 | 2.7 -> 61.4 MiB, linear | linear memory 68.2 -> 103.5 MiB |
1575
- | wasm, after | 2 MiB / 512 KiB | 1,040,000 | 2.1 -> 3.3 MiB | linear memory 68.2 MiB throughout |
1576
- | wasm, after | defaults | 800,000 | 2.3 -> 15.7 MiB (bound 44.25) | linear memory 132.2 MiB throughout |
1577
-
1578
- Native RSS beyond the live bytes is the allocator's high water and the
1579
- harness's CID list (2.7 MiB). The soak reader's frames are never rebuilt:
1580
- `ommRecord` computes `0.0001 + meanMotion * 1e-6`, which one target contracts
1581
- to a fused multiply-add and another does not, so about 11% of a native
1582
- writer's records have other CIDs when a wasm reader rebuilds them.
1583
-
1584
- **What else grows with the store** (bytes per record or segment; none is
1585
- held across statements by the reader beyond the budgets above):
1586
-
1587
- | Where | What | Size | Status |
1588
- |---|---|---|---|
1589
- | reader: `snapshot.cpp` `loadManifest` | a partition's manifest, per statement | about 200 B per segment (3 KiB per GiB at 64 MiB segments) | cached within the lane budget; one larger than it (past about 1.3 TB in one partition) is read per statement: needs a paged manifest |
1590
- | reader: `LazyRun::open` | the run's metadata CRC, once per lane | reads 1% (fences) plus the blooms (1.25 B per entry) of every run it opens | memory bounded (1 MiB buffer); the reads grow with the run: needs per-page CRCs |
1591
- | reader: `loadType` | arrivals fence and segment starts, per statement | 48 B per 64 MiB arrivals segment (2.8M records) | small |
1592
- | reader: labels past 128 partitions, registry, vtab schema | per partition | not per record | small |
1593
- | 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 |
1594
- | writer: `SegmentInfo::cdir` (`compaction.cpp:382`) | a compacted segment's presence directory, kept once loaded | 0.13 B per record | grows with the store |
1595
- | writer: `Partition::segs`, `summary`, the file ledger | per segment | a few hundred bytes per segment | grows with segments |
1596
-
1597
- # Part VI: memory safety and races
1598
-
1599
- ## 38. The ps-wasm trap, the sanitizers, the first emergency
1600
-
1601
- Task flatsql-ps-memory-safety-races-20260929. Machines: the owner's Mac
1602
- Studio (Darwin 25.3.0, arm64, 28 hardware threads) shared with other lanes
1603
- (1-minute load 17–95 during these runs), and linux/arm64 in Docker Desktop on
1604
- it (gcc 13 and clang 18, `--cpus 4` with `stress-ng` on one CPU unless noted).
1605
-
1606
- **The ps-wasm trap is V8's shared-memory grow race, not an engine overrun**
1607
- (PARTITION-STORE-WASM.md §4.3). CI's trap was a `memory.fill` right after an
1608
- `operator new` on the type-merge helper, into memory another thread had just
1609
- grown; V8 bounds-checks it against the thread's stale view of the memory's
1610
- size. The wasm test main and `createPsNodeInstance` now grow the heap to the
1611
- memory's maximum before any guest thread starts, and the Node host reports
1612
- any growth after threads start. Natively, under ASan and UBSan, T2_1 found no
1613
- memory error (loops below). UBSan found two defects:
1614
- - A misaligned `uint64_t` reference: `Counters` is packed (on-disk).
1615
- `Partition::counters` is now 8-aligned.
1616
- - Null-pointer arithmetic in `EntryIter::next` (index_l0.cpp). That fix
1617
- lands with flatsql-format2-query-gaps.
1618
-
1619
- Under WasmEdge the same runs exposed a WasmEdge 0.16.4 AOT defect: atomic
1620
- wait and notify dropped the memarg offset. SDN's static runtime carries the
1621
- fix, patch `04-atomic-memarg-offset` (SDN 74db37e14). `sleepNs` passes its
1622
- address through a volatile, so its wait carries no memarg offset.
1623
- `memory_safety_sleep_and_notify_at_folded_offsets` fails on a runtime without
1624
- the fix.
1625
-
1626
- **ThreadSanitizer.** The whole default suite runs TSAN-clean (Docker, gcc 13
1627
- `-fsanitize=thread`, `--cpus 6`) apart from the stage-1 races below. Each
1628
- race found:
1629
-
1630
- | Where | What | Verdict, change |
1631
- |---|---|---|
1632
- | 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. |
1633
- | 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. |
1634
- | 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`). |
1635
- | 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. |
1636
- | 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. |
1637
- | 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. |
1638
-
1639
- **T3 #6: the first emergency could stall for good** (§36). Traced under
1640
- TSAN:
1641
- - The eviction wave's partition had a full L0 directory (47 blocks) from
1642
- ingest before the emergency, so its TOMB_RANGE needed a merge first.
1643
- - Meanwhile the room the ballast gave back was spent by maintenance outside
1644
- the eviction: the other partition's merges, and a compaction nobody
1645
- requested. Its build failed NOSPACE and started over with a new intent
1646
- batch in the meta log about ten times a second. Usage crept 8,960 B/s until
1647
- the device was exactly full.
1648
- - From then on no batch committed. The wave's merge could not write outputs
1649
- and no TOMB step could stage, for the rest of the minute.
1650
-
1651
- 36. **In a space emergency, maintenance leaves the ballast's room to the
1652
- eviction** (A13 assumes the ballast holds one segment's tombstones, and
1653
- says nothing of what else may spend it). Only a partition with eviction
1654
- work waiting merges (TOMB_RANGE steps, type-level kills, a requested
1655
- compaction: `partitionWantsMerge`). A compaction the planner did not
1656
- request does not start (`Writer::maintenance`); one in flight finishes.
1657
- Ring entries stop `kL0ControlSlots` (4) L0 slots before the directory's
1658
- cap, which mailbox kills and TOMB_RANGE steps may use, so a wave's first
1659
- steps never wait for a merge.
1660
-
1661
- Measured, in Docker:
1662
- - **Before:** under TSAN, 4 of 12 runs, and 1 of 7 traced ones, stayed in
1663
- the first emergency until the test's limit (70 s).
1664
- - **After:** 50 of 50 TSAN runs recovered (whole test 19–43 s), and 340 of
1665
- 340 Debug runs (gcc `-O0`, 8–10 s each). The test also passes natively on
1666
- the Mac and under the Node host.
1667
-
1668
- **Loops** (Docker runs had a CPU hog on one of their CPUs; the Mac ran at
1669
- load 17–95):
1670
-
1671
- | Item | Loop | Runs | Result |
1672
- |---|---|---|---|
1673
- | 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). |
1674
- | 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. |
1675
- | 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. |
1676
- | Seqlock, sync pool | `memory_safety_s*` under TSAN | 520 | 0 warnings, 0 failures |
1677
- | 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. |
1678
- | T3 #6 | TSAN; Debug | 50; 340 | 0 stalls |
1679
-
1680
- **Writer memory that grows with the store.** §37's table lists the writer's
1681
- per-store structures. Measured here with 200,000 OMM records in one partition
1682
- at the defaults:
1683
- - A warm partition's L1 accelerators (`SegmentInfo::runs`, loaded for every
1684
- segment by `partitionWarm`, merge.cpp:172) hold 6.0 B per record of the
1685
- partition's history. A partition written without a pause never cools
1686
- (`idleCloseMs`).
1687
- - A type's catalog accelerators (`TypeOwner::runs`, loaded by `typeWarm`,
1688
- type_owner.cpp:580) hold 3.45 B per record of the type. A type owner never
1689
- cools.
1690
- - Every registered partition keeps a 5,952-byte `Partition` (writer.h:505)
1691
- plus its ring descriptor, active or not.
1692
-
1693
- At 10^9 records (a terabyte of OMM-sized records) the catalog alone is
1694
- 3.4 GB in one writer instance, over the wasm32 limit.
1695
-
1696
- # Part VII: query gaps
1697
-
1698
- ## 39. Query gaps found on the host-02 fixture (T6)
1699
-
1700
- T6 compared format 2 with format 1 on a host-02-sized migrated store (4.36 M
1701
- records: OMM 1.70 M in one partition, MPE 1.69 M, CAT 141 k, IQC 419 k held by
1702
- two producers). Five read shapes were slower than format 1, and store-migrate
1703
- refused one record shape. Each is now an engine plan or check, held to a
1704
- reference by `cpp/test/ps/query_gaps_test.cpp` (`query_gaps_*`) and to format 1
1705
- by SDN's fixture comparison.
1706
-
1707
- **Text CID order (A17).** `ORDER BY _cid [DESC]` with no other index
1708
- condition is its own plan (`kAccCidOrder`). At type level it reads the cid
1709
- catalog (`kIxTypeCid`), whose keys are the A17 sort key: one ordered pass
1710
- gives the live CIDs in text order. A key's entries resolve per copy (the
1711
- latest type commit wins; DEAD over the rest at an equal tcs, as `catalog()`
1712
- does); the live FIRST or PROMOTED copy is the record, and only its row is
1713
- read. An `OFFSET` with no other condition is pushed down and counted from
1714
- catalog entries alone: no row, frame or posting of a skipped record is read.
1715
- At partition level the plan reads the partition's CID postings. With other
1716
- conditions the plan still gives the order; the conditions are checked per
1717
- row.
1718
-
1719
- **Per-object point profiles (A18).** Hidden input columns `_asof`,
1720
- `_forward` and `_nearest` (epoch seconds, `=` only) select `kAccObjPoint`.
1721
- Per object key it returns every live type-level row at the object's best
1722
- second: `_asof` the latest second at or before T, `_forward` the earliest at
1723
- or after, `_nearest` the closer of the two with a tie to the earlier. The
1724
- plan reads the `OBJECT_EPOCH` postings `(object key, epoch)` of every
1725
- partition, restricted by the manifests' epoch zone maps to the segments that
1726
- can hold a candidate, merges the partitions by object key, and reads rows only
1727
- for the candidates of each object's best seconds (from the closest outward,
1728
- until one is live). Tag conditions and one `_epoch` range are applied before
1729
- the choice; any other condition is refused (`SQLITE_CONSTRAINT`: SQLite would
1730
- apply it after the choice, which is another query). `_object` projects the
1731
- key: a u64 key in decimal, otherwise the (capped) string, NULL for a record
1732
- without one. Records without an object key are read only from partitions whose
1733
- `OBJECT_EPOCH` postings are fewer than their CID postings (the EPOCH index,
1734
- their frames); each is its own entity. For OMM (NORAD, then OBJECT_ID) and MPE
1735
- (ENTITY_ID) the object key is the legacy entity key, so SDN ranks the
1736
- candidates exactly as format 1 did.
1737
-
1738
- **The sandbox window (A18).** Untrusted SQL reads `<TYPE>`, `<TYPE>@<source>`
1739
- and every other type-level shape except a CID or gseq lookup from the arrivals
1740
- tail: the newest N entries (`hotWindow`, dead entries included), the
1741
- statement's conditions applied per row. An alias or source statement no longer
1742
- walks every posting of its source; its work is bounded by N whatever the
1743
- store's size.
1744
-
1745
- **Tag conditions on every copy (A2).** Type-level tag conditions (`_provider`,
1746
- `_source_name`, `_batch`, `_peer_id`, `_source`, and the alias source) match a
1747
- record when a live instance of any live copy (FIRST or REPEAT, any partition
1748
- the plan reads) meets all of them, as the legacy tag table held every
1749
- producer's tags per record. This extends §31.1 and T2 deviation 8.
1750
- Row-driven plans check the FIRST copy's own tag first, then the other copies'
1751
- own tags (listed by the cid catalog, only when the type has REPEAT postings),
1752
- then RETAG instances. Posting-driven plans (tag, source, collected pages) let
1753
- a live REPEAT copy's instance stand for its record: the record's FIRST row is
1754
- emitted by the first matching copy in (FIRST, then REPEATs by pid, pseq)
1755
- order, so each record comes once. `<TYPE>@<source>` is `<TYPE>` with that
1756
- condition. The checks cost little per row: an instance's tuple is its lane's
1757
- (`RecRow.laneId`, the partition's lane table read once per statement), a copy
1758
- whose pseq is above the highest `max_pseq` of the lanes that meet the
1759
- conditions cannot match (its RETAGs come after it), TAG_OF lookups read only
1760
- the segments that can hold them and resume in the block the previous row's
1761
- lookup read, and DEAD / TAG_DEAD lookups are skipped for a snapshot that holds
1762
- no posting of the kind.
1763
-
1764
- **Gseq-ordered tag pages (A16).** At type level, `ORDER BY _gseq` with tag
1765
- conditions and a LIMIT (or a closed gseq range) is served in arrivals order
1766
- with each row's tags checked, unless the lane counters say the tags are rare
1767
- (at most 262,144 matching instances and under an eighth of the type's
1768
- arrivals): then the tags' postings are collected (the most selective of batch,
1769
- source, provider, peer), sorted by gseq and emitted in order. A datasync page
1770
- by source no longer sorts every match of the source.
1771
-
1772
- **Records stored with their own size prefix.** A record that carries its own
1773
- size prefix (a `FinishSizePrefixed` buffer stored as is: SDN's
1774
- dataset-publication PNMs and the local EPM) was refused with `kRejFid` (its
1775
- identifier sits at frame offset 12), so store-migrate rejected 131 records of a
1776
- dev node and refused to activate. `frameRootOffset()` (`extract.h`) finds the
1777
- FlatBuffer after both prefixes (the record's u32 equals `len - 8` and the
1778
- identifier is at 12) for the frame check, the verifier (at the record's own
1779
- alignment origin), key extraction and column projection. The stored bytes and
1780
- the CID (over the record, prefix included) are unchanged.
1781
-
1782
- **Reads ahead.** Rows met in ascending pseq order (arrivals order, epoch and
1783
- tag postings, pseq scans) are read 32 at a time, and a frame read covers the
1784
- frames of the rows read ahead in its segment (up to 256 KiB).
1785
- `rowsExamined` still counts the rows a statement uses.
1786
-
1787
- **Measured** through SDN's storage API on the fixture (`TestZZBench`-style
1788
- calls, each query twice in a fresh process: the first call pays the
1789
- instance's warm-up and the page cache; format 1 is the same store before
1790
- migration). Mac Studio (M3 Ultra, 28 cores), shared with other lanes, load
1791
- 47–55. Format 2 "before" is T6's run on the same fixture.
1792
-
1793
- | Gap | Query | Format 1 (call 1 / 2) | Format 2 before | Format 2 after (call 1 / 2) |
1794
- |---|---|---|---|---|
1795
- | 1 | OMM CID order, LIMIT 500 OFFSET 1000 | 730 ms / 26 ms | 9.9–28.4 s | 2.65 s / 3.6 ms |
1796
- | 1 | CAT CID order, LIMIT 200 OFFSET 60000 | 2.39 s / 67 ms | 430–537 ms | 589 ms / 11 ms |
1797
- | 1 | IQC CID order, LIMIT 1000 OFFSET 100000 | 3.90 s / 153 ms | 5.0–5.5 s | 4.14 s / 35 ms |
1798
- | 2 | OMM `nearest` (32,015 objects) | 99 s / 22.7 s | 56–79 s | 10.6 s / 2.3 s |
1799
- | 2 | OMM `as_of`, one source | 36 s / 19.5 s | 9.7–13.1 s | 3.9 s / 4.2 s |
1800
- | 2 | OMM `forward` | 14.6 s / 19.5 s | 8.4–82 s | 6.8 s / 6.1 s |
1801
- | 2 | MPE `nearest`, 2 h delta | 50 s / 17.6 s | 29–35 s | 7.1 s / 6.1 s |
1802
- | 3 | sandbox `SELECT _data FROM "CAT@celestrak-satcat"` (N = 10,000) | 4.4 / 3.9 ms¹ | `timeout` (work budget) | 55 / 11 ms |
1803
- | 3 | `"CAT@celestrak-satcat-csv"` | 1.3 / 1.3 ms¹ | `timeout` | 3.2 / 4.5 ms |
1804
- | 3 | `"IQC@IQEngine"` | 14.8 / 13.3 ms¹ | `timeout` | 67 / 32 ms |
1805
- | 3 | `"MPE@celestrak-gp"` | 3.2 / 4.3 ms¹ | `timeout` | 68 / 7.3 ms |
1806
- | 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) |
1807
- | 5 | IQC datasync by source, 5 pages of 500 | 133 s / 190 s | about 3.8 s a page | 58 / 32 ms (all 5) |
1808
-
1809
- ¹ Format 1's hot window is an in-memory copy of the newest N records, filled
1810
- by a hydration (17 s to 5.5 min on this fixture) and held in the arena whose
1811
- growth is what format 2 replaces. Format 2 reads the window from disk: the
1812
- engine's own cost for the CAT query is 2.4 ms natively (the rest is the wasm
1813
- instance, the result ring and SDN's copy). Gap 3's acceptance (the statement
1814
- answers within the work budget, bounded by N) is met; parity with the
1815
- in-memory copy is not.
1816
-
1817
- The first call of a fresh process pays the instance's warm-up and reads the
1818
- cid catalog's blocks into the page cache (2.65 s for OMM against 730 ms for
1819
- format 1's index); every later call is faster than format 1.
1820
- `TestFormat2DaemonReadsEqualTheLegacyFixture` (SDN) passes with every section
1821
- (windows, A17 exports, A16 syncs, epoch profiles, A18 relations) equal to
1822
- format 1 on this fixture.
1823
-
1824
- **store-migrate with size-prefixed records.** A copy of a dev node's store
1825
- (9.2 GB control database, 163 PNMs in 37 partitions, 1 EPM): 0 copies
1826
- rejected (131 before), partitions (58) and lanes (58) equal, 9 of 10 CID
1827
- sequences equal. The tenth (IQC) differs because that store's index holds
1828
- about 1.06 M IQC rows no producer table holds (orphans the legacy verifier's
1829
- sequence includes; filed for SDN). `TestStoreMigrateActivatesWithSizePrefixedRecords`
1830
- (SDN): a store with 12 PNMs in two partitions and an EPM migrates with 0
1831
- rejects and 0 mismatches, activates, and serves the same bytes.
1832
-
1833
- **What these cost at 1 TB** (per statement; P partitions of the type, S
1834
- segments per partition, R type catalog runs):
1835
-
1836
- - CID order: `(offset + limit) x copies per CID` catalog entries of about
1837
- 72 B (about 56 per 4 KiB block) plus one row read per emitted record. The
1838
- skip is linear in the offset, as format 1's b-tree OFFSET is. Per-block live
1839
- counts in the catalog's fences would make it logarithmic, but a CID's copies
1840
- die in later type commits than their FIRST labels, so the type owner would
1841
- have to post a CID-keyed marker when a CID's last copy dies (outside this
1842
- change: `type_owner.cpp`).
1843
- - Point profiles: the `OBJECT_EPOCH` postings (about 30 B each) of the
1844
- segments whose epoch zone can hold a candidate: for `_forward` near the
1845
- present, the newest segments; for `_asof` and `_nearest`, the segments
1846
- before (or around) T. No payload is read to rank; rows are read for about one
1847
- to two candidates per object. Enumerating the objects stays linear in the
1848
- postings of those segments: a per-partition object directory would make it
1849
- proportional to the objects.
1850
- - Sandbox window: at most N arrivals entries (24 B) and N rows, whatever the
1851
- store's size.
1852
- - Tag conditions: per row an O(1) lane check, and a TAG_OF lookup only for a
1853
- copy that a matching lane can still reach; a catalog lookup (R bloom-gated
1854
- runs) only in a type with REPEAT copies.
1855
- - Gseq tag pages: LIMIT / selectivity arrival entries and rows per page
1856
- (arrivals mode), or the tags' matching instances (collected mode, at most
1857
- 262,144).
1858
- - Size-prefixed records: nothing (a 4-byte comparison per record).
1859
-
1860
- # Part VIII: writer bookkeeping at scale
1861
-
1862
- ## 40. Per-partition bookkeeping at scale (TB audit B4, M3)
1863
-
1864
- The terabyte audit (stack `docs/architecture/flatsql-ps-terabyte.md`, B4)
1865
- found a partition's bookkeeping growing as O(S²) in its segment count S. A
1866
- single dominant partition (the GP archive import, a hot-split partition)
1867
- reaches S = 1,000 at about 170 GB and S = 6,000 at about 1 TB. With 128 KiB
1868
- seals, the rate fell from 116,829 rec/s at S = 235 to 465 rec/s at
1869
- S = 5,188. Commit rounds reached a p99 of 100.7 ms and maintenance steps
1870
- 100.7 ms. The causes:
1871
-
1872
- - The ledger (§26) was a vector searched linearly.
1873
- - `partitionPublishSummary` made three ledger scans per sealed segment on
1874
- every commit that carried a MERGE_DONE, SEAL, SWAP, RETIRE or UNLINKED.
1875
- - The compaction look walked every segment every 50 ms, with the same three
1876
- scans and an L0 scan per segment.
1877
- - Segment lookups were linear.
1878
- - Open rebuilt the ledger in O(S²).
1879
- - A MERGE_DONE and a SWAP recomputed the accelerator bytes by walking every
1880
- run.
1881
- - A split partition re-signed its run set on every commit by walking every
1882
- run, and its helpers matched their previous view run by run in O(runs²).
1883
-
1884
- What the owner keeps now (`PartitionLedger`, `ps/quota.h`):
1885
-
1886
- - **The ledger** is an open-addressing table keyed by `(letter, seg, gen)`,
1887
- with the same matching rules as before: manifests by generation, `d r a m`
1888
- by segment, the rest by both. The byte total changes with every set and
1889
- drop. A lookup, change or drop costs O(1).
1890
- - **A change feed.** A ledger change to a segment's files, a new entry, a
1891
- kill count, a survey and every input of a SWAP name the segments
1892
- concerned. The planner's summary and the compaction candidate index update
1893
- only those segments. The summary does this on the commits that publish
1894
- it, the index on each look. Open rebuilds both in one walk.
1895
- - **The compaction candidate index** applies §24's rules unchanged:
1896
- - segments past the dead ratio, ordered by share (largest first, lowest
1897
- id among equals);
1898
- - starts of adjacent small pairs, where the first run of small segments
1899
- begins at the lowest pair and grows within the coalescing limits;
1900
- - segments not surveyed since a restart.
1901
- A look costs O(log S), plus the output's neighbours.
1902
- - **Segment lookups** binary-search the ascending entry list. Entries are
1903
- created in order, and a pseq search steps over entries that hold no rows
1904
- yet. Open walks the unmerged sealed segments alongside the entries.
1905
- - **Accelerator bytes** change by the runs a MERGE_DONE or SWAP adds and
1906
- removes.
1907
- - **The split run set** is signed by the manifest generation, since every
1908
- change to it commits a new one. A republish for new L0 blocks shares the
1909
- run list instead of copying it. Helpers find reusable runs by
1910
- `(seg, gen)`.
1911
-
1912
- Stage-1 helpers (§32) no longer race the owner or producers (ThreadSanitizer):
1913
- - A slot's `pos`, `len` and `kind` are relaxed atomics. The owner may take a
1914
- CLAIMED slot and republish it for the next ordinal while the helper still
1915
- reads them.
1916
- - A helper copies an entry with `ringReadShared` (relaxed 8-byte word
1917
- loads), never through a pointer into the ring. After a steal, the owner may
1918
- consume and release the entry, and a producer may rewrite its bytes while
1919
- the helper copies.
1920
- - `ringWrite` stores relaxed words and edge bytes, which natively cost what
1921
- plain stores do; the wasm hosts write slabs themselves.
1922
- - In both cases the helper's CLAIMED to WRITING step fails and its result is
1923
- dropped, as before. What it parsed is its own copy.
1924
- - Slabs must be whole 8-byte words.
1925
-
1926
- Deviations:
1927
-
1928
- 37. **The RETIRE set is held under what one batch carries** (A12 said: past
1929
- 2,048 items, the oldest go regardless of the gate once a grace has
1930
- passed). The set rides one ctl record, whose length is a u16, inside the
1931
- batch's 64 KiB ctl buffer, alongside UNLINKED (up to 1,024 items) and the
1932
- lane table. A set that does not fit refuses every batch of the
1933
- partition. A busy partition retires a manifest and folded runs per
1934
- merge, and a coalescing SWAP retires a dozen files per input, so within
1935
- the 60 s grace its set outgrew a batch. Ingest then stopped for the rest
1936
- of the grace. The audit harness showed 843 rec/s for 50,000 records at a
1937
- time, about every minute. The valve counts the set as its record
1938
- encodes it (consecutive manifest generations collapse). The soft cap is
1939
- 2,048 items, or less when a large lane table leaves less room. Past it,
1940
- the oldest items are unlinked at once, without waiting for the reader
1941
- gate or the grace:
1942
- - A statement older than those items gets the retryable SNAPSHOT_GONE.
1943
- - A file held open is BUSY and waits as before.
1944
- - A meta segment still waits for a durable head (A9).
1945
- - Crash safety never depended on the grace. The batch that retired a
1946
- file is durable before its retirement counts, and open replays that
1947
- batch.
1948
- - While a compaction in flight pins the newest items and the set is past
1949
- halfway from the soft cap to the hard cap, merges wait. Merges are the
1950
- steady source of retirements, so the SWAP or abort can land first.
1951
- 38. **Manifest version 3 (M3)** carries the segment count as a u32 in the
1952
- header's last word (the u16 then reads 0xFFFF). It is written only when
1953
- a partition's list outgrows the u16, so an engine that predates it still
1954
- reads every store it can represent, and refuses a version 3 manifest
1955
- instead of misreading it. Every version now decodes strictly: the
1956
- records must end exactly at the CRC trailer. Before, a version 2
1957
- manifest over 65,535 segments wrapped its count, passed the CRC and lost
1958
- the rest.
1959
-
1960
- Measured on the Mac (Darwin 25.3.0, arm64, 28 hardware threads, shared) with
1961
- the in-memory fault host. One partition, 128 KiB seals, coalescing off (so S
1962
- is the seal count), 400 B pad records, lockStats on:
1963
- `bookkeeping_scale_S5k`. The base column is 0df6ef4 (its writer is
1964
- 14a3075's) with the same test. Each row is the 50,000-record window that ends
1965
- at that S.
1966
-
1967
- | | S = 235, base | S = 235 | S = 5,188, base | S = 5,188 |
1968
- |---|---|---|---|---|
1969
- | commit round p99 | 0.328 ms | 0.229 ms | 100.7 ms | 0.115 ms |
1970
- | maintenance step p99 | 0.082 ms | 0.049 ms | 100.7 ms | 0.328 ms |
1971
- | summary refresh p99 (per commit that publishes it) | | 0.016 ms | | 0.004 ms |
1972
- | candidate look p99 | | 0.033 ms | | 0.012 ms |
1973
- | open (half, full: S = 2,594, 5,188) | | | 45.7 ms, 170 ms | 4.0 ms, 4.5 ms |
1974
- | rec/s | 116,829 | 99,796 | 465 | 5,386 |
1975
-
1976
- Load was 39–60 for the base run and 22–30 for the new one. The same test on
1977
- this change over d566293, at load 26–37: commit p99 0.393 → 0.229 ms,
1978
- maintenance p99 0.066 → 0.786 ms, summary refresh 0.012 → 0.020 ms,
1979
- candidate look 0.049 → 0.016 ms, open 6.0 ms at S = 5,188, rate 77,556 →
1980
- 3,363 rec/s.
1981
-
1982
- Over the same 200,000 records (S = 0 to 940), the process used 5.9–6.9 s of
1983
- CPU against 9.2–10.3 s on 14a3075 (six alternating runs). Near S = 235 the new
1984
- build's wall rate was lower than base's by about 7% in alternating runs
1985
- (82,405 against 94,237 rec/s, load 30–37), while at S ≈ 11 the two were equal.
1986
- With shorter commit rounds, the writer commits smaller batches (about 940
1987
- commits per 50,000 records against 700) and waits idle for the test's single
1988
- producer. The time is lost in the handshake, not in work.
1989
-
1990
- On ext4, in Docker's Linux VM on the same Mac, the audit's harness
1991
- (`tb_segments_scaling`: fsync on, one partition, 128 KiB seals, 550,000
1992
- records) was bound by the VM's shared virtual disk. Other lanes' containers
1993
- were using it too, so both builds ran near 500 rec/s at S = 235:
1994
-
1995
- | build | S = 235 | S = 1,886 | S = 2,594 | reopen at S = 2,594 | whole run |
1996
- |---|---|---|---|---|---|
1997
- | 14a3075 | 475 rec/s | 211 rec/s | 356 rec/s | 21.0 s | 31.3 min |
1998
- | this build | 522 rec/s | 795 rec/s | 781 rec/s | 3.6 s | 19.0 min |
1999
-
2000
- `bookkeeping_scale_S5k` itself on the same ext4 volume (`--dir`) is disk
2001
- bound: commit rounds wait on fsync (p99 336 ms at S = 235, 470 ms at
2002
- S = 5,188). The bookkeeping in them stays flat, though: the summary refresh
2003
- p99 is 0.041 → 0.033 ms and the candidate look p99 0.066 → 0.029 ms, and
2004
- open at S = 5,188 takes 134 ms. With `--dir` the test reports commit and
2005
- maintenance p99 without gating them.
2006
-
2007
- The rate that remains decays with S because of B5: every record's CID dedupe
2008
- probes the bloom of every L1 run of every segment (`committedPostings`,
2009
- `partition_log.cpp`). `sample` at S ≈ 4,900 puts 94% of the writer's samples
2010
- in `cidState` and about 1% in the paths this section changed: maintenance,
2011
- commit rounds and reclamation together account for 184 of 12,272 samples.
2012
- Per-partition levelled lookup indexes (B5) are the terabyte design's.
2013
-
2014
- What still costs O(S), and where it belongs:
2015
- - A merge snapshots every segment into the manifest it rewrites (M3's delta
2016
- or two-level manifest). The maintenance p99 above (0.05 → 0.33 ms) is
2017
- that.
2018
- - A SWAP writes a whole manifest and rebuilds the entry list.
2019
- - A split partition's helpers reopen every run file per run set (B2's
2020
- handle LRU).
2021
-
2022
- Tests (`cpp/test/ps/bookkeeping_scale_test.cpp`):
2023
- - `book_manifest_v3_codec`: exact round trips at 0, 1, 65,535, 65,536 and
2024
- 70,001 segments; a wrapped v2 count, a stray byte, a bad v3 header and
2025
- version 4 refused; version 1 read.
2026
- - `book_retire_valve_keeps_ingest_flowing`: 60,000 records at the default
2027
- 60 s grace. 16,767 files retired, 14,299 unlinked by the valve, the
2028
- encoded set peaked at 2,113, 0 batches refused. With the valve off, the
2029
- same test refused 4,397 batches and one 5,000-record chunk took 59.5 s.
2030
- - The valve test also reports the writer's memory, and none of it grows
2031
- with records. From 25,000 to 60,000 records:
2032
- - slab pool: 3.9 → 4.1 MB;
2033
- - ledger: 2,271 → 2,398 files (67 → 69 KB);
2034
- - RETIRE set: 2,145 → 2,217 items (160 KB);
2035
- - segment list: 54 → 68 entries (12 → 15 KB).
2036
- The process does grow: 1.47 GB at 25,000 records and 3.55 GB at 60,000
2037
- (3.4 GiB of wasm's 4 GiB). That is the in-memory test host, which keeps
2038
- every file it ever wrote (7,212 files, 1.45 GB; then 17,281 files,
2039
- 3.52 GB) so that data calls need no lock. The rest of the heap
2040
- (13 → 25 MiB) grows by the host's 231 B per file. On wasm the test runs
2041
- 25,000 records.
2042
- - `book_equivalence_all_commit_kinds`: runs with `gBookCheck`, which checks
2043
- every ledger operation against the linear ledger it replaced, and every
2044
- segment lookup, summary refresh, candidate pick and split run-set
2045
- signature against a walk. A mismatch aborts. The run covers seals,
2046
- merges, SWAPs (dead-ratio, coalescing, requested, quota evictions),
2047
- RETIRE and UNLINKED, meta-segment retirement, a hot split, a clean
2048
- reopen and a kill -9.
2049
- - `book_crash_every_op` (every 37th mutating call) and `_full` (every one:
2050
- 9,416 crash points over seals, merges, SWAPs, retirement, unlinking and
2051
- A9, all five crash modes, 0 failures): after each reopen, the rebuilt
2052
- ledger names exactly the files on disk at `disk_bytes`, counters equal a
2053
- recount, and the engine carries on under `gBookCheck`.
2054
- - `book_check_fast_suite` (slow): every fast test again with `gBookCheck`
2055
- (88 of 88 passed).
2056
- - `bookkeeping_scale_S5k` (slow): the table above. It gates commit and
2057
- maintenance p99 under 10 ms at S ≥ 5,000, and the summary refresh and
2058
- candidate look within 2× (+50 µs) of their S = 235 cost. On the base
2059
- commit it fails both p99 gates.
2060
-
2061
- Sanitizers, on this build over d566293 (macOS, clang):
2062
- - ThreadSanitizer reports nothing on eleven tests: the four book tests,
2063
- `quota_arrival_order_heads_spared_T3_3`,
2064
- `compaction_cat_supersede_plateau_T3_2`,
2065
- `orphan_crash_points_during_compaction_T3_4`, and the four fast hot-split
2066
- tests.
2067
- - AddressSanitizer with UBSan reports nothing on the book tests,
2068
- `compaction_removes_dead_rows_keeps_live`,
2069
- `quota_arrival_order_heads_spared_T3_3`, the orphan harness,
2070
- `hot_split_equals_unsplit_reference_T3_5` and `crash_faults_T1_1`. It had
2071
- found one defect in this section, fixed: `std::min` bound a reference to a
2072
- packed counter.
2073
- - The stage-1 race, before and after, on Docker linux/arm64 with gcc 13 and
2074
- on macOS:
2075
- - 14a3075 reports it in 3 of 5 hot-split runs (`hot_split_throughput_T3_5`,
2076
- and `hot_split_rebalance_stalled_helpers_A26` at 12 s, twice);
2077
- - this change reports it in none of 6 runs.
2078
- - A two-thread program (`ringWrite` against a helper's read of the same
2079
- bytes) shows it on Linux with a plain reader, and still shows it with
2080
- atomic loads against a plain `memcpy` write. It is silent only once both
2081
- sides are atomic.
2082
- - Under ASan the equivalence test needs more than its default 60 s to settle
2083
- (it passes with `--eq-settle-s=600`).
2084
-
2085
- Mutations the checks caught:
2086
- - a missing kill touch;
2087
- - a SWAP input not touched;
2088
- - a ledger update dropped;
2089
- - the valve off.
2090
-
2091
- # Part IX: format levels and lane checkpoints
2092
-
2093
- ## 41. Lanes past 1,170 and format levels (TB03)
2094
-
2095
- The terabyte design (stack `docs/architecture/flatsql-ps-terabyte.md` §2.9 K0,
2096
- §3, §10 TB03) found that a partition stops committing past 1,170 live lanes.
2097
- Past 32, every batch that changed a lane carried the whole live table (56 B a
2098
- lane) in one LANE_CKPT ctl record. A u16 bounds that record, so a table past
2099
- (65,535 − 4) / 56 = 1,170 lanes refused the batch, and every retry refused it
2100
- too. SDN mints a lane per fetch, so a 3-hourly source whose history stays
2101
- live crosses the cap in about 146 days. Below the cap each such batch still
2102
- rewrote about 65 KB near 1,170 lanes. The RETIRE encoder also reserved room
2103
- for every lane ever loaded.
2104
-
2105
- Measured before the change on 2026-09-30:
2106
- - host-02's legacy store (read-only, `sdn_record_source_summary`): the
2107
- largest partition is MPE.fbs, celestrak-gp, at 66 live lanes. That is 5.6%
2108
- of the cap. It grows about 4.3 lanes a day, so it would reach 1,170 in
2109
- about 257 days. The next largest are RFB.fbs at 11 and OMM.fbs at 3.
2110
- - The host-02 fixture migrated with SDN's store-migrate (4,364,873 records):
2111
- OMM has 53 lanes, MPE 52, CAT 2 and IQC 1.
2112
-
2113
- ### Format levels
2114
-
2115
- `cpp/include/flatsql/ps/format_level.h` holds `kFormatMin` 2, `kFormatMax`
2116
- 3 and `kLevelLaneCkpt` 3. `STORE.format` is the store's level.
2117
- `HeadPrefix.format` and MIGRATED stay 2, because SDN's Go head reader checks
2118
- them.
2119
-
2120
- - **Accepted levels.** An engine opens `kFormatMin ≤ format ≤ kFormatMax`.
2121
- A valid STORE above that is refused ("STORE format N is newer than this
2122
- engine") before any file is created or written. So is a torn STORE whose
2123
- STORE.tmp (below) is above it. An intact STORE the engine opens is never
2124
- refused because of a higher STORE.tmp: that ratchet never reached step 2,
2125
- so nothing of its level exists, and the leftover is removed. Readers apply
2126
- the same rules: an intact STORE decides alone, and only a torn one reads
2127
- through STORE.tmp.
2128
- - **writeFormat** (writer TLV 31, `EngineConfig::writeFormat`; 0 is
2129
- `kFormatMax`). A fresh store is created at it. At open, STORE ratchets up
2130
- to it, but only once `registry.fsl` is non-empty: an empty store can still
2131
- be recreated by an older engine. Nothing of a level is written before the
2132
- ratchet to it is durable. A host can pin a lower level for a staged
2133
- rollout, and a pinned host keeps its level: a STORE.tmp above the pin is
2134
- removed, not finished. A store never ratchets down.
2135
- - **A full device.** When STORE.tmp cannot be created, written or synced
2136
- (step 1), the ratchet does not start: the leftover is removed, the store
2137
- opens at its level exactly as the level-2 open would, and the next open
2138
- tries again. A ratcheting engine on a full disk therefore never keeps a
2139
- host down that the older engine would start.
2140
- - **The ratchet.** The I/O ABI has no rename (deviation 39), so the ratchet
2141
- takes three steps:
2142
- 1. STORE.tmp (the new STORE) is written whole and synced; its directory
2143
- entry is durable from creation.
2144
- 2. STORE is rewritten in place (64 bytes, one sector) and synced.
2145
- 3. STORE.tmp is unlinked.
2146
-
2147
- A STORE.tmp is this store's when every field but the format equals
2148
- STORE's. STORE is written once, and only the ratchet rewrites it, changing
2149
- only its format, so a STORE.tmp an older engine left beside the store it
2150
- went on writing still describes that store. An open finishes the ratchet
2151
- from STORE.tmp when STORE is torn (STORE.tmp is the one valid record), or
2152
- when STORE is intact and the open writes that level. Otherwise STORE.tmp is
2153
- removed and STORE stands. Level-3 files exist only after step 2.
2154
-
2155
- The released 3.5.1 engine reads STORE only, run natively on each crash
2156
- state:
2157
-
2158
- | State | 3.5.1 | Files |
2159
- |---|---|---|
2160
- | STORE 2, STORE.tmp 3 (step 1 done) | opens, writes level-2 data | changed |
2161
- | STORE torn, STORE.tmp 3 (inside step 2) | "STORE is corrupt" | unchanged |
2162
- | STORE 3, STORE.tmp 3 (step 2 done) | "STORE is corrupt" | unchanged |
2163
- | STORE 3 (finished) | "STORE is corrupt" | unchanged |
2164
-
2165
- A level-3 open afterwards recovered all four at level 3, with the lanes
2166
- 3.5.1's own reopen reports. In the first state, 3.5.1 writes level-2 data
2167
- into a level-2 store, which level 3 reads and converts.
2168
- - **Rollback.** The ratchet is durable before the open reads a partition,
2169
- so a first level-3 open that then fails for another reason (a tail scan,
2170
- a manifest) also leaves the store at level 3, which 3.5.1 refuses. Rolling
2171
- back after the first start restores a snapshot of the store taken before
2172
- it, or re-syncs. SDN forwards TLV 31 as `SDN_F2_WRITE_FORMAT` for a host
2173
- held at level 2.
2174
- - **Stats** (appended, entries 36–40): the store's level, `kFormatMax`, the
2175
- level this open raised STORE from (0: none; 1: STORE was torn inside a
2176
- ratchet this open finished, so its old level is unknown), lane
2177
- checkpoints cut, and their bytes.
2178
-
2179
- At level 3, manifest version 3 is the written version. Below level 3, v3 is
2180
- written only past 65,535 segments (§40, deviation 38).
2181
-
2182
- ### Lanes at level 3
2183
-
2184
- - **A batch carries only the lanes it changed.** That is its lane-delta
2185
- section: one 48 B LaneDelta per changed lane, counted by a u32 (deviation
2186
- 40: this section is the design's `LANE_PAGE`, so no new ctl kind was
2187
- needed). The per-batch delta table is now indexed by lane id and has no
2188
- cap. It was 256, so a kill of a record with more than 256 tag instances
2189
- failed its batch for good.
2190
- - **Up to 32 live lanes**, the head holds the table inline, as at level 2.
2191
- - **The batch that takes a partition past 32** writes the table once as a
2192
- LANE_CKPT. In inline mode, staging stops before an entry once the batch
2193
- holds 64 lane deltas. An entry makes at most one lane live (a PUT or a
2194
- re-tag), so that table holds at most the live lanes plus 65, 97 lanes.
2195
- - **From that batch on, the head names a LaneRef** (`nLanes` 0xFFFE,
2196
- 48 bytes after the L0 directory): a base plus the batch position its
2197
- deltas replay from. The base is `lk-<gen>.fsl`, or the crossing
2198
- LANE_CKPT. The table is the base, plus the deltas of every batch from the
2199
- replay offset up to the head's `(mSeg, mEnd)`. Batches are walked by
2200
- their headers, and a SEAL moves to segment + 1.
2201
- - **`lk-<gen:06x>.fsl`** is a 4 KiB header page (pid, gen, lane count,
2202
- `mintedThrough`, the cut position, CRC). Then come pages of up to 73
2203
- counters sorted by lane id, each page with a CRC. Count-0 lanes are
2204
- dropped. There is no size cap.
2205
- - **Cuts.** A checkpoint is cut:
2206
- - after the crossing;
2207
- - when A9 wants to retire a meta segment at or above the ref's floor (its
2208
- replay segment, and a LANE_CKPT base's segment);
2209
- - once a fold would walk `laneCkptBatches` batches with lane deltas
2210
- (writer TLV 32, default 128), or four times that many batches in all.
2211
- A fold reads every batch it walks, and every `flatsql_lanes` statement
2212
- and every open folds, so this bounds their work. Batches without lane
2213
- deltas count a quarter, so the RETIRE and UNLINKED batches a cut itself
2214
- causes never make the next cut due.
2215
-
2216
- The owner copies its live counters at a batch boundary (O(live lanes),
2217
- once per cut), and a maintenance helper writes and syncs the file
2218
- (deviation 41). The encoded file counts in the writer's committed memory
2219
- until it is written.
2220
- - **Crash protocol**, as built:
2221
- 1. The helper writes `lk-<g>` and syncs it. One cut is in flight per
2222
- partition, so g is always the ref's gen + 1.
2223
- 2. The owner installs the ref and writes a DURABLE_CKPT head naming it.
2224
- 3. Only once a durable head names it does a RETIRE list `lk-<g−1>`
2225
- (letter `L`), and A9 retire a meta segment below the new floor.
2226
- 4. Open unlinks `lk-<gen + 1>`, a checkpoint no durable head names. An
2227
- `lk-<gen − 1>` whose RETIRE never committed goes after the open's
2228
- durable head.
2229
-
2230
- No cut starts while a RETIRE that lists a replaced checkpoint has not
2231
- committed. Until it commits only the head names what open cleans up, so
2232
- the files on disk stay within `lk-<gen − 1>` to `lk-<gen + 1>`. Without
2233
- that wait, meta writes failing ENOSPC let the next cut install and a crash
2234
- left `lk-<gen − 2>` named by nothing, for good. Open also probes
2235
- `lk-<gen − 2>`.
2236
- - **Torn heads.** Every batch that carries a RETIRE also carries the LaneRef
2237
- in force (ctl kind 13, LANE_REF). A torn-head rebuild from a later meta
2238
- segment folds the table from the last one.
2239
- - **The RETIRE valve** reserves only what the batch's finalize step adds:
2240
- the LANE_REF in ref mode; in inline mode the table a crossing can write,
2241
- the live lanes plus 65; and the whole table at level 2, as before. With 32
2242
- or fewer live lanes the soft cap stays at 2,048 items, as at level 2.
2243
- - **Readers** fold the same way (`LaneStore::laneCounters`) up to their
2244
- snapshot's head. A file retired since then answers SNAPSHOT_GONE.
2245
- - **A level-2 head at a level-3 open.** A head that names a LANE_CKPT
2246
- becomes a LaneRef with that record as its base and the head position as
2247
- its replay offset. This holds because level 2 wrote a LANE_CKPT with every
2248
- batch that changed a lane past 32. A first checkpoint follows.
2249
- - **In memory**, lanes are found by id through an index. It was a linear
2250
- search per delta at publish, replay and RECONCILE. Replay, readers and
2251
- checkpoints apply a delta by one rule (`laneApplyDelta`). A lane at count
2252
- 0 carries no history: checkpoints and head tables drop it, so a delta that
2253
- makes it live again starts its counters afresh (`first_seen`, `max_pseq`,
2254
- `updated`). The writer's memory, a fold from any base and a replay then
2255
- agree whether or not a checkpoint dropped the lane. Before, a lane revived
2256
- after a checkpoint dropped it read its old `first_seen` until the next cut
2257
- and the new one after it, with no write in between.
2258
- - **A failed batch that minted lanes** rolls `nextLaneId` back to the first
2259
- id it minted. The table is not in id order when l.fsl lost frames the
2260
- head still counts, and the old rule, the last lane's id + 1, could fall
2261
- below a live id.
2262
- - **A LaneRef head in a store below level 3** (a partial snapshot restore)
2263
- quarantines that partition. It used to fail the whole open.
2264
-
2265
- ### Live-only candidate caps (N2)
2266
-
2267
- Staging gathered at most a fixed number of postings per lookup and then
2268
- dropped the dead ones. A key with more dead postings than the cap hid its
2269
- live row:
2270
-
2271
- | Lookup | Old cap |
2272
- |---|---|
2273
- | supersede candidates | 8 |
2274
- | CID candidates | 64 |
2275
- | tag instances of one tuple | 64 |
2276
- | live instances of a PUT | 256 |
2277
- | licence and control supersedes | 8 |
2278
-
2279
- Only live rows count now. A key holds at most one live row of each kind:
2280
- every PUT kills the live PUTs of its supersede keys whose CID differs, every
2281
- control record every live row of its key, every licence the live licences
2282
- of its key, and a CID is stored once per partition. A live control record
2283
- is also older than any live PUT beside it. So a supersede or licence lookup
2284
- ends at the key's newest live row, and a control record's at the live
2285
- control record.
2286
-
2287
- The walk goes newest first through the postings' sources. The unmerged L0
2288
- blocks and each segment's L1 runs hold disjoint, ascending pseq ranges,
2289
- with every run below every block, so the sources are read from the newest,
2290
- each one's postings sorted descending, and the walk stops at the end
2291
- without reading older sources. Should the ranges not be in that order, all
2292
- postings are gathered and sorted instead. A PUT of a hot key reads its
2293
- newest version, not every dead version compaction has not dropped yet.
2294
- CID and tag-instance lookups gather every posting and stop at the first
2295
- live one. The lists grow outside the hot-path count.
2296
-
2297
- ### Tests
2298
-
2299
- The tests are in `cpp/test/ps/lane_cap_test.cpp`. The Mac is an M3 Ultra
2300
- with 28 cores, APFS, and the in-memory fault host, shared with other lanes
2301
- (1-minute load 9–20 during the runs). Every crash sweep and trial checks,
2302
- after each reopen, the lane table on disk against a recount from rows
2303
- (count and bytes), and against the engine's own table in every counter
2304
- field, for every live lane.
2305
-
2306
- | Test | Result |
2307
- |---|---|
2308
- | `lanecap_5000_live_lanes` | 5,000 lanes, then 500 re-tags into new lanes and 200 kills, all acked. The engine, `flatsql_lanes`, the on-disk fold and a recount from rows all agree (5,272 lanes); again after a reopen and 100 more lanes |
2309
- | `lanecap_crash_trials_full` | 1,000 trials, 2 writers, helper-written checkpoints, file and journal mode, 114 s. Each crash lands after at least 5,000 live lanes: 160 left an unnamed checkpoint and 362 a replaced one pending. After every reopen the table equals a recount from rows and the engine's table, and no `lk` orphan is left. 0 failures |
2310
- | `lanecap_ckpt_crash_every_op_full` | A crash at each of 2,912 mutating I/O calls of a workload that crosses 32 lanes, cuts 30 checkpoints, seals, and retires meta segments and replaced checkpoints. All five crash modes. Before open: 965 crashes left an unnamed checkpoint and 1,241 a replaced one pending. After open: lanes equal a recount and the engine's table, and the directory holds exactly the checkpoint the head names. 0 failures |
2311
- | `lanecap_2000_lanes_retire_bearing` | 2,000 lanes with seals, merges, a coalescing SWAP, 97 files retired, 5 meta segments retired, 8 checkpoints: 0 batches refused, every lane correct |
2312
- | `lanecap_batch_lane_bytes_proportional` | A batch that changes one lane writes 56 B of lane records at level 3, with 66 live lanes (host-02's largest partition) or 5,000. At level 2 the same batch writes 3,760 B at 66 lanes and 61,664 B at 1,100. At level 2, 1,180 lanes never ack |
2313
- | `lanecap_no_cut_while_lk_retire_pending` | Meta writes fail ENOSPC for 300 steps: no cut starts while a RETIRE naming a replaced checkpoint waits (355 steps waited), and a crash then leaves exactly the named checkpoint after open. With the wait removed, 82 steps had a cut in flight |
2314
- | `lanecap_install_head_nospace` | The head write naming a newly installed checkpoint fails ENOSPC (40 installs), then a crash in each of the five modes at four points after: every reopen agrees with the recount and the engine and keeps only the named checkpoint. 0 failures of 20 trials. Removing the replaced checkpoint's wait for a durable head is not caught here: this workload's checkpoint heads follow in the same step, and every RETIRE batch also carries the LaneRef in force |
2315
- | `lanecap_fold_walk_bounded` | 1,500 one-lane batches on 100 lanes at the default cadence: a fold walks at most 128 batches (13 checkpoints); `flatsql_lanes` 0.3–1.0 ms |
2316
- | `lanecap_revived_lane_counters_stable` | A lane killed to 0, dropped by a checkpoint and written again reads the same `first_seen` (the revival's) before the next cut, after it and after a reopen. Without the restart rule it flipped at the cut |
2317
- | `lanecap_rollback_keeps_lane_ids_unique` | l.fsl loses 6 frames (the table is out of id order), a batch that minted a lane fails ENOSPC and retries: every lane id unique, the new lane's id past every live one. The old rule fails it |
2318
- | `tb_supersede_candidate_cap`, `_autocompact` | 1 live version; 381 of 381 live. The old cap gives 12, and 392 of 381 |
2319
- | `tb_supersede_hot_key_cost` | A CAT key with 400 versions among 19 static objects each, default compaction: its PUT costs 1.8x a new key's (618 vs 340 µs p50); 2.3x at 800 versions. Checking every dead version (the review's measurement: 12.8x at 800) fails it at 7.3x. What remains is the kill's TAG_OF prefix scan across every run, the same as 3.5.1's (TB12's prefix bloom) |
2320
- | `lanecap_kill_record_with_300_instances` | A kill takes all 300 instances and lanes in one batch |
2321
- | `lanecap_cid_candidates_past_64` | The 80th copy of a CID dedupes |
2322
- | `lanecap_torn_heads_rebuild_from_lane_ref` | Both head slots torn: the table is rebuilt from LANE_REF |
2323
- | `format_older_engine_refuses_ratcheted_store` | An engine built with `kFormatMax` 2 refuses a level-3 store, and this engine refuses a level-4 STORE; neither creates or changes a file |
2324
- | `format_empty_registry_never_ratchets` | Opened three times, the empty level-2 store stays at 2; with a registration, the next open ratchets to 3 |
2325
- | `format_ratchet_crash_every_io_call` | A crash at each of the ratcheting open's 13 mutating calls, in all five modes (65 trials). An engine built with `kFormatMax` 2 (this code with `testFormatMax` 2, not the released 3.5.1: see the table above) then opened the store 31 times and refused it 34 times, creating nothing when it refused. This engine then opens at level 3 with every record |
2326
- | `format_ratchet_leftover_tmp_rules` | A STORE.tmp at 3 beside an intact STORE at 2: removed by a host pinned at 2 and by an engine whose `kFormatMax` is 2, which open at 2; finished by this engine (stats entry 38 says 2). One that differs from STORE in more than the format: removed. Beside a torn STORE: refused by the `kFormatMax`-2 engine with no file changed, finished by this one even pinned (entry 38 says 1) |
2327
- | `format_ratchet_waits_on_a_full_device` | No room for STORE.tmp: the store opens at 2, as the level-2 open does, and the next open with room raises it |
2328
- | `format_reader_store_rules` | A reader refuses a STORE at 4 beside a STORE.tmp at 3, reads a torn STORE through a STORE.tmp at 3, and refuses one through a STORE.tmp at 4 |
2329
- | `format_fresh_store_at_write_format`, `format_manifest_v3_from_level_3`, `format_manifest_v3_on_swap` | Fresh stores at writeFormat. Manifests v3 from level 3 and v2 at level 2, from a merge and from a compaction SWAP |
2330
-
2331
- Mutations the checks catch: the old supersede cap (12 live, and 392 of
2332
- 381); checking every dead version (7.3x); no orphan cleanup at open; a cut
2333
- while a replaced checkpoint's RETIRE waits; no restart of a revived lane;
2334
- the old rollback rule; the old STORE.tmp rules in the writer and in
2335
- readers; a ratchet that fails the open on a full device; the old cadence
2336
- default.
2337
-
2338
- The native suite (118 fast tests), `crash_faults_T1_1_full` (10,000
2339
- trials), `orphan_crash_points_during_compaction_T3_4_full` (2,501 trials),
2340
- `fanout_randomized_vs_bruteforce_T2_2_full` (T2 #2) and
2341
- `book_crash_every_op_full` (9,389 crash points) pass, 0 failures. In wasm32
2342
- (wasi-sdk 30, the Node host, `scripts/ps-wasm-suite.mjs`) all 118 fast
2343
- tests and `crash_faults_T1_1` with 1,000 trials pass. ThreadSanitizer
2344
- reports nothing on the threaded `lanecap_` and `format_` tests, the
2345
- supersede autocompaction test and the SWAP manifest test.
2346
-
2347
- Deviations:
2348
-
2349
- 39. **The ratchet uses STORE.tmp and an in-place rewrite** (the design says
2350
- an atomic rename). The host I/O contract has seven imports and no
2351
- rename, and adding one would change every host. The three steps above
2352
- leave the old STORE, the new one, or a torn one that STORE.tmp repairs.
2353
- An engine that predates levels never misreads a ratcheted store. SDN
2354
- reads a torn STORE through STORE.tmp too (its `Migrated` and the update
2355
- guard), so neither the daemon's start nor the Apply/Rollback guard
2356
- stops short of the engine's repair.
2357
- 40. **No LANE_PAGE ctl kind.** The batch's lane-delta section already holds
2358
- exactly the changed lanes, and a u32 counts it.
2359
- 41. **The owner cuts the checkpoint from its in-memory table** (the design:
2360
- a helper streams the previous checkpoint plus the deltas since). The
2361
- writer holds every lane's counters until TB15b removes them. The copy
2362
- costs O(live lanes) once per cut (the review measured 0.055 ms at 5,000
2363
- lanes, 1.5 ms at 100,000 and 14 ms at 1,000,000), and the helper still
2364
- writes and syncs the file; the encoded bytes count in the writer's
2365
- committed memory until then. The copy equals a fold of the checkpoint
2366
- it replaces plus the deltas since because a lane at count 0 restarts
2367
- (above). TB15b switches the source when the writer stops holding the
2368
- table, before a partition can hold 10^5 lanes.
2369
-
2370
- Not built here:
2371
- - A reader cache of folded tables. A reader folds at most 128 batches with
2372
- lane deltas (512 in all) per partition per `flatsql_lanes` statement.
2373
- - `flatsql_lanes` still matches lanes to tuples in O(lanes²)
2374
- (`vtab_meta.cpp` `fillLanes`, the fan-out estimate): 15 ms per statement
2375
- at 5,000 lanes, 0.1 ms at 100. That is TB15a's (pushdown and a hash from
2376
- lane id to tuple); today's largest partition holds 66 lanes.
2377
- - The LANE kind, the lane LRU and `flatsql_lanes` pushdown (TB15a, TB15b).
2378
- - A kill's TAG_OF prefix scan still reads every run of the partition (the
2379
- cost left in `tb_supersede_hot_key_cost`); TB12's prefix bloom removes
2380
- it.