cctally 1.115.0 → 1.116.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 +16 -0
- package/bin/_cctally_cache.py +85 -4
- package/bin/_cctally_completion.py +80 -12
- package/bin/_cctally_core.py +106 -9
- package/bin/_cctally_dashboard.py +6 -9
- package/bin/_cctally_dashboard_envelope.py +26 -32
- package/bin/_cctally_db.py +85 -4
- package/bin/_cctally_doctor.py +38 -5
- package/bin/_cctally_five_hour.py +83 -1017
- package/bin/_cctally_journal.py +1307 -180
- package/bin/_cctally_journal_repair.py +9 -7
- package/bin/_cctally_oauth_samples.py +280 -0
- package/bin/_cctally_parser.py +5 -0
- package/bin/_cctally_quarantine.py +14 -4
- package/bin/_cctally_quota.py +70 -8
- package/bin/_cctally_quota_model.py +35 -4
- package/bin/_cctally_record.py +414 -123
- package/bin/_cctally_rederive.py +632 -235
- package/bin/_cctally_refresh.py +29 -0
- package/bin/_cctally_statusline.py +984 -47
- package/bin/_cctally_store.py +972 -58
- package/bin/_cctally_tui.py +62 -7
- package/bin/_cctally_update.py +25 -0
- package/bin/_lib_account_protocol.py +201 -18
- package/bin/_lib_clone_copy.py +46 -11
- package/bin/_lib_dashboard_sources.py +57 -15
- package/bin/_lib_detector_access_registry.py +273 -52
- package/bin/_lib_doctor.py +302 -1
- package/bin/_lib_journal.py +145 -25
- package/bin/_lib_journal_router.py +166 -48
- package/bin/_lib_oauth_samples.py +238 -0
- package/bin/_lib_pricing_check.py +0 -12
- package/bin/_lib_provider_activity.py +770 -0
- package/bin/_lib_quarantine.py +3 -2
- package/bin/_lib_quota_model.py +96 -6
- package/bin/_lib_rebuild_progress.py +146 -0
- package/bin/_lib_record.py +22 -0
- package/bin/_lib_rederive.py +299 -3
- package/bin/_lib_snapshot_cache.py +43 -1
- package/bin/_lib_stale_proof.py +708 -0
- package/bin/_lib_statusline_candidates.py +65 -5
- package/bin/_lib_worker_launches.py +315 -0
- package/bin/cctally +6 -11
- package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
- package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
- package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
- package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
- package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +7 -2
- package/bin/_lib_merge_gate.py +0 -452
- package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
package/bin/_cctally_store.py
CHANGED
|
@@ -249,6 +249,52 @@ def apply_writer_temp_store(conn: sqlite3.Connection) -> None:
|
|
|
249
249
|
conn.execute(f"PRAGMA temp_store = {WRITER_TEMP_STORE}")
|
|
250
250
|
|
|
251
251
|
|
|
252
|
+
# #997 (spec D1, D6): a full replay's DISPOSABLE scratch. Derivation
|
|
253
|
+
# completion's replay copies and predecessor base, and `db rederive`'s
|
|
254
|
+
# `stats.rederive.db`, are written, read once and discarded: nothing publishes
|
|
255
|
+
# or trusts them, and a crash abandons the whole scratch directory. Under the
|
|
256
|
+
# stats policy (`journal_mode=DELETE`, `synchronous=FULL`) every commit
|
|
257
|
+
# created, wrote, fsynced and unlinked a rollback journal and fsynced the
|
|
258
|
+
# database, which on a full replay was the incident's multi-gigabyte burst.
|
|
259
|
+
#
|
|
260
|
+
# `MEMORY`, never `OFF`: the replay rolls a failed batch back, and `ROLLBACK`
|
|
261
|
+
# under `journal_mode=OFF` is undefined. The three partial indexes serve the
|
|
262
|
+
# replay's per-input "latest prior row" lookups and the reset-crossing sweep;
|
|
263
|
+
# their predicates restrict them to statements carrying the same terms (the
|
|
264
|
+
# order-sensitivity audit is in the #997 plan's appendix). They never reach a
|
|
265
|
+
# published index: `.rebuilding-*` and the live index keep the stats policy
|
|
266
|
+
# and `_REBUILD_SCHEMA_FINGERPRINT`.
|
|
267
|
+
_REPLAY_SCRATCH_INDEXES = (
|
|
268
|
+
"CREATE INDEX IF NOT EXISTS replay_usage_latest_weekly "
|
|
269
|
+
"ON weekly_usage_snapshots(account_key, captured_at_utc DESC, id DESC) "
|
|
270
|
+
"WHERE week_end_at IS NOT NULL AND weekly_observation_held = 0",
|
|
271
|
+
"CREATE INDEX IF NOT EXISTS replay_usage_latest_five_hour "
|
|
272
|
+
"ON weekly_usage_snapshots(account_key, captured_at_utc DESC, id DESC) "
|
|
273
|
+
"WHERE five_hour_window_key IS NOT NULL AND five_hour_percent IS NOT NULL",
|
|
274
|
+
"CREATE INDEX IF NOT EXISTS replay_usage_week_start "
|
|
275
|
+
"ON weekly_usage_snapshots(account_key, unixepoch(week_start_at)) "
|
|
276
|
+
"WHERE week_start_at IS NOT NULL",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def apply_disposable_scratch_policy(conn: sqlite3.Connection) -> None:
|
|
281
|
+
"""Put a full replay's disposable scratch connection on the #997 policy:
|
|
282
|
+
rollback journal in memory, no fsync, and the replay indexes when
|
|
283
|
+
`weekly_usage_snapshots` exists. Called right after the scratch is opened,
|
|
284
|
+
outside any transaction; commits before returning."""
|
|
285
|
+
if conn.in_transaction:
|
|
286
|
+
conn.commit()
|
|
287
|
+
conn.execute("PRAGMA journal_mode=MEMORY")
|
|
288
|
+
conn.execute("PRAGMA synchronous=OFF")
|
|
289
|
+
has_usage = conn.execute(
|
|
290
|
+
"SELECT 1 FROM sqlite_schema WHERE type = 'table' "
|
|
291
|
+
"AND name = 'weekly_usage_snapshots'").fetchone()
|
|
292
|
+
if has_usage is not None:
|
|
293
|
+
for statement in _REPLAY_SCRATCH_INDEXES:
|
|
294
|
+
conn.execute(statement)
|
|
295
|
+
conn.commit()
|
|
296
|
+
|
|
297
|
+
|
|
252
298
|
#: PR-3: the temp store an opener selects for the duration of PENDING schema
|
|
253
299
|
#: and migration work (a first open, an upgrade, a cutover), then restores
|
|
254
300
|
#: ``WRITER_TEMP_STORE``. A one-time index build or a migration over a
|
|
@@ -1780,7 +1826,14 @@ def _heal_flock_blocking(path) -> int:
|
|
|
1780
1826
|
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
1781
1827
|
fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o600)
|
|
1782
1828
|
try:
|
|
1783
|
-
|
|
1829
|
+
try:
|
|
1830
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
1831
|
+
except BlockingIOError:
|
|
1832
|
+
# #997 D9: contended, so the blocking wait is reported as its own
|
|
1833
|
+
# phase (a no-op unless a worker installed a progress sink).
|
|
1834
|
+
import _lib_rebuild_progress
|
|
1835
|
+
with _lib_rebuild_progress.waiting("maintenance-lock"):
|
|
1836
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
1784
1837
|
except BaseException:
|
|
1785
1838
|
os.close(fd)
|
|
1786
1839
|
raise
|
|
@@ -2442,6 +2495,15 @@ def _reserve_stats_corruption_heal(
|
|
|
2442
2495
|
return ("failed", None)
|
|
2443
2496
|
admitted = dict(request)
|
|
2444
2497
|
admitted["coalescedDetections"] = 0
|
|
2498
|
+
# #997 D8: date the unresolved condition, not this request. A stale
|
|
2499
|
+
# request replaced as `stale-retried` hands its date on, so the
|
|
2500
|
+
# pending age never restarts across retries.
|
|
2501
|
+
admitted["firstUnresolvedAt"] = (
|
|
2502
|
+
(stale_request.get("firstUnresolvedAt")
|
|
2503
|
+
or stale_request.get("detectedAtUtc"))
|
|
2504
|
+
if stale_request else None
|
|
2505
|
+
) or request.get("firstUnresolvedAt") or request.get(
|
|
2506
|
+
"detectedAtUtc") or _cctally_core.now_utc_iso()
|
|
2445
2507
|
try:
|
|
2446
2508
|
_cctally_db._atomic_write_private_json(marker, admitted)
|
|
2447
2509
|
except OSError:
|
|
@@ -2484,19 +2546,54 @@ def complete_stats_corruption_heal(reservation: str) -> str:
|
|
|
2484
2546
|
"""
|
|
2485
2547
|
if reservation != "reserved":
|
|
2486
2548
|
return reservation
|
|
2487
|
-
|
|
2488
|
-
|
|
2549
|
+
# #997 D8 launch: the reservation is persisted under the admission flock
|
|
2550
|
+
# before the spawn, and the child's identity filled under it after. The
|
|
2551
|
+
# spawn itself stays outside the flock, as before, so a detection racing
|
|
2552
|
+
# it can still coalesce; the ledger is keyed by launch id, so the child
|
|
2553
|
+
# acknowledging first is harmless. Without the flock the worker's own
|
|
2554
|
+
# acknowledgement records the launch.
|
|
2555
|
+
launch_id = _new_launch_id()
|
|
2556
|
+
reserve = []
|
|
2557
|
+
_with_heal_admission(lambda: reserve.append(_spawner_reserve(
|
|
2558
|
+
"heal", launch_id, _cctally_core.now_utc_iso())))
|
|
2559
|
+
if reserve == ["withheld"]:
|
|
2560
|
+
# Launch capacity: the admitted request stays pending (its marker is
|
|
2561
|
+
# kept) and nothing is spawned; a later detection retries it.
|
|
2562
|
+
return "pending"
|
|
2563
|
+
pid = _spawn_rebuild_worker(STATS_CORRUPTION_HEAL_COMMAND, launch_id)
|
|
2564
|
+
if pid is not None:
|
|
2565
|
+
_with_heal_admission(lambda: _spawner_fill(
|
|
2566
|
+
"heal", launch_id, pid, _cctally_core.now_utc_iso()))
|
|
2489
2567
|
return "spawned"
|
|
2490
2568
|
# Drop our own marker so the next detection is admitted immediately rather
|
|
2491
2569
|
# than waiting out the retry window. Done under the admission flock, as it
|
|
2492
2570
|
# was before the split, so no concurrent caller can coalesce onto a marker
|
|
2493
2571
|
# that is about to disappear. If the flock is unavailable the marker stays
|
|
2494
2572
|
# and the retry window clears it — the retryable direction.
|
|
2495
|
-
_unlink_stats_heal_marker_under_admission()
|
|
2573
|
+
_unlink_stats_heal_marker_under_admission(launch_id=launch_id)
|
|
2496
2574
|
return "failed"
|
|
2497
2575
|
|
|
2498
2576
|
|
|
2499
|
-
def
|
|
2577
|
+
def _with_heal_admission(action):
|
|
2578
|
+
"""Run ``action`` under the heal admission flock (bounded); ``None``
|
|
2579
|
+
when the flock could not be had."""
|
|
2580
|
+
try:
|
|
2581
|
+
fd = _heal_flock_bounded(
|
|
2582
|
+
_stats_heal_admission_path(), _STATS_HEAL_ADMISSION_WAIT_S)
|
|
2583
|
+
except OSError:
|
|
2584
|
+
return None
|
|
2585
|
+
if fd is None:
|
|
2586
|
+
return None
|
|
2587
|
+
try:
|
|
2588
|
+
action()
|
|
2589
|
+
return True
|
|
2590
|
+
finally:
|
|
2591
|
+
_heal_release_flock(fd)
|
|
2592
|
+
|
|
2593
|
+
|
|
2594
|
+
def _unlink_stats_heal_marker_under_admission(
|
|
2595
|
+
launch_id: "str | None" = None,
|
|
2596
|
+
) -> bool:
|
|
2500
2597
|
"""Settle a spawn failure with the final count before allowing retry."""
|
|
2501
2598
|
admission_fd = _heal_flock_bounded(
|
|
2502
2599
|
_stats_heal_admission_path(), _STATS_HEAL_ADMISSION_WAIT_S
|
|
@@ -2504,6 +2601,9 @@ def _unlink_stats_heal_marker_under_admission() -> bool:
|
|
|
2504
2601
|
if admission_fd is None:
|
|
2505
2602
|
return False
|
|
2506
2603
|
try:
|
|
2604
|
+
if launch_id is not None:
|
|
2605
|
+
# #997 D8: a confirmed spawn failure is a recorded failed start.
|
|
2606
|
+
_spawner_fill("heal", launch_id, None, _cctally_core.now_utc_iso())
|
|
2507
2607
|
current = _read_stats_heal_request()
|
|
2508
2608
|
if current:
|
|
2509
2609
|
heal_id = str(current.get("healId") or "")
|
|
@@ -2564,6 +2664,12 @@ def defer_stats_corruption_heal(request: dict) -> str:
|
|
|
2564
2664
|
)
|
|
2565
2665
|
|
|
2566
2666
|
|
|
2667
|
+
#: #997 D10: why the last `_run_stats_corruption_heal` in this process
|
|
2668
|
+
#: declined an unconfirmed heal (its forensics reason), for the run state;
|
|
2669
|
+
#: only `integrity_check_ok` classifies clean. Reset by the worker per run.
|
|
2670
|
+
_last_heal_decline_reason: "str | None" = None
|
|
2671
|
+
|
|
2672
|
+
|
|
2567
2673
|
def _run_stats_corruption_heal(request: dict) -> str:
|
|
2568
2674
|
"""The worker's body, under its own maintenance-EXCLUSIVE hold.
|
|
2569
2675
|
|
|
@@ -2579,6 +2685,7 @@ def _run_stats_corruption_heal(request: dict) -> str:
|
|
|
2579
2685
|
prevent;
|
|
2580
2686
|
* the probe is the one the DETECTION established, carried in the request.
|
|
2581
2687
|
"""
|
|
2688
|
+
global _last_heal_decline_reason
|
|
2582
2689
|
import _cctally_journal
|
|
2583
2690
|
|
|
2584
2691
|
path = _cctally_core.DB_PATH
|
|
@@ -2671,6 +2778,7 @@ def _run_stats_corruption_heal(request: dict) -> str:
|
|
|
2671
2778
|
declineReason=(None if confirmed else reason),
|
|
2672
2779
|
)
|
|
2673
2780
|
if not confirmed:
|
|
2781
|
+
_last_heal_decline_reason = reason
|
|
2674
2782
|
return "declined-unconfirmed"
|
|
2675
2783
|
import _cctally_completion
|
|
2676
2784
|
obligation_failure = None
|
|
@@ -2704,6 +2812,52 @@ def _run_stats_corruption_heal(request: dict) -> str:
|
|
|
2704
2812
|
_heal_release_maintenance_flock(maint_fd)
|
|
2705
2813
|
|
|
2706
2814
|
|
|
2815
|
+
def _stats_heal_worker_body(result: dict) -> bool:
|
|
2816
|
+
"""The heal worker's work under its flock, after its run is recorded.
|
|
2817
|
+
|
|
2818
|
+
Fills ``result`` with the run's primary ``outcome``, its separate
|
|
2819
|
+
``finalization`` (``done`` | ``pending``; #997 D10) and, for a declined
|
|
2820
|
+
unconfirmed heal, the ``declineReason``. Returns whether it published.
|
|
2821
|
+
"""
|
|
2822
|
+
global _last_heal_decline_reason
|
|
2823
|
+
request = _read_stats_heal_request()
|
|
2824
|
+
if not request:
|
|
2825
|
+
_unlink_stats_heal_marker()
|
|
2826
|
+
_log_stats_heal("no-request")
|
|
2827
|
+
result.update(outcome="no-request", finalization="done")
|
|
2828
|
+
return False
|
|
2829
|
+
heal_id = str(request.get("healId") or "")
|
|
2830
|
+
_last_heal_decline_reason = None
|
|
2831
|
+
try:
|
|
2832
|
+
outcome = _run_stats_corruption_heal(request)
|
|
2833
|
+
except (_cctally_db.StatsRebuildDeferred, Exception) as exc:
|
|
2834
|
+
# Retryable: the marker stays so a later detection is admitted
|
|
2835
|
+
# once its retry window expires.
|
|
2836
|
+
_record_stats_heal_outcome(heal_id, "failed", error=exc)
|
|
2837
|
+
_log_stats_heal("error", heal_id=heal_id, error=exc)
|
|
2838
|
+
result.update(outcome="failed")
|
|
2839
|
+
return False
|
|
2840
|
+
result.update(outcome=outcome, declineReason=(
|
|
2841
|
+
_last_heal_decline_reason if outcome == "declined-unconfirmed"
|
|
2842
|
+
else None))
|
|
2843
|
+
if outcome in ("maintenance-busy", "ingest-busy"):
|
|
2844
|
+
_log_stats_heal(outcome, heal_id=heal_id)
|
|
2845
|
+
return False
|
|
2846
|
+
# Fix round K11 (K9K10-R7): a heal that published but lost its
|
|
2847
|
+
# residual obligation is a heal; its ring outcome is already
|
|
2848
|
+
# recorded (with the obligation error) and it wakes like a success.
|
|
2849
|
+
published = outcome in ("success", STATS_HEAL_RESIDUAL_NOT_DURABLE)
|
|
2850
|
+
if not published:
|
|
2851
|
+
_record_stats_heal_outcome(heal_id, outcome)
|
|
2852
|
+
if not _finalize_stats_heal_request(heal_id):
|
|
2853
|
+
_log_stats_heal("finalization-pending", heal_id=heal_id)
|
|
2854
|
+
result.update(finalization="pending")
|
|
2855
|
+
return False
|
|
2856
|
+
result.update(finalization="done")
|
|
2857
|
+
_log_stats_heal(outcome, heal_id=heal_id)
|
|
2858
|
+
return published
|
|
2859
|
+
|
|
2860
|
+
|
|
2707
2861
|
#: Fix round K11 (K9K10-R7): the heal published the rebuilt index, but its
|
|
2708
2862
|
#: residual-derivation obligation could not be made durable. The heal is
|
|
2709
2863
|
#: done (the request is finalized and the publication wake posted); the ring
|
|
@@ -2727,36 +2881,22 @@ def cmd_stats_corruption_heal_internal(args) -> int:
|
|
|
2727
2881
|
try:
|
|
2728
2882
|
fcntl.flock(worker_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
2729
2883
|
except OSError:
|
|
2884
|
+
_record_worker_busy("heal")
|
|
2730
2885
|
return 0
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2886
|
+
# #997 D8: record the run (and any dead predecessor) before any work;
|
|
2887
|
+
# a run that cannot be recorded does none.
|
|
2888
|
+
run = _WorkerRun("heal")
|
|
2889
|
+
if not run.begin():
|
|
2735
2890
|
return 0
|
|
2736
|
-
|
|
2891
|
+
result = {"outcome": "failed", "finalization": None,
|
|
2892
|
+
"declineReason": None}
|
|
2737
2893
|
try:
|
|
2738
|
-
|
|
2739
|
-
except (_cctally_db.StatsRebuildDeferred, Exception) as exc:
|
|
2740
|
-
# Retryable: the marker stays so a later detection is admitted
|
|
2741
|
-
# once its retry window expires.
|
|
2742
|
-
_record_stats_heal_outcome(heal_id, "failed", error=exc)
|
|
2743
|
-
_log_stats_heal("error", heal_id=heal_id, error=exc)
|
|
2744
|
-
return 0
|
|
2745
|
-
if outcome in ("maintenance-busy", "ingest-busy"):
|
|
2746
|
-
_log_stats_heal(outcome, heal_id=heal_id)
|
|
2894
|
+
healed = _stats_heal_worker_body(result)
|
|
2747
2895
|
return 0
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
if not published:
|
|
2753
|
-
_record_stats_heal_outcome(heal_id, outcome)
|
|
2754
|
-
if not _finalize_stats_heal_request(heal_id):
|
|
2755
|
-
_log_stats_heal("finalization-pending", heal_id=heal_id)
|
|
2756
|
-
return 0
|
|
2757
|
-
_log_stats_heal(outcome, heal_id=heal_id)
|
|
2758
|
-
healed = published
|
|
2759
|
-
return 0
|
|
2896
|
+
finally:
|
|
2897
|
+
run.finish(result["outcome"],
|
|
2898
|
+
finalization=result["finalization"],
|
|
2899
|
+
decline_reason=result["declineReason"])
|
|
2760
2900
|
finally:
|
|
2761
2901
|
try:
|
|
2762
2902
|
fcntl.flock(worker_fd, fcntl.LOCK_UN)
|
|
@@ -3126,6 +3266,750 @@ def _log_stats_epoch_rebuild(outcome: str, *, error: Exception | None = None) ->
|
|
|
3126
3266
|
pass
|
|
3127
3267
|
|
|
3128
3268
|
|
|
3269
|
+
# --------------------------------------------------------------------------
|
|
3270
|
+
# #997 D8 — the detached rebuild workers' run state and launch ledger
|
|
3271
|
+
# --------------------------------------------------------------------------
|
|
3272
|
+
#
|
|
3273
|
+
# Both detached workers (the epoch rebuild and the corruption heal) keep two
|
|
3274
|
+
# private JSON records in APP_DIR, written only through
|
|
3275
|
+
# `_cctally_db._atomic_write_private_json`:
|
|
3276
|
+
#
|
|
3277
|
+
# * the RUN STATE (`<worker>.state.json`), written only by the holder of that
|
|
3278
|
+
# worker's flock: the current or last run, its phase and progress, and the
|
|
3279
|
+
# five most recent in-run deaths;
|
|
3280
|
+
# * the LAUNCH LEDGER (`<worker>.launches.json`), written only under that
|
|
3281
|
+
# worker's admission flock: every launch and what became of it, classified
|
|
3282
|
+
# by the pure kernel `_lib_worker_launches`.
|
|
3283
|
+
#
|
|
3284
|
+
# Lock order: worker flock, then admission flock (bounded wait). Spawners hold
|
|
3285
|
+
# admission and only probe the worker flock non-blockingly. `doctor` and
|
|
3286
|
+
# `db status` read both records and take neither lock. A death is settled by
|
|
3287
|
+
# evidence (a finished run state, or a process confirmed gone by identity),
|
|
3288
|
+
# never by elapsed time.
|
|
3289
|
+
|
|
3290
|
+
import _lib_worker_launches as _wl # noqa: E402 — stdlib-only kernel
|
|
3291
|
+
|
|
3292
|
+
REBUILD_LAUNCH_ENV = "CCTALLY_REBUILD_LAUNCH_ID"
|
|
3293
|
+
#: The workers' bounded wait for their admission flock (spec D8 step 1).
|
|
3294
|
+
_WORKER_ADMISSION_WAIT_S = 30.0
|
|
3295
|
+
_WORKER_DEATHS_KEEP = 5
|
|
3296
|
+
_WORKER_RECORD_SCHEMA = 1
|
|
3297
|
+
_WORKER_FILES = {
|
|
3298
|
+
"epoch": ("stats-epoch-rebuild.state.json",
|
|
3299
|
+
"stats-epoch-rebuild.launches.json"),
|
|
3300
|
+
"heal": ("stats-heal.state.json", "stats-heal.launches.json"),
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
|
|
3304
|
+
def _worker_state_path(worker: str) -> pathlib.Path:
|
|
3305
|
+
return pathlib.Path(_cctally_core.APP_DIR) / _WORKER_FILES[worker][0]
|
|
3306
|
+
|
|
3307
|
+
|
|
3308
|
+
def _worker_ledger_path(worker: str) -> pathlib.Path:
|
|
3309
|
+
return pathlib.Path(_cctally_core.APP_DIR) / _WORKER_FILES[worker][1]
|
|
3310
|
+
|
|
3311
|
+
|
|
3312
|
+
def _worker_admission_path(worker: str) -> pathlib.Path:
|
|
3313
|
+
if worker == "epoch":
|
|
3314
|
+
return _stats_epoch_rebuild_admission_path()
|
|
3315
|
+
return _stats_heal_admission_path()
|
|
3316
|
+
|
|
3317
|
+
|
|
3318
|
+
def _read_private_record(path: pathlib.Path):
|
|
3319
|
+
"""``(payload | None, unreadable, raw bytes | None)`` for one record.
|
|
3320
|
+
|
|
3321
|
+
A missing file is ``(None, False, None)``. Anything present that is not a
|
|
3322
|
+
JSON object at ``schemaVersion`` 1 is unreadable; ``raw`` carries its
|
|
3323
|
+
bytes for the archive when they could be read at all.
|
|
3324
|
+
"""
|
|
3325
|
+
try:
|
|
3326
|
+
raw = path.read_bytes()
|
|
3327
|
+
except FileNotFoundError:
|
|
3328
|
+
return None, False, None
|
|
3329
|
+
except OSError:
|
|
3330
|
+
return None, True, None
|
|
3331
|
+
try:
|
|
3332
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
3333
|
+
except (UnicodeDecodeError, ValueError):
|
|
3334
|
+
return None, True, raw
|
|
3335
|
+
if (not isinstance(payload, dict)
|
|
3336
|
+
or payload.get("schemaVersion") != _WORKER_RECORD_SCHEMA):
|
|
3337
|
+
return None, True, raw
|
|
3338
|
+
return payload, False, raw
|
|
3339
|
+
|
|
3340
|
+
|
|
3341
|
+
def _validated_worker_state(payload):
|
|
3342
|
+
"""The run state when it has the expected shape, else ``None``."""
|
|
3343
|
+
if payload is None:
|
|
3344
|
+
return None
|
|
3345
|
+
run = payload.get("run")
|
|
3346
|
+
deaths = payload.get("deaths", [])
|
|
3347
|
+
if run is not None and not isinstance(run, dict):
|
|
3348
|
+
return None
|
|
3349
|
+
# The run's launch id keys its ledger entry and every prune's protected
|
|
3350
|
+
# set: a list id is unhashable (the prune raised before the state was
|
|
3351
|
+
# rewritten, so every later launch failed the same way) and an int id
|
|
3352
|
+
# makes the next ledger normalization reject the whole ledger.
|
|
3353
|
+
launch_id = (run or {}).get("launchId")
|
|
3354
|
+
if launch_id is not None and not isinstance(launch_id, str):
|
|
3355
|
+
return None
|
|
3356
|
+
if not isinstance(deaths, list) or not all(
|
|
3357
|
+
isinstance(d, dict) for d in deaths):
|
|
3358
|
+
return None
|
|
3359
|
+
return payload
|
|
3360
|
+
|
|
3361
|
+
|
|
3362
|
+
def read_worker_state(worker: str) -> dict:
|
|
3363
|
+
"""``{"state": dict | None, "unreadable": bool}``; never writes."""
|
|
3364
|
+
payload, unreadable, _raw = _read_private_record(
|
|
3365
|
+
_worker_state_path(worker))
|
|
3366
|
+
if payload is not None and _validated_worker_state(payload) is None:
|
|
3367
|
+
payload, unreadable = None, True
|
|
3368
|
+
return {"state": payload, "unreadable": unreadable}
|
|
3369
|
+
|
|
3370
|
+
|
|
3371
|
+
def read_launch_ledger(worker: str) -> dict:
|
|
3372
|
+
"""``{"ledger": dict | None, "unreadable": bool}``; never writes."""
|
|
3373
|
+
payload, unreadable, _raw = _read_private_record(
|
|
3374
|
+
_worker_ledger_path(worker))
|
|
3375
|
+
if payload is not None:
|
|
3376
|
+
try:
|
|
3377
|
+
payload = _wl.normalize(payload)
|
|
3378
|
+
except ValueError:
|
|
3379
|
+
payload, unreadable = None, True
|
|
3380
|
+
return {"ledger": payload, "unreadable": unreadable}
|
|
3381
|
+
|
|
3382
|
+
|
|
3383
|
+
def _mtime_iso(path: pathlib.Path) -> "str | None":
|
|
3384
|
+
try:
|
|
3385
|
+
mtime = path.stat().st_mtime
|
|
3386
|
+
except OSError:
|
|
3387
|
+
return None
|
|
3388
|
+
return _cctally_core.now_utc_iso(
|
|
3389
|
+
dt.datetime.fromtimestamp(mtime, dt.timezone.utc))
|
|
3390
|
+
|
|
3391
|
+
|
|
3392
|
+
def read_epoch_marker() -> dict:
|
|
3393
|
+
"""The pending epoch marker: ``{"present", "firstPendingAt", "legacy"}``.
|
|
3394
|
+
|
|
3395
|
+
The marker carries ``{schemaVersion: 1, firstPendingAt}``; an empty marker
|
|
3396
|
+
from an older binary reads as ``firstPendingAt`` = its mtime. Never writes.
|
|
3397
|
+
"""
|
|
3398
|
+
marker = _stats_epoch_rebuild_marker_path()
|
|
3399
|
+
try:
|
|
3400
|
+
raw = marker.read_bytes()
|
|
3401
|
+
except FileNotFoundError:
|
|
3402
|
+
return {"present": False, "firstPendingAt": None, "legacy": False}
|
|
3403
|
+
except OSError:
|
|
3404
|
+
return {"present": True, "firstPendingAt": _mtime_iso(marker),
|
|
3405
|
+
"legacy": True}
|
|
3406
|
+
try:
|
|
3407
|
+
payload = json.loads(raw.decode("utf-8")) if raw.strip() else None
|
|
3408
|
+
except (UnicodeDecodeError, ValueError):
|
|
3409
|
+
payload = None
|
|
3410
|
+
first = payload.get("firstPendingAt") if isinstance(payload, dict) else None
|
|
3411
|
+
if isinstance(first, str) and first:
|
|
3412
|
+
return {"present": True, "firstPendingAt": first, "legacy": False}
|
|
3413
|
+
return {"present": True, "firstPendingAt": _mtime_iso(marker),
|
|
3414
|
+
"legacy": True}
|
|
3415
|
+
|
|
3416
|
+
|
|
3417
|
+
def _write_epoch_marker(first_pending_at: str) -> None:
|
|
3418
|
+
"""Publish the marker (under the epoch admission flock). The write also
|
|
3419
|
+
refreshes its mtime, which stays the retry throttle."""
|
|
3420
|
+
_cctally_db._atomic_write_private_json(
|
|
3421
|
+
_stats_epoch_rebuild_marker_path(),
|
|
3422
|
+
{"schemaVersion": 1, "firstPendingAt": first_pending_at})
|
|
3423
|
+
|
|
3424
|
+
|
|
3425
|
+
def read_stats_heal_request_snapshot() -> "dict | None":
|
|
3426
|
+
"""``{"present", "healId", "firstUnresolvedAt"}`` or ``None``; never
|
|
3427
|
+
writes. ``firstUnresolvedAt`` falls back to the request's detection time,
|
|
3428
|
+
and an unreadable request to the marker's mtime."""
|
|
3429
|
+
request = _read_stats_heal_request()
|
|
3430
|
+
if request is None:
|
|
3431
|
+
return None
|
|
3432
|
+
if not request:
|
|
3433
|
+
return {"present": True, "healId": None,
|
|
3434
|
+
"firstUnresolvedAt": _mtime_iso(_stats_heal_marker_path())}
|
|
3435
|
+
return {
|
|
3436
|
+
"present": True,
|
|
3437
|
+
"healId": request.get("healId"),
|
|
3438
|
+
"firstUnresolvedAt": (request.get("firstUnresolvedAt")
|
|
3439
|
+
or request.get("detectedAtUtc")),
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
|
|
3443
|
+
def process_identity(pid) -> dict:
|
|
3444
|
+
"""``{"status": "alive"|"gone"|"unknown", "birth": str | None}``.
|
|
3445
|
+
|
|
3446
|
+
From ``ps -o stat=,lstart= -p <pid>`` under ``LC_ALL=C``: no such process
|
|
3447
|
+
(``ps`` exits 1 with no output) or a zombie (``Z`` in ``stat``) is
|
|
3448
|
+
``gone``; ``ps`` failing in any other way is ``unknown``. ``birth`` is the
|
|
3449
|
+
one-second-resolution start time, whitespace-normalized.
|
|
3450
|
+
"""
|
|
3451
|
+
import subprocess
|
|
3452
|
+
|
|
3453
|
+
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
|
|
3454
|
+
return {"status": "unknown", "birth": None}
|
|
3455
|
+
try:
|
|
3456
|
+
out = subprocess.run(
|
|
3457
|
+
["ps", "-o", "stat=,lstart=", "-p", str(pid)],
|
|
3458
|
+
capture_output=True, text=True, timeout=10, check=False,
|
|
3459
|
+
env={**os.environ, "LC_ALL": "C"})
|
|
3460
|
+
except (OSError, subprocess.SubprocessError):
|
|
3461
|
+
return {"status": "unknown", "birth": None}
|
|
3462
|
+
line = out.stdout.strip()
|
|
3463
|
+
if not line:
|
|
3464
|
+
if out.returncode == 1 and not out.stderr.strip():
|
|
3465
|
+
return {"status": "gone", "birth": None}
|
|
3466
|
+
return {"status": "unknown", "birth": None}
|
|
3467
|
+
stat_field, _sep, rest = line.partition(" ")
|
|
3468
|
+
birth = " ".join(rest.split()) or None
|
|
3469
|
+
if "Z" in stat_field:
|
|
3470
|
+
return {"status": "gone", "birth": birth}
|
|
3471
|
+
return {"status": "alive", "birth": birth}
|
|
3472
|
+
|
|
3473
|
+
|
|
3474
|
+
def _archive_unreadable_record(path: pathlib.Path, raw) -> "str | None":
|
|
3475
|
+
"""Copy an unreadable record's bytes to ``<name>.unreadable-<UTC stamp>``
|
|
3476
|
+
(a new private file, ``fsync``ed); the original stays in place until its
|
|
3477
|
+
replacement is published. ``None`` when its bytes could not be read."""
|
|
3478
|
+
if raw is None:
|
|
3479
|
+
return None
|
|
3480
|
+
stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
3481
|
+
archive = path.with_name(f"{path.name}.unreadable-{stamp}")
|
|
3482
|
+
fd = os.open(archive, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
3483
|
+
try:
|
|
3484
|
+
view = memoryview(raw)
|
|
3485
|
+
while view:
|
|
3486
|
+
written = os.write(fd, view)
|
|
3487
|
+
if written <= 0:
|
|
3488
|
+
raise OSError("short write while archiving an unreadable record")
|
|
3489
|
+
view = view[written:]
|
|
3490
|
+
os.fsync(fd)
|
|
3491
|
+
finally:
|
|
3492
|
+
os.close(fd)
|
|
3493
|
+
_cctally_db._fsync_directory(path.parent)
|
|
3494
|
+
return str(archive)
|
|
3495
|
+
|
|
3496
|
+
|
|
3497
|
+
def _load_ledger_for_update(worker: str) -> dict:
|
|
3498
|
+
"""The ledger to update under the admission flock. An unreadable one is
|
|
3499
|
+
archived first and replaced by an empty ledger (raises ``OSError`` when
|
|
3500
|
+
the archive cannot be written)."""
|
|
3501
|
+
path = _worker_ledger_path(worker)
|
|
3502
|
+
payload, unreadable, raw = _read_private_record(path)
|
|
3503
|
+
if payload is not None:
|
|
3504
|
+
try:
|
|
3505
|
+
return _wl.normalize(payload)
|
|
3506
|
+
except ValueError:
|
|
3507
|
+
unreadable = True
|
|
3508
|
+
if unreadable:
|
|
3509
|
+
_archive_unreadable_record(path, raw)
|
|
3510
|
+
return _wl.empty_ledger()
|
|
3511
|
+
|
|
3512
|
+
|
|
3513
|
+
def _write_ledger(worker: str, ledger: dict) -> None:
|
|
3514
|
+
_cctally_db._atomic_write_private_json(_worker_ledger_path(worker), ledger)
|
|
3515
|
+
|
|
3516
|
+
|
|
3517
|
+
def _probe_unresolved(ledger: dict, *, skip: "str | None" = None) -> dict:
|
|
3518
|
+
"""``process_identity`` for each unresolved entry's pid."""
|
|
3519
|
+
identities: dict = {}
|
|
3520
|
+
for entry in _wl.unresolved(ledger):
|
|
3521
|
+
pid = entry.get("pid")
|
|
3522
|
+
if entry.get("launchId") == skip or pid in identities:
|
|
3523
|
+
continue
|
|
3524
|
+
if isinstance(pid, int) and pid > 0:
|
|
3525
|
+
identities[pid] = process_identity(pid)
|
|
3526
|
+
return identities
|
|
3527
|
+
|
|
3528
|
+
|
|
3529
|
+
def _current_run_launch_id(state) -> "str | None":
|
|
3530
|
+
run = state.get("run") if isinstance(state, dict) else None
|
|
3531
|
+
launch_id = run.get("launchId") if isinstance(run, dict) else None
|
|
3532
|
+
return launch_id if isinstance(launch_id, str) else None
|
|
3533
|
+
|
|
3534
|
+
|
|
3535
|
+
def _spawner_classify(worker: str, ledger: dict, now: str) -> dict:
|
|
3536
|
+
"""The spawner's classification under the admission flock: probe the
|
|
3537
|
+
unresolved pids, THEN read the run state (a run that finished before the
|
|
3538
|
+
probe is then visible), and settle what that evidence settles."""
|
|
3539
|
+
identities = _probe_unresolved(ledger)
|
|
3540
|
+
state = read_worker_state(worker)["state"]
|
|
3541
|
+
return _wl.classify(ledger, state, identities, now=now)["ledger"]
|
|
3542
|
+
|
|
3543
|
+
|
|
3544
|
+
def _spawner_reserve(worker: str, launch_id: str, now: str) -> str:
|
|
3545
|
+
"""Classify the ledger and persist this launch's reservation (caller
|
|
3546
|
+
holds the admission flock).
|
|
3547
|
+
|
|
3548
|
+
``"reserved"`` when the reservation is durable; ``"withheld"`` when
|
|
3549
|
+
``LAUNCH_CAPACITY`` entries are still unresolved after classification
|
|
3550
|
+
(the settlements are written, no reservation is, and the caller must not
|
|
3551
|
+
spawn: the request stays pending and `doctor` reports
|
|
3552
|
+
``details.admission = "launch-capacity"``); ``"unwritten"`` when the
|
|
3553
|
+
ledger could not be written, in which case the caller still spawns and
|
|
3554
|
+
the worker's acknowledgement records the launch."""
|
|
3555
|
+
try:
|
|
3556
|
+
ledger = _load_ledger_for_update(worker)
|
|
3557
|
+
ledger = _spawner_classify(worker, ledger, now)
|
|
3558
|
+
state = read_worker_state(worker)["state"]
|
|
3559
|
+
protected = _current_run_launch_id(state)
|
|
3560
|
+
if not _wl.has_launch_capacity(ledger):
|
|
3561
|
+
_write_ledger(worker, _wl.prune(ledger, protected))
|
|
3562
|
+
return "withheld"
|
|
3563
|
+
ledger = _wl.reserve(ledger, launch_id=launch_id, at=now)
|
|
3564
|
+
_write_ledger(worker, _wl.prune(ledger, protected))
|
|
3565
|
+
return "reserved"
|
|
3566
|
+
except (OSError, ValueError):
|
|
3567
|
+
return "unwritten"
|
|
3568
|
+
|
|
3569
|
+
|
|
3570
|
+
def _spawner_fill(worker: str, launch_id: str, pid, now: str) -> None:
|
|
3571
|
+
"""Complete the reservation with the spawn's result (caller holds the
|
|
3572
|
+
admission flock). ``pid is None`` records a failed spawn."""
|
|
3573
|
+
try:
|
|
3574
|
+
ledger = _load_ledger_for_update(worker)
|
|
3575
|
+
if pid is None:
|
|
3576
|
+
ledger = _wl.fill_spawn(ledger, launch_id, pid=None, birth=None,
|
|
3577
|
+
at=now, spawn="failed")
|
|
3578
|
+
else:
|
|
3579
|
+
birth = process_identity(pid).get("birth")
|
|
3580
|
+
ledger = _wl.fill_spawn(ledger, launch_id, pid=pid, birth=birth,
|
|
3581
|
+
at=now)
|
|
3582
|
+
state = read_worker_state(worker)["state"]
|
|
3583
|
+
_write_ledger(worker, _wl.prune(ledger, _current_run_launch_id(state)))
|
|
3584
|
+
except (OSError, ValueError):
|
|
3585
|
+
pass
|
|
3586
|
+
|
|
3587
|
+
|
|
3588
|
+
def _spawn_rebuild_worker(command: str, launch_id: str) -> "int | None":
|
|
3589
|
+
from _cctally_update import _spawn_detached_with_pid
|
|
3590
|
+
return _spawn_detached_with_pid(
|
|
3591
|
+
command, env={**os.environ, REBUILD_LAUNCH_ENV: launch_id})
|
|
3592
|
+
|
|
3593
|
+
|
|
3594
|
+
def _new_launch_id() -> str:
|
|
3595
|
+
import uuid
|
|
3596
|
+
return str(uuid.uuid4())
|
|
3597
|
+
|
|
3598
|
+
|
|
3599
|
+
def _take_launch_id() -> str:
|
|
3600
|
+
"""This worker's launch id, consumed from its environment so a later
|
|
3601
|
+
worker started by the same process never reuses it; a worker nobody
|
|
3602
|
+
launched through a spawner gets a fresh one."""
|
|
3603
|
+
return os.environ.pop(REBUILD_LAUNCH_ENV, None) or _new_launch_id()
|
|
3604
|
+
|
|
3605
|
+
|
|
3606
|
+
def _worker_log(worker: str, line: str) -> None:
|
|
3607
|
+
"""Append one structural line to the worker's own log."""
|
|
3608
|
+
try:
|
|
3609
|
+
path = (_stats_epoch_rebuild_log_path() if worker == "epoch"
|
|
3610
|
+
else _stats_heal_log_path())
|
|
3611
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
3612
|
+
name = ("stats-epoch-rebuild" if worker == "epoch"
|
|
3613
|
+
else "stats-corruption-heal")
|
|
3614
|
+
data = (f"{_cctally_core.now_utc_iso()} worker={name} {line}\n"
|
|
3615
|
+
).encode("utf-8")
|
|
3616
|
+
fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
3617
|
+
try:
|
|
3618
|
+
os.write(fd, data)
|
|
3619
|
+
finally:
|
|
3620
|
+
os.close(fd)
|
|
3621
|
+
except Exception:
|
|
3622
|
+
pass
|
|
3623
|
+
|
|
3624
|
+
|
|
3625
|
+
def _worker_log_error(worker: str, stage: str, error=None) -> None:
|
|
3626
|
+
detail = ""
|
|
3627
|
+
if error is not None:
|
|
3628
|
+
detail = f" error={type(error).__name__}"
|
|
3629
|
+
code = getattr(error, "errno", None)
|
|
3630
|
+
if code is not None:
|
|
3631
|
+
detail += f" code={int(code)}"
|
|
3632
|
+
_worker_log(worker, f"result=error stage={stage}{detail}")
|
|
3633
|
+
|
|
3634
|
+
|
|
3635
|
+
def _json_counters(counters) -> dict:
|
|
3636
|
+
"""Counters a state file can carry: JSON scalars only."""
|
|
3637
|
+
return {str(k): v for k, v in (counters or {}).items()
|
|
3638
|
+
if isinstance(v, (int, float, str, bool)) or v is None}
|
|
3639
|
+
|
|
3640
|
+
|
|
3641
|
+
def _record_worker_busy(worker: str) -> None:
|
|
3642
|
+
"""A flock loser is not a run: under the admission flock it writes only
|
|
3643
|
+
its ``busy`` acknowledgement, which is also its entry's terminal."""
|
|
3644
|
+
launch_id = _take_launch_id()
|
|
3645
|
+
pid = os.getpid()
|
|
3646
|
+
|
|
3647
|
+
def unrecorded(stage: str) -> None:
|
|
3648
|
+
# Without the acknowledgement the entry later reads as a pre-start
|
|
3649
|
+
# death; the log keeps what really happened.
|
|
3650
|
+
_worker_log(worker, (f"result=busy-unrecorded launch={launch_id} "
|
|
3651
|
+
f"pid={pid} stage={stage}"))
|
|
3652
|
+
|
|
3653
|
+
try:
|
|
3654
|
+
fd = _heal_flock_bounded(_worker_admission_path(worker),
|
|
3655
|
+
_WORKER_ADMISSION_WAIT_S)
|
|
3656
|
+
except OSError:
|
|
3657
|
+
unrecorded("admission")
|
|
3658
|
+
return
|
|
3659
|
+
if fd is None:
|
|
3660
|
+
unrecorded("admission")
|
|
3661
|
+
return
|
|
3662
|
+
try:
|
|
3663
|
+
now = _cctally_core.now_utc_iso()
|
|
3664
|
+
ledger = _load_ledger_for_update(worker)
|
|
3665
|
+
ledger = _wl.acknowledge(ledger, launch_id, "busy", at=now, pid=pid,
|
|
3666
|
+
birth=process_identity(pid).get("birth"))
|
|
3667
|
+
state = read_worker_state(worker)["state"]
|
|
3668
|
+
_write_ledger(worker, _wl.prune(ledger, _current_run_launch_id(state)))
|
|
3669
|
+
except (OSError, ValueError):
|
|
3670
|
+
unrecorded("ledger")
|
|
3671
|
+
finally:
|
|
3672
|
+
_heal_release_flock(fd)
|
|
3673
|
+
|
|
3674
|
+
|
|
3675
|
+
def rebuild_worker_snapshot(worker: str) -> dict:
|
|
3676
|
+
"""What `doctor` and `db status` classify, gathered without any lock
|
|
3677
|
+
and without writing (spec D8 "Liveness without the flock").
|
|
3678
|
+
|
|
3679
|
+
Reads the run state then the ledger, probes the identity of a running
|
|
3680
|
+
run's pid and of each unresolved entry's pid, then re-reads both files in
|
|
3681
|
+
the same order: a run that finished, an acknowledgement or outcome that
|
|
3682
|
+
appeared, or a run that started in between is classified from the fresh
|
|
3683
|
+
files, so a death is reported only when the process was confirmed gone
|
|
3684
|
+
BEFORE a read that still shows it unfinished. (A ledger write precedes the
|
|
3685
|
+
state write that replaces a run slot, so reading the state first never
|
|
3686
|
+
pairs a replaced slot with a ledger that lacks the transfer.)
|
|
3687
|
+
"""
|
|
3688
|
+
first_state = read_worker_state(worker)
|
|
3689
|
+
first_ledger = read_launch_ledger(worker)
|
|
3690
|
+
pids = set()
|
|
3691
|
+
run = (first_state["state"] or {}).get("run")
|
|
3692
|
+
if isinstance(run, dict) and run.get("status") == "running":
|
|
3693
|
+
pids.add(run.get("pid"))
|
|
3694
|
+
for entry in _wl.unresolved(first_ledger["ledger"] or {}):
|
|
3695
|
+
pids.add(entry.get("pid"))
|
|
3696
|
+
identities = {pid: process_identity(pid) for pid in pids
|
|
3697
|
+
if isinstance(pid, int) and not isinstance(pid, bool)
|
|
3698
|
+
and pid > 0}
|
|
3699
|
+
state = read_worker_state(worker)
|
|
3700
|
+
ledger = read_launch_ledger(worker)
|
|
3701
|
+
if worker == "epoch":
|
|
3702
|
+
marker = read_epoch_marker()
|
|
3703
|
+
pending = {
|
|
3704
|
+
"present": marker["present"],
|
|
3705
|
+
"since": marker["firstPendingAt"],
|
|
3706
|
+
"indexPending": (stats_epoch_rebuild_pending()
|
|
3707
|
+
if marker["present"] else False),
|
|
3708
|
+
}
|
|
3709
|
+
else:
|
|
3710
|
+
request = read_stats_heal_request_snapshot()
|
|
3711
|
+
pending = {
|
|
3712
|
+
"present": request is not None,
|
|
3713
|
+
"since": (request or {}).get("firstUnresolvedAt"),
|
|
3714
|
+
"indexPending": None,
|
|
3715
|
+
}
|
|
3716
|
+
return {
|
|
3717
|
+
"worker": worker,
|
|
3718
|
+
"state": state["state"],
|
|
3719
|
+
"stateUnreadable": state["unreadable"],
|
|
3720
|
+
"ledger": ledger["ledger"],
|
|
3721
|
+
"ledgerUnreadable": ledger["unreadable"],
|
|
3722
|
+
"identities": identities,
|
|
3723
|
+
"pending": pending,
|
|
3724
|
+
"reread": (first_state != state or first_ledger != ledger),
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
|
|
3728
|
+
def _parse_utc_instant(value) -> "dt.datetime | None":
|
|
3729
|
+
"""An ISO-8601 instant as an aware UTC datetime (``Z`` or an offset; a
|
|
3730
|
+
naive value is UTC), or ``None`` when it is not one."""
|
|
3731
|
+
if not isinstance(value, str) or not value:
|
|
3732
|
+
return None
|
|
3733
|
+
try:
|
|
3734
|
+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
3735
|
+
except ValueError:
|
|
3736
|
+
return None
|
|
3737
|
+
if parsed.tzinfo is None:
|
|
3738
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
3739
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
3740
|
+
|
|
3741
|
+
|
|
3742
|
+
def _launches_before_start(entries, launch_id, pending_since) -> int:
|
|
3743
|
+
"""How many other launches the ledger records since ``pending_since``,
|
|
3744
|
+
compared as UTC instants (``Z`` and ``+00:00`` forms are one clock; a
|
|
3745
|
+
value that does not parse is never counted)."""
|
|
3746
|
+
since = _parse_utc_instant(pending_since)
|
|
3747
|
+
if since is None:
|
|
3748
|
+
return 0
|
|
3749
|
+
count = 0
|
|
3750
|
+
for e in entries:
|
|
3751
|
+
if e.get("launchId") == launch_id:
|
|
3752
|
+
continue
|
|
3753
|
+
at = _parse_utc_instant(e.get("at"))
|
|
3754
|
+
if at is not None and at >= since:
|
|
3755
|
+
count += 1
|
|
3756
|
+
return count
|
|
3757
|
+
|
|
3758
|
+
|
|
3759
|
+
class _WorkerRun:
|
|
3760
|
+
"""One detached worker run, entered immediately after the worker flock is
|
|
3761
|
+
won (spec D8 steps 1–5). ``begin()`` returns ``False`` when the run could
|
|
3762
|
+
not be recorded, and the worker then returns without doing any work."""
|
|
3763
|
+
|
|
3764
|
+
def __init__(self, worker: str):
|
|
3765
|
+
self.worker = worker
|
|
3766
|
+
self.launch_id = _take_launch_id()
|
|
3767
|
+
self.run_id = _new_heal_id()
|
|
3768
|
+
self.pid = os.getpid()
|
|
3769
|
+
self.birth = None
|
|
3770
|
+
self.state: "dict | None" = None
|
|
3771
|
+
self._started = None
|
|
3772
|
+
self._ack_written = False
|
|
3773
|
+
|
|
3774
|
+
# -- step 1–3 ----------------------------------------------------------
|
|
3775
|
+
def _pending_since(self) -> "str | None":
|
|
3776
|
+
if self.worker == "epoch":
|
|
3777
|
+
return read_epoch_marker().get("firstPendingAt")
|
|
3778
|
+
snapshot = read_stats_heal_request_snapshot()
|
|
3779
|
+
return snapshot.get("firstUnresolvedAt") if snapshot else None
|
|
3780
|
+
|
|
3781
|
+
def begin(self) -> bool:
|
|
3782
|
+
import _lib_rebuild_progress
|
|
3783
|
+
|
|
3784
|
+
worker = self.worker
|
|
3785
|
+
now = _cctally_core.now_utc_iso()
|
|
3786
|
+
self._started = time.monotonic()
|
|
3787
|
+
self.birth = process_identity(self.pid).get("birth")
|
|
3788
|
+
state_path = _worker_state_path(worker)
|
|
3789
|
+
prior, unreadable, raw = _read_private_record(state_path)
|
|
3790
|
+
if prior is not None and _validated_worker_state(prior) is None:
|
|
3791
|
+
prior, unreadable = None, True
|
|
3792
|
+
previous_unreadable = (prior or {}).get("previousUnreadable")
|
|
3793
|
+
if unreadable:
|
|
3794
|
+
try:
|
|
3795
|
+
archive = _archive_unreadable_record(state_path, raw)
|
|
3796
|
+
except OSError as exc:
|
|
3797
|
+
_worker_log_error(worker, "state", exc)
|
|
3798
|
+
return False
|
|
3799
|
+
previous_unreadable = {"detectedAt": now, "archive": archive}
|
|
3800
|
+
prior = None
|
|
3801
|
+
deaths = [dict(d) for d in (prior or {}).get("deaths") or []]
|
|
3802
|
+
prior_run = (prior or {}).get("run")
|
|
3803
|
+
prior_run = dict(prior_run) if isinstance(prior_run, dict) else None
|
|
3804
|
+
new_deaths: list = []
|
|
3805
|
+
# Until this worker's state write lands, the slot still names the
|
|
3806
|
+
# prior run: its entry is protected from every prune made before then
|
|
3807
|
+
# with this launch's own, or a worker that dies (or fails) before its
|
|
3808
|
+
# state write would let the next one account the prior death twice.
|
|
3809
|
+
protected = (self.launch_id, _current_run_launch_id(prior))
|
|
3810
|
+
try:
|
|
3811
|
+
fd = _heal_flock_bounded(_worker_admission_path(worker),
|
|
3812
|
+
_WORKER_ADMISSION_WAIT_S)
|
|
3813
|
+
except OSError as exc:
|
|
3814
|
+
_worker_log_error(worker, "ledger", exc)
|
|
3815
|
+
return False
|
|
3816
|
+
if fd is None:
|
|
3817
|
+
_worker_log_error(worker, "ledger")
|
|
3818
|
+
return False
|
|
3819
|
+
try:
|
|
3820
|
+
try:
|
|
3821
|
+
ledger = _load_ledger_for_update(worker)
|
|
3822
|
+
except OSError as exc:
|
|
3823
|
+
_worker_log_error(worker, "ledger", exc)
|
|
3824
|
+
return False
|
|
3825
|
+
ledger, prior_run = self._reconcile_prior_run(
|
|
3826
|
+
ledger, prior_run, deaths, new_deaths, now)
|
|
3827
|
+
identities = _probe_unresolved(ledger, skip=self.launch_id)
|
|
3828
|
+
classified = _wl.classify(
|
|
3829
|
+
ledger, {"run": prior_run, "deaths": deaths}, identities,
|
|
3830
|
+
now=now)
|
|
3831
|
+
ledger = classified["ledger"]
|
|
3832
|
+
pre_start = [e for e in classified["settled"]
|
|
3833
|
+
if e.get("outcome") in _wl.DEATHS]
|
|
3834
|
+
ledger = _wl.acknowledge(
|
|
3835
|
+
ledger, self.launch_id, "started", self.run_id, at=now,
|
|
3836
|
+
pid=self.pid, birth=self.birth)
|
|
3837
|
+
pending_since = self._pending_since()
|
|
3838
|
+
before = _launches_before_start(
|
|
3839
|
+
ledger["entries"], self.launch_id, pending_since)
|
|
3840
|
+
try:
|
|
3841
|
+
_write_ledger(worker, _wl.prune(ledger, protected))
|
|
3842
|
+
except OSError as exc:
|
|
3843
|
+
_worker_log_error(worker, "ledger", exc)
|
|
3844
|
+
return False
|
|
3845
|
+
self._ack_written = True
|
|
3846
|
+
run = {
|
|
3847
|
+
"runId": self.run_id, "launchId": self.launch_id,
|
|
3848
|
+
"pid": self.pid, "birth": self.birth,
|
|
3849
|
+
"version": _worker_version(),
|
|
3850
|
+
"binaryEpoch": _cctally_core.STATS_INDEX_EPOCH,
|
|
3851
|
+
"indexEpoch": _raw_user_version(_cctally_core.DB_PATH),
|
|
3852
|
+
"pendingSince": pending_since,
|
|
3853
|
+
"launchesBeforeStart": before,
|
|
3854
|
+
"status": "running", "phase": "starting",
|
|
3855
|
+
"phaseStartedAt": now, "progressAt": now, "counters": {},
|
|
3856
|
+
"residentBytes": _lib_rebuild_progress.current_resident_bytes(),
|
|
3857
|
+
"peakResidentBytes":
|
|
3858
|
+
_lib_rebuild_progress.peak_resident_bytes(),
|
|
3859
|
+
"outcome": None, "startedAt": now, "finishedAt": None,
|
|
3860
|
+
"wallSeconds": None,
|
|
3861
|
+
}
|
|
3862
|
+
if worker == "heal":
|
|
3863
|
+
run["finalization"] = None
|
|
3864
|
+
self.state = {
|
|
3865
|
+
"schemaVersion": _WORKER_RECORD_SCHEMA,
|
|
3866
|
+
"run": run,
|
|
3867
|
+
"deaths": deaths[-_WORKER_DEATHS_KEEP:],
|
|
3868
|
+
"previousUnreadable": previous_unreadable,
|
|
3869
|
+
}
|
|
3870
|
+
try:
|
|
3871
|
+
_cctally_db._atomic_write_private_json(state_path, self.state)
|
|
3872
|
+
except OSError as exc:
|
|
3873
|
+
self.state = None
|
|
3874
|
+
try:
|
|
3875
|
+
_write_ledger(worker, _wl.prune(_wl.settle(
|
|
3876
|
+
ledger, self.launch_id, "finished:error", at=now),
|
|
3877
|
+
protected))
|
|
3878
|
+
except OSError:
|
|
3879
|
+
pass
|
|
3880
|
+
_worker_log_error(worker, "state", exc)
|
|
3881
|
+
return False
|
|
3882
|
+
finally:
|
|
3883
|
+
_heal_release_flock(fd)
|
|
3884
|
+
# Step 3: state first, log second.
|
|
3885
|
+
for death in new_deaths:
|
|
3886
|
+
_worker_log(worker, (
|
|
3887
|
+
f"result=died run={death.get('runId')} pid={death.get('pid')}"
|
|
3888
|
+
f" phase={death.get('lastPhase')}"))
|
|
3889
|
+
for entry in pre_start:
|
|
3890
|
+
if entry.get("outcome") == "died-pre-start":
|
|
3891
|
+
_worker_log(worker, (
|
|
3892
|
+
f"result=died run=none launch={entry.get('launchId')} "
|
|
3893
|
+
f"pid={entry.get('pid')} phase=pre-start"))
|
|
3894
|
+
_lib_rebuild_progress.install(self._sink)
|
|
3895
|
+
return True
|
|
3896
|
+
|
|
3897
|
+
def _reconcile_prior_run(self, ledger, prior_run, deaths, new_deaths,
|
|
3898
|
+
now):
|
|
3899
|
+
"""Step 1 before the run slot is overwritten: transfer a finished
|
|
3900
|
+
run's outcome; account a stale ``running`` run exactly once."""
|
|
3901
|
+
if not prior_run or not prior_run.get("launchId"):
|
|
3902
|
+
return ledger, prior_run
|
|
3903
|
+
launch_id = prior_run["launchId"]
|
|
3904
|
+
if prior_run.get("status") == "finished":
|
|
3905
|
+
return (_wl.settle(ledger, launch_id,
|
|
3906
|
+
f"finished:{prior_run.get('outcome')}",
|
|
3907
|
+
at=now), prior_run)
|
|
3908
|
+
if prior_run.get("status") != "running":
|
|
3909
|
+
return ledger, prior_run
|
|
3910
|
+
entry = _wl.find(ledger, launch_id)
|
|
3911
|
+
terminal = (entry or {}).get("outcome")
|
|
3912
|
+
if terminal and terminal not in _wl.DEATHS:
|
|
3913
|
+
# A recorded terminal is authoritative (revision 6): the state
|
|
3914
|
+
# write that raised had taken effect. Repair the run, no death.
|
|
3915
|
+
outcome = (terminal[len("finished:"):]
|
|
3916
|
+
if terminal.startswith("finished:") else terminal)
|
|
3917
|
+
prior_run.update(status="finished", outcome=outcome)
|
|
3918
|
+
return ledger, prior_run
|
|
3919
|
+
if not any(d.get("launchId") == launch_id for d in deaths):
|
|
3920
|
+
death = {
|
|
3921
|
+
"runId": prior_run.get("runId"), "launchId": launch_id,
|
|
3922
|
+
"pid": prior_run.get("pid"), "birth": prior_run.get("birth"),
|
|
3923
|
+
"startedAt": prior_run.get("startedAt"),
|
|
3924
|
+
"lastPhase": prior_run.get("phase"),
|
|
3925
|
+
"lastProgressAt": prior_run.get("progressAt"),
|
|
3926
|
+
"detectedAt": now,
|
|
3927
|
+
}
|
|
3928
|
+
deaths.append(death)
|
|
3929
|
+
new_deaths.append(death)
|
|
3930
|
+
if entry is None:
|
|
3931
|
+
ledger = _wl.acknowledge(
|
|
3932
|
+
ledger, launch_id, "started", prior_run.get("runId"),
|
|
3933
|
+
at=prior_run.get("startedAt") or now,
|
|
3934
|
+
pid=prior_run.get("pid"), birth=prior_run.get("birth"))
|
|
3935
|
+
ledger = _wl.settle(ledger, launch_id, "died-in-run", at=now)
|
|
3936
|
+
return ledger, prior_run
|
|
3937
|
+
|
|
3938
|
+
# -- progress ----------------------------------------------------------
|
|
3939
|
+
def _sink(self, phase, counters, forced) -> None:
|
|
3940
|
+
import _lib_rebuild_progress
|
|
3941
|
+
|
|
3942
|
+
del forced
|
|
3943
|
+
if self.state is None:
|
|
3944
|
+
return
|
|
3945
|
+
run = self.state["run"]
|
|
3946
|
+
now = _cctally_core.now_utc_iso()
|
|
3947
|
+
if phase is not None and phase != run.get("phase"):
|
|
3948
|
+
run["phase"] = phase
|
|
3949
|
+
run["phaseStartedAt"] = now
|
|
3950
|
+
run["progressAt"] = now
|
|
3951
|
+
run["counters"] = _json_counters(counters)
|
|
3952
|
+
run["residentBytes"] = _lib_rebuild_progress.current_resident_bytes()
|
|
3953
|
+
run["peakResidentBytes"] = _lib_rebuild_progress.peak_resident_bytes()
|
|
3954
|
+
_cctally_db._atomic_write_private_json(
|
|
3955
|
+
_worker_state_path(self.worker), self.state)
|
|
3956
|
+
|
|
3957
|
+
# -- step 5 ------------------------------------------------------------
|
|
3958
|
+
def finish(self, outcome: str, *, finalization: "str | None" = None,
|
|
3959
|
+
decline_reason: "str | None" = None) -> None:
|
|
3960
|
+
"""The state first, then the entry's terminal in the ledger."""
|
|
3961
|
+
import _lib_rebuild_progress
|
|
3962
|
+
|
|
3963
|
+
_lib_rebuild_progress.uninstall()
|
|
3964
|
+
if self.state is None:
|
|
3965
|
+
return
|
|
3966
|
+
worker = self.worker
|
|
3967
|
+
now = _cctally_core.now_utc_iso()
|
|
3968
|
+
run = self.state["run"]
|
|
3969
|
+
run.update(
|
|
3970
|
+
status="finished", outcome=outcome, finishedAt=now,
|
|
3971
|
+
wallSeconds=round(time.monotonic() - (self._started or
|
|
3972
|
+
time.monotonic()), 3),
|
|
3973
|
+
residentBytes=_lib_rebuild_progress.current_resident_bytes(),
|
|
3974
|
+
peakResidentBytes=_lib_rebuild_progress.peak_resident_bytes(),
|
|
3975
|
+
)
|
|
3976
|
+
if worker == "heal":
|
|
3977
|
+
run["finalization"] = finalization
|
|
3978
|
+
if decline_reason is not None:
|
|
3979
|
+
run["declineReason"] = decline_reason
|
|
3980
|
+
try:
|
|
3981
|
+
_cctally_db._atomic_write_private_json(
|
|
3982
|
+
_worker_state_path(worker), self.state)
|
|
3983
|
+
except OSError as exc:
|
|
3984
|
+
_worker_log_error(worker, "state", exc)
|
|
3985
|
+
try:
|
|
3986
|
+
fd = _heal_flock_bounded(_worker_admission_path(worker),
|
|
3987
|
+
_WORKER_ADMISSION_WAIT_S)
|
|
3988
|
+
except OSError:
|
|
3989
|
+
return
|
|
3990
|
+
if fd is None:
|
|
3991
|
+
return
|
|
3992
|
+
try:
|
|
3993
|
+
ledger = _load_ledger_for_update(worker)
|
|
3994
|
+
ledger = _wl.settle(ledger, self.launch_id,
|
|
3995
|
+
f"finished:{outcome}", at=now)
|
|
3996
|
+
_write_ledger(worker, _wl.prune(ledger, self.launch_id))
|
|
3997
|
+
except (OSError, ValueError):
|
|
3998
|
+
# Rule (a) transfers the outcome from the state later.
|
|
3999
|
+
pass
|
|
4000
|
+
finally:
|
|
4001
|
+
_heal_release_flock(fd)
|
|
4002
|
+
|
|
4003
|
+
|
|
4004
|
+
def _worker_version() -> "str | None":
|
|
4005
|
+
try:
|
|
4006
|
+
import _lib_changelog
|
|
4007
|
+
found = _lib_changelog._read_latest_changelog_version()
|
|
4008
|
+
except Exception:
|
|
4009
|
+
return None
|
|
4010
|
+
return found[0] if found else None
|
|
4011
|
+
|
|
4012
|
+
|
|
3129
4013
|
def defer_stats_epoch_rebuild() -> str:
|
|
3130
4014
|
"""Schedule one retryable detached epoch worker without blocking callers."""
|
|
3131
4015
|
try:
|
|
@@ -3151,23 +4035,37 @@ def defer_stats_epoch_rebuild() -> str:
|
|
|
3151
4035
|
return "failed"
|
|
3152
4036
|
if age is not None and age < _STATS_EPOCH_REBUILD_RETRY_SECONDS:
|
|
3153
4037
|
return "pending"
|
|
4038
|
+
# #997 D8: the marker dates the pending condition. `firstPendingAt`
|
|
4039
|
+
# is set when it is created and preserved by every retry; an empty
|
|
4040
|
+
# marker from an older binary is converted with its mtime first, so
|
|
4041
|
+
# refreshing the throttle never loses when the epoch went pending.
|
|
4042
|
+
now = _cctally_core.now_utc_iso()
|
|
4043
|
+
first_pending_at = (read_epoch_marker().get("firstPendingAt")
|
|
4044
|
+
if age is not None else None) or now
|
|
3154
4045
|
if _stats_epoch_rebuild_worker_active():
|
|
3155
4046
|
# A representative replay outlives the marker retry interval.
|
|
3156
4047
|
# Refresh the admission stamp instead of launching a process that
|
|
3157
4048
|
# can only lose the worker flock and exit.
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
4049
|
+
if age is not None:
|
|
4050
|
+
try:
|
|
4051
|
+
_write_epoch_marker(first_pending_at)
|
|
4052
|
+
except OSError:
|
|
4053
|
+
pass
|
|
3162
4054
|
return "pending"
|
|
3163
4055
|
try:
|
|
3164
|
-
|
|
3165
|
-
os.close(marker_fd)
|
|
3166
|
-
os.utime(marker, None)
|
|
4056
|
+
_write_epoch_marker(first_pending_at)
|
|
3167
4057
|
except OSError:
|
|
3168
4058
|
return "failed"
|
|
3169
|
-
|
|
3170
|
-
|
|
4059
|
+
# #997 D8 launch: classify, persist the reservation, spawn with the
|
|
4060
|
+
# launch id, then fill the child's identity (or record the failure).
|
|
4061
|
+
launch_id = _new_launch_id()
|
|
4062
|
+
if _spawner_reserve("epoch", launch_id, now) == "withheld":
|
|
4063
|
+
# Launch capacity: the marker (just refreshed, so the retry
|
|
4064
|
+
# throttle holds) keeps the request pending; nothing is spawned.
|
|
4065
|
+
return "pending"
|
|
4066
|
+
pid = _spawn_rebuild_worker(STATS_EPOCH_REBUILD_COMMAND, launch_id)
|
|
4067
|
+
_spawner_fill("epoch", launch_id, pid, _cctally_core.now_utc_iso())
|
|
4068
|
+
if pid is not None:
|
|
3171
4069
|
return "spawned"
|
|
3172
4070
|
_unlink_stats_epoch_marker()
|
|
3173
4071
|
return "failed"
|
|
@@ -3250,26 +4148,19 @@ def _stats_epoch_rebuild_worker(args) -> str:
|
|
|
3250
4148
|
try:
|
|
3251
4149
|
fcntl.flock(worker_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
3252
4150
|
except OSError:
|
|
4151
|
+
_record_worker_busy("epoch")
|
|
3253
4152
|
return "busy"
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
import _cctally_completion
|
|
3259
|
-
try:
|
|
3260
|
-
conn = resolve_stats_epoch_mismatch()
|
|
3261
|
-
conn.close()
|
|
3262
|
-
except _cctally_completion.ResidualObligationNotDurable as exc:
|
|
3263
|
-
_unlink_stats_epoch_marker()
|
|
3264
|
-
_log_stats_epoch_rebuild(STATS_EPOCH_RESIDUAL_NOT_DURABLE,
|
|
3265
|
-
error=exc)
|
|
3266
|
-
return STATS_EPOCH_RESIDUAL_NOT_DURABLE
|
|
3267
|
-
except Exception as exc:
|
|
3268
|
-
_log_stats_epoch_rebuild("error", error=exc)
|
|
4153
|
+
# #997 D8: record the run (and any dead predecessor) before any work;
|
|
4154
|
+
# a run that cannot be recorded does none.
|
|
4155
|
+
run = _WorkerRun("epoch")
|
|
4156
|
+
if not run.begin():
|
|
3269
4157
|
return "error"
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
4158
|
+
outcome = "error"
|
|
4159
|
+
try:
|
|
4160
|
+
outcome = _stats_epoch_rebuild_body()
|
|
4161
|
+
return outcome
|
|
4162
|
+
finally:
|
|
4163
|
+
run.finish(outcome)
|
|
3273
4164
|
finally:
|
|
3274
4165
|
try:
|
|
3275
4166
|
fcntl.flock(worker_fd, fcntl.LOCK_UN)
|
|
@@ -3278,6 +4169,29 @@ def _stats_epoch_rebuild_worker(args) -> str:
|
|
|
3278
4169
|
os.close(worker_fd)
|
|
3279
4170
|
|
|
3280
4171
|
|
|
4172
|
+
def _stats_epoch_rebuild_body() -> str:
|
|
4173
|
+
"""The epoch worker's work under its flock, after its run is recorded."""
|
|
4174
|
+
if not stats_epoch_rebuild_pending():
|
|
4175
|
+
_unlink_stats_epoch_marker()
|
|
4176
|
+
_log_stats_epoch_rebuild("current")
|
|
4177
|
+
return "current"
|
|
4178
|
+
import _cctally_completion
|
|
4179
|
+
try:
|
|
4180
|
+
conn = resolve_stats_epoch_mismatch()
|
|
4181
|
+
conn.close()
|
|
4182
|
+
except _cctally_completion.ResidualObligationNotDurable as exc:
|
|
4183
|
+
_unlink_stats_epoch_marker()
|
|
4184
|
+
_log_stats_epoch_rebuild(STATS_EPOCH_RESIDUAL_NOT_DURABLE,
|
|
4185
|
+
error=exc)
|
|
4186
|
+
return STATS_EPOCH_RESIDUAL_NOT_DURABLE
|
|
4187
|
+
except Exception as exc:
|
|
4188
|
+
_log_stats_epoch_rebuild("error", error=exc)
|
|
4189
|
+
return "error"
|
|
4190
|
+
_unlink_stats_epoch_marker()
|
|
4191
|
+
_log_stats_epoch_rebuild("success")
|
|
4192
|
+
return "success"
|
|
4193
|
+
|
|
4194
|
+
|
|
3281
4195
|
def _raw_user_version(path) -> int:
|
|
3282
4196
|
"""Read the SQLite header's user_version without opening the family.
|
|
3283
4197
|
|