cctally 1.112.0 → 1.114.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 +27 -0
- package/README.md +4 -5
- package/bin/_cctally_cache.py +1683 -485
- package/bin/_cctally_core.py +223 -1
- package/bin/_cctally_dashboard.py +96 -18
- package/bin/_cctally_dashboard_perf.py +128 -9
- package/bin/_cctally_dashboard_sources.py +267 -204
- package/bin/_cctally_db.py +861 -18
- package/bin/_cctally_diagnosis_sources.py +22 -4
- package/bin/_cctally_doctor.py +136 -18
- package/bin/_cctally_five_hour.py +2 -2
- package/bin/_cctally_forecast.py +27 -6
- package/bin/_cctally_journal.py +67 -5
- package/bin/_cctally_milestone_history.py +82 -9
- package/bin/_cctally_percent_breakdown.py +3 -2
- package/bin/_cctally_quota.py +201 -33
- package/bin/_cctally_record.py +14 -0
- package/bin/_cctally_rederive.py +23 -9
- package/bin/_cctally_source_analytics.py +60 -15
- package/bin/_cctally_statusline.py +17 -0
- package/bin/_cctally_store.py +80 -5
- package/bin/_cctally_tui.py +4 -3
- package/bin/_cctally_weekrefs.py +128 -21
- package/bin/_lib_cache_report.py +35 -6
- package/bin/_lib_codex_conversation.py +32 -0
- package/bin/_lib_codex_conversation_query.py +164 -30
- package/bin/_lib_conversation_query.py +95 -56
- package/bin/_lib_conversation_retention.py +1605 -640
- package/bin/_lib_cost_provenance.py +53 -2
- package/bin/_lib_doctor.py +263 -69
- package/bin/_lib_fmt.py +11 -0
- package/bin/_lib_merge_gate.py +469 -51
- package/bin/_lib_perf.py +27 -1
- package/bin/_lib_pricing.py +209 -8
- package/bin/_lib_pricing_check.py +20 -4
- package/bin/_lib_reclaim_planner.py +792 -0
- package/bin/_lib_rederive.py +225 -77
- package/bin/_lib_snapshot_cache.py +21 -0
- package/bin/_lib_sqlite_close.py +81 -0
- 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 +8 -0
- 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 +6 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
- package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
package/bin/_lib_tick_stats.py
CHANGED
|
@@ -25,12 +25,15 @@ Bounds: at most ``RING_CAPACITY`` retained records and at most
|
|
|
25
25
|
"""
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
|
+
import array
|
|
28
29
|
import dataclasses
|
|
29
30
|
import sys
|
|
30
31
|
import threading
|
|
31
32
|
import time
|
|
32
33
|
import types
|
|
33
34
|
|
|
35
|
+
import _lib_write_io
|
|
36
|
+
|
|
34
37
|
RING_CAPACITY = 64
|
|
35
38
|
MEMORY_BUDGET_BYTES = 65536
|
|
36
39
|
|
|
@@ -58,6 +61,10 @@ PUBLICATIONS = ("final", "partial", "seed", "degraded")
|
|
|
58
61
|
#: frozen field typed `str` enforces nothing on its own.
|
|
59
62
|
CONVERSATION_STATUSES = ("ok", "store_unavailable", "error")
|
|
60
63
|
CONVERSATION_MODES = ("caught_up", "targeted", "full", "not_observed")
|
|
64
|
+
#: The write status of an interval (#901 §5.5): `ok`, a typed unavailable
|
|
65
|
+
#: reason, or `not_sampled` when the recorder was given no observation. A
|
|
66
|
+
#: number is published only with `ok`; it is never fabricated as zero.
|
|
67
|
+
WRITE_STATUSES = _lib_write_io.WRITE_STATUSES
|
|
61
68
|
|
|
62
69
|
_INGEST = "ingest"
|
|
63
70
|
_BUILD = "build"
|
|
@@ -84,6 +91,9 @@ class TickRecord:
|
|
|
84
91
|
period_ns: "int | None"
|
|
85
92
|
cache_pin_ns: int
|
|
86
93
|
cpu_ns: int
|
|
94
|
+
process_write_bytes: "int | None"
|
|
95
|
+
write_status: str
|
|
96
|
+
write_overlap: bool
|
|
87
97
|
|
|
88
98
|
def as_wire(self) -> dict:
|
|
89
99
|
return {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
|
|
@@ -125,6 +135,9 @@ class ConversationSyncRecord:
|
|
|
125
135
|
codex_mode: str
|
|
126
136
|
claude_files: int
|
|
127
137
|
codex_files: int
|
|
138
|
+
process_write_bytes: "int | None"
|
|
139
|
+
write_status: str
|
|
140
|
+
write_overlap: bool
|
|
128
141
|
|
|
129
142
|
def as_wire(self) -> dict:
|
|
130
143
|
return {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
|
|
@@ -147,16 +160,42 @@ MAINTENANCE_PHASES = ("delete", "reclaim", "checkpoint", "other")
|
|
|
147
160
|
#: maintenance history at the throttled cadence, and many continuation passes
|
|
148
161
|
#: at the escalated one.
|
|
149
162
|
MAINTENANCE_RING_CAPACITY = 16
|
|
163
|
+
#: #901: one record per maintenance OPERATION (a deletion group or a reclaim
|
|
164
|
+
#: chunk) carries its own outcome. `gated` means the allowance was in debt,
|
|
165
|
+
#: `skipped` that reclaim could not run without checkpointing.
|
|
166
|
+
MAINTENANCE_OUTCOMES = ("ok", "deadline", "no_progress", "busy", "gated",
|
|
167
|
+
"skipped")
|
|
168
|
+
MAINTENANCE_MODES = ("", "spill_free", "fallback", "reclaim")
|
|
169
|
+
STARTED_AT_MAX_CHARS = 32
|
|
170
|
+
#: #901 Q9: the closed set of reclaim skip reasons and reader statuses a
|
|
171
|
+
#: record may carry (`_lib_conversation_retention.RECLAIM_SKIP_REASONS`,
|
|
172
|
+
#: pinned equal by `tests/test_tick_stats_write_io.py`); anything else is
|
|
173
|
+
#: published as "unknown".
|
|
174
|
+
MAINTENANCE_SKIP_REASONS = (
|
|
175
|
+
"no_checkpoint_on_close_unavailable", "connection_settings_unavailable",
|
|
176
|
+
"sqlite_error", "state_record_unnormalized", "state_record_oversize",
|
|
177
|
+
"helper_failed", "helper_timeout", "wal_index_unreadable",
|
|
178
|
+
"wal_checksum_mismatch", "wal_endpoint_mismatch", "read_budget_exhausted",
|
|
179
|
+
"unsupported_geometry", "malformed_page", "cyclic_freelist",
|
|
180
|
+
"identity_changed", "sqlite_unaudited")
|
|
181
|
+
SQLITE_SOURCE_ID_MAX_CHARS = 96
|
|
182
|
+
PLAN_DIGEST_MAX_CHARS = 16
|
|
183
|
+
#: One shared object per distinct SQLite source id, so sixteen records of
|
|
184
|
+
#: one runtime hold one string (the 65,536-byte budget).
|
|
185
|
+
_SOURCE_IDS: "dict[str, str]" = {}
|
|
150
186
|
|
|
151
187
|
|
|
152
188
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
153
189
|
class MaintenancePhaseRecord:
|
|
154
|
-
"""One timed maintenance phase.
|
|
155
|
-
|
|
156
|
-
`phase`
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
debug
|
|
190
|
+
"""One timed maintenance phase or operation (#780, #901).
|
|
191
|
+
|
|
192
|
+
`phase`, `outcome`, `mode` and `write_status` are normalized against
|
|
193
|
+
closed sets in the recorder, for the same reason
|
|
194
|
+
`ConversationSyncRecord.status` is: a frozen field typed `str` would
|
|
195
|
+
accept `str(exc)` or a filesystem path and publish it through the debug
|
|
196
|
+
endpoint. The operation fields default so a legacy phase record stays
|
|
197
|
+
constructible; `charged_bytes` is the operation's reservation and
|
|
198
|
+
`process_write_bytes` is telemetry only.
|
|
160
199
|
"""
|
|
161
200
|
|
|
162
201
|
seq: int
|
|
@@ -167,9 +206,63 @@ class MaintenancePhaseRecord:
|
|
|
167
206
|
pages_reclaimed: int = 0
|
|
168
207
|
bytes_returned: int = 0
|
|
169
208
|
pending: bool = False
|
|
209
|
+
op_id: int = 0
|
|
210
|
+
started_at: str = ""
|
|
211
|
+
rows: int = 0
|
|
212
|
+
charged_bytes: int = 0
|
|
213
|
+
process_write_bytes: "int | None" = None
|
|
214
|
+
write_status: str = "not_sampled"
|
|
215
|
+
write_overlap: bool = False
|
|
216
|
+
balance_before_bytes: "int | None" = None
|
|
217
|
+
balance_after_bytes: "int | None" = None
|
|
218
|
+
mode: str = ""
|
|
219
|
+
# #901 Q9: a reclaim chunk's skip, its plan's identity and its reader.
|
|
220
|
+
skip_reason: "str | None" = None
|
|
221
|
+
sqlite_source_id: "str | None" = None
|
|
222
|
+
plan_digest: "str | None" = None
|
|
223
|
+
reader_status: "str | None" = None
|
|
224
|
+
# #901 Q8 (a deletion's reservation inputs) and Q9 (a reclaim chunk's
|
|
225
|
+
# plan and outcome, §5.5): `OPERATION_INT_FIELDS` packed into ONE
|
|
226
|
+
# array, -1 for "not recorded", because sixteen records of twenty
|
|
227
|
+
# separate int objects do not fit the frozen 65,536-byte budget. Read
|
|
228
|
+
# them by name (`record.page_count`); `as_wire` publishes them by name.
|
|
229
|
+
numbers: "array.array | None" = None # typecode "i"
|
|
170
230
|
|
|
171
231
|
def as_wire(self) -> dict:
|
|
172
|
-
|
|
232
|
+
wire = {f.name: getattr(self, f.name)
|
|
233
|
+
for f in dataclasses.fields(self) if f.name != "numbers"}
|
|
234
|
+
for name in OPERATION_INT_FIELDS:
|
|
235
|
+
wire[name] = getattr(self, name)
|
|
236
|
+
return wire
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
#: Every packed value is a page count, a byte count bounded by the
|
|
240
|
+
#: planner's read budget, or a small count: a C int holds each (a larger
|
|
241
|
+
#: value is clamped rather than wrapped).
|
|
242
|
+
_PACKED_MAX = 2 ** 31 - 1
|
|
243
|
+
#: The packed numeric operation fields, in their fixed array order.
|
|
244
|
+
OPERATION_INT_FIELDS = (
|
|
245
|
+
"page_count", "usable_size", "page_size", "pointer_map_cap",
|
|
246
|
+
"reservation_version", "planner_version", "freelist_count", "steps",
|
|
247
|
+
"kind_free", "kind_overflow", "kind_leaf", "kind_interior",
|
|
248
|
+
"identified_pages", "unidentified_pages", "fixed_bytes",
|
|
249
|
+
"inspected_bytes", "inspection_ms", "freelist_reduction",
|
|
250
|
+
"page_count_reduction")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _packed_field(index: int):
|
|
254
|
+
def read(record):
|
|
255
|
+
numbers = record.numbers
|
|
256
|
+
if numbers is None:
|
|
257
|
+
return None
|
|
258
|
+
value = numbers[index]
|
|
259
|
+
return None if value < 0 else value
|
|
260
|
+
return property(read)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
for _index, _name in enumerate(OPERATION_INT_FIELDS):
|
|
264
|
+
setattr(MaintenancePhaseRecord, _name, _packed_field(_index))
|
|
265
|
+
del _index, _name
|
|
173
266
|
|
|
174
267
|
|
|
175
268
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
@@ -286,7 +379,8 @@ class TickContext:
|
|
|
286
379
|
__slots__ = ("_now", "_cpu_now", "_standalone", "_started_ns",
|
|
287
380
|
"_started_cpu_ns", "_spans", "_finished",
|
|
288
381
|
"_ingest_ns", "_builder_ns", "_ingest_ran", "_dispatch",
|
|
289
|
-
"_codex_regime", "_publication", "_cold", "_cache_pin_ns"
|
|
382
|
+
"_codex_regime", "_publication", "_cold", "_cache_pin_ns",
|
|
383
|
+
"_write")
|
|
290
384
|
|
|
291
385
|
def __init__(self, *, monotonic_ns, thread_time_ns, standalone: bool):
|
|
292
386
|
self._now = monotonic_ns
|
|
@@ -308,6 +402,7 @@ class TickContext:
|
|
|
308
402
|
self._publication = "final"
|
|
309
403
|
self._cold = False
|
|
310
404
|
self._cache_pin_ns = 0
|
|
405
|
+
self._write = _lib_write_io.begin_interval("tick")
|
|
311
406
|
|
|
312
407
|
# ── measurement ──────────────────────────────────────────────────────
|
|
313
408
|
|
|
@@ -444,6 +539,9 @@ class TickContext:
|
|
|
444
539
|
ended_cpu_ns = self._cpu_now()
|
|
445
540
|
if getattr(_tls, "tick", None) is self:
|
|
446
541
|
_tls.tick = None
|
|
542
|
+
# #901: the interval's process write delta. It includes concurrent
|
|
543
|
+
# work (`write_overlap` says so) and is never summed across loops.
|
|
544
|
+
written = _lib_write_io.end_interval(self._write)
|
|
447
545
|
|
|
448
546
|
global _STATE
|
|
449
547
|
with _LOCK:
|
|
@@ -474,6 +572,9 @@ class TickContext:
|
|
|
474
572
|
period_ns=period,
|
|
475
573
|
cache_pin_ns=self._cache_pin_ns,
|
|
476
574
|
cpu_ns=max(0, ended_cpu_ns - self._started_cpu_ns),
|
|
575
|
+
process_write_bytes=written.process_write_bytes,
|
|
576
|
+
write_status=written.status,
|
|
577
|
+
write_overlap=written.overlap,
|
|
477
578
|
)
|
|
478
579
|
if self._standalone:
|
|
479
580
|
_STATE = dataclasses.replace(prior, standalone=record)
|
|
@@ -498,12 +599,30 @@ class TickContext:
|
|
|
498
599
|
# wiped it 128 times and the suite reported one record.
|
|
499
600
|
maintenance_records=prior.maintenance_records,
|
|
500
601
|
)
|
|
602
|
+
# A completed main publication: the per-publication denominator,
|
|
603
|
+
# warm admission and the hysteresis step (outside this module's lock).
|
|
604
|
+
_lib_write_io.note_publication(cold=self._cold)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _safe_write(process_write_bytes, write_status) -> "tuple[int | None, str]":
|
|
608
|
+
"""Coerce an interval's write fields: a number only with `ok`."""
|
|
609
|
+
status = write_status if write_status in WRITE_STATUSES else "not_sampled"
|
|
610
|
+
if status != "ok":
|
|
611
|
+
return None, status
|
|
612
|
+
try:
|
|
613
|
+
value = int(process_write_bytes)
|
|
614
|
+
except (TypeError, ValueError):
|
|
615
|
+
return None, "not_sampled"
|
|
616
|
+
if value < 0:
|
|
617
|
+
return None, "not_sampled"
|
|
618
|
+
return value, "ok"
|
|
501
619
|
|
|
502
620
|
|
|
503
621
|
def record_conversation_pass(
|
|
504
622
|
*, seq, started_ns, ended_ns, duration_ns, cpu_ns, status,
|
|
505
623
|
claude_mode="not_observed", codex_mode="not_observed",
|
|
506
624
|
claude_files=0, codex_files=0,
|
|
625
|
+
process_write_bytes=None, write_status="not_sampled", write_overlap=False,
|
|
507
626
|
) -> None:
|
|
508
627
|
"""Append one conversation sync pass to its ring, under the SHARED lock.
|
|
509
628
|
|
|
@@ -534,6 +653,8 @@ def record_conversation_pass(
|
|
|
534
653
|
claude_mode if claude_mode in CONVERSATION_MODES else "not_observed")
|
|
535
654
|
safe_codex_mode = (
|
|
536
655
|
codex_mode if codex_mode in CONVERSATION_MODES else "not_observed")
|
|
656
|
+
safe_bytes, safe_write_status = _safe_write(
|
|
657
|
+
process_write_bytes, write_status)
|
|
537
658
|
start = int(started_ns)
|
|
538
659
|
record = ConversationSyncRecord(
|
|
539
660
|
seq=int(seq),
|
|
@@ -547,6 +668,9 @@ def record_conversation_pass(
|
|
|
547
668
|
codex_mode=safe_codex_mode,
|
|
548
669
|
claude_files=max(0, int(claude_files)),
|
|
549
670
|
codex_files=max(0, int(codex_files)),
|
|
671
|
+
process_write_bytes=safe_bytes,
|
|
672
|
+
write_status=safe_write_status,
|
|
673
|
+
write_overlap=bool(write_overlap),
|
|
550
674
|
)
|
|
551
675
|
with _LOCK:
|
|
552
676
|
prior = _STATE
|
|
@@ -565,7 +689,8 @@ def record_conversation_pass(
|
|
|
565
689
|
|
|
566
690
|
|
|
567
691
|
def record_maintenance_phase(phase: str, payload: "dict | None" = None) -> None:
|
|
568
|
-
"""Append one maintenance phase to its own bounded ring
|
|
692
|
+
"""Append one maintenance phase or operation to its own bounded ring
|
|
693
|
+
(#780, #901).
|
|
569
694
|
|
|
570
695
|
Separate from the conversation-sync ring on purpose, so a two-second
|
|
571
696
|
reclaim and a slow checkpoint each appear as their own timed phase with
|
|
@@ -592,8 +717,87 @@ def record_maintenance_phase(phase: str, payload: "dict | None" = None) -> None:
|
|
|
592
717
|
duration_ns = max(0, int(float(duration_s) * 1_000_000_000))
|
|
593
718
|
except (TypeError, ValueError):
|
|
594
719
|
duration_ns = 0
|
|
595
|
-
|
|
596
|
-
|
|
720
|
+
|
|
721
|
+
def _as_int(value):
|
|
722
|
+
try:
|
|
723
|
+
return max(0, int(value or 0))
|
|
724
|
+
except (TypeError, ValueError):
|
|
725
|
+
return 0
|
|
726
|
+
|
|
727
|
+
def _signed_or_none(value):
|
|
728
|
+
if value is None or isinstance(value, bool):
|
|
729
|
+
return None
|
|
730
|
+
try:
|
|
731
|
+
return int(value)
|
|
732
|
+
except (TypeError, ValueError):
|
|
733
|
+
return None
|
|
734
|
+
|
|
735
|
+
def _count_or_none(value):
|
|
736
|
+
value = _signed_or_none(value)
|
|
737
|
+
return None if value is None or value < 0 else value
|
|
738
|
+
|
|
739
|
+
def _reason_or_none(value):
|
|
740
|
+
if value is None:
|
|
741
|
+
return None
|
|
742
|
+
return value if value in MAINTENANCE_SKIP_REASONS else "unknown"
|
|
743
|
+
|
|
744
|
+
def _status_or_none(value):
|
|
745
|
+
return "ok" if value == "ok" else _reason_or_none(value)
|
|
746
|
+
|
|
747
|
+
def _source_id(value):
|
|
748
|
+
if not isinstance(value, str) or not value:
|
|
749
|
+
return None
|
|
750
|
+
value = value[:SQLITE_SOURCE_ID_MAX_CHARS]
|
|
751
|
+
shared = _SOURCE_IDS.get(value)
|
|
752
|
+
if shared is None:
|
|
753
|
+
if len(_SOURCE_IDS) >= 4:
|
|
754
|
+
_SOURCE_IDS.clear()
|
|
755
|
+
shared = _SOURCE_IDS.setdefault(value, value)
|
|
756
|
+
return shared
|
|
757
|
+
|
|
758
|
+
def _packed(data):
|
|
759
|
+
values = [_count_or_none(data.get(name))
|
|
760
|
+
for name in OPERATION_INT_FIELDS]
|
|
761
|
+
if all(v is None for v in values):
|
|
762
|
+
return None
|
|
763
|
+
return array.array("i", [
|
|
764
|
+
-1 if v is None else min(v, _PACKED_MAX) for v in values])
|
|
765
|
+
|
|
766
|
+
def _digest(value):
|
|
767
|
+
if not isinstance(value, str):
|
|
768
|
+
return None
|
|
769
|
+
value = value[:PLAN_DIGEST_MAX_CHARS]
|
|
770
|
+
return value if all(c in "0123456789abcdef" for c in value) else None
|
|
771
|
+
|
|
772
|
+
extra = {}
|
|
773
|
+
if "op_id" in data:
|
|
774
|
+
outcome = data.get("outcome")
|
|
775
|
+
outcome = outcome if outcome in MAINTENANCE_OUTCOMES else "ok"
|
|
776
|
+
mode = data.get("mode")
|
|
777
|
+
written, written_status = _safe_write(
|
|
778
|
+
data.get("process_write_bytes"), data.get("write_status"))
|
|
779
|
+
pending = (safe_phase == "reclaim" and outcome == "ok") or outcome in (
|
|
780
|
+
"gated", "skipped")
|
|
781
|
+
extra = dict(
|
|
782
|
+
op_id=_as_int(data.get("op_id")),
|
|
783
|
+
started_at=str(data.get("started_at") or "")[:STARTED_AT_MAX_CHARS],
|
|
784
|
+
rows=_as_int(data.get("rows")),
|
|
785
|
+
charged_bytes=_as_int(data.get("charged_bytes")),
|
|
786
|
+
process_write_bytes=written,
|
|
787
|
+
write_status=written_status,
|
|
788
|
+
write_overlap=bool(data.get("write_overlap")),
|
|
789
|
+
balance_before_bytes=_signed_or_none(
|
|
790
|
+
data.get("balance_before_bytes")),
|
|
791
|
+
balance_after_bytes=_signed_or_none(
|
|
792
|
+
data.get("balance_after_bytes")),
|
|
793
|
+
mode=mode if mode in MAINTENANCE_MODES else "",
|
|
794
|
+
skip_reason=_reason_or_none(data.get("skip_reason")),
|
|
795
|
+
sqlite_source_id=_source_id(data.get("sqlite_source_id")),
|
|
796
|
+
plan_digest=_digest(data.get("plan_digest")),
|
|
797
|
+
reader_status=_status_or_none(data.get("reader_status")),
|
|
798
|
+
numbers=_packed(data),
|
|
799
|
+
)
|
|
800
|
+
elif safe_phase == "reclaim":
|
|
597
801
|
pending = bool(data.get("deadline_hit")) or not bool(
|
|
598
802
|
data.get("made_progress", True))
|
|
599
803
|
outcome = "deadline" if data.get("deadline_hit") else (
|
|
@@ -603,14 +807,9 @@ def record_maintenance_phase(phase: str, payload: "dict | None" = None) -> None:
|
|
|
603
807
|
outcome = "ok" if (result and result[0] == 0) else "busy"
|
|
604
808
|
pending = not (result and result[0] == 0 and result[1] == 0)
|
|
605
809
|
else:
|
|
810
|
+
pending = bool(data.get("pending"))
|
|
606
811
|
outcome = "ok"
|
|
607
812
|
|
|
608
|
-
def _as_int(value):
|
|
609
|
-
try:
|
|
610
|
-
return max(0, int(value or 0))
|
|
611
|
-
except (TypeError, ValueError):
|
|
612
|
-
return 0
|
|
613
|
-
|
|
614
813
|
record = MaintenancePhaseRecord(
|
|
615
814
|
seq=0,
|
|
616
815
|
phase=safe_phase,
|
|
@@ -620,6 +819,7 @@ def record_maintenance_phase(phase: str, payload: "dict | None" = None) -> None:
|
|
|
620
819
|
pages_reclaimed=_as_int(data.get("pages_reclaimed")),
|
|
621
820
|
bytes_returned=_as_int(data.get("bytes_returned")),
|
|
622
821
|
pending=pending,
|
|
822
|
+
**extra,
|
|
623
823
|
)
|
|
624
824
|
with _LOCK:
|
|
625
825
|
prior = _STATE
|