1 import Base
2 import ./Keys.bend as Keys
3 import ./MemTable.bend as MemTable
4 import ./Sstable.bend as Sstable
5 import ./SortedRun.bend as SortedRun
6 import ./SstStreamIo.bend as SstStreamIo
7 import ./Wal.bend as Wal
8 import ./Manifest.bend as Manifest
9 import ./StorageBytes.bend as StorageBytes
10 import ./Fs.bend as Fs
11 import ./DbIo.bend as DbIo
12 import bend-kit-bytes@0.3.2.0/bytes.bend as Bytes
13 import ./Db.bend as Db
14 import ./CrashPoint.bend as CrashPoint
15 import ./FlushPolicy.bend as Policy
16
17 # Represent ManifestReadState data used by the memtable flush.
18 type ManifestReadState is Type:
19 ManifestNeed{file: File, remaining: Nat, chunks: List<&1, Bytes.Bytes>}
20 ManifestGot{pair: File & Result<&1, &1, U32 & String, Bytes.Bytes>, remaining: Nat, chunks: List<&1, Bytes.Bytes>}
21
22 # Flush (Task 8): sealed memtable -> L0 SSTable file + manifest + WAL reset.
23 #
24 # Crash protocol (each step durable before the next, tmp + rename for
25 # atomic visibility):
26 # 1. ensure
/l0; write .tmp; fsync; rename to ; fsync l0.
27 # 2. load manifest (absent file = fresh M{Nil}; corrupt = ABORT Fail).
28 # 3. write MANIFEST.tmp; fsync; rename; fsync dir.
29 # 4. remove wal.log (failure ignored: a stale WAL replays idempotently —
30 # same versions, mem-first reads return equal values; merge dedups).
31 # A crash before step 3 leaves the table file orphaned (recovery ignores
32 # files absent from the manifest; Task 12 sweeps orphans). A crash after
33 # step 3 with a live WAL replays duplicates harmlessly (see above).
34 # Empty memtable flush is a no-op (no files touched).
35 # CrashPoint calls are test-only host effects. Unset or unequal checkpoints are
36 # successful no-ops; signal delivery and filesystem ordering are not Bend proofs.
37
38 # Handle l0 add in the memtable flush.
39 def l0_add(+levels: List<&2, List<&2, Sstable.Table>>, +tbl: Sstable.Table) -> List<&2, List<&2, Sstable.Table>>:
40 match levels:
41 case Nil{}:
42 Con{Con{tbl, Nil{}}, Nil{}}
43 case Con{l0, rest}:
44 Con{Con{tbl, l0}, rest}
45
46 # Handle mfst add in the memtable flush.
47 def mfst_add(+mfst: Manifest.Manifest, +name: String) -> Manifest.Manifest:
48 match mfst:
49 case Manifest.M{lvs}:
50 match lvs:
51 case Nil{}:
52 Manifest.M{Con{Con{name, Nil{}}, Nil{}}}
53 case Con{l0, rest}:
54 Manifest.M{Con{Con{name, l0}, rest}}
55
56 # --- File helpers (pair-splits contained; straight-line callers above) ---
57
58 # Write tail for the memtable flush.
59 def write_tail(
60 fr: (File & Result<&1, &1, U32 & String, Unit>),
61 +path: String
62 ) -> IO(Result<&1, &1, U32 & String, Unit>):
63 match fr:
64 case (f2, r):
65 do IO>:
66 res : Unit <- IO.try(Unit, IO.pure(Result<&1, &1, U32 & String, Unit>, r))
67 _cls : Unit <- File.close(f2)
68 _syn : Unit <- IO.try(Unit, Fs.fsync(path))
69 _p_rm : Unit <- IO.try(Unit, Fs.chmod(path, U32.from_nat(384n)))
70 return Done{res}
71
72 # Write file for the memtable flush.
73 def write_file(+path: String, +data: String) -> IO(Result<&1, &1, U32 & String, Unit>):
74 do IO>:
75 f : File <- IO.try(File, File.open(path, "w"))
76 fr : (File & Result<&1, &1, U32 & String, Unit>) <- File.write(f, data)
77 write_tail(fr, path)
78
79 def write_manifest.bytes(+path: String, bytes: Bytes.Bytes) -> IO(Result<&1, &1, U32 & String, Unit>):
80 do IO>:
81 f : File <- IO.try(File, File.open(path, "w"))
82 fr : File & Result<&1, &1, U32 & String, Unit> <- Fs.write_bytes(f, bytes)
83 write_tail(fr, path)
84
85 def write_manifest.encoded(
86 path: String,
87 encoded: Result<&1, &1, Manifest.Error, Bytes.Bytes>
88 ) -> IO(Result<&1, &1, U32 & String, Unit>):
89 match encoded:
90 case Fail{_}:
91 IO.pure(Result<&1, &1, U32 & String, Unit>, Fail{(U32.from_nat(1n), "cannot encode manifest")})
92 case Done{bytes}:
93 write_manifest.bytes(path, bytes)
94
95 # Write manifest for the memtable flush.
96 def write_manifest(path: String, manifest: Manifest.Manifest) -> IO(Result<&1, &1, U32 & String, Unit>):
97 write_manifest.encoded(path, Manifest.serialize(manifest))
98
99 # Read tail3 for the memtable flush.
100 def read_tail3(fr: (File & Result<&1, &1, U32 & String, String>)) -> IO(Result<&1, &1, U32 & String, String>):
101 match fr:
102 case (f3, r1):
103 do IO>:
104 _cls : Unit <- File.close(f3)
105 IO.pure(Result<&1, &1, U32 & String, String>, r1)
106
107
108 # Base File.read decodes each returned chunk independently. A chunk boundary can
109 # split a multi-byte UTF-8 character, so table files are read in one bounded call
110 # until a byte-oriented streaming effect with decoder carry is available.
111 def read_file(+path: String) -> IO(Result<&1, &1, U32 & String, String>):
112 do IO>:
113 f : File <- IO.try(File, File.open(path, "r"))
114 fr : (File & Result<&1, &1, U32 & String, String>) <- File.read(f, U32.from_nat(4294967295n))
115 read_tail3(fr)
116
117 # Handle mfst parsed in the memtable flush.
118 def mfst_parsed(+opt: Maybe<&2, Manifest.Manifest>) -> Result<&1, &1, U32 & String, Manifest.Manifest>:
119 match opt:
120 case None{}:
121 Fail{(U32.from_nat(1n), "bad manifest")}
122 case Some{mm}:
123 Done{mm}
124
125 def manifest.read.result(
126 pair: File & Result<&1, &1, U32 & String, Bytes.Bytes>
127 ) -> IO(Result<&1, &1, U32 & String, Manifest.Manifest>):
128 match pair:
129 case (file, Fail{error}):
130 do IO>:
131 _closed : Unit <- File.close(file)
132 return Fail{error}
133 case (file, Done{bytes}):
134 do IO>:
135 _closed : Unit <- File.close(file)
136 return mfst_parsed(Manifest.parse(bytes))
137
138 def manifest.read.loop(fuel: Nat, state: ManifestReadState) -> IO(File & Result<&1, &1, U32 & String, Bytes.Bytes>):
139 match fuel:
140 case 0n:
141 match state:
142 case ManifestNeed{file, +remaining, chunks}:
143 match remaining:
144 case 0n:
145 IO.pure(File & Result<&1, &1, U32 & String, Bytes.Bytes>, (file, Done{Bytes.concat(List.reverse(&1, Bytes.Bytes, chunks))}))
146 case 1n+_:
147 IO.pure(File & Result<&1, &1, U32 & String, Bytes.Bytes>, (file, Fail{(U32.from_nat(7n), "bounded manifest read exhausted")}))
148 case ManifestGot{pair, _, _}:
149 match pair:
150 case (file, Fail{error}):
151 IO.pure(File & Result<&1, &1, U32 & String, Bytes.Bytes>, (file, Fail{error}))
152 case (file, Done{_}):
153 IO.pure(File & Result<&1, &1, U32 & String, Bytes.Bytes>, (file, Fail{(U32.from_nat(7n), "bounded manifest read exhausted")}))
154 case 1n+rest:
155 match state:
156 case ManifestNeed{file, +remaining, chunks}:
157 match remaining:
158 case 0n:
159 IO.pure(File & Result<&1, &1, U32 & String, Bytes.Bytes>, (file, Done{Bytes.concat(List.reverse(&1, Bytes.Bytes, chunks))}))
160 case 1n+_:
161 do IO>:
162 pair : File & Result<&1, &1, U32 & String, Bytes.Bytes> <- Fs.read_bytes(file, U32.from_nat(remaining))
163 manifest.read.loop(rest, ManifestGot{pair, remaining, chunks})
164 case ManifestGot{pair, +remaining, chunks}:
165 match pair:
166 case (file, Fail{error}):
167 IO.pure(File & Result<&1, &1, U32 & String, Bytes.Bytes>, (file, Fail{error}))
168 case (file, Done{Bytes.Bytes{0, _}}):
169 IO.pure(File & Result<&1, &1, U32 & String, Bytes.Bytes>, (file, Fail{(U32.from_nat(8n), "truncated manifest file")}))
170 case (file, Done{Bytes.Bytes{+len, buf}}):
171 manifest.read.loop(rest, ManifestNeed{file, Nat.sub(remaining, U32.to_nat(len)), Con{Bytes.Bytes{len, buf}, chunks}})
172
173 def manifest.read.size.run(file: File, +size: Nat) -> IO(Result<&1, &1, U32 & String, Manifest.Manifest>):
174 do IO>:
175 pair : File & Result<&1, &1, U32 & String, Bytes.Bytes> <- manifest.read.loop(Nat.add(size, 1n), ManifestNeed{file, size, Nil{}})
176 manifest.read.result(pair)
177
178 def manifest.read.size.valid(
179 file: File,
180 size: Nat,
181 within: Bool
182 ) -> IO(Result<&1, &1, U32 & String, Manifest.Manifest>):
183 match within:
184 case False{}:
185 do IO>:
186 _closed : Unit <- File.close(file)
187 return Fail{(U32.from_nat(9n), "manifest too large")}
188 case True{}:
189 manifest.read.size.run(file, size)
190
191 def manifest.read.size(
192 file: File,
193 result: Result<&1, &1, U32 & String, Nat>
194 ) -> IO(Result<&1, &1, U32 & String, Manifest.Manifest>):
195 match result:
196 case Fail{error}:
197 do IO>:
198 _closed : Unit <- File.close(file)
199 return Fail{error}
200 case Done{+size}:
201 manifest.read.size.valid(file, size, Nat.is_le(size, U32.to_nat(StorageBytes.MAX_MANIFEST_BYTES())))
202
203 # Handle mfst open error in the memtable flush.
204 def mfst_open_error(
205 missing: Bool,
206 +code: U32
207 ) -> IO(Result<&1, &1, U32 & String, Manifest.Manifest>):
208 match missing:
209 case True{}:
210 IO.pure(Result<&1, &1, U32 & String, Manifest.Manifest>, Done{Manifest.M{Nil{}}})
211 case False{}:
212 IO.pure(Result<&1, &1, U32 & String, Manifest.Manifest>, Fail{(code, "cannot open manifest")})
213
214 # Handle mfst opened in the memtable flush.
215 def mfst_opened(
216 path: String,
217 result: Result<&1, &1, U32 & String, File>
218 ) -> IO(Result<&1, &1, U32 & String, Manifest.Manifest>):
219 match result:
220 case Fail{(+code, _)}:
221 mfst_open_error(U32.is_eq(code, 2), code)
222 case Done{file}:
223 do IO>:
224 size : Result<&1, &1, U32 & String, Nat> <- Fs.file_size(path)
225 manifest.read.size(file, size)
226
227 # Handle load mfst in the memtable flush.
228 def load_mfst(+path: String) -> IO(Result<&1, &1, U32 & String, Manifest.Manifest>):
229 do IO>:
230 opened : Result<&1, &1, U32 & String, File> <- File.open(path, "r")
231 mfst_opened(path, opened)
232
233 # Handle dir tail in the memtable flush.
234 def dir_tail(res: Result<&1, &1, U32 & String, Nat>, +path: String) -> IO(Result<&1, &1, U32 & String, Unit>):
235 match res:
236 case Done{n}:
237 IO.pure(Result<&1, &1, U32 & String, Unit>, Done{Unit{}})
238 case Fail{e}:
239 Fs.make_dir(path)
240
241 # Handle ensure dir in the memtable flush.
242 def ensure_dir(+path: String) -> IO(Result<&1, &1, U32 & String, Unit>):
243 do IO>:
244 r : Result<&1, &1, U32 & String, Nat> <- Fs.read_dir_count(path)
245 dir_tail(r, path)
246
247 # --- Flush chain (defined bottom-up: tails first, entry last) ---
248
249 # Flush goB for the memtable flush.
250 def flush_goB(
251 +dir: String,
252 +mpath: String,
253 +mtmp: String,
254 +m2: Manifest.Manifest,
255 +tbl: Sstable.Table,
256 +levels: List<&2, List<&2, Sstable.Table>>,
257 +flushed: Nat,
258 +mem: MemTable.MemTable,
259 +batch_cap: Nat,
260 ) -> IO(Result<&1, &1, U32 & String, Db.Db>):
261 do IO>:
262 _v1 : Unit <- IO.try(Unit, write_manifest(mtmp, m2))
263 _cp1 : Unit <- IO.try(Unit, CrashPoint.hit("flush.manifest_synced"))
264 _v2 : Unit <- IO.try(Unit, Fs.rename(mtmp, mpath))
265 _v3 : Unit <- IO.try(Unit, Fs.fsync(dir))
266 _cp2 : Unit <- IO.try(Unit, CrashPoint.hit("flush.manifest_published"))
267 _rm : Result<&1, &1, U32 & String, Unit> <- Fs.remove(Db.wal_path(dir))
268 _wal : Unit <- IO.try(Unit, DbIo.wal_initialize(dir))
269 return Done{Db.Db{dir, mem, MemTable.MT{Nil{}}, batch_cap, Nil{}, l0_add(levels, tbl), Nat.add(flushed, 1n), Manifest.token(m2), 0n, 0n}}
270
271 # Flush drift for the memtable flush.
272 def flush_drift(
273 ok: Bool,
274 +dir: String,
275 +mpath: String,
276 +mtmp: String,
277 +mfst: Manifest.Manifest,
278 +tbl: Sstable.Table,
279 +name: String,
280 +levels: List<&2, List<&2, Sstable.Table>>,
281 +flushed: Nat,
282 +mem: MemTable.MemTable,
283 +batch_cap: Nat,
284 ) -> IO(Result<&1, &1, U32 & String, Db.Db>):
285 match ok:
286 case False{}:
287 IO.pure(Result<&1, &1, U32 & String, Db.Db>, Fail{(U32.from_nat(2n), "manifest drift")})
288 case True{}:
289 flush_goB(dir, mpath, mtmp, mfst_add(mfst, name), tbl, levels, flushed, mem, batch_cap)
290
291 # Flush goA for the memtable flush.
292 def flush_goA(
293 +dir: String,
294 +l0dir: String,
295 +tmp: String,
296 +final: String,
297 +mpath: String,
298 +mtmp: String,
299 +entries: List<&2, MemTable.Entry>,
300 +level: U32,
301 +tbl: Sstable.Table,
302 +name: String,
303 +levels: List<&2, List<&2, Sstable.Table>>,
304 +flushed: Nat,
305 +manifest_token: String,
306 +mem: MemTable.MemTable,
307 +batch_cap: Nat,
308 ) -> IO(Result<&1, &1, U32 & String, Db.Db>):
309 do IO>:
310 _u1 : Unit <- IO.try(Unit, ensure_dir(l0dir))
311 _u2 : Unit <- IO.try(Unit, SstStreamIo.write_table(tmp, entries, level))
312 _cp1 : Unit <- IO.try(Unit, CrashPoint.hit("flush.table_synced"))
313 _u3 : Unit <- IO.try(Unit, Fs.rename(tmp, final))
314 _u4 : Unit <- IO.try(Unit, Fs.fsync(l0dir))
315 _cp2 : Unit <- IO.try(Unit, CrashPoint.hit("flush.table_published"))
316 +m : Manifest.Manifest <- IO.try(Manifest.Manifest, load_mfst(mpath))
317 flush_drift(String.eq(Manifest.token(m), manifest_token), dir, mpath, mtmp, m, tbl, name, levels, flushed, mem, batch_cap)
318
319 # Flush pre for the memtable flush.
320 def flush_pre(
321 +dir: String,
322 +entries: List<&2, MemTable.Entry>,
323 +levels: List<&2, List<&2, Sstable.Table>>,
324 +flushed: Nat,
325 +manifest_token: String,
326 +batch_cap: Nat,
327 ) -> IO(Result<&1, &1, U32 & String, Db.Db>):
328 +tbl = Sstable.build(entries, 0n, List.length(&2, MemTable.Entry, entries))
329 +name = Policy.table_name(flushed)
330 +l0dir = dir ++ "/l0"
331 flush_goA(dir, l0dir, l0dir ++ "/" ++ name ++ ".tmp", l0dir ++ "/" ++ name, dir ++ "/MANIFEST", dir ++ "/MANIFEST.tmp", Db.table_entries(tbl), 0, tbl, name, levels, flushed,
332 manifest_token, MemTable.MT{Nil{}}, batch_cap)
333
334 # Frozen-path flush MERGES mem (newer) over frozen (older) into one L0 table
335 # and drains both: the WAL is truncated on publish, so anything left in mem
336 # would otherwise exist nowhere on disk (loss window caught by 20485 smoke).
337 def flush_frozen(
338 +dir: String,
339 +mem: MemTable.MemTable,
340 +entries: List<&2, MemTable.Entry>,
341 +levels: List<&2, List<&2, Sstable.Table>>,
342 +flushed: Nat,
343 +manifest_token: String,
344 +batch_cap: Nat,
345 ) -> IO(Result<&1, &1, U32 & String, Db.Db>):
346 match mem:
347 case MemTable.MT{live}:
348 +merged = SortedRun.merge_many_newest(Con{live, Con{entries, Nil{}}})
349 +tbl = Sstable.build(merged, 0n, List.length(&2, MemTable.Entry, merged))
350 +name = Policy.table_name(flushed)
351 +l0dir = dir ++ "/l0"
352 flush_goA(dir, l0dir, l0dir ++ "/" ++ name ++ ".tmp", l0dir ++ "/" ++ name, dir ++ "/MANIFEST", dir ++ "/MANIFEST.tmp", Db.table_entries(tbl), 0, tbl, name, levels, flushed,
353 manifest_token, MemTable.MT{Nil{}}, batch_cap)
354
355 # Flush pick for the memtable flush.
356 def flush_pick(
357 +frozen: MemTable.MemTable,
358 +mem: MemTable.MemTable,
359 +dir: String,
360 +levels: List<&2, List<&2, Sstable.Table>>,
361 +flushed: Nat,
362 +manifest_token: String,
363 +batch_cap: Nat,
364 +bcache: List<&2, Db.BEntry>,
365 ) -> IO(Result<&1, &1, U32 & String, Db.Db>):
366 match frozen mem:
367 case MemTable.MT{Nil{}} MemTable.MT{Nil{}}:
368 IO.pure(Result<&1, &1, U32 & String, Db.Db>, Done{Db.Db{dir, MemTable.MT{Nil{}}, MemTable.MT{Nil{}}, batch_cap, bcache, levels, flushed, manifest_token, 0n, 0n}})
369 case MemTable.MT{Nil{}} MemTable.MT{Con{e, t}}:
370 flush_pre(dir, Con{e, t}, levels, flushed, manifest_token, batch_cap)
371 case MemTable.MT{Con{fe, ft}} _:
372 flush_frozen(dir, mem, Con{fe, ft}, levels, flushed, manifest_token, batch_cap)
373
374 # Handle flush in the memtable flush.
375 def flush(+db: Db.Db) -> IO(Result<&1, &1, U32 & String, Db.Db>):
376 match db:
377 case Db.Db{dir, mem, frozen, batch_cap, bcache, levels, flushed, manifest_token, mem_count, frozen_count}:
378 flush_pick(frozen, mem, dir, levels, flushed, manifest_token, batch_cap, bcache)
379
380 # --- Closed-vector laws live in laws/Flush.bend (spec law 6) ---