cctally 1.79.2 → 1.80.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.
@@ -622,8 +622,18 @@ def _arming_row(conn: sqlite3.Connection, identity: QuotaWindowIdentity) -> sqli
622
622
 
623
623
  def _activate_quota_rule(
624
624
  conn: sqlite3.Connection, identity: QuotaWindowIdentity, fingerprint: str, now_iso: str,
625
+ *, journal_emit=None,
625
626
  ) -> tuple[bool, dt.datetime]:
626
- """Persist one identity's resolved rule boundary, returning (changed, at)."""
627
+ """Persist one identity's resolved rule boundary, returning (changed, at).
628
+
629
+ Task 7 Item 5: on a genuine activation (a new/changed boundary) the arming
630
+ state is journaled — its ``activated_at_utc`` is a forward-only alert
631
+ boundary that must survive a stats.db rebuild so the reconcile honors it
632
+ (no historical re-fires, spec §5.3 "state"). ``journal_emit`` (set only on
633
+ the LIVE ingest-cycle path; ``None`` for a rebuild re-materialization, which
634
+ must not append) appends the ``quota_alert_arming`` evt. The row is still
635
+ written directly here; the evt is the additional durable record, and its
636
+ fold applier's natural-key upsert converges with this write on replay."""
627
637
  row = _arming_row(conn, identity)
628
638
  if row is not None and row["rule_fingerprint"] == fingerprint:
629
639
  return False, _parse_utc(str(row["activated_at_utc"]), "activated_at_utc")
@@ -641,6 +651,8 @@ def _activate_quota_rule(
641
651
  identity.observed_slot, identity.window_minutes, fingerprint, now_iso,
642
652
  ),
643
653
  )
654
+ if journal_emit is not None:
655
+ journal_emit(identity, fingerprint, now_iso)
644
656
  return True, _parse_utc(now_iso, "activated_at_utc")
645
657
 
646
658
 
@@ -705,7 +717,7 @@ def _quota_alert_payload(
705
717
  def _evaluate_quota_alerts(
706
718
  conn: sqlite3.Connection,
707
719
  *, observations: tuple[QuotaObservation, ...], alert_eligible_roots: set[str],
708
- now: dt.datetime, now_iso: str,
720
+ now: dt.datetime, now_iso: str, journal_emit=None,
709
721
  ) -> list[dict]:
710
722
  """Arm or claim quota alerts within the caller's stats transaction.
711
723
 
@@ -742,7 +754,8 @@ def _evaluate_quota_alerts(
742
754
  identity, resolved, global_enabled=global_enabled,
743
755
  quota_enabled=quota_enabled,
744
756
  )
745
- changed, activated_at = _activate_quota_rule(conn, identity, fingerprint, now_iso)
757
+ changed, activated_at = _activate_quota_rule(
758
+ conn, identity, fingerprint, now_iso, journal_emit=journal_emit)
746
759
 
747
760
  # Future evidence is never a threshold qualifier (including a first
748
761
  # activation backfill). A later well-clocked observation creates the
@@ -812,6 +825,112 @@ def _evaluate_quota_alerts(
812
825
  return queued
813
826
 
814
827
 
828
+ def _apply_quota_projection_rows(
829
+ conn, *, observations, active_roots, now, now_iso,
830
+ sink, alert_eligible_roots, journal_emit=None, holder=None,
831
+ ):
832
+ """Transaction-neutral quota projection apply (spec §5.3 "projection").
833
+
834
+ Materializes the interpreted ``quota_*`` rows on ``conn`` (no ``BEGIN`` /
835
+ ``commit`` — the caller owns the transaction), queues alerts into ``sink``
836
+ (``None`` → none), and journals arming state changes via ``journal_emit``
837
+ (``None`` → none). ONE shared body drives both the live ingest ``codex_apply``
838
+ leg (``sink`` = the cycle's pending-alerts, ``journal_emit`` = the arming evt
839
+ emitter) AND the rebuild re-materialization pass (``sink=None``,
840
+ ``journal_emit=None``, ``alert_eligible_roots`` empty) so the two never drift.
841
+ ``holder`` (optional) captures the certificate signatures + result for the
842
+ live caller; rebuild passes ``None``."""
843
+ historic_roots = _historic_root_keys(conn)
844
+ roots_to_reconcile = active_roots | historic_roots
845
+ if not roots_to_reconcile:
846
+ return
847
+ generation = secrets.token_hex(16)
848
+ blocks = build_blocks(observations)
849
+ for block in blocks:
850
+ conn.execute(_BLOCK_UPSERT, _block_params(block, generation))
851
+ for milestone in percent_milestones(block):
852
+ conn.execute(
853
+ _MILESTONE_UPSERT,
854
+ _milestone_params(block, milestone, generation),
855
+ )
856
+ blocks_orphaned, milestones_orphaned = _orphan_unseen(
857
+ conn, roots_to_reconcile, generation, now_iso,
858
+ )
859
+ queued = _evaluate_quota_alerts(
860
+ conn, observations=observations,
861
+ alert_eligible_roots=alert_eligible_roots & active_roots,
862
+ now=now, now_iso=now_iso, journal_emit=journal_emit,
863
+ )
864
+ # The completion stamp is intentionally the final DML in the stats
865
+ # transaction. A pre-commit failure rolls all projection updates back;
866
+ # a retry sees the prior complete generation or rederives it.
867
+ for root_key in sorted(active_roots):
868
+ conn.execute(
869
+ """INSERT INTO quota_projection_state
870
+ (source_root_key, generation, physical_signature, completed_at_utc)
871
+ VALUES (?,?,?,?)
872
+ ON CONFLICT(source_root_key) DO UPDATE SET
873
+ generation=excluded.generation,
874
+ physical_signature=excluded.physical_signature,
875
+ completed_at_utc=excluded.completed_at_utc""",
876
+ (root_key, generation, _signature(observations, root_key), now_iso),
877
+ )
878
+ if sink is not None:
879
+ # Set-then-dispatch: all claims committed with the cycle before the
880
+ # cycle's post-commit ALERT_DISPATCHER fires them (spec §5.2 step 6).
881
+ sink.extend(queued)
882
+ if holder is not None:
883
+ holder["signatures"] = {
884
+ root_key: _signature(observations, root_key) for root_key in active_roots
885
+ }
886
+ holder["result"] = QuotaProjectionResult(
887
+ generation=generation,
888
+ blocks_upserted=len(blocks),
889
+ milestones_upserted=sum(len(percent_milestones(b)) for b in blocks),
890
+ blocks_orphaned=blocks_orphaned,
891
+ milestones_orphaned=milestones_orphaned,
892
+ roots_stamped=len(active_roots),
893
+ alerts_dispatched=len(queued),
894
+ )
895
+
896
+
897
+ def rematerialize_quota_projection_for_rebuild(stats_conn, *, now=None) -> None:
898
+ """Rebuild path (spec §5.4 / §5.3 "projection"): re-run the Codex quota
899
+ projection over the materialized cache.db ``quota_window_snapshots`` directly
900
+ onto the fresh rebuilt ``stats_conn``, side-effect-free.
901
+
902
+ Called by ``rebuild_stats_index`` AFTER the ``quota_alert_arming`` evts have
903
+ folded (order 45), so ``_evaluate_quota_alerts`` honors the replayed
904
+ activation boundary and re-fires nothing. NO alerts (``sink=None``), NO arming
905
+ journaling (``journal_emit=None``), NO alert-eligible roots. Opens no stats
906
+ connection of its own — writes on the caller's rebuild transaction. Uses the
907
+ SAME ``active_roots`` source as the live reconcile (``_cache_root_keys`` over
908
+ the cache) so the materialized projection matches live. A missing cache.db is
909
+ a clean no-op (the journal quota obs remain the durable source; a later
910
+ ``cache-sync`` + reconcile re-materializes)."""
911
+ if now is None:
912
+ now = dt.datetime.now(UTC)
913
+ if now.tzinfo is None or now.utcoffset() is None:
914
+ raise ValueError("now must be timezone-aware")
915
+ now_iso = _utc_iso(now)
916
+ try:
917
+ cache = _cache_connection()
918
+ except (FileNotFoundError, sqlite3.Error):
919
+ return
920
+ try:
921
+ active_roots = _cache_root_keys(cache)
922
+ observations = load_codex_quota_observations(
923
+ source_root_keys=None, cache_conn=cache,
924
+ )
925
+ finally:
926
+ cache.close()
927
+ _apply_quota_projection_rows(
928
+ stats_conn, observations=observations, active_roots=active_roots,
929
+ now=now, now_iso=now_iso, sink=None,
930
+ alert_eligible_roots=frozenset(), journal_emit=None, holder=None,
931
+ )
932
+
933
+
815
934
  def reconcile_codex_quota_projection(
816
935
  *,
817
936
  source_root_keys: Iterable[str] | None = None,
@@ -875,82 +994,82 @@ def reconcile_codex_quota_projection(
875
994
  finally:
876
995
  cache.close()
877
996
 
878
- # No configured roots and no existing interpreted history means there is no
879
- # stats work. This preserves the existing empty-Codex sync fast path.
880
- stats = _cctally_core.open_db()
881
- try:
882
- historic_roots = _historic_root_keys(stats)
883
- roots_to_reconcile = active_roots | historic_roots
884
- if not roots_to_reconcile:
885
- return QuotaProjectionResult(None, 0, 0, 0, 0, 0, 0)
886
-
887
- generation = secrets.token_hex(16)
888
- blocks = build_blocks(observations)
889
- queued_alerts: list[dict] = []
890
- stats.execute("BEGIN IMMEDIATE")
891
- try:
892
- for block in blocks:
893
- stats.execute(_BLOCK_UPSERT, _block_params(block, generation))
894
- for milestone in percent_milestones(block):
895
- stats.execute(
896
- _MILESTONE_UPSERT,
897
- _milestone_params(block, milestone, generation),
898
- )
899
- blocks_orphaned, milestones_orphaned = _orphan_unseen(
900
- stats, roots_to_reconcile, generation, now_iso,
901
- )
902
- queued_alerts = _evaluate_quota_alerts(
903
- stats, observations=observations,
904
- alert_eligible_roots=alert_eligible_roots & active_roots,
905
- now=now, now_iso=now_iso,
906
- )
907
- # The completion stamp is intentionally the final DML in the stats
908
- # transaction. A pre-commit failure rolls all projection updates
909
- # back; a retry sees the prior complete generation or rederives it.
910
- for root_key in sorted(active_roots):
911
- stats.execute(
912
- """INSERT INTO quota_projection_state
913
- (source_root_key, generation, physical_signature, completed_at_utc)
914
- VALUES (?,?,?,?)
915
- ON CONFLICT(source_root_key) DO UPDATE SET
916
- generation=excluded.generation,
917
- physical_signature=excluded.physical_signature,
918
- completed_at_utc=excluded.completed_at_utc""",
919
- (root_key, generation, _signature(observations, root_key), now_iso),
920
- )
921
- if _before_stats_commit is not None:
922
- _before_stats_commit()
923
- stats.commit()
924
- except Exception:
925
- stats.rollback()
926
- raise
927
- finally:
928
- stats.close()
997
+ # ── Apply phase (Task 7 Item 3) ─────────────────────────────────────────
998
+ # The stats.db writes route through the single-flight ingest cycle instead
999
+ # of this function opening its own stats connection + BEGIN IMMEDIATE. The
1000
+ # nested `_apply_projection(conn, sink)` is the transaction-neutral chokepoint
1001
+ # (writes on `conn`, no BEGIN/commit; alerts to `sink`) — the ingest cycle's
1002
+ # `codex_apply` seam drives it on the cycle's conn, AFTER the codex flock has
1003
+ # released (the existing after-flock-release rule). The interpreted `quota_*`
1004
+ # tables are mutable projections re-materialized here (spec §5.3), and each
1005
+ # genuine arming activation journals a `quota_alert_arming` evt so the
1006
+ # forward-only boundary survives a stats.db rebuild (Item 5).
1007
+ holder: dict = {
1008
+ "result": QuotaProjectionResult(None, 0, 0, 0, 0, 0, 0),
1009
+ "signatures": None,
1010
+ }
929
1011
 
930
- if _after_stats_commit is not None:
931
- _after_stats_commit()
932
- _store_codex_quota_projection_certificate(
933
- sequence=physical_sequence,
934
- signatures={root_key: _signature(observations, root_key) for root_key in active_roots},
935
- )
936
- # Set-then-dispatch: all alert claims committed before this best-effort I/O.
937
- # The dispatch helper never raises on notifier/FS failures; retain the
938
- # defensive guard so an injected test double cannot reopen a refire path.
939
- for payload in queued_alerts:
940
- try:
941
- _cctally()._dispatch_alert_notification(payload, mode="real")
942
- except Exception:
943
- pass
944
- return QuotaProjectionResult(
945
- generation=generation,
946
- blocks_upserted=len(blocks),
947
- milestones_upserted=sum(len(percent_milestones(block)) for block in blocks),
948
- blocks_orphaned=blocks_orphaned,
949
- milestones_orphaned=milestones_orphaned,
950
- roots_stamped=len(active_roots),
951
- alerts_dispatched=len(queued_alerts),
1012
+ def _apply_projection(conn, sink, *, journal_emit=None):
1013
+ # No configured roots and no existing interpreted history means there is
1014
+ # no stats work. This preserves the existing empty-Codex fast path.
1015
+ # Delegates to the shared module-level apply so the live leg and the
1016
+ # rebuild re-materialization pass (Task 8) never drift.
1017
+ _apply_quota_projection_rows(
1018
+ conn, observations=observations, active_roots=active_roots,
1019
+ now=now, now_iso=now_iso, sink=sink,
1020
+ alert_eligible_roots=alert_eligible_roots,
1021
+ journal_emit=journal_emit, holder=holder,
1022
+ )
1023
+
1024
+ import _cctally_journal as _jr
1025
+ import _lib_journal as _jl
1026
+
1027
+ def _codex_leg(ctx):
1028
+ def _emit_arming(identity, fingerprint, activated_at):
1029
+ # Item 5: journal the arming state change (`quota_alert_arming` evt).
1030
+ eid = _jl.evt_id(
1031
+ "qaa", identity.source, identity.source_root_key,
1032
+ identity.logical_limit_key, identity.observed_slot,
1033
+ identity.window_minutes,
1034
+ )
1035
+ _jr.append_record(_jl.make_evt(
1036
+ kind="quota_alert_arming", id=eid, at=activated_at,
1037
+ payload={
1038
+ "source": identity.source,
1039
+ "source_root_key": identity.source_root_key,
1040
+ "logical_limit_key": identity.logical_limit_key,
1041
+ "observed_slot": identity.observed_slot,
1042
+ "window_minutes": identity.window_minutes,
1043
+ "rule_fingerprint": fingerprint,
1044
+ "activated_at_utc": activated_at,
1045
+ },
1046
+ ))
1047
+ ctx.events_emitted += 1
1048
+
1049
+ _apply_projection(ctx.conn, ctx.pending_alerts, journal_emit=_emit_arming)
1050
+ # `_before_stats_commit` fires INSIDE the cycle txn, before COMMIT — a
1051
+ # raise rolls the whole cycle back, so the projection updates undo
1052
+ # together (the crash-consistency contract the callers test).
1053
+ if _before_stats_commit is not None:
1054
+ _before_stats_commit()
1055
+
1056
+ # AUTHORITATIVE: the reconcile must observe its own write + return the
1057
+ # materialized result synchronously; the cycle dispatches the queued alerts
1058
+ # post-commit via the ALERT_DISPATCHER, and `post_commit` runs the
1059
+ # `_after_stats_commit` seam after the commit (before dispatch).
1060
+ _jr.run_stats_ingest(
1061
+ mode="authoritative", codex_apply=_codex_leg,
1062
+ post_commit=_after_stats_commit,
952
1063
  )
953
1064
 
1065
+ # Finalize: the cache-side certificate (self-healing no-op optimization) is
1066
+ # stored only when the apply actually materialized a projection.
1067
+ if holder["signatures"] is not None:
1068
+ _store_codex_quota_projection_certificate(
1069
+ sequence=physical_sequence, signatures=holder["signatures"],
1070
+ )
1071
+ return holder["result"]
1072
+
954
1073
 
955
1074
  def _load_active_milestones(
956
1075
  identity: QuotaWindowIdentity, resets_at: dt.datetime,