cctally 1.113.0 → 1.115.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.
Files changed (97) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +32 -8
  4. package/bin/_cctally_cache.py +6035 -722
  5. package/bin/_cctally_completion.py +1287 -0
  6. package/bin/_cctally_config.py +37 -0
  7. package/bin/_cctally_core.py +400 -2
  8. package/bin/_cctally_dashboard.py +98 -20
  9. package/bin/_cctally_dashboard_conversation.py +292 -23
  10. package/bin/_cctally_dashboard_envelope.py +16 -3
  11. package/bin/_cctally_dashboard_perf.py +128 -9
  12. package/bin/_cctally_dashboard_sources.py +285 -213
  13. package/bin/_cctally_db.py +1177 -23
  14. package/bin/_cctally_diagnosis_sources.py +22 -4
  15. package/bin/_cctally_doctor.py +572 -45
  16. package/bin/_cctally_five_hour.py +84 -33
  17. package/bin/_cctally_forecast.py +108 -15
  18. package/bin/_cctally_journal.py +3950 -116
  19. package/bin/_cctally_journal_repair.py +101 -12
  20. package/bin/_cctally_milestone_history.py +82 -9
  21. package/bin/_cctally_parser.py +42 -0
  22. package/bin/_cctally_percent_breakdown.py +3 -2
  23. package/bin/_cctally_project.py +8 -2
  24. package/bin/_cctally_quarantine.py +652 -0
  25. package/bin/_cctally_quota.py +227 -37
  26. package/bin/_cctally_quota_model.py +360 -83
  27. package/bin/_cctally_record.py +691 -149
  28. package/bin/_cctally_rederive.py +320 -41
  29. package/bin/_cctally_refresh.py +84 -13
  30. package/bin/_cctally_source_analytics.py +83 -17
  31. package/bin/_cctally_statusline.py +6154 -517
  32. package/bin/_cctally_store.py +367 -53
  33. package/bin/_cctally_transcript.py +8 -7
  34. package/bin/_cctally_tui.py +61 -56
  35. package/bin/_cctally_weekrefs.py +128 -21
  36. package/bin/_lib_account_binding.py +152 -0
  37. package/bin/_lib_account_protocol.py +386 -0
  38. package/bin/_lib_aggregators.py +4 -0
  39. package/bin/_lib_cache_coverage.py +13 -1
  40. package/bin/_lib_cache_report.py +35 -6
  41. package/bin/_lib_cache_writer_lock.py +19 -3
  42. package/bin/_lib_clone_copy.py +142 -0
  43. package/bin/_lib_codex_conversation.py +32 -0
  44. package/bin/_lib_codex_conversation_query.py +297 -49
  45. package/bin/_lib_codex_conversation_watch.py +59 -14
  46. package/bin/_lib_codex_drain_admission.py +214 -0
  47. package/bin/_lib_codex_metadata.py +18 -1
  48. package/bin/_lib_codex_publication_barrier.py +277 -0
  49. package/bin/_lib_codex_rollout_container.py +502 -0
  50. package/bin/_lib_conversation_dispatch.py +3 -0
  51. package/bin/_lib_conversation_query.py +106 -59
  52. package/bin/_lib_conversation_retention.py +1605 -640
  53. package/bin/_lib_cost_provenance.py +53 -2
  54. package/bin/_lib_detector_access_registry.py +3817 -0
  55. package/bin/_lib_doctor.py +714 -69
  56. package/bin/_lib_five_hour.py +126 -0
  57. package/bin/_lib_fmt.py +11 -0
  58. package/bin/_lib_ingest_frontier.py +414 -20
  59. package/bin/_lib_journal.py +141 -10
  60. package/bin/_lib_jsonl.py +5 -4
  61. package/bin/_lib_legacy_spool.py +126 -0
  62. package/bin/_lib_legacy_writer_evidence.py +136 -0
  63. package/bin/_lib_merge_gate.py +231 -86
  64. package/bin/_lib_meter_rate_change.py +10 -0
  65. package/bin/_lib_outcome_quarantine.py +690 -0
  66. package/bin/_lib_perf.py +27 -1
  67. package/bin/_lib_pricing.py +199 -9
  68. package/bin/_lib_pricing_check.py +20 -4
  69. package/bin/_lib_quarantine.py +154 -0
  70. package/bin/_lib_quota_calibration.py +53 -8
  71. package/bin/_lib_quota_copy.py +2 -1
  72. package/bin/_lib_quota_model.py +288 -46
  73. package/bin/_lib_reclaim_planner.py +792 -0
  74. package/bin/_lib_rederive.py +319 -82
  75. package/bin/_lib_segment_summary.py +35 -2
  76. package/bin/_lib_snapshot_cache.py +21 -0
  77. package/bin/_lib_sqlite_close.py +81 -0
  78. package/bin/_lib_statusline_candidates.py +292 -23
  79. package/bin/_lib_subscription_weeks.py +100 -17
  80. package/bin/_lib_tick_stats.py +217 -17
  81. package/bin/_lib_wal_checkpoint.py +1077 -0
  82. package/bin/_lib_write_budget.py +483 -0
  83. package/bin/_lib_write_io.py +703 -0
  84. package/bin/cctally +31 -1
  85. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  86. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  87. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  88. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  89. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  90. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  91. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  92. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  93. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  94. package/dashboard/static/dashboard.html +2 -2
  95. package/package.json +19 -1
  96. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  97. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -0,0 +1,792 @@
1
+ """#901 Q9: the reclaim chunk planner and its WAL-snapshot read helper.
2
+
3
+ A reclaim chunk (spec §5.4) is one `BEGIN IMMEDIATE` transaction on the
4
+ reclaim connection: plan, then the planned `PRAGMA incremental_vacuum(1)`
5
+ steps, then the frozen reservation's state write, then `COMMIT`. This module
6
+ computes the plan: the pages the chunk's steps may write, bounded BEFORE the
7
+ chunk mutates anything, from the original page images.
8
+
9
+ The bound (`dc5`, `dc6`, audited against SQLite `btree.c`, `pager.c` and
10
+ `wal.c` at 3.37.2, 3.53.4 and 3.54.0, where `finalDbSize`,
11
+ `sqlite3BtreeIncrVacuum`, `allocateBtreePage`, `setChildPtrmaps`,
12
+ `ptrmapPutOvflPtr`, `ptrmapPageno`, `btreeGetUnusedPage` and
13
+ `autoVacuumCommit` are identical and `incrVacuumStep` / `modifyPagePointer`
14
+ differ only by corruption checks):
15
+
16
+ * Each step removes the file's last page, skipping pointer-map and
17
+ pending-byte pages, exactly as `incrVacuumStep` does.
18
+ * ID (identified pages) starts as {page 1}. A free tail page adds 2 to UNID
19
+ (its trunk, or a promoted trunk and its predecessor), except that until the
20
+ chunk's first trunk removal or relocation a free LEAF adds its own trunk to
21
+ ID (the hybrid refinement, `dc6` F2).
22
+ * A relocated page (overflow or b-tree) adds 4 to UNID (up to two freelist
23
+ pages, the destination and the destination's pointer-map page), adds its
24
+ pointer-map parent to ID when the parent lies at or below the chunk's new
25
+ end (else 1 to UNID), and for each child, overflow-chain head or next
26
+ overflow page x adds x's pointer-map page to ID when x lies at or below the
27
+ new end (else 1 to UNID). The destination is allocated at or below the
28
+ full-vacuum end (`finalDbSize`), which the preconditions keep below every
29
+ tail page; a commit drops every page past its new end (`pagerWalFrames`).
30
+ * The original images suffice: an earlier step changes pointer values but
31
+ never a tail page's type, cell count or overflow-ness, and every reference
32
+ whose identity an earlier step can change lies past the new end, counted in
33
+ UNID (`dc6` F1).
34
+
35
+ ID and UNID are recomputed for every candidate prefix against that prefix's
36
+ own new end (`dc6` F3). The chunk grows one step at a time, up to 16 steps,
37
+ while the whole reservation (|ID| + UNID + 1) x (2P + 24) + F_r stays at most
38
+ the budget (4 MiB); a single step over the budget runs alone.
39
+
40
+ The read path (`WalSnapshotReader`, `dc6` F4) runs ONLY in a fresh helper
41
+ subprocess (`python3 _lib_reclaim_planner.py --plan`, JSON in on stdin,
42
+ JSON out on stdout), never in the dashboard process: closing a raw
43
+ descriptor of `conversations.db` or its `-shm` there would release the POSIX
44
+ locks SQLite holds on them. It validates both wal-index header copies, scans
45
+ and checksums the WAL frames through exactly `mxFrame` (ignoring any frame
46
+ past it, even a checksum-valid commit), reads each page from its latest frame
47
+ at or below `mxFrame` and otherwise from the main file, which a concurrent
48
+ PASSIVE checkpoint never changes for such a page, and rechecks the header and
49
+ the file identities before returning the plan. Every read counts against a
50
+ byte and time budget inside the 2 s secondary deadline.
51
+
52
+ Any inspection failure is a `ReclaimPlanRefused(reason)` with a typed reason;
53
+ the caller rolls back with no vacuum and no charge. Stdlib only.
54
+ """
55
+ from __future__ import annotations
56
+
57
+ import dataclasses
58
+ import hashlib
59
+ import json
60
+ import os
61
+ import stat
62
+ import struct
63
+ import sys
64
+ import time
65
+
66
+ PLANNER_VERSION = 1
67
+ #: The SQLite releases whose incremental-vacuum sources were audited (spec
68
+ #: §1.4, G3q). A runtime outside the range refuses reclaim until G3q passes on
69
+ #: that version and a source review widens it.
70
+ AUDITED_SQLITE_MIN = (3, 37, 2)
71
+ AUDITED_SQLITE_MAX = (3, 54, 0)
72
+
73
+ WAL_INDEX_VERSION = 3007000
74
+ WAL_FORMAT_VERSION = 3007000
75
+ WAL_MAGIC_LE = 0x377F0682
76
+ WAL_MAGIC_BE = 0x377F0683
77
+ WAL_HEADER_BYTES = 32
78
+ FRAME_HEADER_BYTES = 24
79
+ WAL_INDEX_HEADER_BYTES = 48
80
+ PENDING_BYTE = 0x40000000
81
+
82
+ PTRMAP_ROOTPAGE = 1
83
+ PTRMAP_FREEPAGE = 2
84
+ PTRMAP_OVERFLOW1 = 3
85
+ PTRMAP_OVERFLOW2 = 4
86
+ PTRMAP_BTREE = 5
87
+
88
+ #: Every typed refusal the planner raises (the caller adds the connection,
89
+ #: record and helper reasons of spec §5.4 "Refusal").
90
+ REFUSAL_REASONS = (
91
+ "wal_index_unreadable", # missing, inconsistent or unsupported wal-index
92
+ "wal_checksum_mismatch", # a WAL header or frame checksum or salt
93
+ "wal_endpoint_mismatch", # the WAL's committed end disagrees with the index
94
+ "read_budget_exhausted", # the byte or time budget ran out
95
+ "unsupported_geometry", # auto-vacuum, reserved bytes, free pages, root page
96
+ "malformed_page", # an unexpected page type or a malformed cell
97
+ "cyclic_freelist", # a trunk chain that loops or miscounts
98
+ "identity_changed", # the snapshot or a file changed under the plan
99
+ "sqlite_unaudited", # a runtime outside the audited range
100
+ )
101
+
102
+ _U32 = 0xFFFFFFFF
103
+
104
+
105
+ class ReclaimPlanRefused(Exception):
106
+ """The planner could not bound the chunk; carries a typed reason."""
107
+
108
+ def __init__(self, reason: str, detail: str = ""):
109
+ super().__init__(f"{reason}: {detail}" if detail else reason)
110
+ self.reason = reason
111
+ self.detail = detail
112
+
113
+
114
+ def sqlite_audited(version_info) -> bool:
115
+ return AUDITED_SQLITE_MIN <= tuple(version_info)[:3] <= AUDITED_SQLITE_MAX
116
+
117
+
118
+ # ── pure geometry (mirrors btree.c) ───────────────────────────────────────
119
+
120
+ def pending_byte_page(page_size: int) -> int:
121
+ return PENDING_BYTE // int(page_size) + 1
122
+
123
+
124
+ def ptrmap_pageno(page: int, usable_size: int, page_size: int) -> int:
125
+ """`ptrmapPageno`: the pointer-map page holding `page`'s entry."""
126
+ if page < 2:
127
+ return 0
128
+ per = usable_size // 5 + 1
129
+ ret = ((page - 2) // per) * per + 2
130
+ if ret == pending_byte_page(page_size):
131
+ ret += 1
132
+ return ret
133
+
134
+
135
+ def is_ptrmap(page: int, usable_size: int, page_size: int) -> bool:
136
+ return page >= 2 and ptrmap_pageno(page, usable_size, page_size) == page
137
+
138
+
139
+ def final_db_size(n_orig: int, n_free: int, usable_size: int,
140
+ page_size: int) -> int:
141
+ """`finalDbSize`: the file's end after a full incremental vacuum."""
142
+ entries = usable_size // 5
143
+ n_ptrmap = ((n_free - n_orig + ptrmap_pageno(n_orig, usable_size,
144
+ page_size) + entries)
145
+ // entries)
146
+ fin = n_orig - n_free - n_ptrmap
147
+ pending = pending_byte_page(page_size)
148
+ if n_orig > pending and fin < pending:
149
+ fin -= 1
150
+ while is_ptrmap(fin, usable_size, page_size) or fin == pending:
151
+ fin -= 1
152
+ return fin
153
+
154
+
155
+ @dataclasses.dataclass(frozen=True)
156
+ class TailStep:
157
+ """One `incremental_vacuum(1)` step: the page it moves (None when the
158
+ file's last page is a pointer-map or pending-byte page) and the file's
159
+ end after it."""
160
+ page: "int | None"
161
+ n_fin: int
162
+ new_end: int
163
+
164
+
165
+ def tail_steps(page_count: int, freelist_count: int, max_steps: int, *,
166
+ usable_size: int, page_size: int) -> "list[TailStep]":
167
+ """The steps `incrVacuumStep` takes from the file's end, one page per
168
+ step, skipping pointer-map and pending-byte pages; stops when the
169
+ freelist would be empty (SQLITE_DONE)."""
170
+ pending = pending_byte_page(page_size)
171
+ last, free, out = int(page_count), int(freelist_count), []
172
+ for _ in range(int(max_steps)):
173
+ if free <= 0 or last <= 1:
174
+ break
175
+ if free >= last:
176
+ raise ReclaimPlanRefused("unsupported_geometry",
177
+ "freelist count >= page count")
178
+ n_fin = final_db_size(last, free, usable_size, page_size)
179
+ if last > n_fin and not (is_ptrmap(last, usable_size, page_size)
180
+ or last == pending):
181
+ page = last
182
+ free -= 1
183
+ elif last <= n_fin:
184
+ break
185
+ else:
186
+ page = None
187
+ last -= 1
188
+ while last == pending or is_ptrmap(last, usable_size, page_size):
189
+ last -= 1
190
+ out.append(TailStep(page, n_fin, last))
191
+ return out
192
+
193
+
194
+ def _varint(image: bytes, offset: int) -> "tuple[int, int]":
195
+ value = 0
196
+ for k in range(8):
197
+ byte = image[offset + k]
198
+ value = (value << 7) | (byte & 0x7F)
199
+ if not byte & 0x80:
200
+ return value, offset + k + 1
201
+ return (value << 8) | image[offset + 8], offset + 9
202
+
203
+
204
+ def btree_refs(image: bytes, usable_size: int, page_count: int):
205
+ """`(kind, children, overflow_heads)` of a non-page-1 b-tree page image,
206
+ by the file format's local-payload rule. Raises `malformed_page` on an
207
+ unknown page type or a cell outside the page."""
208
+ try:
209
+ kind = image[0]
210
+ if kind not in (2, 5, 10, 13):
211
+ raise ReclaimPlanRefused("malformed_page", f"page type {kind}")
212
+ interior = kind in (2, 5)
213
+ header = 12 if interior else 8
214
+ ncell = struct.unpack(">H", image[3:5])[0]
215
+ if header + 2 * ncell > usable_size:
216
+ raise ReclaimPlanRefused("malformed_page", "cell count")
217
+ min_local = (usable_size - 12) * 32 // 255 - 23
218
+ max_local = (usable_size - 35 if kind == 13
219
+ else (usable_size - 12) * 64 // 255 - 23)
220
+ children, heads = [], []
221
+ for i in range(ncell):
222
+ ptr = struct.unpack(">H", image[header + 2 * i:header + 2 * i + 2])[0]
223
+ if not header + 2 * ncell <= ptr < usable_size:
224
+ raise ReclaimPlanRefused("malformed_page", "cell pointer")
225
+ cursor = ptr
226
+ if interior:
227
+ children.append(struct.unpack(">I", image[cursor:cursor + 4])[0])
228
+ cursor += 4
229
+ if kind == 5:
230
+ continue
231
+ payload, cursor = _varint(image, cursor)
232
+ if kind == 13:
233
+ _rowid, cursor = _varint(image, cursor)
234
+ if payload > max_local:
235
+ local = min_local + (payload - min_local) % (usable_size - 4)
236
+ if local > max_local:
237
+ local = min_local
238
+ end = cursor + local
239
+ if end + 4 > usable_size:
240
+ raise ReclaimPlanRefused("malformed_page", "cell overflow")
241
+ heads.append(struct.unpack(">I", image[end:end + 4])[0])
242
+ elif cursor + payload > usable_size:
243
+ raise ReclaimPlanRefused("malformed_page", "cell payload")
244
+ if interior:
245
+ children.append(struct.unpack(">I", image[8:12])[0])
246
+ except (IndexError, struct.error) as exc:
247
+ raise ReclaimPlanRefused("malformed_page", "truncated cell") from exc
248
+ for ref in children + heads:
249
+ if not 2 <= ref <= page_count:
250
+ raise ReclaimPlanRefused("malformed_page", f"reference {ref}")
251
+ return kind, children, heads
252
+
253
+
254
+ # ── the plan ──────────────────────────────────────────────────────────────
255
+
256
+ def page_frame_bytes(page_size: int) -> int:
257
+ return 2 * int(page_size) + 24
258
+
259
+
260
+ def reservation_bytes(identified: int, unidentified: int, page_size: int,
261
+ fixed_bytes: int) -> int:
262
+ """(|ID| + UNID + 1) x (2P + 24) + F_r: the 1 is the state write's one
263
+ page; F_r covers the WAL header and framing (spec §5.4)."""
264
+ return ((int(identified) + int(unidentified) + 1)
265
+ * page_frame_bytes(page_size) + int(fixed_bytes))
266
+
267
+
268
+ @dataclasses.dataclass(frozen=True)
269
+ class ReclaimPlan:
270
+ steps: int
271
+ identified: "tuple[int, ...]"
272
+ unidentified: int
273
+ new_end: int
274
+ page_size: int
275
+ usable_size: int
276
+ page_count: int
277
+ freelist_count: int
278
+ fixed_bytes: int
279
+ reservation_bytes: int
280
+ kinds: "dict"
281
+ digest: str
282
+ inspected_bytes: int = 0
283
+ inspection_ms: int = 0
284
+ reader_status: str = "ok"
285
+ detail: "tuple" = ()
286
+
287
+ def as_wire(self) -> dict:
288
+ wire = dataclasses.asdict(self)
289
+ wire["identified"] = list(self.identified)
290
+ wire["detail"] = [dict(d) for d in self.detail]
291
+ return wire
292
+
293
+ @classmethod
294
+ def from_wire(cls, wire: dict) -> "ReclaimPlan":
295
+ if not isinstance(wire, dict):
296
+ raise ValueError("plan")
297
+ ints = ("steps", "unidentified", "new_end", "page_size",
298
+ "usable_size", "page_count", "freelist_count", "fixed_bytes",
299
+ "reservation_bytes", "inspected_bytes", "inspection_ms")
300
+ for key in ints:
301
+ value = wire.get(key)
302
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
303
+ raise ValueError(key)
304
+ identified = wire.get("identified")
305
+ if (not isinstance(identified, list)
306
+ or not all(isinstance(p, int) and not isinstance(p, bool)
307
+ and p >= 1 for p in identified)):
308
+ raise ValueError("identified")
309
+ kinds = wire.get("kinds")
310
+ if (not isinstance(kinds, dict)
311
+ or set(kinds) != {"free", "overflow", "leaf", "interior"}
312
+ or not all(isinstance(v, int) and v >= 0
313
+ for v in kinds.values())):
314
+ raise ValueError("kinds")
315
+ if not isinstance(wire.get("digest"), str):
316
+ raise ValueError("digest")
317
+ plan = cls(**{key: wire[key] for key in ints},
318
+ identified=tuple(identified), kinds=dict(kinds),
319
+ digest=wire["digest"][:64],
320
+ reader_status=str(wire.get("reader_status") or "ok")[:40],
321
+ detail=tuple(wire.get("detail") or ()))
322
+ if plan.reservation_bytes != reservation_bytes(
323
+ len(plan.identified), plan.unidentified, plan.page_size,
324
+ plan.fixed_bytes) or plan.steps < 1:
325
+ raise ValueError("reservation")
326
+ return plan
327
+
328
+
329
+ def _ptrmap_entry(read_page, page: int, usable_size: int, page_size: int):
330
+ map_page = ptrmap_pageno(page, usable_size, page_size)
331
+ image = read_page(map_page)
332
+ offset = 5 * (page - map_page - 1)
333
+ if offset < 0 or offset + 5 > usable_size:
334
+ raise ReclaimPlanRefused("malformed_page", "pointer-map offset")
335
+ return image[offset], struct.unpack(">I", image[offset + 1:offset + 5])[0]
336
+
337
+
338
+ def scan_freelist(read_page, first_trunk: int, freelist_count: int,
339
+ page_count: int, usable_size: int, tail: "set[int]"):
340
+ """The hybrid refinement's freelist map, keeping only the tail pages'
341
+ entries (`dc6` F2): `({leaf: trunk}, {trunk: (predecessor, k)})`. The
342
+ whole chain is walked to verify it: a loop, a page outside the file or a
343
+ count that disagrees with page 1 refuses as `cyclic_freelist`."""
344
+ leaf_of, trunks = {}, {}
345
+ seen, counted = set(), 0
346
+ trunk, previous = first_trunk, 1
347
+ max_leaves = usable_size // 4 - 2
348
+ while trunk:
349
+ if trunk in seen or not 2 <= trunk <= page_count:
350
+ raise ReclaimPlanRefused("cyclic_freelist", f"trunk {trunk}")
351
+ seen.add(trunk)
352
+ image = read_page(trunk)
353
+ following, count = struct.unpack(">II", image[:8])
354
+ if count > max_leaves:
355
+ raise ReclaimPlanRefused("cyclic_freelist", "trunk leaf count")
356
+ counted += 1 + count
357
+ if counted > freelist_count:
358
+ raise ReclaimPlanRefused("cyclic_freelist", "count overflow")
359
+ if trunk in tail:
360
+ trunks[trunk] = (previous, count)
361
+ for i in range(count):
362
+ leaf = struct.unpack(">I", image[8 + 4 * i:12 + 4 * i])[0]
363
+ if not 2 <= leaf <= page_count:
364
+ raise ReclaimPlanRefused("cyclic_freelist", f"leaf {leaf}")
365
+ if leaf in tail:
366
+ leaf_of[leaf] = trunk
367
+ previous, trunk = trunk, following
368
+ if counted != freelist_count:
369
+ raise ReclaimPlanRefused("cyclic_freelist",
370
+ f"{counted} pages on the chain, "
371
+ f"{freelist_count} counted")
372
+ return leaf_of, trunks
373
+
374
+
375
+ def plan_chunk(read_page, *, page_size: int, max_steps: int,
376
+ budget_bytes: int, fixed_bytes: int) -> ReclaimPlan:
377
+ """Plan one chunk from page images (`read_page(pgno) -> bytes`) of one
378
+ consistent snapshot. Pure apart from `read_page`."""
379
+ page1 = read_page(1)
380
+ if page1[:16] != b"SQLite format 3\x00":
381
+ raise ReclaimPlanRefused("malformed_page", "page 1 header")
382
+ header_size = struct.unpack(">H", page1[16:18])[0]
383
+ header_size = 65536 if header_size == 1 else header_size
384
+ if header_size != page_size:
385
+ raise ReclaimPlanRefused("unsupported_geometry", "page size")
386
+ if page1[20] != 0:
387
+ raise ReclaimPlanRefused("unsupported_geometry", "reserved bytes")
388
+ usable_size = page_size - page1[20]
389
+ largest_root = struct.unpack(">I", page1[52:56])[0]
390
+ incremental = struct.unpack(">I", page1[64:68])[0]
391
+ if largest_root == 0 or incremental != 1:
392
+ raise ReclaimPlanRefused("unsupported_geometry",
393
+ "auto_vacuum is not INCREMENTAL")
394
+ page_count = struct.unpack(">I", page1[28:32])[0]
395
+ first_trunk = struct.unpack(">I", page1[32:36])[0]
396
+ freelist_count = struct.unpack(">I", page1[36:40])[0]
397
+ steps = tail_steps(page_count, freelist_count, max_steps,
398
+ usable_size=usable_size, page_size=page_size)
399
+ if not steps:
400
+ raise ReclaimPlanRefused("unsupported_geometry",
401
+ "no free page below the file's end")
402
+ tail = {s.page for s in steps if s.page is not None}
403
+ entries = {}
404
+ for page in sorted(tail, reverse=True):
405
+ kind, parent = _ptrmap_entry(read_page, page, usable_size, page_size)
406
+ if kind not in (PTRMAP_ROOTPAGE, PTRMAP_FREEPAGE, PTRMAP_OVERFLOW1,
407
+ PTRMAP_OVERFLOW2, PTRMAP_BTREE):
408
+ raise ReclaimPlanRefused("malformed_page", f"pointer-map type {kind}")
409
+ if kind != PTRMAP_FREEPAGE and kind != PTRMAP_ROOTPAGE and not (
410
+ 1 <= parent <= page_count):
411
+ raise ReclaimPlanRefused("malformed_page", "pointer-map parent")
412
+ entries[page] = (kind, parent)
413
+ leaf_of, trunks = scan_freelist(read_page, first_trunk, freelist_count,
414
+ page_count, usable_size, tail)
415
+ refs = {}
416
+ for page, (kind, _parent) in entries.items():
417
+ if kind == PTRMAP_FREEPAGE:
418
+ if page not in leaf_of and page not in trunks:
419
+ raise ReclaimPlanRefused("malformed_page",
420
+ f"free page {page} not on the freelist")
421
+ elif kind in (PTRMAP_OVERFLOW1, PTRMAP_OVERFLOW2):
422
+ following = struct.unpack(">I", read_page(page)[:4])[0]
423
+ if following and not 2 <= following <= page_count:
424
+ raise ReclaimPlanRefused("malformed_page", "overflow link")
425
+ refs[page] = ("overflow", [following] if following else [], [])
426
+ elif kind == PTRMAP_BTREE:
427
+ btype, children, heads = btree_refs(read_page(page), usable_size,
428
+ page_count)
429
+ refs[page] = ("interior" if btype in (2, 5) else "leaf",
430
+ children, heads)
431
+ best = None
432
+ for k in range(1, len(steps) + 1):
433
+ prefix = steps[:k]
434
+ new_end = prefix[-1].new_end
435
+ roots = [s for s in prefix if s.page is not None
436
+ and entries[s.page][0] == PTRMAP_ROOTPAGE]
437
+ below = all(s.n_fin <= new_end for s in prefix)
438
+ if roots or not below:
439
+ if k == 1:
440
+ raise ReclaimPlanRefused(
441
+ "unsupported_geometry",
442
+ "root page in the tail" if roots
443
+ else "the full-vacuum end reaches the tail")
444
+ break
445
+ candidate = _prefix_plan(prefix, new_end, entries, refs, leaf_of,
446
+ trunks, usable_size, page_size)
447
+ identified, unidentified, kinds, detail = candidate
448
+ reservation = reservation_bytes(len(identified), unidentified,
449
+ page_size, fixed_bytes)
450
+ if k > 1 and reservation > budget_bytes:
451
+ break
452
+ best = (k, new_end, identified, unidentified, kinds, detail,
453
+ reservation)
454
+ k, new_end, identified, unidentified, kinds, detail, reservation = best
455
+ digest = hashlib.sha256(json.dumps({
456
+ "v": PLANNER_VERSION, "pageCount": page_count,
457
+ "freelist": freelist_count, "newEnd": new_end,
458
+ "steps": [[s.page, *(entries.get(s.page) or (0, 0))]
459
+ for s in steps[:k]],
460
+ "id": sorted(identified), "unid": unidentified,
461
+ }, separators=(",", ":")).encode()).hexdigest()[:16]
462
+ return ReclaimPlan(
463
+ steps=k, identified=tuple(sorted(identified)),
464
+ unidentified=unidentified, new_end=new_end, page_size=page_size,
465
+ usable_size=usable_size, page_count=page_count,
466
+ freelist_count=freelist_count, fixed_bytes=int(fixed_bytes),
467
+ reservation_bytes=reservation, kinds=kinds, digest=digest,
468
+ detail=tuple(detail))
469
+
470
+
471
+ def _prefix_plan(prefix, new_end, entries, refs, leaf_of, trunks,
472
+ usable_size, page_size):
473
+ identified, unidentified = {1}, 0
474
+ kinds = {"free": 0, "overflow": 0, "leaf": 0, "interior": 0}
475
+ detail = []
476
+ changed = False
477
+
478
+ def reference(page):
479
+ nonlocal unidentified
480
+ if page <= new_end:
481
+ identified.add(ptrmap_pageno(page, usable_size, page_size))
482
+ else:
483
+ unidentified += 1
484
+
485
+ for step in prefix:
486
+ if step.page is None:
487
+ detail.append({"page": None})
488
+ continue
489
+ kind, parent = entries[step.page]
490
+ if kind == PTRMAP_FREEPAGE:
491
+ kinds["free"] += 1
492
+ if not changed and step.page in leaf_of:
493
+ identified.add(leaf_of[step.page])
494
+ detail.append({"page": step.page, "kind": "free_leaf",
495
+ "trunk": leaf_of[step.page]})
496
+ else:
497
+ unidentified += 2
498
+ if step.page in trunks:
499
+ previous, count = trunks[step.page]
500
+ detail.append({"page": step.page, "kind": "free_trunk",
501
+ "leaves": count,
502
+ "predecessor": previous != 1})
503
+ else:
504
+ detail.append({"page": step.page, "kind": "free_leaf",
505
+ "trunk": None})
506
+ changed = changed or step.page in trunks
507
+ continue
508
+ changed = True
509
+ unidentified += 4
510
+ if parent <= new_end:
511
+ identified.add(parent)
512
+ else:
513
+ unidentified += 1
514
+ label, children, heads = refs[step.page]
515
+ kinds[label] += 1
516
+ for page in children + heads:
517
+ reference(page)
518
+ regions = {ptrmap_pageno(p, usable_size, page_size)
519
+ for p in children + heads}
520
+ detail.append({"page": step.page, "kind": label,
521
+ "children": len(children), "heads": len(heads),
522
+ "regions": len(regions),
523
+ "parentInTail": parent > new_end})
524
+ return identified, unidentified, kinds, detail
525
+
526
+
527
+ # ── the snapshot reader (helper subprocess only) ──────────────────────────
528
+
529
+ def _checksum(data: bytes, s0: int, s1: int, big_endian: bool):
530
+ words = struct.unpack((">" if big_endian else "<") + "%dI" % (len(data) // 4),
531
+ data)
532
+ for i in range(0, len(words), 2):
533
+ s0 = (s0 + words[i] + s1) & _U32
534
+ s1 = (s1 + words[i + 1] + s0) & _U32
535
+ return s0, s1
536
+
537
+
538
+ def _identity(st) -> tuple:
539
+ return (st.st_dev, st.st_ino)
540
+
541
+
542
+ @dataclasses.dataclass(frozen=True)
543
+ class WalIndexHeader:
544
+ change: int
545
+ page_size: int
546
+ max_frame: int
547
+ page_count: int
548
+ frame_checksum: "tuple[int, int]"
549
+ salt: bytes
550
+ big_endian_checksum: bool
551
+ raw: bytes
552
+
553
+
554
+ def parse_wal_index_header(raw: bytes, *, byteorder: str = sys.byteorder
555
+ ) -> WalIndexHeader:
556
+ """Validate the two 48-byte wal-index header copies at the start of the
557
+ `-shm` (native byte order, as SQLite maps it): equal, initialized, the
558
+ supported version, and their checksum (`walIndexTryHdr`)."""
559
+ if len(raw) < 2 * WAL_INDEX_HEADER_BYTES:
560
+ raise ReclaimPlanRefused("wal_index_unreadable", "short -shm")
561
+ first, second = raw[:48], raw[48:96]
562
+ if first != second:
563
+ raise ReclaimPlanRefused("wal_index_unreadable", "header copies differ")
564
+ order = "<" if byteorder == "little" else ">"
565
+ version, _unused, change = struct.unpack(order + "III", first[:12])
566
+ is_init, big_end = first[12], first[13]
567
+ size = struct.unpack(order + "H", first[14:16])[0]
568
+ max_frame, n_page = struct.unpack(order + "II", first[16:24])
569
+ frame_ck = struct.unpack(order + "II", first[24:32])
570
+ salt = first[32:40]
571
+ stored = struct.unpack(order + "II", first[40:48])
572
+ if not is_init or version != WAL_INDEX_VERSION:
573
+ raise ReclaimPlanRefused("wal_index_unreadable", "uninitialized or version")
574
+ if _checksum(first[:40], 0, 0, order == ">") != stored:
575
+ raise ReclaimPlanRefused("wal_index_unreadable", "header checksum")
576
+ page_size = (size & 0xFF00) | ((size & 0x0001) << 16)
577
+ return WalIndexHeader(change, page_size, max_frame, n_page, frame_ck,
578
+ salt, bool(big_end), first)
579
+
580
+
581
+ class WalSnapshotReader:
582
+ """Page images of the committed snapshot the wal-index publishes, read
583
+ WAL-over-main through exactly `mxFrame`. Used only inside the helper."""
584
+
585
+ def __init__(self, db_path, *, page_size: int, read_budget_bytes: int,
586
+ deadline: float, clock=time.monotonic, hooks=None):
587
+ self.db_path = str(db_path)
588
+ self.page_size = int(page_size)
589
+ self.budget = int(read_budget_bytes)
590
+ self.deadline = float(deadline)
591
+ self.clock = clock
592
+ self.hooks = hooks or {}
593
+ self.read_bytes = 0
594
+ self.frames = {}
595
+ self.fds = {}
596
+ self.identities = {}
597
+ self.header = None
598
+ self._pages = {}
599
+
600
+ # ── budget ────────────────────────────────────────────────────────────
601
+ def _charge(self, nbytes: int) -> None:
602
+ self.read_bytes += nbytes
603
+ if self.read_bytes > self.budget:
604
+ raise ReclaimPlanRefused("read_budget_exhausted",
605
+ f"{self.read_bytes} bytes")
606
+ if self.clock() >= self.deadline:
607
+ raise ReclaimPlanRefused("read_budget_exhausted", "deadline")
608
+
609
+ def _pread(self, which: str, size: int, offset: int) -> bytes:
610
+ self._charge(size)
611
+ data = os.pread(self.fds[which], size, offset)
612
+ if len(data) != size:
613
+ raise ReclaimPlanRefused(
614
+ "wal_endpoint_mismatch" if which == "wal" else "malformed_page",
615
+ f"short read of {which} at {offset}")
616
+ return data
617
+
618
+ # ── lifecycle ─────────────────────────────────────────────────────────
619
+ _SUFFIX = {"db": "", "wal": "-wal", "shm": "-shm"}
620
+
621
+ def _open(self, which: str) -> None:
622
+ path = self.db_path + self._SUFFIX[which]
623
+ try:
624
+ fd = os.open(path, os.O_RDONLY)
625
+ except OSError as exc:
626
+ raise ReclaimPlanRefused("wal_index_unreadable",
627
+ f"cannot open {which}") from exc
628
+ self.fds[which] = fd
629
+ st = os.fstat(fd)
630
+ if not stat.S_ISREG(st.st_mode):
631
+ raise ReclaimPlanRefused("identity_changed", f"{which} type")
632
+ self.identities[which] = _identity(st)
633
+
634
+ def open(self) -> "WalSnapshotReader":
635
+ self._open("db")
636
+ self._open("shm")
637
+ self.header = parse_wal_index_header(
638
+ self._pread("shm", 2 * WAL_INDEX_HEADER_BYTES, 0))
639
+ # An index recovered from an empty WAL has learnt no page size (0).
640
+ if self.header.max_frame and self.header.page_size != self.page_size:
641
+ raise ReclaimPlanRefused("wal_index_unreadable", "page size")
642
+ if self.header.max_frame:
643
+ # An empty generation (mxFrame 0) reads the main file only and
644
+ # never revives a stale frame; otherwise the WAL is required.
645
+ self._open("wal")
646
+ self._scan()
647
+ if "after_scan" in self.hooks:
648
+ self.hooks["after_scan"](self)
649
+ return self
650
+
651
+ def _scan(self) -> None:
652
+ header = self._pread("wal", WAL_HEADER_BYTES, 0)
653
+ magic, version, size = struct.unpack(">III", header[:12])
654
+ if magic not in (WAL_MAGIC_LE, WAL_MAGIC_BE) or version != WAL_FORMAT_VERSION:
655
+ raise ReclaimPlanRefused("wal_checksum_mismatch", "WAL header")
656
+ if size != self.page_size:
657
+ raise ReclaimPlanRefused("wal_checksum_mismatch", "WAL page size")
658
+ big_endian = magic == WAL_MAGIC_BE
659
+ checksum = _checksum(header[:24], 0, 0, big_endian)
660
+ if checksum != struct.unpack(">II", header[24:32]):
661
+ raise ReclaimPlanRefused("wal_checksum_mismatch", "WAL header checksum")
662
+ salt = header[16:24]
663
+ if salt != self.header.salt:
664
+ raise ReclaimPlanRefused("wal_checksum_mismatch",
665
+ "WAL salt differs from the wal-index")
666
+ frame_size = FRAME_HEADER_BYTES + self.page_size
667
+ wal_size = os.fstat(self.fds["wal"]).st_size
668
+ if WAL_HEADER_BYTES + self.header.max_frame * frame_size > wal_size:
669
+ raise ReclaimPlanRefused("wal_endpoint_mismatch", "WAL too short")
670
+ frames, last_commit = {}, None
671
+ for index in range(1, self.header.max_frame + 1):
672
+ offset = WAL_HEADER_BYTES + (index - 1) * frame_size
673
+ frame = self._pread("wal", frame_size, offset)
674
+ if frame[8:16] != salt:
675
+ raise ReclaimPlanRefused("wal_checksum_mismatch",
676
+ f"frame {index} salt")
677
+ checksum = _checksum(frame[:8], *checksum, big_endian)
678
+ checksum = _checksum(frame[24:], *checksum, big_endian)
679
+ if checksum != struct.unpack(">II", frame[16:24]):
680
+ raise ReclaimPlanRefused("wal_checksum_mismatch",
681
+ f"frame {index} checksum")
682
+ pgno, commit = struct.unpack(">II", frame[:8])
683
+ frames[pgno] = offset
684
+ last_commit = commit
685
+ if (not last_commit or last_commit != self.header.page_count
686
+ or checksum != self.header.frame_checksum):
687
+ raise ReclaimPlanRefused("wal_endpoint_mismatch",
688
+ "the frame at mxFrame is not the index's commit")
689
+ self.frames = frames
690
+
691
+ def page(self, pgno: int) -> bytes:
692
+ image = self._pages.get(pgno)
693
+ if image is not None:
694
+ return image
695
+ if pgno < 1:
696
+ raise ReclaimPlanRefused("malformed_page", f"page {pgno}")
697
+ offset = self.frames.get(pgno)
698
+ if offset is not None:
699
+ image = self._pread("wal", self.page_size,
700
+ offset + FRAME_HEADER_BYTES)
701
+ else:
702
+ image = self._pread("db", self.page_size,
703
+ (pgno - 1) * self.page_size)
704
+ self._pages[pgno] = image
705
+ return image
706
+
707
+ def recheck(self) -> None:
708
+ """The snapshot and every file identity are unchanged."""
709
+ raw = self._pread("shm", 2 * WAL_INDEX_HEADER_BYTES, 0)
710
+ try:
711
+ again = parse_wal_index_header(raw)
712
+ except ReclaimPlanRefused as exc:
713
+ raise ReclaimPlanRefused("identity_changed", exc.detail) from exc
714
+ if (again.change, again.max_frame, again.page_count, again.salt) != (
715
+ self.header.change, self.header.max_frame,
716
+ self.header.page_count, self.header.salt):
717
+ raise ReclaimPlanRefused("identity_changed", "wal-index generation")
718
+ for which, fd in self.fds.items():
719
+ path = self.db_path + self._SUFFIX[which]
720
+ try:
721
+ current = _identity(os.stat(path))
722
+ except OSError as exc:
723
+ raise ReclaimPlanRefused("identity_changed", which) from exc
724
+ if (_identity(os.fstat(fd)) != self.identities[which]
725
+ or current != self.identities[which]):
726
+ raise ReclaimPlanRefused("identity_changed", which)
727
+
728
+ def close(self) -> None:
729
+ for fd in self.fds.values():
730
+ try:
731
+ os.close(fd)
732
+ except OSError:
733
+ pass
734
+ self.fds = {}
735
+
736
+
737
+ def plan_from_store(request: dict, *, clock=time.monotonic,
738
+ hooks=None) -> dict:
739
+ """The helper's whole job: read the snapshot, plan, recheck. Returns the
740
+ JSON answer `{"ok": true, "plan": {...}}` or `{"ok": false, "reason"}`."""
741
+ started = clock()
742
+ deadline = started + max(0.0, float(request["deadlineMs"])) / 1000.0
743
+ reader = WalSnapshotReader(
744
+ request["db"], page_size=int(request["pageSize"]),
745
+ read_budget_bytes=int(request["readBudgetBytes"]), deadline=deadline,
746
+ clock=clock, hooks=hooks)
747
+ try:
748
+ reader.open()
749
+ plan = plan_chunk(reader.page, page_size=int(request["pageSize"]),
750
+ max_steps=int(request["maxSteps"]),
751
+ budget_bytes=int(request["budgetBytes"]),
752
+ fixed_bytes=int(request["fixedBytes"]))
753
+ if "before_recheck" in (hooks or {}):
754
+ hooks["before_recheck"](reader)
755
+ reader.recheck()
756
+ if (plan.page_count, plan.freelist_count) != (
757
+ int(request["pageCount"]), int(request["freelistCount"])):
758
+ raise ReclaimPlanRefused(
759
+ "wal_endpoint_mismatch",
760
+ "the snapshot disagrees with the connection's geometry")
761
+ if reader.header.max_frame and plan.page_count != reader.header.page_count:
762
+ raise ReclaimPlanRefused("wal_endpoint_mismatch", "nPage")
763
+ plan = dataclasses.replace(
764
+ plan, inspected_bytes=reader.read_bytes,
765
+ inspection_ms=int(round((clock() - started) * 1000)))
766
+ return {"ok": True, "plan": plan.as_wire()}
767
+ except ReclaimPlanRefused as exc:
768
+ return {"ok": False, "reason": exc.reason, "detail": exc.detail[:200],
769
+ "inspectedBytes": reader.read_bytes,
770
+ "inspectionMs": int(round((clock() - started) * 1000))}
771
+ finally:
772
+ reader.close()
773
+
774
+
775
+ def main(argv=None) -> int:
776
+ argv = list(sys.argv[1:] if argv is None else argv)
777
+ if argv != ["--plan"]:
778
+ print("usage: _lib_reclaim_planner.py --plan (JSON request on stdin)",
779
+ file=sys.stderr)
780
+ return 2
781
+ try:
782
+ request = json.loads(sys.stdin.read())
783
+ answer = plan_from_store(request)
784
+ except (ValueError, KeyError, TypeError) as exc:
785
+ print(f"bad request: {type(exc).__name__}", file=sys.stderr)
786
+ return 2
787
+ sys.stdout.write(json.dumps(answer, separators=(",", ":")))
788
+ return 0
789
+
790
+
791
+ if __name__ == "__main__":
792
+ raise SystemExit(main())