nx_store_janitor.nx source
↩ module page · 428 lines · 19855 B
1// nx_store_janitor.nx -- reclaim SUPERSEDED seg-store segment files. The janitor pass that seq724/RK013
2// named as reclaimable and nobody had built.
3//
4// MEASURED 2026-07-30 BEFORE WRITING THIS: knowledge/store holds 63,669 MB across 14,585 segment .docs files,
5// while all 1,088 manifests TOGETHER reference only 3,031 live segments. debt-manifest.txt lists exactly TWO
6// segments while 2,055 debt-seg-*.docs exist on disk, ~2.4 GB for one plane. So roughly 79 percent of the
7// information plane's bytes are segments no reader can ever reach: the seg-store is append-only and every
8// commit writes a NEW segment, so superseded generations simply accumulate forever.
9//
10// WHY THIS IS SAFE BY CONSTRUCTION: the manifest IS the definition of live. ss_manifest/ss_scan_seglist read
11// ONLY segments listed there, so a file whose segid is absent from its own plane's manifest is unreachable BY
12// DEFINITION, not by inference. That is the whole safety argument and it is why this tool refuses to run when
13// it cannot read the manifest -- an unknown live set means an unknown safe set.
14//
15// RULE 13 -- MOVE ASIDE, NEVER DELETE. Superseded files are RENAMED into knowledge/store/retired/ (the
16// convention already present on the box). Nothing is unlinked, so a mistake is reversible by renaming back.
17// DRY RUN IS THE DEFAULT: it reports what it WOULD move and touches nothing. Pass 'apply' to actually move.
18//
19// 2026-08-18 -- THE BIRTH RACE, MEASURED TWICE AND CLOSED BY CONSTRUCTION. ss_commit_body writes the four
20// segment files (.docs/.idx/.pos/.imp) FIRST and appends the manifest line SECOND, and every new segid is
21// max(manifest)+1 under the plane lock. So a segment being born is, for a moment, an unlisted file set with an
22// id ABOVE the manifest's max. This janitor read the manifest, then walked, and moved exactly such a birth
23// (comparewatch-seg-1786828803 on 08-16/17, debt-seg-1787064612 on 08-18); its post-sweep race check then put
24// back ONLY the .docs, so every reader saw a live segment with no index: ss_get still answered q:n while
25// sts_load resolved zero rows -> "lossy load"/SHORTFALL refusals on the estate's debt board and hive plane, and
26// one sibling seat's debt row was overwritten during the hand repair. Two fixes, both here:
27// 1. AN UNLISTED SEGMENT WHOSE ID IS ABOVE THE MANIFEST'S MAX IS A BIRTH IN FLIGHT, NEVER GARBAGE. It is
28// counted (inflight_files=, in files like every other counter on the line) and left alone; once the plane's max passes it, it is either listed (live)
29// or a crashed birth (swept on a later beat). No lock is needed: the ordering is the store's own commit
30// protocol.
31// 2. THE POST-SWEEP RESTORE PUTS BACK EVERY FILE OF A LIVE SEGMENT, not the record alone: it walks retired/
32// and renames back each <base>seg-<id>.<ext> whose id is live NOW and whose live path is absent -- no
33// extension list here (the store owns its layout), and never a clobber. The same routine is the `heal`
34// verb, so the two hand repairs above become one call: nx_store_janitor <prefix> heal.
35// And the manifest is read whole via sys_read_file -- the old fixed 256 KiB read plus an 8192-line table
36// were SILENT CAPS: a manifest past either would have swept live segments as dead.
37//
38// EXIT: 0 clean/reported - 2 usage - 3 REFUSED (manifest unreadable or empty live set) - 4 nothing superseded
39// license_tier: ORIGINAL No hw writes (Rule 26).
40import "nx_syscalls.nx"
41
42const SJ_DBUF: i64 = 262144
43const SJ_PATH: i64 = 1024
44const SJ_MSG: i64 = 8192
45const SJ_STAT: i64 = 256
46const SJ_STAT_SIZE_OFF: i64 = 48
47const SJ_NL: i64 = 10
48const SJ_DOT: i64 = 46
49const SJ_SLASH: i64 = 47
50const SJ_STDOUT: i64 = 1
51const SJ_EXIT_USAGE: i64 = 2
52const SJ_EXIT_REFUSED: i64 = 3
53const SJ_EXIT_NONE: i64 = 4
54const SJ_MB: i64 = 1048576
55const SJ_D0: i64 = 48 // '0'
56const SJ_D9: i64 = 57 // '9'
57const SJ_BASE10: i64 = 10
58const SJ_SEGPFX: i64 = 4 // "seg-" -- the token prefix every segment name carries
59const SJ_CH_S: i64 = 115 // 's'
60const SJ_CH_E: i64 = 101 // 'e'
61const SJ_CH_G: i64 = 103 // 'g'
62const SJ_CH_DASH: i64 = 45 // '-'
63const SJ_CH_A: i64 = 97 // 'a' (apply)
64const SJ_CH_H: i64 = 104 // 'h' (heal)
65const SJ_I64: i64 = 8
66
67func sj_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
68func sj_cat(d: *u8, off: i64, s: *u8) -> i64 {
69 var i: i64 = 0
70 while s[i] != (0 as u8) { d[off + i] = s[i]; i = i + 1 }
71 return off + i
72}
73func sj_num(d: *u8, off: i64, v: i64) -> i64 {
74 var m: i64 = v
75 var o: i64 = off
76 if m < 0 { m = 0 - m; d[o] = SJ_CH_DASH as u8; o = o + 1 }
77 let t: *u8 = sys_mmap(32)
78 var k: i64 = 0
79 if m == 0 { t[0] = SJ_D0 as u8; k = 1 }
80 while m > 0 { t[k] = (SJ_D0 + (m % SJ_BASE10)) as u8; m = m / SJ_BASE10; k = k + 1 }
81 var i: i64 = 0
82 while i < k { d[o + i] = t[k - 1 - i]; i = i + 1 }
83 sys_munmap(t, 32)
84 return o + k
85}
86// file size via fstatat (st_size at +48), -1 if unstatable
87func sj_size(path: *u8) -> i64 {
88 let st: *u8 = sys_mmap(SJ_STAT)
89 if sys_fstatat(path, st) != 0 { sys_munmap(st, SJ_STAT); return 0 - 1 }
90 let p: *i64 = (st as i64 + SJ_STAT_SIZE_OFF) as *i64
91 let v: i64 = p[0]
92 sys_munmap(st, SJ_STAT)
93 return v
94}
95// decimal id of a "seg-<digits>" token; -1 when it carries no digits or a non-digit
96func sj_segid(tok: *u8, toklen: i64) -> i64 {
97 var v: i64 = 0
98 var any: i64 = 0
99 var i: i64 = SJ_SEGPFX
100 while i < toklen {
101 let c: i64 = tok[i] as i64
102 if c < SJ_D0 { return 0 - 1 }
103 if c > SJ_D9 { return 0 - 1 }
104 v = v * SJ_BASE10 + (c - SJ_D0)
105 any = 1
106 i = i + 1
107 }
108 if any == 0 { return 0 - 1 }
109 return v
110}
111// count newline-terminated non-empty lines (a final line without '\n' counts)
112func sj_lines(b: *u8, n: i64) -> i64 {
113 var cnt: i64 = 0
114 var ls: i64 = 0
115 var ci: i64 = 0
116 while ci <= n {
117 var atend: i64 = 0
118 if ci == n { atend = 1 } else { if b[ci] == (SJ_NL as u8) { atend = 1 } }
119 if atend == 1 { if ci - ls > 0 { cnt = cnt + 1 } ls = ci + 1 }
120 ci = ci + 1
121 }
122 return cnt
123}
124// index a manifest buffer: one "seg-<id>" per line -> (offset,len) tables sized by the caller from
125// sj_lines; returns the live count and writes the highest live id (or -1) into maxout[0]
126func sj_index(b: *u8, n: i64, loff: *i64, llen: *i64, maxout: *i64) -> i64 {
127 var nlive: i64 = 0
128 var mx: i64 = 0 - 1
129 var ls: i64 = 0
130 var ci: i64 = 0
131 while ci <= n {
132 var atend: i64 = 0
133 if ci == n { atend = 1 } else { if b[ci] == (SJ_NL as u8) { atend = 1 } }
134 if atend == 1 {
135 let ll: i64 = ci - ls
136 if ll > 0 {
137 loff[nlive] = ls
138 llen[nlive] = ll
139 nlive = nlive + 1
140 let id: i64 = sj_segid((b as i64 + ls) as *u8, ll)
141 if id > mx { mx = id }
142 }
143 ls = ci + 1
144 }
145 ci = ci + 1
146 }
147 maxout[0] = mx
148 return nlive
149}
150// 1 iff nm is "<base>seg-..." (a segment file of THIS plane)
151func sj_is_segfile(nm: *u8, base: *u8, blen: i64) -> i64 {
152 var k: i64 = 0
153 while k < blen { if nm[k] != base[k] { return 0 } k = k + 1 }
154 if nm[blen] != (SJ_CH_S as u8) { return 0 }
155 if nm[blen + 1] != (SJ_CH_E as u8) { return 0 }
156 if nm[blen + 2] != (SJ_CH_G as u8) { return 0 }
157 if nm[blen + 3] != (SJ_CH_DASH as u8) { return 0 }
158 return 1
159}
160// length of the segment token in nm starting at blen: up to the first '.'
161func sj_toklen(nm: *u8, blen: i64) -> i64 {
162 var e: i64 = blen
163 while nm[e] != (0 as u8) { if nm[e] == (SJ_DOT as u8) { return e - blen } e = e + 1 }
164 return e - blen
165}
166// 1 iff the token nm[blen..blen+seglen) equals one of the manifest lines
167func sj_islive(nm: *u8, blen: i64, seglen: i64, mbuf: *u8, loff: *i64, llen: *i64, nlive: i64) -> i64 {
168 var q: i64 = 0
169 while q < nlive {
170 if llen[q] == seglen {
171 var same: i64 = 1
172 var z: i64 = 0
173 while z < seglen { if mbuf[loff[q] + z] != nm[blen + z] { same = 0; z = seglen } else { z = z + 1 } }
174 if same == 1 { return 1 }
175 }
176 q = q + 1
177 }
178 return 0
179}
180// HEAL: walk <dir>/retired and rename back every <base>seg-<id>.<ext> whose id is live in the manifest
181// buffer given and whose live path is ABSENT (never a clobber). Counts restored / failed into out[0]/out[1].
182// This is both the post-sweep race repair and the `heal` verb -- one routine, two callers.
183func sj_heal(dir: *u8, base: *u8, blen: i64, mbuf: *u8, loff: *i64, llen: *i64, nlive: i64, out: *i64) -> i64 {
184 out[0] = 0
185 out[1] = 0
186 let rdir: *u8 = sys_mmap(SJ_PATH)
187 var ro: i64 = sj_cat(rdir, 0, dir)
188 ro = sj_cat(rdir, ro, "/retired" as *u8)
189 rdir[ro] = 0 as u8
190 let rfd: i64 = sys_openat_rd(rdir)
191 if rfd < 0 { sys_munmap(rdir, SJ_PATH); return 0 - 1 }
192 let dbuf: *u8 = sys_mmap(SJ_DBUF)
193 let fpath: *u8 = sys_mmap(SJ_PATH)
194 let tpath: *u8 = sys_mmap(SJ_PATH)
195 var go: i64 = 1
196 while go == 1 {
197 let nb: i64 = sys_getdents64(rfd, dbuf, SJ_DBUF)
198 if nb <= 0 { go = 0 } else {
199 var off: i64 = 0
200 while off < nb {
201 let rec: *u8 = (dbuf as i64 + off) as *u8
202 let rl: i64 = dirent_reclen(rec)
203 if rl <= 0 { off = nb } else {
204 let nm: *u8 = dirent_name(rec)
205 if sj_is_segfile(nm, base, blen) == 1 {
206 let seglen: i64 = sj_toklen(nm, blen)
207 if sj_islive(nm, blen, seglen, mbuf, loff, llen, nlive) == 1 {
208 var fo: i64 = sj_cat(fpath, 0, dir)
209 fpath[fo] = SJ_SLASH as u8; fo = fo + 1
210 fo = sj_cat(fpath, fo, nm)
211 fpath[fo] = 0 as u8
212 if sj_size(fpath) < 0 {
213 var to: i64 = sj_cat(tpath, 0, rdir)
214 tpath[to] = SJ_SLASH as u8; to = to + 1
215 to = sj_cat(tpath, to, nm)
216 tpath[to] = 0 as u8
217 if sys_renameat(tpath, fpath) == 0 { out[0] = out[0] + 1 } else { out[1] = out[1] + 1 }
218 }
219 }
220 }
221 off = off + rl
222 }
223 }
224 }
225 }
226 sys_close(rfd)
227 sys_munmap(dbuf, SJ_DBUF)
228 sys_munmap(fpath, SJ_PATH)
229 sys_munmap(tpath, SJ_PATH)
230 sys_munmap(rdir, SJ_PATH)
231 return 0
232}
233
234func main(argc: i64, argv: *i64) -> i64 {
235 let msg: *u8 = sys_mmap(SJ_MSG)
236 if argc < 2 {
237 var u: i64 = sj_cat(msg, 0, "usage: nx_store_janitor <knowledge/store/prefix-> [apply|heal]\n DRY RUN by default. apply: moves segments absent from the plane manifest into knowledge/store/retired/ (a birth in flight -- unlisted id above the manifest max -- is left alone).\n heal: renames back from retired/ every file of a segment the manifest lists NOW whose live path is absent.\n" as *u8)
238 sys_write(SJ_STDOUT, msg, u); return SJ_EXIT_USAGE
239 }
240 let prefix: *u8 = argv[1] as *u8
241 var doapply: i64 = 0
242 var doheal: i64 = 0
243 if argc >= 3 {
244 let a: *u8 = argv[2] as *u8
245 if a[0] == (SJ_CH_A as u8) { doapply = 1 }
246 if a[0] == (SJ_CH_H as u8) { doheal = 1 }
247 }
248 let plen: i64 = sj_len(prefix)
249
250 // split prefix into dir and basename (everything after the last '/')
251 var cut: i64 = 0 - 1
252 var i0: i64 = 0
253 while i0 < plen { if prefix[i0] == (SJ_SLASH as u8) { cut = i0 } i0 = i0 + 1 }
254 let dir: *u8 = sys_mmap(SJ_PATH)
255 let base: *u8 = sys_mmap(SJ_PATH)
256 var di: i64 = 0
257 while di < cut { dir[di] = prefix[di]; di = di + 1 }
258 dir[di] = 0 as u8
259 var bi: i64 = 0
260 while cut + 1 + bi < plen { base[bi] = prefix[cut + 1 + bi]; bi = bi + 1 }
261 base[bi] = 0 as u8
262 let blen: i64 = bi
263
264 // ---- READ THE MANIFEST WHOLE. This is the definition of live; without it there is no safe set.
265 let mpath: *u8 = sys_mmap(SJ_PATH)
266 var mo: i64 = sj_cat(mpath, 0, prefix)
267 mo = sj_cat(mpath, mo, "manifest.txt" as *u8)
268 mpath[mo] = 0 as u8
269 let mlen: *i64 = sys_mmap(SJ_I64) as *i64
270 let mbuf: *u8 = sys_read_file(mpath, mlen)
271 let mn: i64 = mlen[0]
272 var nlive: i64 = 0
273 var maxlive: i64 = 0 - 1
274 let maxp: *i64 = sys_mmap(SJ_I64) as *i64
275 var loff: *i64 = 0 as *i64
276 var llen: *i64 = 0 as *i64
277 if mn > 0 {
278 let nl: i64 = sj_lines(mbuf, mn)
279 if nl > 0 {
280 loff = sys_mmap(SJ_I64 * nl) as *i64
281 llen = sys_mmap(SJ_I64 * nl) as *i64
282 nlive = sj_index(mbuf, mn, loff, llen, maxp)
283 maxlive = maxp[0]
284 }
285 }
286 var o: i64 = sj_cat(msg, 0, "NX-STORE-JANITOR prefix=" as *u8)
287 o = sj_cat(msg, o, prefix)
288 o = sj_cat(msg, o, " live_segments=" as *u8); o = sj_num(msg, o, nlive)
289 if nlive == 0 {
290 o = sj_cat(msg, o, " verdict=REFUSED-manifest-unreadable-or-empty-UNKNOWN-LIVE-SET-nothing-touched\n" as *u8)
291 sys_write(SJ_STDOUT, msg, o); return SJ_EXIT_REFUSED
292 }
293 o = sj_cat(msg, o, " max_live_id=" as *u8); o = sj_num(msg, o, maxlive)
294
295 // ---- HEAL VERB: put back every retired file of a segment that is live NOW; nothing else is touched.
296 if doheal == 1 {
297 let hout: *i64 = sys_mmap(SJ_I64 + SJ_I64) as *i64
298 let hrc: i64 = sj_heal(dir, base, blen, mbuf, loff, llen, nlive, hout)
299 if hrc < 0 {
300 o = sj_cat(msg, o, " verdict=REFUSED-cannot-open-retired-dir\n" as *u8)
301 sys_write(SJ_STDOUT, msg, o); return SJ_EXIT_REFUSED
302 }
303 o = sj_cat(msg, o, " healed=" as *u8); o = sj_num(msg, o, hout[0])
304 o = sj_cat(msg, o, " heal_failed=" as *u8); o = sj_num(msg, o, hout[1])
305 o = sj_cat(msg, o, " mode=HEAL-live-segment-files-renamed-back-from-retired\n" as *u8)
306 sys_write(SJ_STDOUT, msg, o)
307 if hout[1] > 0 { return SJ_EXIT_REFUSED }
308 return 0
309 }
310
311 // ---- walk the store dir; any <base>seg-<id>.<ext> whose seg-<id> is absent from the manifest is dead,
312 // UNLESS its id is above the manifest's max: that is a segment being born (files land before the
313 // manifest line, ids are max+1 under the plane lock) and it is left for the writer to list.
314 let dbuf: *u8 = sys_mmap(SJ_DBUF)
315 let fpath: *u8 = sys_mmap(SJ_PATH)
316 let tpath: *u8 = sys_mmap(SJ_PATH)
317 var dead: i64 = 0
318 var deadbytes: i64 = 0
319 var moved: i64 = 0
320 var failed: i64 = 0
321 var inflight: i64 = 0
322 let fd: i64 = sys_openat_rd(dir)
323 if fd < 0 {
324 o = sj_cat(msg, o, " verdict=REFUSED-cannot-open-dir\n" as *u8)
325 sys_write(SJ_STDOUT, msg, o); return SJ_EXIT_REFUSED
326 }
327 var go: i64 = 1
328 while go == 1 {
329 let nb: i64 = sys_getdents64(fd, dbuf, SJ_DBUF)
330 if nb <= 0 { go = 0 } else {
331 var off: i64 = 0
332 while off < nb {
333 let rec: *u8 = (dbuf as i64 + off) as *u8
334 let rl: i64 = dirent_reclen(rec)
335 if rl <= 0 { off = nb } else {
336 let nm: *u8 = dirent_name(rec)
337 if sj_is_segfile(nm, base, blen) == 1 {
338 let seglen: i64 = sj_toklen(nm, blen)
339 if sj_islive(nm, blen, seglen, mbuf, loff, llen, nlive) == 0 {
340 let sid: i64 = sj_segid((nm as i64 + blen) as *u8, seglen)
341 if sid > maxlive {
342 inflight = inflight + 1
343 } else {
344 var fo: i64 = sj_cat(fpath, 0, dir)
345 fpath[fo] = SJ_SLASH as u8; fo = fo + 1
346 fo = sj_cat(fpath, fo, nm)
347 fpath[fo] = 0 as u8
348 let sz: i64 = sj_size(fpath)
349 dead = dead + 1
350 if sz > 0 { deadbytes = deadbytes + sz }
351 if doapply == 1 {
352 var to: i64 = sj_cat(tpath, 0, dir)
353 to = sj_cat(tpath, to, "/retired/" as *u8)
354 to = sj_cat(tpath, to, nm)
355 tpath[to] = 0 as u8
356 if sys_renameat(fpath, tpath) == 0 { moved = moved + 1 } else { failed = failed + 1 }
357 }
358 }
359 }
360 }
361 off = off + rl
362 }
363 }
364 }
365 }
366 sys_close(fd)
367
368 // ★★★POST-SWEEP LIVE-SET VERIFICATION (the second line of defence behind the birth rule above). The
369 // manifest was read at the START; re-read it as it is NOW and confirm every segment it lists still has its
370 // .docs on disk. If one is missing we moved a live file: rename back EVERY file of every live segment that
371 // sits in retired/ (sj_heal -- the record AND its index, which is what the .docs-only restore of the first
372 // version got wrong) and fail LOUD.
373 // A JANITOR THAT CAN RACE A WRITER MUST CHECK THE WRITER'S ANSWER AFTER IT ACTS, NOT ONLY BEFORE.
374 var restored: i64 = 0
375 var lost: i64 = 0
376 if doapply == 1 {
377 let m2len: *i64 = sys_mmap(SJ_I64) as *i64
378 let m2: *u8 = sys_read_file(mpath, m2len)
379 let m2n: i64 = m2len[0]
380 if m2n > 0 {
381 let nl2: i64 = sj_lines(m2, m2n)
382 if nl2 > 0 {
383 let loff2: *i64 = sys_mmap(SJ_I64 * nl2) as *i64
384 let llen2: *i64 = sys_mmap(SJ_I64 * nl2) as *i64
385 let max2: *i64 = sys_mmap(SJ_I64) as *i64
386 let nlive2: i64 = sj_index(m2, m2n, loff2, llen2, max2)
387 var missing: i64 = 0
388 var q2: i64 = 0
389 while q2 < nlive2 {
390 var fo2: i64 = sj_cat(fpath, 0, prefix)
391 var z2: i64 = 0
392 while z2 < llen2[q2] { fpath[fo2 + z2] = m2[loff2[q2] + z2]; z2 = z2 + 1 }
393 fo2 = fo2 + llen2[q2]
394 fo2 = sj_cat(fpath, fo2, ".docs" as *u8)
395 fpath[fo2] = 0 as u8
396 if sj_size(fpath) < 0 { missing = missing + 1 }
397 q2 = q2 + 1
398 }
399 if missing > 0 {
400 let hout: *i64 = sys_mmap(SJ_I64 + SJ_I64) as *i64
401 sj_heal(dir, base, blen, m2, loff2, llen2, nlive2, hout)
402 restored = hout[0]
403 lost = hout[1]
404 if restored == 0 { lost = lost + missing }
405 }
406 }
407 }
408 }
409 o = sj_cat(msg, o, " superseded_files=" as *u8); o = sj_num(msg, o, dead)
410 o = sj_cat(msg, o, " inflight_files=" as *u8); o = sj_num(msg, o, inflight)
411 if restored > 0 { o = sj_cat(msg, o, " RACE-RESTORED=" as *u8); o = sj_num(msg, o, restored) }
412 if lost > 0 { o = sj_cat(msg, o, " **LOST-LIVE-SEGMENT=" as *u8); o = sj_num(msg, o, lost) }
413 o = sj_cat(msg, o, " reclaimable_MB=" as *u8); o = sj_num(msg, o, deadbytes / SJ_MB)
414 if doapply == 1 {
415 o = sj_cat(msg, o, " MOVED=" as *u8); o = sj_num(msg, o, moved)
416 o = sj_cat(msg, o, " failed=" as *u8); o = sj_num(msg, o, failed)
417 o = sj_cat(msg, o, " mode=APPLY-moved-to-retired-NOT-deleted" as *u8)
418 } else {
419 o = sj_cat(msg, o, " mode=DRY-RUN-nothing-touched-pass-apply-to-move" as *u8)
420 }
421 var rc: i64 = 0
422 if dead == 0 { rc = SJ_EXIT_NONE }
423 if failed > 0 { rc = SJ_EXIT_REFUSED }
424 if lost > 0 { rc = SJ_EXIT_REFUSED }
425 msg[o] = SJ_NL as u8; o = o + 1
426 sys_write(SJ_STDOUT, msg, o)
427 return rc
428}