cctally 1.92.0 → 1.92.1

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.
@@ -0,0 +1,243 @@
1
+ """Pure planning kernel for in-place stats index publication (#496 S3).
2
+
3
+ No I/O and no SQLite connection: every function takes plain data so the
4
+ publication protocol's decisions can be tested without a database.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import re
11
+ import sqlite3
12
+
13
+ _INTERNAL = "sqlite_"
14
+ _VIRTUAL = re.compile(r"CREATE\s+VIRTUAL\s+TABLE", re.IGNORECASE)
15
+ # `INSERT INTO t SELECT * FROM src.t` is not valid for a generated column, so a
16
+ # table carrying one is refused rather than mis-copied. Both spellings SQLite
17
+ # accepts are matched, and the pattern is anchored on what actually precedes the
18
+ # `AS (` of a generated column: either the `GENERATED ALWAYS` keywords, or the
19
+ # end of a column's type/constraint text at a `,` or `(` boundary. A bare
20
+ # `\bAS\s*\(` would also match a `CHECK (x AS (…))`-shaped expression or any
21
+ # future DDL that merely contains those characters, and the refusal is a hard,
22
+ # non-fallback-eligible raise — so a false positive would abort publication on a
23
+ # table the copy handles perfectly well.
24
+ _GENERATED = re.compile(
25
+ r"GENERATED\s+ALWAYS\s+AS\s*\("
26
+ r"|[(,]\s*\"?\w+\"?(?:[^,()]|\([^()]*\))*?\bAS\s*\(",
27
+ re.IGNORECASE,
28
+ )
29
+
30
+ # Drop order: dropping a table already removes its own indexes and triggers, so
31
+ # the dependent classes go first and a precomputed flat list never touches an
32
+ # object that no longer exists.
33
+ _DROP_ORDER = (
34
+ ("view", "DROP VIEW IF EXISTS"),
35
+ ("trigger", "DROP TRIGGER IF EXISTS"),
36
+ ("index", "DROP INDEX IF EXISTS"),
37
+ ("table", "DROP TABLE IF EXISTS"),
38
+ )
39
+ # Create order for everything that is not a table: after the rows, so index
40
+ # builds are single-pass, and indexes before triggers before views.
41
+ _CREATE_ORDER = ("index", "trigger", "view")
42
+
43
+
44
+ @dataclasses.dataclass(frozen=True)
45
+ class GenerationSwapPlan:
46
+ """One transaction's worth of statements, in the order they must run."""
47
+
48
+ drop_statements: tuple
49
+ create_table_statements: tuple
50
+ copy_tables: tuple
51
+ # Indexes, then triggers, then views — every non-table object, created
52
+ # after the row copy. Named for the only class the stats schema uses today.
53
+ create_index_statements: tuple
54
+ rejected: tuple
55
+
56
+
57
+ def _usable(objects):
58
+ """Objects a swap may act on: named by the user, and carrying their DDL.
59
+
60
+ `sqlite_sequence` cannot be dropped, and an automatic index created by a
61
+ UNIQUE constraint has `sql IS NULL` and no independent existence.
62
+ """
63
+ for kind, name, sql in objects:
64
+ if str(name).startswith(_INTERNAL):
65
+ continue
66
+ if sql is None:
67
+ continue
68
+ yield str(kind), str(name), str(sql)
69
+
70
+
71
+ def _unsupported(kind: str, sql: str) -> bool:
72
+ if _VIRTUAL.search(sql):
73
+ return True
74
+ return kind == "table" and bool(_GENERATED.search(sql))
75
+
76
+
77
+ def plan_generation_swap(dest_objects, src_objects) -> GenerationSwapPlan:
78
+ """Plan the swap of ``dest_objects`` for ``src_objects``.
79
+
80
+ Each argument is a sequence of ``(type, name, sql)`` triples exactly as
81
+ ``SELECT type, name, sql FROM sqlite_schema`` returns them. Deriving the
82
+ drop list from the destination and the create list from the source retires
83
+ whatever the live generation holds without a maintained table of names, so
84
+ a table added in a later epoch cannot be forgotten.
85
+ """
86
+ rejected = tuple(
87
+ name
88
+ for kind, name, sql in _usable(src_objects)
89
+ if _unsupported(kind, sql)
90
+ )
91
+
92
+ drops = []
93
+ for wanted, statement in _DROP_ORDER:
94
+ for kind, name, _sql in _usable(dest_objects):
95
+ if kind == wanted:
96
+ drops.append(f'{statement} "{name}"')
97
+
98
+ creates, copies = [], []
99
+ others: dict = {kind: [] for kind in _CREATE_ORDER}
100
+ for kind, name, sql in _usable(src_objects):
101
+ if _unsupported(kind, sql):
102
+ continue
103
+ if kind == "table":
104
+ creates.append(sql)
105
+ copies.append(name)
106
+ elif kind in others:
107
+ others[kind].append(sql)
108
+
109
+ post = [sql for kind in _CREATE_ORDER for sql in others[kind]]
110
+ return GenerationSwapPlan(
111
+ drop_statements=tuple(drops),
112
+ create_table_statements=tuple(creates),
113
+ copy_tables=tuple(copies),
114
+ create_index_statements=tuple(post),
115
+ rejected=rejected,
116
+ )
117
+
118
+
119
+ # --------------------------------------------------------------------------
120
+ # The publication phase machine (#496 S3 §4.1)
121
+ # --------------------------------------------------------------------------
122
+ #
123
+ # "The transaction raised" is not a safe discriminator, because a failure
124
+ # before the commit can roll back while a failure after it cannot, and a
125
+ # commit-time I/O error leaves the outcome genuinely unknown. The publisher
126
+ # therefore records which of these four phases it had reached when it failed.
127
+
128
+ # Rollback is proven, the old generation is intact, and physical fallback is
129
+ # legal.
130
+ PRE_COMMIT = "pre_commit"
131
+ # The commit's outcome is undetermined. Do not fall back; reopen and resolve
132
+ # through the publication stamp.
133
+ COMMIT_UNKNOWN = "commit_unknown"
134
+ # The new generation is live. Physical fallback is never legal from here.
135
+ COMMITTED = "committed"
136
+ # The record and the marker agree with the bytes.
137
+ VERDICT_SETTLED = "verdict_settled"
138
+
139
+ # Only a structural inability to operate on the destination authorizes
140
+ # discarding it. Busy, full, out-of-memory and I/O-resource failures leave a
141
+ # perfectly good generation live and must return a retryable failure instead.
142
+ # The structural tokens are `_cctally_db._SQLITE_CORRUPTION_MESSAGES` plus the
143
+ # encrypted-file spelling of SQLITE_NOTADB.
144
+ _STRUCTURAL = (
145
+ "database disk image is malformed",
146
+ "file is not a database",
147
+ "malformed database schema",
148
+ "encrypted or is not a database",
149
+ )
150
+ _RETRYABLE = (
151
+ "locked",
152
+ "busy",
153
+ "full",
154
+ "out of memory",
155
+ "i/o error",
156
+ "readonly",
157
+ "permission denied",
158
+ )
159
+
160
+
161
+ # --------------------------------------------------------------------------
162
+ # Three-state stamp resolution (#496 S3 §5)
163
+ # --------------------------------------------------------------------------
164
+ #
165
+ # An in-place publish attaches its scratch read-only and detaches it, so the
166
+ # scratch survives commit and rollback identically and the publication marker's
167
+ # `scratchPath` crash discriminator inverts. The publication writes its own
168
+ # identity into `stats_publication_stamp` inside the publication transaction
169
+ # instead, and the opener compares that against the marker's `recordPath`.
170
+ #
171
+ # The answer is three-valued, not boolean. "The stamp does not name this
172
+ # record" proves a rollback only if the stamp was READ successfully; a missing
173
+ # table, a corrupt page, an unreadable schema, a malformed or duplicated row,
174
+ # or any query error proves nothing at all. Treating one of those as
175
+ # never-committed would discard a verdict owed on bytes that are live.
176
+
177
+ #: The stamp names this marker's rebuild record: the publication COMMITTED.
178
+ STAMP_MATCH = "MATCH"
179
+ #: The stamp was read and does not name this record: the publication never
180
+ #: became live, and the live bytes are its untouched predecessor.
181
+ STAMP_PROVEN_PREDECESSOR = "PROVEN_PREDECESSOR"
182
+ #: The stamp could not be read, or read as something it may not be. Preserve
183
+ #: the marker and fail closed; only PROVEN_PREDECESSOR may discard it.
184
+ STAMP_INDETERMINATE = "INDETERMINATE"
185
+
186
+
187
+ def resolve_stamp(stamp, marker_record_path) -> str:
188
+ """Resolve a pending publication against the stamp read from its destination.
189
+
190
+ ``stamp`` is whatever the read produced: the exception that prevented it,
191
+ ``None`` or an empty sequence when the table read cleanly and held no row,
192
+ a single row mapping, or the sequence of row mappings the table held. Each
193
+ mapping carries at least ``record_path``.
194
+
195
+ ``marker_record_path`` is the marker's ``recordPath`` as a string.
196
+
197
+ Returns one of `STAMP_MATCH`, `STAMP_PROVEN_PREDECESSOR` or
198
+ `STAMP_INDETERMINATE`. Anything the function cannot interpret resolves
199
+ INDETERMINATE, because that is the state that preserves evidence.
200
+ """
201
+ if isinstance(stamp, BaseException):
202
+ return STAMP_INDETERMINATE
203
+ if not isinstance(marker_record_path, str) or not marker_record_path:
204
+ return STAMP_INDETERMINATE
205
+ if stamp is None:
206
+ return STAMP_PROVEN_PREDECESSOR
207
+ if isinstance(stamp, (list, tuple)):
208
+ if not stamp:
209
+ return STAMP_PROVEN_PREDECESSOR
210
+ if len(stamp) != 1:
211
+ # The publication transaction deletes before it inserts, so more
212
+ # than one row is a state the protocol cannot produce.
213
+ return STAMP_INDETERMINATE
214
+ stamp = stamp[0]
215
+ if not isinstance(stamp, dict):
216
+ return STAMP_INDETERMINATE
217
+ recorded = stamp.get("record_path")
218
+ if not isinstance(recorded, str) or not recorded:
219
+ return STAMP_INDETERMINATE
220
+ if recorded == marker_record_path:
221
+ return STAMP_MATCH
222
+ return STAMP_PROVEN_PREDECESSOR
223
+
224
+
225
+ def may_fall_back_to_replacement(exc) -> bool:
226
+ """Whether ``exc`` authorizes physically replacing the destination.
227
+
228
+ Fails closed: anything unrecognized returns False, leaving the old
229
+ generation live and the failure retryable. SQLite's numeric code is
230
+ preferred where the exception still carries one, mirroring
231
+ ``_cctally_db._is_sqlite_corruption_error``; the canonical messages are the
232
+ string-only boundary.
233
+ """
234
+ code = getattr(exc, "sqlite_errorcode", None)
235
+ if isinstance(code, int):
236
+ primary = code & 0xFF
237
+ if primary in {sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB}:
238
+ return True
239
+ return False
240
+ text = str(exc).casefold()
241
+ if any(token in text for token in _RETRYABLE):
242
+ return False
243
+ return any(token in text for token in _STRUCTURAL)
package/bin/cctally CHANGED
@@ -967,7 +967,9 @@ ProdMigrationRefused = _cctally_db.ProdMigrationRefused
967
967
  StatsDbCorruptError = _cctally_db.StatsDbCorruptError
968
968
  StatsDbMaintenanceError = _cctally_db.StatsDbMaintenanceError
969
969
  StatsEpochMismatchError = _cctally_db.StatsEpochMismatchError
970
+ StatsRebuildDeferred = _cctally_db.StatsRebuildDeferred
970
971
  StatsEpochRebuildDeferred = _cctally_db.StatsEpochRebuildDeferred
972
+ StatsHealDeferred = _cctally_db.StatsHealDeferred
971
973
  _is_sqlite_corruption_error = _cctally_db._is_sqlite_corruption_error
972
974
  _stats_corruption_guidance = _cctally_db._stats_corruption_guidance
973
975
  _STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
@@ -997,6 +999,9 @@ _cctally_store = _load_sibling("_cctally_store")
997
999
  cmd_stats_epoch_rebuild_internal = (
998
1000
  _cctally_store.cmd_stats_epoch_rebuild_internal
999
1001
  )
1002
+ cmd_stats_corruption_heal_internal = (
1003
+ _cctally_store.cmd_stats_corruption_heal_internal
1004
+ )
1000
1005
  _print_migration_error_banner_if_needed = _cctally_db._print_migration_error_banner_if_needed
1001
1006
  cmd_db_status = _cctally_db.cmd_db_status
1002
1007
  _db_status_for = _cctally_db._db_status_for
@@ -3355,7 +3360,10 @@ def main(argv: list[str] | None = None) -> int:
3355
3360
  # generic DatabaseError so it never renders as a raw traceback.
3356
3361
  eprint(f"cctally: {exc}")
3357
3362
  return 3
3358
- except StatsEpochRebuildDeferred as exc:
3363
+ except StatsRebuildDeferred as exc:
3364
+ # One catch for both deferral classes (#496 S3 §6): the epoch worker
3365
+ # and the corruption-heal worker both mean "a rebuild is running; do
3366
+ # not print a partial report".
3359
3367
  eprint(f"cctally: {exc}")
3360
3368
  return 3
3361
3369
  except AccountAttributionUnavailable as exc:
@@ -3477,8 +3485,9 @@ def _post_command_update_hooks(command: str | None, args) -> None:
3477
3485
  # still cctally-dev at this point). Same rationale class as doctor.
3478
3486
  return
3479
3487
  if command in ("_update-check", "_telemetry-beat", "_codex-quota-verify",
3480
- "_stats-epoch-rebuild", "_codex-replay-drain"):
3481
- # All five hidden workers are detached and have already done their one job
3488
+ "_stats-epoch-rebuild", "_stats-corruption-heal",
3489
+ "_codex-replay-drain"):
3490
+ # All six hidden workers are detached and have already done their one job
3482
3491
  # in their own command handler; none must re-enter this hook.
3483
3492
  # Without the guard the ``_update-check`` worker would fall through to
3484
3493
  # the telemetry gate below and (throttle-bounded) spawn a