code wiki / (root) / nx_store_janitor.nx

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}