cctally 1.117.2 → 1.118.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 +30 -0
- package/README.md +7 -3
- package/bin/_cctally_cache.py +2783 -175
- package/bin/_cctally_completion.py +106 -33
- package/bin/_cctally_core.py +69 -4
- package/bin/_cctally_dashboard.py +188 -13
- package/bin/_cctally_dashboard_conversation.py +5 -1
- package/bin/_cctally_dashboard_envelope.py +50 -19
- package/bin/_cctally_dashboard_sources.py +23 -1
- package/bin/_cctally_db.py +193 -153
- package/bin/_cctally_diagnosis_sources.py +63 -7
- package/bin/_cctally_doctor.py +865 -93
- package/bin/_cctally_five_hour.py +67 -50
- package/bin/_cctally_forecast.py +35 -13
- package/bin/_cctally_journal.py +186 -18
- package/bin/_cctally_milestone_history.py +859 -62
- package/bin/_cctally_oauth_samples.py +286 -15
- package/bin/_cctally_parser.py +2 -2
- package/bin/_cctally_quota.py +104 -12
- package/bin/_cctally_record.py +153 -29
- package/bin/_cctally_rederive.py +608 -68
- package/bin/_cctally_statusline.py +108 -81
- package/bin/_cctally_store.py +179 -38
- package/bin/_cctally_tui.py +892 -181
- package/bin/_cctally_update.py +32 -13
- package/bin/_lib_codex_conversation_query.py +52 -14
- package/bin/_lib_codex_drain_admission.py +224 -6
- package/bin/_lib_codex_metadata.py +39 -0
- package/bin/_lib_conversation_anon.py +15 -8
- package/bin/_lib_conversation_dispatch.py +7 -0
- package/bin/_lib_conversation_query.py +289 -70
- package/bin/_lib_dashboard_sources.py +37 -1
- package/bin/_lib_detector_access_registry.py +497 -26
- package/bin/_lib_doctor.py +85 -20
- package/bin/_lib_doctor_scan_index.py +321 -0
- package/bin/_lib_ingest_frontier.py +20 -1
- package/bin/_lib_oauth_samples.py +23 -7
- package/bin/_lib_pricing.py +146 -16
- package/bin/_lib_quota.py +19 -0
- package/bin/_lib_rederive.py +11 -6
- package/bin/_lib_retained_records.py +516 -0
- package/bin/_lib_snapshot_cache.py +10 -0
- package/bin/_lib_stale_proof.py +272 -43
- package/bin/_lib_stats_content_revision.py +831 -0
- package/bin/_lib_write_io.py +11 -4
- package/bin/cctally +4 -1
- package/dashboard/static/assets/ConversationsView-D4Iv4ku0.js +72 -0
- package/dashboard/static/assets/{DoctorModal-B0DnU9d9.js → DoctorModal-D4QSq6uz.js} +1 -1
- package/dashboard/static/assets/ModalRoot-C9F5dhZi.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-DSxXQSa-.js → ProjectsDrillPanel-WxAdRDMt.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BleDpQqG.js → SourceDetailModal-NyyEfXmc.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CMqaAkSK.js → UpdateModal-CFLq9pT1.js} +1 -1
- package/dashboard/static/assets/{index-BjQV-0-D.js → index-BKTWH9JE.js} +2 -2
- package/dashboard/static/assets/{outlineNavigation-CO-VWBm7.js → outlineNavigation-BnGUwCS3.js} +3 -3
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +4 -1
- package/dashboard/static/assets/ConversationsView-oqNsWzWV.js +0 -72
- package/dashboard/static/assets/ModalRoot-CYua2wP_.js +0 -1
|
@@ -292,8 +292,8 @@ def cmd_statusline(args: argparse.Namespace) -> int:
|
|
|
292
292
|
# writer (spec 2026-07-17-usage-statusline-fallback). This is a pure
|
|
293
293
|
# side effect that runs AFTER the line is printed and is FULLY guarded:
|
|
294
294
|
# persistence must NEVER break or slow rendering, so the whole call is
|
|
295
|
-
# wrapped here (the feeder itself
|
|
296
|
-
# errors, but this is belt-and-suspenders). Absence of rate_limits or a
|
|
295
|
+
# wrapped here (the feeder itself dispatches a detached worker + swallows
|
|
296
|
+
# its own errors, but this is belt-and-suspenders). Absence of rate_limits or a
|
|
297
297
|
# lost persist lock degrades to a clean no-op; the authoritative OAuth
|
|
298
298
|
# confirmation below covers the missing/stale-payload case.
|
|
299
299
|
try:
|
|
@@ -458,7 +458,7 @@ def _try_acquire_persist_lock() -> "int | None":
|
|
|
458
458
|
|
|
459
459
|
Returns the open fd on success; ``None`` when another render already
|
|
460
460
|
holds it (EWOULDBLOCK) or the lock file can't be opened/locked — in
|
|
461
|
-
which case the caller renders without
|
|
461
|
+
which case the caller renders without dispatching."""
|
|
462
462
|
try:
|
|
463
463
|
_durable_mkdir(_bound_root())
|
|
464
464
|
fd = os.open(_selected_lock_path(), os.O_WRONLY | os.O_CREAT, 0o600)
|
|
@@ -1009,7 +1009,24 @@ def _db_file_fingerprint() -> dict[str, "_candidates.FileFingerprint | None"]:
|
|
|
1009
1009
|
|
|
1010
1010
|
|
|
1011
1011
|
def _epoch_from_iso(value: object) -> int:
|
|
1012
|
-
|
|
1012
|
+
"""The epoch second of a stored projection timestamp.
|
|
1013
|
+
|
|
1014
|
+
#1064: an aware value's instant does not depend on the host zone, so it
|
|
1015
|
+
skips ``parse_iso_datetime``'s final conversion to local time. That is
|
|
1016
|
+
one libc local-time call per value, about 60 µs in a bare-fork child on
|
|
1017
|
+
macOS, and the projection sorts every snapshot row by it: the hook's
|
|
1018
|
+
OAuth publication held the selected state 4.3 s instead of 1.6 s. A
|
|
1019
|
+
naive, unparsable or edge-of-range value takes the shared parser
|
|
1020
|
+
unchanged, so every result and every error stays the same.
|
|
1021
|
+
"""
|
|
1022
|
+
text = str(value) # once, as before: the fallback reuses the same string
|
|
1023
|
+
try:
|
|
1024
|
+
parsed = dt.datetime.fromisoformat(text.strip().replace("Z", "+00:00"))
|
|
1025
|
+
except ValueError:
|
|
1026
|
+
parsed = None
|
|
1027
|
+
if parsed is not None and parsed.tzinfo is not None and 1 < parsed.year < 9999:
|
|
1028
|
+
return int(parsed.timestamp())
|
|
1029
|
+
return int(_cctally_core.parse_iso_datetime(text, "statusline projection").timestamp())
|
|
1013
1030
|
|
|
1014
1031
|
|
|
1015
1032
|
def _statusline_active_account() -> "str | None":
|
|
@@ -1341,6 +1358,21 @@ class _StaleInputs:
|
|
|
1341
1358
|
five_credits: tuple = ()
|
|
1342
1359
|
five_samples: tuple = ()
|
|
1343
1360
|
|
|
1361
|
+
# #1064 (spec 5.1.5): each sample tuple is prepared once, on its first
|
|
1362
|
+
# kernel use, and every kernel call that reads it passes this view. The
|
|
1363
|
+
# view lives exactly as long as this snapshot (`dataclasses.replace` and
|
|
1364
|
+
# a new classification make a new owner); the tuples stay untouched for
|
|
1365
|
+
# every other reader.
|
|
1366
|
+
@functools.cached_property
|
|
1367
|
+
def sample_view(self):
|
|
1368
|
+
import _lib_stale_proof
|
|
1369
|
+
return _lib_stale_proof.prepare(self.samples)
|
|
1370
|
+
|
|
1371
|
+
@functools.cached_property
|
|
1372
|
+
def five_sample_view(self):
|
|
1373
|
+
import _lib_stale_proof
|
|
1374
|
+
return _lib_stale_proof.prepare(self.five_samples)
|
|
1375
|
+
|
|
1344
1376
|
|
|
1345
1377
|
#: #797 spec 4.4: ``(samples_after, receipts_held, drop_work)`` for the
|
|
1346
1378
|
#: partition the detached reducer is working on — the earliest instant any
|
|
@@ -2895,6 +2927,18 @@ class _StaleCache:
|
|
|
2895
2927
|
#: an eligible one and every render dispatches the detached reducer.
|
|
2896
2928
|
refused: frozenset = frozenset()
|
|
2897
2929
|
|
|
2930
|
+
# #1064 (spec 5.1.5): the render's kernel calls read the cached samples
|
|
2931
|
+
# through one view per decoded cache, exactly as `_StaleInputs` does.
|
|
2932
|
+
@functools.cached_property
|
|
2933
|
+
def sample_view(self):
|
|
2934
|
+
import _lib_stale_proof
|
|
2935
|
+
return _lib_stale_proof.prepare(self.samples)
|
|
2936
|
+
|
|
2937
|
+
@functools.cached_property
|
|
2938
|
+
def five_sample_view(self):
|
|
2939
|
+
import _lib_stale_proof
|
|
2940
|
+
return _lib_stale_proof.prepare(self.five_samples)
|
|
2941
|
+
|
|
2898
2942
|
|
|
2899
2943
|
@dataclasses.dataclass(frozen=True)
|
|
2900
2944
|
class AccountResult:
|
|
@@ -4252,9 +4296,11 @@ def _all_members(candidates) -> list:
|
|
|
4252
4296
|
def _stale_axes(stale: "_StaleInputs") -> tuple:
|
|
4253
4297
|
"""Per axis: ``(axis, window, samples, credits, supports)``. The
|
|
4254
4298
|
five-hour axis checks no supports (its accounted value is detector
|
|
4255
|
-
state).
|
|
4256
|
-
|
|
4257
|
-
|
|
4299
|
+
state). ``samples`` is the snapshot's prepared view (#1064), which every
|
|
4300
|
+
consumer passes to the kernel readers only."""
|
|
4301
|
+
return (("sevenDay", stale.window, stale.sample_view, stale.credits, stale.supports),
|
|
4302
|
+
("fiveHour", stale.five_window, stale.five_sample_view, stale.five_credits,
|
|
4303
|
+
None))
|
|
4258
4304
|
|
|
4259
4305
|
|
|
4260
4306
|
def _stale_member_facts(stale: "_StaleInputs | None", receipts, candidates):
|
|
@@ -4340,11 +4386,12 @@ def _completion_receipts(transition, classified, members):
|
|
|
4340
4386
|
if accounted is None or window is None:
|
|
4341
4387
|
continue
|
|
4342
4388
|
# The five-hour axis earns by exact equality alone (path (i)); its
|
|
4343
|
-
# samples feed refusal, never earning.
|
|
4389
|
+
# samples feed refusal, never earning. (#1064: the empty tuple of an
|
|
4390
|
+
# uncovered or five-hour earn holds no sample, so it needs no view.)
|
|
4344
4391
|
earned.extend(_lib_stale_proof.earn_receipts(
|
|
4345
4392
|
[m for m in _axis_members(members, axis) if m.sigma.window == window],
|
|
4346
4393
|
accounted,
|
|
4347
|
-
stale.
|
|
4394
|
+
stale.sample_view if axis == "sevenDay" and stale.samples_covered else (),
|
|
4348
4395
|
completed_at_utc=completed, index_identity=stale.index_identity))
|
|
4349
4396
|
receipts = _lib_stale_proof.latest_per_token(
|
|
4350
4397
|
_lib_stale_proof.merge_receipts(kept, earned))
|
|
@@ -4359,12 +4406,12 @@ def _completion_receipts(transition, classified, members):
|
|
|
4359
4406
|
cache = _StaleCache(
|
|
4360
4407
|
stale.window, stale.accounted,
|
|
4361
4408
|
() if stale.window is None else _lib_stale_proof.compact_samples(
|
|
4362
|
-
stale.
|
|
4409
|
+
stale.sample_view, window=stale.window, floor_epoch=floor),
|
|
4363
4410
|
stale.samples_after, stale.samples_covered,
|
|
4364
4411
|
stale.five_window,
|
|
4365
4412
|
None if stale.five_accounted is None else stale.five_accounted.percent,
|
|
4366
4413
|
five_samples=() if stale.five_window is None else _lib_stale_proof.compact_samples(
|
|
4367
|
-
stale.
|
|
4414
|
+
stale.five_sample_view, window=stale.five_window, floor_epoch=None,
|
|
4368
4415
|
axis="fiveHour"),
|
|
4369
4416
|
refused=refused)
|
|
4370
4417
|
return receipts, cache
|
|
@@ -4474,7 +4521,7 @@ def _receipt_work(transition: _Transition, members) -> bool:
|
|
|
4474
4521
|
if cache.samples_after is None or bound is None or cache.samples_after > bound:
|
|
4475
4522
|
return True
|
|
4476
4523
|
if _lib_stale_proof.earn_receipts(
|
|
4477
|
-
[member], cache.accounted, cache.
|
|
4524
|
+
[member], cache.accounted, cache.sample_view,
|
|
4478
4525
|
completed_at_utc="1970-01-01T00:00:00.000000Z",
|
|
4479
4526
|
index_identity="render"):
|
|
4480
4527
|
return True
|
|
@@ -4578,8 +4625,8 @@ def _cached_drop_proof(cache, member) -> tuple:
|
|
|
4578
4625
|
|
|
4579
4626
|
if cache is None or not cache.samples_covered:
|
|
4580
4627
|
return False, None
|
|
4581
|
-
window, samples = ((cache.window, cache.
|
|
4582
|
-
else (cache.five_window, cache.
|
|
4628
|
+
window, samples = ((cache.window, cache.sample_view) if member.axis == "sevenDay"
|
|
4629
|
+
else (cache.five_window, cache.five_sample_view))
|
|
4583
4630
|
if window is None or member.window != window:
|
|
4584
4631
|
return False, None
|
|
4585
4632
|
bound = _lib_stale_proof.floor_microsecond(member.bound_utc)
|
|
@@ -4816,6 +4863,8 @@ def _plan_drop_facts(account_key: str, plan: dict, stale) -> "dict | None":
|
|
|
4816
4863
|
bound.append(member)
|
|
4817
4864
|
if len(bound) != 1:
|
|
4818
4865
|
continue
|
|
4866
|
+
# #1064: ``samples`` is the snapshot's view; the empty tuple of a
|
|
4867
|
+
# window-less axis holds no sample, so it needs none.
|
|
4819
4868
|
facts = _lib_stale_proof.refusal_facts(
|
|
4820
4869
|
axis, [], samples if window is not None else (), (), (),
|
|
4821
4870
|
index_identity=stale.index_identity,
|
|
@@ -5396,7 +5445,7 @@ def _publish_plan(transition, plan: dict, binding, classified: "_Classified", *,
|
|
|
5396
5445
|
|
|
5397
5446
|
account_key = transition.account_key
|
|
5398
5447
|
_crash_point("before-append")
|
|
5399
|
-
# 6f mode wiring (P2 exit-code fix): the statusline
|
|
5448
|
+
# 6f mode wiring (P2 exit-code fix): the statusline reducer's publish
|
|
5400
5449
|
# step ingests OPPORTUNISTICALLY. A render tick must NEVER crash on a
|
|
5401
5450
|
# derivation error — the opportunistic path swallows a cycle exception
|
|
5402
5451
|
# (loud log only) instead of propagating.
|
|
@@ -8134,17 +8183,21 @@ def _spawn_continuation(kind: str, fd: int, argument: "str | None" = None) -> bo
|
|
|
8134
8183
|
"""Detach one continuation process (`cctally _statusline-continue`) that
|
|
8135
8184
|
inherits ``fd``, the reservation it runs under. The parent's copy is
|
|
8136
8185
|
closed here without unlocking, so the child alone holds it. The child
|
|
8137
|
-
is told the store explicitly, so it can only act on this one.
|
|
8138
|
-
command
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
command += ["--wait", str(argument)]
|
|
8143
|
-
env = dict(os.environ)
|
|
8144
|
-
env["CCTALLY_DATA_DIR"] = str(_cctally_core.APP_DIR)
|
|
8186
|
+
is told the store explicitly, so it can only act on this one. The
|
|
8187
|
+
command is built inside the ``try`` (#1064 review F4), so a failure to
|
|
8188
|
+
build it still closes ``fd`` and releases the reservation rather than
|
|
8189
|
+
leaving it with the caller (the render, whose OAuth child would inherit
|
|
8190
|
+
it)."""
|
|
8145
8191
|
import subprocess
|
|
8146
8192
|
|
|
8147
8193
|
try:
|
|
8194
|
+
command = [sys.executable,
|
|
8195
|
+
str(pathlib.Path(__file__).resolve().with_name("cctally")),
|
|
8196
|
+
_CONTINUE_COMMAND, "--reservation-fd", str(fd)]
|
|
8197
|
+
if kind == "wait":
|
|
8198
|
+
command += ["--wait", str(argument)]
|
|
8199
|
+
env = dict(os.environ)
|
|
8200
|
+
env["CCTALLY_DATA_DIR"] = str(_cctally_core.APP_DIR)
|
|
8148
8201
|
subprocess.Popen(
|
|
8149
8202
|
command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
8150
8203
|
stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True,
|
|
@@ -8685,12 +8738,13 @@ def _preliminary_decision() -> "_candidates.ReductionDecision | None":
|
|
|
8685
8738
|
|
|
8686
8739
|
|
|
8687
8740
|
def _finalize_wal_checkpoints() -> None:
|
|
8688
|
-
"""The W9 finalizer before a
|
|
8741
|
+
"""The W9 finalizer before a detached process exits (#901 Q14).
|
|
8689
8742
|
|
|
8690
|
-
`os._exit` skips `atexit`. The
|
|
8691
|
-
|
|
8692
|
-
|
|
8693
|
-
|
|
8743
|
+
`os._exit` skips `atexit`. The continuation worker records usage (it runs
|
|
8744
|
+
this before returning from `_statusline-continue`) and the OAuth child,
|
|
8745
|
+
which leaves through `os._exit`, may too; either can reach a cache sync
|
|
8746
|
+
that arms a store. The finalizer then attempts one PASSIVE per armed
|
|
8747
|
+
store and closes the keepers. With nothing armed it does nothing."""
|
|
8694
8748
|
try:
|
|
8695
8749
|
import _lib_wal_checkpoint
|
|
8696
8750
|
|
|
@@ -8699,55 +8753,22 @@ def _finalize_wal_checkpoints() -> None:
|
|
|
8699
8753
|
pass
|
|
8700
8754
|
|
|
8701
8755
|
|
|
8702
|
-
def
|
|
8703
|
-
"""
|
|
8704
|
-
|
|
8705
|
-
Spec 4.3 revision 5: the render's dispatch goes through the ONE reducer
|
|
8706
|
-
reservation, ``reservation_fd``, which the child inherits and runs the
|
|
8707
|
-
continuation worker under — the pass under the selected-state lock it
|
|
8708
|
-
takes once the render releases it, the dashboard nudge after that lock
|
|
8709
|
-
is released (#583 S2), the hand-off of what it could not finish (a
|
|
8710
|
-
dependency waiter per held lock, a frontier wake, the epoch rebuild no
|
|
8711
|
-
worker owns) and the final release check — so a render's child and a
|
|
8712
|
-
dispatched worker never overlap. The parent's copy is closed without
|
|
8713
|
-
unlocking."""
|
|
8714
|
-
try:
|
|
8715
|
-
pid = os.fork()
|
|
8716
|
-
except OSError:
|
|
8717
|
-
_release_reservation(reservation_fd)
|
|
8718
|
-
return
|
|
8719
|
-
if pid > 0:
|
|
8720
|
-
try:
|
|
8721
|
-
os.close(reservation_fd)
|
|
8722
|
-
except OSError:
|
|
8723
|
-
pass
|
|
8724
|
-
return
|
|
8756
|
+
def _dispatch_render_worker(reservation_fd: int) -> None:
|
|
8757
|
+
"""Hand the render's ONE reducer reservation to an exec'd continuation
|
|
8758
|
+
worker (`cctally _statusline-continue --reservation-fd <fd>`).
|
|
8725
8759
|
|
|
8726
|
-
#
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
os.dup2(devnull, 0)
|
|
8739
|
-
os.dup2(devnull, 1)
|
|
8740
|
-
os.dup2(devnull, 2)
|
|
8741
|
-
if devnull > 2:
|
|
8742
|
-
os.close(devnull)
|
|
8743
|
-
except OSError:
|
|
8744
|
-
pass
|
|
8745
|
-
_continuation_worker(reservation_fd)
|
|
8746
|
-
except BaseException:
|
|
8747
|
-
pass
|
|
8748
|
-
finally:
|
|
8749
|
-
_finalize_wal_checkpoints()
|
|
8750
|
-
os._exit(0)
|
|
8760
|
+
#1064 spec 5.3: the reducer never runs in a bare-fork child of the
|
|
8761
|
+
render. On macOS a child of a bare `os.fork()` reloads the zone file on
|
|
8762
|
+
every local-time conversion (50-340x slower), which held the selected
|
|
8763
|
+
state for up to an hour per pass. The caller has already released the
|
|
8764
|
+
selected state, so only the reservation reaches the worker
|
|
8765
|
+
(`close_fds=True`, `pass_fds=(fd,)`); it stays held across the hand-off,
|
|
8766
|
+
by the render until the spawn and by the worker after it, so a render's
|
|
8767
|
+
worker and a dispatched worker never overlap. `_spawn_continuation` owns
|
|
8768
|
+
the descriptor in every outcome: a failed spawn closes it, releasing the
|
|
8769
|
+
reservation, the arrival stays durable in the intake and the next
|
|
8770
|
+
activating event reclaims it. Never raises."""
|
|
8771
|
+
_spawn_owned("worker", reservation_fd)
|
|
8751
8772
|
|
|
8752
8773
|
|
|
8753
8774
|
def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
|
|
@@ -8756,9 +8777,10 @@ def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
|
|
|
8756
8777
|
This is the RENDER path (C4): it captures one binding, logs the arrival
|
|
8757
8778
|
with the evaluation it actually saw in the account's durable intake and
|
|
8758
8779
|
only then installs its spool slot (spec 4.2 and 4.3 revision 3, Codex
|
|
8759
|
-
design-fix-round-4 Q4), and decides from files alone whether to
|
|
8760
|
-
detached reducer
|
|
8761
|
-
|
|
8780
|
+
design-fix-round-4 Q4), and decides from files alone whether to dispatch
|
|
8781
|
+
the detached reducer (an exec'd continuation worker, #1064). Capture
|
|
8782
|
+
precedes the selected-state try-lock, so a render that loses it leaves a
|
|
8783
|
+
durable arrival and registers its wake for
|
|
8762
8784
|
the running holder or a worker — it never returns silently on a busy
|
|
8763
8785
|
lock. It never scans the journal, never establishes the cutover and
|
|
8764
8786
|
never touches the index."""
|
|
@@ -8801,15 +8823,20 @@ def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
|
|
|
8801
8823
|
_register_capture()
|
|
8802
8824
|
return
|
|
8803
8825
|
if sync_for_test:
|
|
8804
|
-
# The synchronous twin of the detached
|
|
8826
|
+
# The synchronous twin of the detached worker: the worker body,
|
|
8805
8827
|
# once this frame no longer holds the selected state.
|
|
8806
8828
|
c._release_persist_lock(lock_fd)
|
|
8807
8829
|
lock_fd = None
|
|
8808
8830
|
fd, reservation = reservation, None
|
|
8809
8831
|
_continuation_worker(fd)
|
|
8810
8832
|
return
|
|
8833
|
+
# #1064 spec 5.3: the selected state is released BEFORE the dispatch,
|
|
8834
|
+
# so the worker inherits only the reservation, which this frame still
|
|
8835
|
+
# holds and hands over without releasing it.
|
|
8836
|
+
c._release_persist_lock(lock_fd)
|
|
8837
|
+
lock_fd = None
|
|
8811
8838
|
fd, reservation = reservation, None
|
|
8812
|
-
|
|
8839
|
+
_dispatch_render_worker(fd)
|
|
8813
8840
|
finally:
|
|
8814
8841
|
if lock_fd is not None:
|
|
8815
8842
|
c._release_persist_lock(lock_fd)
|
package/bin/_cctally_store.py
CHANGED
|
@@ -277,15 +277,27 @@ _REPLAY_SCRATCH_INDEXES = (
|
|
|
277
277
|
)
|
|
278
278
|
|
|
279
279
|
|
|
280
|
+
#: #1003 D2: the page cache (KiB) of a full replay's disposable scratch. A
|
|
281
|
+
#: replay batch dirties more distinct pages than SQLite's 2 MiB default holds,
|
|
282
|
+
#: so each overflow spilled pages the batch kept re-dirtying and wrote them
|
|
283
|
+
#: again; 64 MiB removes every spill (256 MiB writes the same bytes). SQLite
|
|
284
|
+
#: allocates cache pages only as a transaction touches them, and completion
|
|
285
|
+
#: closes each replay copy before opening the next.
|
|
286
|
+
DISPOSABLE_SCRATCH_CACHE_SIZE_KIB = 65536
|
|
287
|
+
|
|
288
|
+
|
|
280
289
|
def apply_disposable_scratch_policy(conn: sqlite3.Connection) -> None:
|
|
281
290
|
"""Put a full replay's disposable scratch connection on the #997 policy:
|
|
282
|
-
rollback journal in memory, no fsync,
|
|
283
|
-
`weekly_usage_snapshots` exists. Called right
|
|
284
|
-
outside any transaction; commits before
|
|
291
|
+
rollback journal in memory, no fsync, a 64 MiB page cache (#1003 D2), and
|
|
292
|
+
the replay indexes when `weekly_usage_snapshots` exists. Called right
|
|
293
|
+
after the scratch is opened, outside any transaction; commits before
|
|
294
|
+
returning. The live rebuild's `.rebuilding-*` scratch never comes here."""
|
|
285
295
|
if conn.in_transaction:
|
|
286
296
|
conn.commit()
|
|
287
297
|
conn.execute("PRAGMA journal_mode=MEMORY")
|
|
288
298
|
conn.execute("PRAGMA synchronous=OFF")
|
|
299
|
+
conn.execute(
|
|
300
|
+
f"PRAGMA cache_size=-{int(DISPOSABLE_SCRATCH_CACHE_SIZE_KIB)}")
|
|
289
301
|
has_usage = conn.execute(
|
|
290
302
|
"SELECT 1 FROM sqlite_schema WHERE type = 'table' "
|
|
291
303
|
"AND name = 'weekly_usage_snapshots'").fetchone()
|
|
@@ -609,6 +621,7 @@ _INGEST_LOCK_HELD = _cctally_core._STATS_INGEST_LOCK_HELD
|
|
|
609
621
|
_INTERRUPTED_RECOVERY_SUPPRESSED = (
|
|
610
622
|
_cctally_core._STATS_INTERRUPTED_RECOVERY_SUPPRESSED
|
|
611
623
|
)
|
|
624
|
+
_DIAGNOSTIC_ALLOWANCE = _cctally_core._STATS_DIAGNOSTIC_WAIT_ALLOWANCE
|
|
612
625
|
|
|
613
626
|
|
|
614
627
|
def in_stats_write_scope() -> bool:
|
|
@@ -627,6 +640,42 @@ def holds_ingest_lock() -> bool:
|
|
|
627
640
|
return _INGEST_LOCK_HELD.get() > 0
|
|
628
641
|
|
|
629
642
|
|
|
643
|
+
@dataclass
|
|
644
|
+
class _DiagnosticWaitAllowance:
|
|
645
|
+
"""The one mutable record a ``bounded_diagnostic_waits`` scope shares."""
|
|
646
|
+
|
|
647
|
+
remaining_s: float
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
@contextlib.contextmanager
|
|
651
|
+
def bounded_diagnostic_waits(allowance_s: float):
|
|
652
|
+
"""Bound every maintenance-lock wait in this context by one allowance (#1005).
|
|
653
|
+
|
|
654
|
+
Doctor's gather opens stats.db through several independent guarded opens,
|
|
655
|
+
and each one used to pay the opener's full 5 s wait while maintenance held
|
|
656
|
+
the lock: 8 × 5 s ≈ 40 s for one report. Inside this scope every wait made
|
|
657
|
+
through ``diagnostic_flock`` (the opener's shared, re-acquisition and
|
|
658
|
+
exclusive-upgrade waits, ``stats_open_time_guard``'s loop, and Doctor's own
|
|
659
|
+
cache-maintenance admission) draws from ONE cumulative allowance. Elapsed
|
|
660
|
+
acquisition time is charged on success, expiry and exception and never
|
|
661
|
+
replenished; once it is spent each acquisition still makes exactly one
|
|
662
|
+
non-blocking attempt, so a released lock is still taken and nothing latches.
|
|
663
|
+
|
|
664
|
+
Nested scopes share the outer record rather than minting a fresh one, and
|
|
665
|
+
exit restores the prior context, also on an exception. Outside any scope
|
|
666
|
+
every caller keeps its own timeout, byte for byte.
|
|
667
|
+
"""
|
|
668
|
+
if _DIAGNOSTIC_ALLOWANCE.get() is not None:
|
|
669
|
+
yield
|
|
670
|
+
return
|
|
671
|
+
token = _DIAGNOSTIC_ALLOWANCE.set(
|
|
672
|
+
_DiagnosticWaitAllowance(max(0.0, float(allowance_s))))
|
|
673
|
+
try:
|
|
674
|
+
yield
|
|
675
|
+
finally:
|
|
676
|
+
_DIAGNOSTIC_ALLOWANCE.reset(token)
|
|
677
|
+
|
|
678
|
+
|
|
630
679
|
@contextlib.contextmanager
|
|
631
680
|
def suppress_interrupted_stats_recovery():
|
|
632
681
|
"""Keep every nested Doctor stats opener read-only in this context."""
|
|
@@ -896,22 +945,16 @@ def stats_open_time_guard(
|
|
|
896
945
|
)
|
|
897
946
|
acquired = False
|
|
898
947
|
try:
|
|
899
|
-
|
|
948
|
+
# #1005: the same poll as before outside a Doctor scope; inside one,
|
|
949
|
+
# `diagnostic_flock` caps it by the remaining allowance (the smaller
|
|
950
|
+
# of that and any `wait_seconds` override) and charges it.
|
|
951
|
+
if not diagnostic_flock(
|
|
952
|
+
fd, fcntl.LOCK_EX,
|
|
900
953
|
_STATS_OPEN_TIME_MAINTENANCE_WAIT_S if wait_seconds is None
|
|
901
|
-
else float(wait_seconds)
|
|
902
|
-
)
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
906
|
-
acquired = True
|
|
907
|
-
break
|
|
908
|
-
except (BlockingIOError, OSError) as exc:
|
|
909
|
-
if time.monotonic() >= deadline:
|
|
910
|
-
_GUARD_FAILURE.set(
|
|
911
|
-
"contention" if isinstance(exc, BlockingIOError)
|
|
912
|
-
else "error")
|
|
913
|
-
raise _maintenance_busy_error("exclusive")
|
|
914
|
-
time.sleep(0.02)
|
|
954
|
+
else float(wait_seconds),
|
|
955
|
+
):
|
|
956
|
+
raise _maintenance_busy_error("exclusive")
|
|
957
|
+
acquired = True
|
|
915
958
|
_cctally_core.note_stats_maintenance_acquired()
|
|
916
959
|
try:
|
|
917
960
|
with stats_write_scope("open-time"):
|
|
@@ -990,18 +1033,45 @@ _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG = (
|
|
|
990
1033
|
)
|
|
991
1034
|
|
|
992
1035
|
|
|
993
|
-
def
|
|
994
|
-
"""
|
|
1036
|
+
def _confirmed_stats_heal_active() -> bool:
|
|
1037
|
+
"""A confirmed corruption heal whose detached worker may be running."""
|
|
995
1038
|
request = _read_stats_heal_request()
|
|
996
|
-
|
|
1039
|
+
return bool(
|
|
997
1040
|
request
|
|
998
1041
|
and request.get("forensicsDisposition") == "confirmed"
|
|
999
1042
|
and _stats_heal_worker_active()
|
|
1000
|
-
)
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _stats_open_maintenance_timeout_message() -> str:
|
|
1047
|
+
"""Name a confirmed detached rebuild when it owns the maintenance wait."""
|
|
1048
|
+
if _confirmed_stats_heal_active():
|
|
1001
1049
|
return str(_cctally_db.StatsHealDeferred("pending"))
|
|
1002
1050
|
return _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
|
|
1003
1051
|
|
|
1004
1052
|
|
|
1053
|
+
def active_stats_recovery_deferral() -> "_cctally_db.StatsRebuildDeferred | None":
|
|
1054
|
+
"""The pending deferral a running detached stats recovery stands for (#932).
|
|
1055
|
+
|
|
1056
|
+
Both detached recoveries hold the maintenance lock EXCLUSIVE for their
|
|
1057
|
+
whole rebuild, so an open during one times out with a plain
|
|
1058
|
+
`StatsDbMaintenanceError` rather than a deferral. A caller that already
|
|
1059
|
+
holds an explicit deferral frame uses this to tell "the recovery is still
|
|
1060
|
+
running" from a genuine maintenance conflict: a confirmed heal with a live
|
|
1061
|
+
worker is ``StatsHealDeferred("pending")``, else a positively owned epoch
|
|
1062
|
+
rebuild is ``StatsEpochRebuildDeferred("pending")``, else ``None``. Reads
|
|
1063
|
+
the workers' records only; takes no lock, writes nothing, never raises.
|
|
1064
|
+
"""
|
|
1065
|
+
try:
|
|
1066
|
+
if _confirmed_stats_heal_active():
|
|
1067
|
+
return _cctally_db.StatsHealDeferred("pending")
|
|
1068
|
+
if _stats_epoch_rebuild_worker_active():
|
|
1069
|
+
return _cctally_db.StatsEpochRebuildDeferred("pending")
|
|
1070
|
+
except Exception: # noqa: BLE001 — unknown recovery state is no recovery
|
|
1071
|
+
return None
|
|
1072
|
+
return None
|
|
1073
|
+
|
|
1074
|
+
|
|
1005
1075
|
#: Why this context's most recent bounded maintenance wait expired
|
|
1006
1076
|
#: (#825/#832 Codex fix-round-4 Q3): ``contention`` when the lock stayed
|
|
1007
1077
|
#: held, ``error`` for an OS error no holder caused.
|
|
@@ -1023,6 +1093,56 @@ def _maintenance_busy_error(mode: str) -> "_cctally_db.StatsDbMaintenanceError":
|
|
|
1023
1093
|
return exc
|
|
1024
1094
|
|
|
1025
1095
|
|
|
1096
|
+
def diagnostic_flock(fh, operation: int, timeout_s: float) -> bool:
|
|
1097
|
+
"""Poll for ``operation`` on ``fh`` (a file object or fd), bounded (#1005).
|
|
1098
|
+
|
|
1099
|
+
``True`` when the lock is held, ``False`` on expiry (nothing acquired, so
|
|
1100
|
+
the caller must not release). Outside a ``bounded_diagnostic_waits`` scope
|
|
1101
|
+
this is the opener's historical loop unchanged: poll every 0.02 s until
|
|
1102
|
+
``timeout_s``. Inside one it is the single place the shared allowance is
|
|
1103
|
+
charged: the deadline is ``now + min(timeout_s, remaining)``, no sleep
|
|
1104
|
+
passes that deadline or follows the last attempt, and the call's elapsed
|
|
1105
|
+
time is subtracted (floored at 0) however it ends. With nothing left it
|
|
1106
|
+
makes exactly one ``LOCK_NB`` attempt.
|
|
1107
|
+
|
|
1108
|
+
``_GUARD_FAILURE`` is set exactly as before, so ``_maintenance_busy_error``
|
|
1109
|
+
still tells contention from an OS error.
|
|
1110
|
+
"""
|
|
1111
|
+
allowance = _DIAGNOSTIC_ALLOWANCE.get()
|
|
1112
|
+
_GUARD_FAILURE.set(None)
|
|
1113
|
+
if allowance is None:
|
|
1114
|
+
deadline = time.monotonic() + timeout_s
|
|
1115
|
+
while True:
|
|
1116
|
+
try:
|
|
1117
|
+
fcntl.flock(fh, operation | fcntl.LOCK_NB)
|
|
1118
|
+
return True
|
|
1119
|
+
except (BlockingIOError, OSError) as exc:
|
|
1120
|
+
if time.monotonic() >= deadline:
|
|
1121
|
+
_GUARD_FAILURE.set(
|
|
1122
|
+
"contention" if isinstance(exc, BlockingIOError)
|
|
1123
|
+
else "error")
|
|
1124
|
+
return False
|
|
1125
|
+
time.sleep(0.02)
|
|
1126
|
+
start = time.monotonic()
|
|
1127
|
+
deadline = start + min(float(timeout_s), allowance.remaining_s)
|
|
1128
|
+
try:
|
|
1129
|
+
while True:
|
|
1130
|
+
try:
|
|
1131
|
+
fcntl.flock(fh, operation | fcntl.LOCK_NB)
|
|
1132
|
+
return True
|
|
1133
|
+
except (BlockingIOError, OSError) as exc:
|
|
1134
|
+
now = time.monotonic()
|
|
1135
|
+
if now >= deadline:
|
|
1136
|
+
_GUARD_FAILURE.set(
|
|
1137
|
+
"contention" if isinstance(exc, BlockingIOError)
|
|
1138
|
+
else "error")
|
|
1139
|
+
return False
|
|
1140
|
+
time.sleep(min(0.02, deadline - now))
|
|
1141
|
+
finally:
|
|
1142
|
+
elapsed = max(0.0, time.monotonic() - start)
|
|
1143
|
+
allowance.remaining_s = max(0.0, allowance.remaining_s - elapsed)
|
|
1144
|
+
|
|
1145
|
+
|
|
1026
1146
|
def _flock_bounded(lock_fh, operation: int, timeout_s: float) -> bool:
|
|
1027
1147
|
"""Poll for ``operation`` on ``lock_fh`` until ``timeout_s`` expires.
|
|
1028
1148
|
|
|
@@ -1030,20 +1150,9 @@ def _flock_bounded(lock_fh, operation: int, timeout_s: float) -> bool:
|
|
|
1030
1150
|
the caller must not release). Same shape as ``_heal_flock_bounded``, but it
|
|
1031
1151
|
operates on an already-open file object rather than minting an fd, because
|
|
1032
1152
|
the opener holds one file object across its whole marker/pending/connect
|
|
1033
|
-
sequence.
|
|
1153
|
+
sequence. Delegates to ``diagnostic_flock`` so a Doctor scope bounds it.
|
|
1034
1154
|
"""
|
|
1035
|
-
|
|
1036
|
-
_GUARD_FAILURE.set(None)
|
|
1037
|
-
while True:
|
|
1038
|
-
try:
|
|
1039
|
-
fcntl.flock(lock_fh, operation | fcntl.LOCK_NB)
|
|
1040
|
-
return True
|
|
1041
|
-
except (BlockingIOError, OSError) as exc:
|
|
1042
|
-
if time.monotonic() >= deadline:
|
|
1043
|
-
_GUARD_FAILURE.set(
|
|
1044
|
-
"contention" if isinstance(exc, BlockingIOError) else "error")
|
|
1045
|
-
return False
|
|
1046
|
-
time.sleep(0.02)
|
|
1155
|
+
return diagnostic_flock(lock_fh, operation, timeout_s)
|
|
1047
1156
|
|
|
1048
1157
|
|
|
1049
1158
|
def _stats_repair_marker(db_path) -> pathlib.Path:
|
|
@@ -1416,6 +1525,33 @@ def _has_completed_stats_quarantine_incident(
|
|
|
1416
1525
|
return False
|
|
1417
1526
|
|
|
1418
1527
|
|
|
1528
|
+
def _lock_free_journal_high_water() -> "tuple[str, int] | None":
|
|
1529
|
+
"""``(latest segment, size)`` read without ``journal.lock`` (#1005).
|
|
1530
|
+
|
|
1531
|
+
The value ``journal_high_water()`` returns, but with no leaf lock and no
|
|
1532
|
+
created file, as ``_cctally_rederive._read_only_journal_high_water`` does.
|
|
1533
|
+
A writer may remove an empty future segment under ``journal.lock``
|
|
1534
|
+
between the listing and the size read, so a ``FileNotFoundError`` there
|
|
1535
|
+
re-lists once; a second failure, or any other ``OSError``, propagates.
|
|
1536
|
+
"""
|
|
1537
|
+
import _cctally_journal
|
|
1538
|
+
|
|
1539
|
+
for attempt in range(2):
|
|
1540
|
+
segments = _cctally_journal.list_segments()
|
|
1541
|
+
if not segments:
|
|
1542
|
+
return None
|
|
1543
|
+
latest = segments[-1]
|
|
1544
|
+
try:
|
|
1545
|
+
return (
|
|
1546
|
+
latest,
|
|
1547
|
+
os.path.getsize(_cctally_core.JOURNAL_DIR / latest),
|
|
1548
|
+
)
|
|
1549
|
+
except FileNotFoundError:
|
|
1550
|
+
if attempt:
|
|
1551
|
+
raise
|
|
1552
|
+
raise AssertionError("unreachable")
|
|
1553
|
+
|
|
1554
|
+
|
|
1419
1555
|
def stats_interrupted_rebuild_evidence(
|
|
1420
1556
|
db_path: pathlib.Path,
|
|
1421
1557
|
) -> "dict | None":
|
|
@@ -1429,9 +1565,7 @@ def stats_interrupted_rebuild_evidence(
|
|
|
1429
1565
|
or not _has_completed_stats_quarantine_incident(db_path, artifacts)
|
|
1430
1566
|
):
|
|
1431
1567
|
return None
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
high_water = _cctally_journal.journal_high_water()
|
|
1568
|
+
high_water = _lock_free_journal_high_water()
|
|
1435
1569
|
if high_water is None or high_water[1] == 0:
|
|
1436
1570
|
return None
|
|
1437
1571
|
lock_fh = open(lock_path, "r+")
|
|
@@ -1648,6 +1782,13 @@ def stats_open_guarded(
|
|
|
1648
1782
|
# deadlock while upgrading; recheck under exclusive because a
|
|
1649
1783
|
# live owner may have completed it in the gap.
|
|
1650
1784
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
1785
|
+
if _DIAGNOSTIC_ALLOWANCE.get() is not None:
|
|
1786
|
+
# #1005 C3: a diagnostic (Doctor) open never resumes a
|
|
1787
|
+
# pending quarantine — handle discovery and the family
|
|
1788
|
+
# quarantine are mutations. Report maintenance instead,
|
|
1789
|
+
# with no fabricated `blocked_by`: nothing was contended.
|
|
1790
|
+
raise _cctally_db.StatsDbMaintenanceError(
|
|
1791
|
+
_stats_open_maintenance_timeout_message())
|
|
1651
1792
|
if not _flock_bounded(
|
|
1652
1793
|
lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
|
|
1653
1794
|
):
|