flatsql 3.11.1 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -795
- package/bin/flatsql.mjs +76 -0
- package/cpp/README.md +18 -0
- package/cpp/include/flatsql/flatsql.h +162 -0
- package/cpp/include/flatsql/flatsql_io.h +159 -0
- package/docs/API-3.12.md +114 -0
- package/package.json +28 -123
- package/wasm/flatsql.wasm +0 -0
- package/wasm/index.d.ts +61 -548
- package/wasm/index.js +280 -1281
- package/wasm/integrity.json +6 -26
- package/wasm/runtime/LICENSE.sdk.txt +190 -0
- package/wasm/runtime/NOTICE.txt +2 -0
- package/wasm/runtime/browser-guest.mjs +1 -0
- package/wasm/runtime/browser-host.mjs +1 -0
- package/wasm/runtime/node-guest.mjs +2 -0
- package/wasm/runtime/node-host.mjs +5 -0
- package/wasm/runtime/opfsIoWorker.mjs +1 -0
- package/wasm/runtime/wasiThreadBrowserWorker.mjs +1 -0
- package/dist/artifacts/index.d.ts +0 -3
- package/dist/artifacts/index.d.ts.map +0 -1
- package/dist/artifacts/index.js +0 -3
- package/dist/artifacts/index.js.map +0 -1
- package/dist/artifacts/transport.d.ts +0 -5
- package/dist/artifacts/transport.d.ts.map +0 -1
- package/dist/artifacts/transport.js +0 -45
- package/dist/artifacts/transport.js.map +0 -1
- package/dist/artifacts/types.d.ts +0 -34
- package/dist/artifacts/types.d.ts.map +0 -1
- package/dist/artifacts/types.js +0 -2
- package/dist/artifacts/types.js.map +0 -1
- package/dist/btree/btree.d.ts +0 -34
- package/dist/btree/btree.d.ts.map +0 -1
- package/dist/btree/btree.js +0 -234
- package/dist/btree/btree.js.map +0 -1
- package/dist/btree/index.d.ts +0 -3
- package/dist/btree/index.d.ts.map +0 -1
- package/dist/btree/index.js +0 -3
- package/dist/btree/index.js.map +0 -1
- package/dist/btree/types.d.ts +0 -36
- package/dist/btree/types.d.ts.map +0 -1
- package/dist/btree/types.js +0 -55
- package/dist/btree/types.js.map +0 -1
- package/dist/cluster/index.d.ts +0 -11
- package/dist/cluster/index.d.ts.map +0 -1
- package/dist/cluster/index.js +0 -59
- package/dist/cluster/index.js.map +0 -1
- package/dist/response/artifact.d.ts +0 -4
- package/dist/response/artifact.d.ts.map +0 -1
- package/dist/response/artifact.js +0 -102
- package/dist/response/artifact.js.map +0 -1
- package/dist/response/cache-key.d.ts +0 -6
- package/dist/response/cache-key.d.ts.map +0 -1
- package/dist/response/cache-key.js +0 -114
- package/dist/response/cache-key.js.map +0 -1
- package/dist/response/index.d.ts +0 -5
- package/dist/response/index.d.ts.map +0 -1
- package/dist/response/index.js +0 -5
- package/dist/response/index.js.map +0 -1
- package/dist/response/memory-cache.d.ts +0 -17
- package/dist/response/memory-cache.d.ts.map +0 -1
- package/dist/response/memory-cache.js +0 -76
- package/dist/response/memory-cache.js.map +0 -1
- package/dist/response/types.d.ts +0 -57
- package/dist/response/types.d.ts.map +0 -1
- package/dist/response/types.js +0 -2
- package/dist/response/types.js.map +0 -1
- package/dist/schema/index.d.ts +0 -3
- package/dist/schema/index.d.ts.map +0 -1
- package/dist/schema/index.js +0 -3
- package/dist/schema/index.js.map +0 -1
- package/dist/schema/parser.d.ts +0 -25
- package/dist/schema/parser.d.ts.map +0 -1
- package/dist/schema/parser.js +0 -474
- package/dist/schema/parser.js.map +0 -1
- package/dist/schema/types.d.ts +0 -33
- package/dist/schema/types.d.ts.map +0 -1
- package/dist/schema/types.js +0 -29
- package/dist/schema/types.js.map +0 -1
- package/dist/standalone/index.d.ts +0 -3
- package/dist/standalone/index.d.ts.map +0 -1
- package/dist/standalone/index.js +0 -3
- package/dist/standalone/index.js.map +0 -1
- package/dist/standalone/process-client.d.ts +0 -85
- package/dist/standalone/process-client.d.ts.map +0 -1
- package/dist/standalone/process-client.js +0 -537
- package/dist/standalone/process-client.js.map +0 -1
- package/dist/standalone/wasmedge-runner.d.ts +0 -19
- package/dist/standalone/wasmedge-runner.d.ts.map +0 -1
- package/dist/standalone/wasmedge-runner.js +0 -108
- package/dist/standalone/wasmedge-runner.js.map +0 -1
- package/dist/storage/index.d.ts +0 -3
- package/dist/storage/index.d.ts.map +0 -1
- package/dist/storage/index.js +0 -3
- package/dist/storage/index.js.map +0 -1
- package/dist/storage/stacked-flatbuffers.d.ts +0 -38
- package/dist/storage/stacked-flatbuffers.d.ts.map +0 -1
- package/dist/storage/stacked-flatbuffers.js +0 -253
- package/dist/storage/stacked-flatbuffers.js.map +0 -1
- package/dist/storage/types.d.ts +0 -23
- package/dist/storage/types.d.ts.map +0 -1
- package/dist/storage/types.js +0 -27
- package/dist/storage/types.js.map +0 -1
- package/dist/stress/aggregate.d.ts +0 -3
- package/dist/stress/aggregate.d.ts.map +0 -1
- package/dist/stress/aggregate.js +0 -61
- package/dist/stress/aggregate.js.map +0 -1
- package/dist/stress/docker-compose.d.ts +0 -17
- package/dist/stress/docker-compose.d.ts.map +0 -1
- package/dist/stress/docker-compose.js +0 -156
- package/dist/stress/docker-compose.js.map +0 -1
- package/dist/stress/index.d.ts +0 -10
- package/dist/stress/index.d.ts.map +0 -1
- package/dist/stress/index.js +0 -10
- package/dist/stress/index.js.map +0 -1
- package/dist/stress/metrics.d.ts +0 -3
- package/dist/stress/metrics.d.ts.map +0 -1
- package/dist/stress/metrics.js +0 -83
- package/dist/stress/metrics.js.map +0 -1
- package/dist/stress/native-publish-event-workload.d.ts +0 -3
- package/dist/stress/native-publish-event-workload.d.ts.map +0 -1
- package/dist/stress/native-publish-event-workload.js +0 -465
- package/dist/stress/native-publish-event-workload.js.map +0 -1
- package/dist/stress/production-gate.d.ts +0 -4
- package/dist/stress/production-gate.d.ts.map +0 -1
- package/dist/stress/production-gate.js +0 -12
- package/dist/stress/production-gate.js.map +0 -1
- package/dist/stress/report.d.ts +0 -3
- package/dist/stress/report.d.ts.map +0 -1
- package/dist/stress/report.js +0 -174
- package/dist/stress/report.js.map +0 -1
- package/dist/stress/sds-discovery.d.ts +0 -4
- package/dist/stress/sds-discovery.d.ts.map +0 -1
- package/dist/stress/sds-discovery.js +0 -40
- package/dist/stress/sds-discovery.js.map +0 -1
- package/dist/stress/smoke-runner.d.ts +0 -3
- package/dist/stress/smoke-runner.d.ts.map +0 -1
- package/dist/stress/smoke-runner.js +0 -64
- package/dist/stress/smoke-runner.js.map +0 -1
- package/dist/stress/types.d.ts +0 -219
- package/dist/stress/types.d.ts.map +0 -1
- package/dist/stress/types.js +0 -2
- package/dist/stress/types.js.map +0 -1
- package/dist/stress/workload.d.ts +0 -6
- package/dist/stress/workload.d.ts.map +0 -1
- package/dist/stress/workload.js +0 -144
- package/dist/stress/workload.js.map +0 -1
- package/docs/PARTITION-STORE-WASM.md +0 -338
- package/docs/PARTITION-STORE.md +0 -2380
- package/docs/agents/response-artifacts.md +0 -65
- package/docs/response-artifacts.md +0 -86
- package/scripts/stress-docker.sh +0 -279
- package/sdm/flatsql-spatial.wasm +0 -0
- package/sdm/index.d.ts +0 -88
- package/sdm/index.js +0 -253
- package/sdm/manifest.json +0 -402
- package/stress/docker/Dockerfile +0 -11
- package/stress/docker/docker-compose.yml +0 -51
- package/stress/sds/aggregate.mjs +0 -97
- package/stress/sds/run.mjs +0 -105
- package/stress/sds/write-compose.mjs +0 -96
- package/wasm/flatsql-client.js +0 -280
- package/wasm/flatsql-io.d.ts +0 -97
- package/wasm/flatsql-io.js +0 -625
- package/wasm/flatsql-p4-threads.wasm +0 -0
- package/wasm/flatsql-ps-threads.wasm +0 -0
- package/wasm/flatsql-wasi-noeh.wasm +0 -0
- package/wasm/flatsql-wasi.wasm +0 -0
- package/wasm/flatsql.js +0 -2
- package/wasm/flatsql.worker.js +0 -209
- package/wasm/native/flatsql_wasmedge_runner.cpp +0 -858
- package/wasm/ps-node-fault.d.mts +0 -23
- package/wasm/ps-node-fault.mjs +0 -381
- package/wasm/ps-node-guest.mjs +0 -173
- package/wasm/ps-node-host.d.mts +0 -95
- package/wasm/ps-node-host.mjs +0 -476
- package/wasm/ps-node-imports.mjs +0 -847
- package/wasm/ps.d.ts +0 -21
- package/wasm/ps.js +0 -75
- package/wasm/wasi.d.ts +0 -11
- package/wasm/wasi.js +0 -51
package/docs/PARTITION-STORE.md
DELETED
|
@@ -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.
|