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.
- package/CHANGELOG.md +47 -0
- package/README.md +4 -6
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +6035 -722
- package/bin/_cctally_completion.py +1287 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +400 -2
- package/bin/_cctally_dashboard.py +98 -20
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +16 -3
- package/bin/_cctally_dashboard_perf.py +128 -9
- package/bin/_cctally_dashboard_sources.py +285 -213
- package/bin/_cctally_db.py +1177 -23
- package/bin/_cctally_diagnosis_sources.py +22 -4
- package/bin/_cctally_doctor.py +572 -45
- package/bin/_cctally_five_hour.py +84 -33
- package/bin/_cctally_forecast.py +108 -15
- package/bin/_cctally_journal.py +3950 -116
- package/bin/_cctally_journal_repair.py +101 -12
- package/bin/_cctally_milestone_history.py +82 -9
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_percent_breakdown.py +3 -2
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +652 -0
- package/bin/_cctally_quota.py +227 -37
- package/bin/_cctally_quota_model.py +360 -83
- package/bin/_cctally_record.py +691 -149
- package/bin/_cctally_rederive.py +320 -41
- package/bin/_cctally_refresh.py +84 -13
- package/bin/_cctally_source_analytics.py +83 -17
- package/bin/_cctally_statusline.py +6154 -517
- package/bin/_cctally_store.py +367 -53
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +61 -56
- package/bin/_cctally_weekrefs.py +128 -21
- package/bin/_lib_account_binding.py +152 -0
- package/bin/_lib_account_protocol.py +386 -0
- package/bin/_lib_aggregators.py +4 -0
- package/bin/_lib_cache_coverage.py +13 -1
- package/bin/_lib_cache_report.py +35 -6
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +142 -0
- package/bin/_lib_codex_conversation.py +32 -0
- package/bin/_lib_codex_conversation_query.py +297 -49
- package/bin/_lib_codex_conversation_watch.py +59 -14
- package/bin/_lib_codex_drain_admission.py +214 -0
- package/bin/_lib_codex_metadata.py +18 -1
- package/bin/_lib_codex_publication_barrier.py +277 -0
- package/bin/_lib_codex_rollout_container.py +502 -0
- package/bin/_lib_conversation_dispatch.py +3 -0
- package/bin/_lib_conversation_query.py +106 -59
- package/bin/_lib_conversation_retention.py +1605 -640
- package/bin/_lib_cost_provenance.py +53 -2
- package/bin/_lib_detector_access_registry.py +3817 -0
- package/bin/_lib_doctor.py +714 -69
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_fmt.py +11 -0
- package/bin/_lib_ingest_frontier.py +414 -20
- package/bin/_lib_journal.py +141 -10
- package/bin/_lib_jsonl.py +5 -4
- package/bin/_lib_legacy_spool.py +126 -0
- package/bin/_lib_legacy_writer_evidence.py +136 -0
- package/bin/_lib_merge_gate.py +231 -86
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_perf.py +27 -1
- package/bin/_lib_pricing.py +199 -9
- package/bin/_lib_pricing_check.py +20 -4
- package/bin/_lib_quarantine.py +154 -0
- package/bin/_lib_quota_calibration.py +53 -8
- package/bin/_lib_quota_copy.py +2 -1
- package/bin/_lib_quota_model.py +288 -46
- package/bin/_lib_reclaim_planner.py +792 -0
- package/bin/_lib_rederive.py +319 -82
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_snapshot_cache.py +21 -0
- package/bin/_lib_sqlite_close.py +81 -0
- package/bin/_lib_statusline_candidates.py +292 -23
- package/bin/_lib_subscription_weeks.py +100 -17
- package/bin/_lib_tick_stats.py +217 -17
- package/bin/_lib_wal_checkpoint.py +1077 -0
- package/bin/_lib_write_budget.py +483 -0
- package/bin/_lib_write_io.py +703 -0
- package/bin/cctally +31 -1
- package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
- package/dashboard/static/assets/index-BKITk1CT.css +1 -0
- package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +19 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
- 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())
|