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.
Files changed (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/_cctally_cache.py +85 -4
  3. package/bin/_cctally_completion.py +80 -12
  4. package/bin/_cctally_core.py +106 -9
  5. package/bin/_cctally_dashboard.py +6 -9
  6. package/bin/_cctally_dashboard_envelope.py +26 -32
  7. package/bin/_cctally_db.py +85 -4
  8. package/bin/_cctally_doctor.py +38 -5
  9. package/bin/_cctally_five_hour.py +83 -1017
  10. package/bin/_cctally_journal.py +1307 -180
  11. package/bin/_cctally_journal_repair.py +9 -7
  12. package/bin/_cctally_oauth_samples.py +280 -0
  13. package/bin/_cctally_parser.py +5 -0
  14. package/bin/_cctally_quarantine.py +14 -4
  15. package/bin/_cctally_quota.py +70 -8
  16. package/bin/_cctally_quota_model.py +35 -4
  17. package/bin/_cctally_record.py +414 -123
  18. package/bin/_cctally_rederive.py +632 -235
  19. package/bin/_cctally_refresh.py +29 -0
  20. package/bin/_cctally_statusline.py +984 -47
  21. package/bin/_cctally_store.py +972 -58
  22. package/bin/_cctally_tui.py +62 -7
  23. package/bin/_cctally_update.py +25 -0
  24. package/bin/_lib_account_protocol.py +201 -18
  25. package/bin/_lib_clone_copy.py +46 -11
  26. package/bin/_lib_dashboard_sources.py +57 -15
  27. package/bin/_lib_detector_access_registry.py +273 -52
  28. package/bin/_lib_doctor.py +302 -1
  29. package/bin/_lib_journal.py +145 -25
  30. package/bin/_lib_journal_router.py +166 -48
  31. package/bin/_lib_oauth_samples.py +238 -0
  32. package/bin/_lib_pricing_check.py +0 -12
  33. package/bin/_lib_provider_activity.py +770 -0
  34. package/bin/_lib_quarantine.py +3 -2
  35. package/bin/_lib_quota_model.py +96 -6
  36. package/bin/_lib_rebuild_progress.py +146 -0
  37. package/bin/_lib_record.py +22 -0
  38. package/bin/_lib_rederive.py +299 -3
  39. package/bin/_lib_snapshot_cache.py +43 -1
  40. package/bin/_lib_stale_proof.py +708 -0
  41. package/bin/_lib_statusline_candidates.py +65 -5
  42. package/bin/_lib_worker_launches.py +315 -0
  43. package/bin/cctally +6 -11
  44. package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
  45. package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
  46. package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
  47. package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
  48. package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
  49. package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
  50. package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
  51. package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
  52. package/dashboard/static/dashboard.html +1 -1
  53. package/package.json +7 -2
  54. package/bin/_lib_merge_gate.py +0 -452
  55. package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
@@ -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
- fcntl.flock(fd, fcntl.LOCK_EX)
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
- from _cctally_update import _spawn_detached
2488
- if _spawn_detached(STATS_CORRUPTION_HEAL_COMMAND):
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 _unlink_stats_heal_marker_under_admission() -> bool:
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
- request = _read_stats_heal_request()
2732
- if not request:
2733
- _unlink_stats_heal_marker()
2734
- _log_stats_heal("no-request")
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
- heal_id = str(request.get("healId") or "")
2891
+ result = {"outcome": "failed", "finalization": None,
2892
+ "declineReason": None}
2737
2893
  try:
2738
- outcome = _run_stats_corruption_heal(request)
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
- # Fix round K11 (K9K10-R7): a heal that published but lost its
2749
- # residual obligation is a heal; its ring outcome is already
2750
- # recorded (with the obligation error) and it wakes like a success.
2751
- published = outcome in ("success", STATS_HEAL_RESIDUAL_NOT_DURABLE)
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
- try:
3159
- os.utime(marker, None)
3160
- except OSError:
3161
- pass
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
- marker_fd = os.open(marker, os.O_WRONLY | os.O_CREAT, 0o600)
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
- from _cctally_update import _spawn_detached
3170
- if _spawn_detached(STATS_EPOCH_REBUILD_COMMAND):
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
- if not stats_epoch_rebuild_pending():
3255
- _unlink_stats_epoch_marker()
3256
- _log_stats_epoch_rebuild("current")
3257
- return "current"
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
- _unlink_stats_epoch_marker()
3271
- _log_stats_epoch_rebuild("success")
3272
- return "success"
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