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.
@@ -4315,6 +4315,7 @@ _REBUILD_REQUIRED_TABLES = frozenset(
4315
4315
  "schema_migrations",
4316
4316
  "schema_migrations_skipped",
4317
4317
  "stats_open_fixups",
4318
+ "stats_publication_stamp",
4318
4319
  "week_reset_events",
4319
4320
  "weekly_cost_snapshots",
4320
4321
  "weekly_credit_floors",
@@ -4363,7 +4364,7 @@ _REBUILD_REQUIRED_INDEXES = frozenset(
4363
4364
  # omitted column, constraint, partial predicate, or index definition. An epoch
4364
4365
  # schema change must update this contract alongside STATS_INDEX_EPOCH.
4365
4366
  _REBUILD_SCHEMA_FINGERPRINT = (
4366
- "47bbbfde25fe4e4d40cb39671cf0c6c8fe2d8e9a7a5ce276927b410b916afd54"
4367
+ "7dde5a7995f441558d08b0204136824d6ff7208b221e576c79a76854b76aa178"
4367
4368
  )
4368
4369
 
4369
4370
 
@@ -4633,8 +4634,89 @@ def _utc_iso_now() -> str:
4633
4634
  ).replace("+00:00", "Z")
4634
4635
 
4635
4636
 
4637
+ def read_publication_stamp(path):
4638
+ """Read `stats_publication_stamp` from ``path`` on a fresh read-only conn.
4639
+
4640
+ Never raises. Returns the input `_lib_stats_publish.resolve_stamp` expects:
4641
+
4642
+ - the exception that prevented the read, which resolves INDETERMINATE;
4643
+ - `None` when the read succeeded and named no publication;
4644
+ - the list of row mappings the table held.
4645
+
4646
+ **A destination whose `user_version` is not this binary's
4647
+ `STATS_INDEX_EPOCH` returns `None`, and that is a proof rather than a
4648
+ convenience.** Every scratch eligible for publication has already been
4649
+ validated at `STATS_INDEX_EPOCH`, and the publication transaction stamps
4650
+ that epoch onto the destination in the same commit as the stamp row, so a
4651
+ committed publication always leaves the destination at this epoch. A
4652
+ destination at any other epoch therefore proves this publication did not
4653
+ commit — which is exactly what makes an interrupted upgrade rebuild
4654
+ recoverable: the epoch-1007 index it was publishing into has no stamp
4655
+ table at all, and reading that absence as INDETERMINATE would condemn a
4656
+ perfectly healthy index instead of discarding a marker that never became
4657
+ live. When the epochs differ in the other direction, a newer binary reading
4658
+ an older destination, the same conclusion holds and the epoch gate refuses
4659
+ the destination anyway.
4660
+ """
4661
+ try:
4662
+ conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
4663
+ try:
4664
+ epoch = int(conn.execute("PRAGMA user_version").fetchone()[0])
4665
+ if epoch != _cctally_core.STATS_INDEX_EPOCH:
4666
+ return None
4667
+ rows = conn.execute(
4668
+ "SELECT record_path FROM stats_publication_stamp"
4669
+ ).fetchall()
4670
+ finally:
4671
+ conn.close()
4672
+ except BaseException as exc:
4673
+ return exc
4674
+ if not rows:
4675
+ return None
4676
+ return [{"record_path": row[0]} for row in rows]
4677
+
4678
+
4679
+ def in_place_publication_proven_predecessor(destination, state) -> bool:
4680
+ """Whether a PENDING in-place marker's publication provably never committed.
4681
+
4682
+ True only on `PROVEN_PREDECESSOR`: the stamp was read and does not name
4683
+ this marker's record, so the live bytes are the untouched predecessor and
4684
+ the marker may be discarded. `MATCH` means the verdict is still owed, and
4685
+ `INDETERMINATE` fails closed — it must never collapse into either of the
4686
+ other two states, because discarding on an unreadable stamp is exactly the
4687
+ silent-acceptance class the publication transaction exists to close.
4688
+
4689
+ Public because both discriminator sites consume it: the opener's
4690
+ `_cctally_store._pending_stats_publication_never_replaced` and this
4691
+ module's `_settle_prior_publication_verdict`.
4692
+ """
4693
+ import _lib_stats_publish as sp
4694
+
4695
+ record_path = state.get("recordPath")
4696
+ verdict = sp.resolve_stamp(
4697
+ read_publication_stamp(destination),
4698
+ record_path if isinstance(record_path, str) else None,
4699
+ )
4700
+ return verdict == sp.STAMP_PROVEN_PREDECESSOR
4701
+
4702
+
4703
+ def _stamp_identity_error(path, expected_record_path: str) -> "str | None":
4704
+ import _lib_stats_publish as sp
4705
+
4706
+ verdict = sp.resolve_stamp(
4707
+ read_publication_stamp(path), expected_record_path
4708
+ )
4709
+ if verdict == sp.STAMP_MATCH:
4710
+ return None
4711
+ return (
4712
+ "published stats index does not carry this publication's stamp "
4713
+ f"({verdict}); expected {expected_record_path}"
4714
+ )
4715
+
4716
+
4636
4717
  def validate_published_stats_index(
4637
- path, high_water: "tuple[str, int] | None"
4718
+ path, high_water: "tuple[str, int] | None", *,
4719
+ expected_record_path: "str | None" = None,
4638
4720
  ) -> "str | None":
4639
4721
  """Validate an index on a FRESH read-only connection (#496 S1 F1).
4640
4722
 
@@ -4644,6 +4726,16 @@ def validate_published_stats_index(
4644
4726
  mechanism is already proven by `stats_index_matches_journal_prefix`, which
4645
4727
  runs the same check on the same kind of connection.
4646
4728
 
4729
+ ``expected_record_path`` names the publication whose bytes these are meant
4730
+ to be, and the stamp row is verified against it (#496 S3 §5): the
4731
+ high-water alone answers "is this index a correct materialization of the
4732
+ journal prefix", not "is this index the generation THIS publication just
4733
+ installed". The in-place publisher passes it because it has just written
4734
+ that identity inside the publication transaction. The opener deliberately
4735
+ does NOT: it has already resolved the stamp as a three-state question, and
4736
+ folding that into a boolean validation error would turn an INDETERMINATE
4737
+ read into a settled `failed` verdict.
4738
+
4647
4739
  Public because `stats_open_guarded` runs exactly this check when it
4648
4740
  resolves a pending publication marker.
4649
4741
  """
@@ -4655,6 +4747,8 @@ def validate_published_stats_index(
4655
4747
  conn.close()
4656
4748
  except BaseException as exc:
4657
4749
  return f"{type(exc).__name__}: {exc}"[:500]
4750
+ if expected_record_path is not None:
4751
+ return _stamp_identity_error(path, expected_record_path)
4658
4752
  return None
4659
4753
 
4660
4754
 
@@ -4665,7 +4759,7 @@ def _publication_marker_path(destination) -> pathlib.Path:
4665
4759
  def _write_publication_marker(
4666
4760
  destination, record_path, *, started_at: str, scratch_path,
4667
4761
  status: str = "pending", error: "str | None" = None,
4668
- prior: "dict | None" = None,
4762
+ prior: "dict | None" = None, mechanism: str = "replace",
4669
4763
  ) -> None:
4670
4764
  """Publish the durable marker a later opener honours (#496 S1 F1).
4671
4765
 
@@ -4681,6 +4775,14 @@ def _write_publication_marker(
4681
4775
  (Within one process the claim is weaker; see
4682
4776
  `_cctally_store._pending_stats_publication_never_replaced`.)
4683
4777
 
4778
+ `mechanism` states which publication protocol this marker belongs to, so
4779
+ the opener SELECTS its discriminator instead of inferring one (#496 S3 §5).
4780
+ `replace` keeps the `scratchPath` proxy above, which remains exactly
4781
+ correct there. `in_place` attaches the scratch read-only and leaves it on
4782
+ disk whether the transaction committed or rolled back, so the proxy
4783
+ inverts and the publication's own `stats_publication_stamp` row answers
4784
+ instead.
4785
+
4684
4786
  `priorFailure` carries a settled verdict this publication is about to
4685
4787
  overwrite, so a crash before `os.replace` cannot discard it — see
4686
4788
  `_settle_prior_publication_verdict`.
@@ -4693,6 +4795,7 @@ def _write_publication_marker(
4693
4795
  "recordPath": str(record_path),
4694
4796
  "startedAtUtc": started_at,
4695
4797
  "scratchPath": str(scratch_path),
4798
+ "mechanism": mechanism,
4696
4799
  }
4697
4800
  if error is not None:
4698
4801
  payload["error"] = error
@@ -4719,6 +4822,30 @@ def _read_publication_marker(destination) -> "dict | None":
4719
4822
  return state if isinstance(state, dict) else {}
4720
4823
 
4721
4824
 
4825
+ def _pending_publication_owes_nothing(destination, state) -> bool:
4826
+ """Whether a PENDING marker's own publication never reached the live bytes.
4827
+
4828
+ The marker STATES its mechanism, so the discriminator is selected rather
4829
+ than inferred (#496 S3 §5). `replace` keeps the `scratchPath` proxy, which
4830
+ is exactly correct there because across processes `os.replace` is the only
4831
+ thing that consumes a scratch. `in_place` attaches its scratch read-only
4832
+ and leaves it on disk whether the transaction committed or rolled back, so
4833
+ that proxy INVERTS and the publication's own stamp answers instead. Neither
4834
+ is generalized over the other.
4835
+
4836
+ A marker written before the mechanism field existed reads as `replace`,
4837
+ which is what those binaries did.
4838
+ """
4839
+ if str(state.get("mechanism") or "replace") == "in_place":
4840
+ return in_place_publication_proven_predecessor(destination, state)
4841
+ scratch = state.get("scratchPath")
4842
+ return (
4843
+ isinstance(scratch, str)
4844
+ and bool(scratch)
4845
+ and pathlib.Path(scratch).exists()
4846
+ )
4847
+
4848
+
4722
4849
  def _settle_prior_publication_verdict(destination) -> "dict | None":
4723
4850
  """Settle the verdict a PREVIOUS publication still owes, before this one
4724
4851
  overwrites the single marker slot (#496 S1 F1).
@@ -4738,15 +4865,15 @@ def _settle_prior_publication_verdict(destination) -> "dict | None":
4738
4865
  when nothing is owed:
4739
4866
 
4740
4867
  - a `failed` marker is already settled and is carried verbatim;
4741
- - a `pending` marker whose own scratch is still on disk never replaced
4742
- anything, so it owes nothing about the live bytes of its own — but it may
4743
- still be CARRYING an older run's verdict, which is passed through so a
4744
- third consecutive crashed run cannot drop it;
4745
- - a `pending` marker whose scratch is gone is settled HERE, by validating
4746
- the destination against its record's pinned high-water — the same check
4747
- the opener would have run. Success clears it; failure is written to both
4748
- the marker and the record before this run touches the destination, and is
4749
- then carried forward.
4868
+ - a `pending` marker whose own publication provably never became live owes
4869
+ nothing about the live bytes of its own — but it may still be CARRYING an
4870
+ older run's verdict, which is passed through so a third consecutive
4871
+ crashed run cannot drop it;
4872
+ - any other `pending` marker is settled HERE, by validating the destination
4873
+ against its record's pinned high-water — the same check the opener would
4874
+ have run. Success clears it; failure is written to both the marker and
4875
+ the record before this run touches the destination, and is then carried
4876
+ forward.
4750
4877
 
4751
4878
  A marker that cannot be judged (no record, or no pinned high-water) is left
4752
4879
  to the opener's existing discard policy rather than wedging the rebuild.
@@ -4761,8 +4888,7 @@ def _settle_prior_publication_verdict(destination) -> "dict | None":
4761
4888
  return state
4762
4889
  if status != "pending":
4763
4890
  return None
4764
- scratch = state.get("scratchPath")
4765
- if isinstance(scratch, str) and scratch and pathlib.Path(scratch).exists():
4891
+ if _pending_publication_owes_nothing(destination, state):
4766
4892
  # This marker owes nothing itself, but dropping what it carries would
4767
4893
  # lose an older run's verdict once a third run crashes the same way.
4768
4894
  carried = state.get("priorFailure")
@@ -4802,12 +4928,30 @@ def _settle_prior_publication_verdict(destination) -> "dict | None":
4802
4928
  _write_rebuild_record(record_path, record)
4803
4929
  except OSError:
4804
4930
  pass
4805
- print(
4806
- "[stats] an earlier stats.db publication was interrupted before it "
4807
- f"could validate what it published, and it FAILED that check: {error}. "
4808
- f"Rebuild record: {record_path}.",
4809
- file=sys.stderr,
4931
+ # #496 exists partly because corruption gets MISATTRIBUTED, so the wording
4932
+ # must not accuse an earlier publication of failing a check that never ran.
4933
+ # A destination that cannot be read fails this validation for a reason that
4934
+ # is not evidence about the publication: the stamp read is INDETERMINATE
4935
+ # and the high-water check then trips on the unrelated damage.
4936
+ unreadable = _cctally_db._is_sqlite_corruption_error(error) or error.startswith(
4937
+ ("DatabaseError:", "OperationalError:", "InterfaceError:", "sqlite3.")
4810
4938
  )
4939
+ if unreadable:
4940
+ print(
4941
+ "[stats] an earlier stats.db publication was interrupted before it "
4942
+ "could validate what it published, and that check could not be "
4943
+ f"completed because the index could not be read: {error}. The read "
4944
+ "failure is not evidence about that publication. Rebuild record: "
4945
+ f"{record_path}.",
4946
+ file=sys.stderr,
4947
+ )
4948
+ else:
4949
+ print(
4950
+ "[stats] an earlier stats.db publication was interrupted before it "
4951
+ f"could validate what it published, and it FAILED that check: "
4952
+ f"{error}. Rebuild record: {record_path}.",
4953
+ file=sys.stderr,
4954
+ )
4811
4955
  return settled
4812
4956
 
4813
4957
 
@@ -4969,6 +5113,438 @@ def _preserve_stats_family_for_cutover(
4969
5113
  return incident
4970
5114
 
4971
5115
 
5116
+ #: Set on the exception a failed in-place publication raises, so the caller can
5117
+ #: read the phase it had reached. "The transaction raised" is not a safe
5118
+ #: discriminator: a failure before the commit can roll back while a failure
5119
+ #: after it cannot, and a commit-time I/O error leaves the outcome unknown.
5120
+ _PUBLICATION_PHASE_ATTR = "_cctally_publication_phase"
5121
+
5122
+
5123
+ def publication_phase_of(exc) -> "str | None":
5124
+ """The publication phase ``exc`` was raised in, or None when unrecorded."""
5125
+ return getattr(exc, _PUBLICATION_PHASE_ATTR, None)
5126
+
5127
+
5128
+ def _open_publication_connection(destination) -> sqlite3.Connection:
5129
+ """Open the LIVE destination for an in-place publish.
5130
+
5131
+ `stats_open_guarded` skips its own flock when the caller already holds
5132
+ stats maintenance, which every production trigger does. Interrupted-rebuild
5133
+ recovery is suppressed because this run's own `.rebuilding-*` scratch is on
5134
+ disk right now and is not an interruption to recover from.
5135
+
5136
+ `stats_open_guarded` does NOT apply connection policy — `open_db` does that
5137
+ separately — so the busy timeout, journal mode and WAL size limit are
5138
+ applied here rather than assumed.
5139
+
5140
+ The connection is opened with `uri=True` because the publisher ATTACHes the
5141
+ scratch through a `file:...?mode=ro` URI. SQLite honours a URI filename in
5142
+ `ATTACH` only when the main connection carries `SQLITE_OPEN_URI`, or when
5143
+ the library happens to be built with `SQLITE_USE_URI`. Relying on the
5144
+ latter would make the read-only attach ambient rather than guaranteed.
5145
+ """
5146
+ import _cctally_store
5147
+
5148
+ conn = _cctally_store.stats_open_guarded(
5149
+ pathlib.Path(destination),
5150
+ connect=lambda path: sqlite3.connect(
5151
+ pathlib.Path(path).resolve().as_uri(), uri=True
5152
+ ),
5153
+ recover_interruptions=False,
5154
+ )
5155
+ try:
5156
+ _cctally_store.apply_policy(conn, "stats")
5157
+ except BaseException:
5158
+ try:
5159
+ conn.close()
5160
+ except Exception:
5161
+ pass
5162
+ raise
5163
+ return conn
5164
+
5165
+
5166
+ def _carry_sqlite_sequence(conn: sqlite3.Connection) -> None:
5167
+ """Install the scratch's AUTOINCREMENT watermarks, not the copy's.
5168
+
5169
+ A table-by-table row copy sets each counter to `max(rowid)`, whereas
5170
+ `os.replace` publishes the scratch's `sqlite_sequence` verbatim. Measured on
5171
+ SQLite 3.53.4: a scratch with `max(id)=6` and a counter of 10 — the shape
5172
+ produced whenever the fold inserts rows and later deletes them, as the
5173
+ `five_hour_block_close` fold's exact-child DELETE/INSERT does — published a
5174
+ counter of 6, and the next insert took id 7, an id a deleted row had
5175
+ already used.
5176
+
5177
+ Delete-then-insert rather than update, because `DROP TABLE` removes the
5178
+ table's `sqlite_sequence` row and `CREATE TABLE` does not put one back, so
5179
+ an UPDATE has nothing of its own to act on. Measured on SQLite 3.53.4, a
5180
+ zero-row `INSERT ... SELECT` does create the row with seq 0 — so on that
5181
+ version an UPDATE would in fact land — but that is an undocumented
5182
+ implementation detail rather than a contract, and silently losing an
5183
+ AUTOINCREMENT watermark hands out an id a deleted row already used.
5184
+ """
5185
+ present = conn.execute(
5186
+ "SELECT (SELECT 1 FROM src.sqlite_schema WHERE type = 'table' "
5187
+ "AND name = 'sqlite_sequence'), "
5188
+ "(SELECT 1 FROM main.sqlite_schema WHERE type = 'table' "
5189
+ "AND name = 'sqlite_sequence')"
5190
+ ).fetchone()
5191
+ if present is None or present[0] is None or present[1] is None:
5192
+ return
5193
+ rows = conn.execute("SELECT name, seq FROM src.sqlite_sequence").fetchall()
5194
+ for name, seq in rows:
5195
+ conn.execute("DELETE FROM main.sqlite_sequence WHERE name = ?", (name,))
5196
+ conn.execute(
5197
+ "INSERT INTO main.sqlite_sequence (name, seq) VALUES (?, ?)",
5198
+ (name, seq),
5199
+ )
5200
+
5201
+
5202
+ def _publish_generation_in_place(
5203
+ conn: sqlite3.Connection, scratch, *, record_path, started_at: str
5204
+ ) -> str:
5205
+ """Install the validated scratch's generation into the LIVE database.
5206
+
5207
+ Returns the terminating phase. The whole swap is ONE `BEGIN IMMEDIATE`, so
5208
+ a reader inside a transaction keeps the generation it opened on, an
5209
+ abandoned attempt leaves the prior generation live and sound, and
5210
+ `PRAGMA user_version` flips atomically at the commit.
5211
+ """
5212
+ import _lib_stats_publish as sp
5213
+
5214
+ if int(conn.execute("PRAGMA foreign_keys").fetchone()[0]) != 0:
5215
+ # `apply_policy` never enables foreign keys and the schema documents
5216
+ # them as enforcement-off, so the derived-FK seam for
5217
+ # `five_hour_milestones.block_id` is a fold ordering contract rather
5218
+ # than an enforced constraint. Assert that rather than depend on it
5219
+ # silently, so a future change that turns them on fails loudly here.
5220
+ raise JournalError(
5221
+ "stats publication requires foreign_keys=0; the schema's derived-FK "
5222
+ "seam is a fold ordering contract, not an enforced constraint"
5223
+ )
5224
+ resolved = pathlib.Path(scratch).resolve()
5225
+ conn.execute("ATTACH DATABASE ? AS src", (resolved.as_uri() + "?mode=ro",))
5226
+ attached = conn.execute(
5227
+ "SELECT file FROM pragma_database_list WHERE name = 'src'"
5228
+ ).fetchone()
5229
+ if attached is None or pathlib.Path(str(attached[0])) != resolved:
5230
+ # A connection without SQLITE_OPEN_URI treats the URI as a literal
5231
+ # filename and silently attaches a new, EMPTY database under that name.
5232
+ # Publishing from it would install an empty generation, so the identity
5233
+ # of what was attached is checked rather than assumed.
5234
+ try:
5235
+ conn.execute("DETACH DATABASE src")
5236
+ except Exception:
5237
+ pass
5238
+ raise JournalError(
5239
+ "stats publication attached the wrong file as its scratch: "
5240
+ f"expected {resolved}, got {attached[0] if attached else '<none>'}"
5241
+ )
5242
+ phase = sp.PRE_COMMIT
5243
+ try:
5244
+ try:
5245
+ conn.execute("BEGIN IMMEDIATE")
5246
+ # Both schemas are read INSIDE the transaction, so the drop list
5247
+ # describes the generation actually being retired.
5248
+ src_objects = conn.execute(
5249
+ "SELECT type, name, sql FROM src.sqlite_schema"
5250
+ ).fetchall()
5251
+ dest_objects = conn.execute(
5252
+ "SELECT type, name, sql FROM main.sqlite_schema"
5253
+ ).fetchall()
5254
+ plan = sp.plan_generation_swap(dest_objects, src_objects)
5255
+ if plan.rejected:
5256
+ raise JournalError(
5257
+ "stats publication cannot copy unsupported object(s): "
5258
+ + ", ".join(plan.rejected)
5259
+ )
5260
+ for statement in plan.drop_statements:
5261
+ conn.execute(statement)
5262
+ for statement in plan.create_table_statements:
5263
+ conn.execute(statement)
5264
+ for name in plan.copy_tables:
5265
+ conn.execute(
5266
+ f'INSERT INTO main."{name}" SELECT * FROM src."{name}"'
5267
+ )
5268
+ for statement in plan.create_index_statements:
5269
+ conn.execute(statement)
5270
+ _carry_sqlite_sequence(conn)
5271
+ epoch = int(conn.execute("PRAGMA src.user_version").fetchone()[0])
5272
+ if epoch != _cctally_core.STATS_INDEX_EPOCH:
5273
+ # `read_publication_stamp`'s entire short-circuit rests on the
5274
+ # claim that a committed publication always leaves the
5275
+ # destination at THIS binary's epoch. Upstream validation
5276
+ # already guarantees the scratch carries it; asserting it here
5277
+ # costs nothing and turns the argument into an invariant.
5278
+ raise JournalError(
5279
+ "stats publication refuses to stamp a scratch at index "
5280
+ f"epoch {epoch}; this binary builds "
5281
+ f"{_cctally_core.STATS_INDEX_EPOCH}"
5282
+ )
5283
+ conn.execute(f"PRAGMA main.user_version={epoch:d}")
5284
+ # The publication's own identity, committed atomically with the
5285
+ # content and the epoch it describes (#496 S3 §5).
5286
+ conn.execute("DELETE FROM main.stats_publication_stamp")
5287
+ conn.execute(
5288
+ "INSERT INTO main.stats_publication_stamp "
5289
+ "(record_path, started_at_utc, stamped_at_utc) VALUES (?, ?, ?)",
5290
+ (str(record_path), started_at, _utc_iso_now()),
5291
+ )
5292
+ phase = sp.COMMIT_UNKNOWN
5293
+ conn.commit()
5294
+ phase = sp.COMMITTED
5295
+ _stats_rebuild_test_pause("publication_after_commit_before_detach")
5296
+ except BaseException as exc:
5297
+ if phase == sp.PRE_COMMIT:
5298
+ try:
5299
+ conn.rollback()
5300
+ except Exception:
5301
+ pass
5302
+ try:
5303
+ setattr(exc, _PUBLICATION_PHASE_ATTR, phase)
5304
+ except Exception: # pragma: no cover — some exceptions are frozen
5305
+ pass
5306
+ raise
5307
+ finally:
5308
+ # DETACH cannot run inside a transaction, so this is best-effort: a
5309
+ # failure that left one open is already being raised.
5310
+ try:
5311
+ conn.execute("DETACH DATABASE src")
5312
+ except Exception:
5313
+ pass
5314
+ return phase
5315
+
5316
+
5317
+ def _checkpoint_after_publication(conn: sqlite3.Connection) -> str:
5318
+ """Drain the WAL after a committed in-place publish — BEST EFFORT.
5319
+
5320
+ `wal_checkpoint(TRUNCATE)` returns a busy ROW rather than raising, and this
5321
+ repository has measured it taking about 16 seconds against a 15-second
5322
+ `busy_timeout` under a pinned reader. Its result is recorded and never
5323
+ interpreted as a transaction failure, and it is never a reason to fall back
5324
+ after a commit.
5325
+ """
5326
+ try:
5327
+ row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
5328
+ except sqlite3.Error as exc:
5329
+ return f"error:{type(exc).__name__}"
5330
+ if row is None:
5331
+ return "unknown"
5332
+ return "checkpointed" if int(row[0]) == 0 else "busy"
5333
+
5334
+
5335
+ def _remove_empty_db_sidecars(path) -> None:
5336
+ """Remove the sidecars a read-only validation open leaves behind.
5337
+
5338
+ Unlike the physical path, an in-place publish leaves a LEGITIMATE WAL: it
5339
+ belongs to the live database, and deleting a non-empty one would discard
5340
+ committed frames. Only a zero-length WAL is removed — the state a
5341
+ successful TRUNCATE checkpoint and a clean last close leave — so the
5342
+ documented sidecar-free end state still holds without ever risking data.
5343
+ """
5344
+ path = pathlib.Path(path)
5345
+ wal = pathlib.Path(str(path) + "-wal")
5346
+ try:
5347
+ if wal.stat().st_size:
5348
+ return
5349
+ except OSError:
5350
+ pass
5351
+ for suffix in ("-wal", "-shm"):
5352
+ try:
5353
+ pathlib.Path(str(path) + suffix).unlink()
5354
+ except FileNotFoundError:
5355
+ pass
5356
+ except OSError:
5357
+ return
5358
+ _fsync_dir(path.parent)
5359
+
5360
+
5361
+ #: Probe 2 measured the live WAL peaking at 1.01x the main file across five
5362
+ #: successive in-place publishes, so the projection is that plus a margin.
5363
+ _PUBLICATION_WAL_PROJECTION = 1.05
5364
+ #: Headroom for the rollback journal and freelist churn of an abandoned attempt.
5365
+ _PUBLICATION_ROLLBACK_MARGIN = 0.25
5366
+
5367
+
5368
+ def _free_disk_bytes(directory) -> int:
5369
+ """Free bytes on the filesystem holding ``directory`` (mockable in tests)."""
5370
+ import _cctally_db
5371
+
5372
+ return _cctally_db._free_disk_bytes(directory)
5373
+
5374
+
5375
+ def _db_family_bytes(path) -> int:
5376
+ total = 0
5377
+ for suffix in ("", "-wal", "-shm"):
5378
+ try:
5379
+ total += pathlib.Path(str(path) + suffix).stat().st_size
5380
+ except OSError:
5381
+ pass
5382
+ return total
5383
+
5384
+
5385
+ def _publication_required_free_bytes(scratch, destination) -> int:
5386
+ """Conservative free-space floor for publishing ``scratch``.
5387
+
5388
+ The live attempt adds a database-sized WAL while the scratch still exists,
5389
+ and a physical fallback would then add a full quarantine copy of the old
5390
+ family on top of that. All three are counted, because the run cannot know
5391
+ at this point which mechanism it will end up using.
5392
+ """
5393
+ scratch_bytes = _db_family_bytes(scratch)
5394
+ projected = _PUBLICATION_WAL_PROJECTION + _PUBLICATION_ROLLBACK_MARGIN
5395
+ return int(scratch_bytes * projected) + _db_family_bytes(destination)
5396
+
5397
+
5398
+ def _preflight_publication_space(scratch, destination) -> None:
5399
+ """Abort before any live mutation when the disk cannot hold the publish.
5400
+
5401
+ Aborting is deliberately NOT a reason to fall back to replacement: a full
5402
+ disk leaves a perfectly good generation live, and physically replacing it
5403
+ is exactly the outcome §12 refuses.
5404
+ """
5405
+ parent = pathlib.Path(destination).parent
5406
+ needed = _publication_required_free_bytes(scratch, destination)
5407
+ try:
5408
+ free = _free_disk_bytes(parent)
5409
+ except OSError as exc: # pragma: no cover — statvfs failing is exotic
5410
+ raise JournalError(
5411
+ f"could not determine free space on {parent} before publishing "
5412
+ f"the rebuilt stats index: {exc}"
5413
+ ) from exc
5414
+ if free < needed:
5415
+ raise JournalError(
5416
+ "publishing the rebuilt stats index needs about "
5417
+ f"{needed / (1024 * 1024):.1f} MB free on {parent}, but only "
5418
+ f"{free / (1024 * 1024):.1f} MB is available. The existing index "
5419
+ "is untouched; free space and retry."
5420
+ )
5421
+
5422
+
5423
+ #: Returned by the in-place publisher when the destination cannot be operated
5424
+ #: on structurally and physical replacement is the sanctioned fallback.
5425
+ _FALL_BACK = object()
5426
+
5427
+
5428
+ def _publish_stats_index_in_place(
5429
+ *, scratch, destination, context, high_water, record, fire_before_swap,
5430
+ prior,
5431
+ ):
5432
+ """Publish transactionally into the live file (#496 S3 §4).
5433
+
5434
+ Returns `None` on success (an in-place publish never preserves, so there is
5435
+ no incident directory), or `_FALL_BACK` when physical replacement is the
5436
+ sanctioned response.
5437
+ """
5438
+ import _lib_stats_publish as sp
5439
+
5440
+ try:
5441
+ conn = _open_publication_connection(destination)
5442
+ except BaseException as exc:
5443
+ if sp.may_fall_back_to_replacement(exc):
5444
+ print(
5445
+ "[rebuild] the live stats index cannot be opened "
5446
+ f"({exc}); publishing by replacement instead",
5447
+ file=sys.stderr,
5448
+ )
5449
+ return _FALL_BACK
5450
+ raise
5451
+
5452
+ started_at = _utc_iso_now()
5453
+ record_path = pathlib.Path(context.record_path)
5454
+ live = dict(record)
5455
+ live.update({
5456
+ "status": "pending",
5457
+ "startedAtUtc": started_at,
5458
+ "completedAtUtc": None,
5459
+ # An in-place publish never preserves. Preservation is a consequence of
5460
+ # destroying a file; `db backup --db stats` is the supported snapshot.
5461
+ "incidentPath": None,
5462
+ "damageShapeTokens": None,
5463
+ "postPublicationValidation": None,
5464
+ "publicationMechanism": "in_place",
5465
+ })
5466
+ try:
5467
+ fire_before_swap()
5468
+ # Phase 1 of the publication transaction: the record and then the
5469
+ # marker, each fsynced, BEFORE any live byte changes.
5470
+ _write_rebuild_record(record_path, live)
5471
+ _stats_rebuild_test_pause("publication_before_marker")
5472
+ _write_publication_marker(
5473
+ destination, record_path, started_at=started_at,
5474
+ scratch_path=scratch, prior=prior, mechanism="in_place",
5475
+ )
5476
+ _stats_rebuild_test_pause("rebuild_before_cutover")
5477
+ _publish_generation_in_place(
5478
+ conn, scratch, record_path=record_path, started_at=started_at,
5479
+ )
5480
+ except BaseException as exc:
5481
+ # Rollback, detach and CLOSE are all mandatory before any fallback: the
5482
+ # drain gate is a whole-system handle scan, and this connection would
5483
+ # either fail it or hollow out the invariant it exists to enforce.
5484
+ try:
5485
+ conn.close()
5486
+ except Exception:
5487
+ pass
5488
+ phase = publication_phase_of(exc)
5489
+ if phase == sp.PRE_COMMIT and sp.may_fall_back_to_replacement(exc):
5490
+ print(
5491
+ "[rebuild] the in-place stats publication rolled back "
5492
+ f"({exc}); publishing by replacement instead",
5493
+ file=sys.stderr,
5494
+ )
5495
+ record["inPlaceAttempt"] = {
5496
+ "phase": phase, "error": f"{type(exc).__name__}: {exc}"[:500],
5497
+ }
5498
+ return _FALL_BACK
5499
+ raise
5500
+
5501
+ checkpoint_outcome = _checkpoint_after_publication(conn)
5502
+ try:
5503
+ conn.close()
5504
+ except Exception:
5505
+ pass
5506
+ _stats_rebuild_test_pause("rebuild_after_publication_replace")
5507
+
5508
+ # Phase 2: validate the bytes that are now live, on a connection that never
5509
+ # saw them being written. The expected publication identity goes with it:
5510
+ # the high-water alone cannot distinguish this generation from an equally
5511
+ # journal-consistent one some other run installed.
5512
+ post_error = validate_published_stats_index(
5513
+ destination, high_water, expected_record_path=str(record_path),
5514
+ )
5515
+ _remove_empty_db_sidecars(destination)
5516
+
5517
+ live["publicationCheckpoint"] = checkpoint_outcome
5518
+ live["postPublicationValidation"] = {
5519
+ "ok": post_error is None, "error": post_error,
5520
+ }
5521
+ live["completedAtUtc"] = _utc_iso_now()
5522
+ live["status"] = "ok" if post_error is None else "failed"
5523
+ _write_rebuild_record(record_path, live)
5524
+ if post_error is not None:
5525
+ # The scratch is deliberately NOT removed here: it is the last
5526
+ # independently validated copy of this generation, and the live bytes
5527
+ # just failed.
5528
+ _write_publication_marker(
5529
+ destination, record_path, started_at=started_at,
5530
+ scratch_path=scratch, status="failed", error=post_error,
5531
+ mechanism="in_place",
5532
+ )
5533
+ raise JournalError(
5534
+ "published stats index failed post-publication validation: "
5535
+ f"{post_error}; rebuild record: {record_path}"
5536
+ )
5537
+ _stats_rebuild_test_pause("publication_after_verdict_before_marker_removal")
5538
+ _remove_publication_marker(destination)
5539
+ # Removal follows verdict settlement. A surviving `.rebuilding-*` family is
5540
+ # classified FIRST by the next opener and would route this healthy index
5541
+ # through interrupted-rebuild recovery.
5542
+ _stats_rebuild_test_pause("publication_before_scratch_removal")
5543
+ _remove_db_family(scratch)
5544
+ _fsync_dir(pathlib.Path(destination).parent)
5545
+ return None
5546
+
5547
+
4972
5548
  def _publish_rebuilt_stats_index(
4973
5549
  *,
4974
5550
  scratch: pathlib.Path,
@@ -4979,24 +5555,51 @@ def _publish_rebuilt_stats_index(
4979
5555
  record: dict,
4980
5556
  before_swap=None,
4981
5557
  ) -> "pathlib.Path | None":
4982
- """Publish one validated, closed, sidecar-free scratch index atomically.
4983
-
4984
- Publication is a two-phase durable transaction (#496 S1 F1). After
4985
- `os.replace` no scratch pathname remains, so interrupted-rebuild recovery
4986
- cannot activate, and the published file carries the current epoch so
4987
- `open_db`'s zero-DDL fast path returns it with no validation. A
4988
- post-publication failure that only RAISED would therefore leave a known-bad
4989
- index accepted by every later command. The record and the marker are what
4990
- make the verdict outlive this process.
5558
+ """Publish one validated, closed, sidecar-free scratch index.
5559
+
5560
+ In-place transactional publication is the mechanism (#496 S3). Physical
5561
+ replacement is the fallback, taken when the destination cannot be operated
5562
+ on structurally. The mechanism is chosen against the destination in front
5563
+ of this run, not by the trigger that reached it: corruption is not uniform,
5564
+ and a readable-but-damaged destination publishes in place like any other.
5565
+
5566
+ Publication is a two-phase durable transaction (#496 S1 F1) under either
5567
+ mechanism. A published file carries the current epoch, so `open_db`'s
5568
+ zero-DDL fast path returns it with no validation and a post-publication
5569
+ failure that only RAISED would leave a known-bad index accepted by every
5570
+ later command. The record and the marker are what make the verdict outlive
5571
+ this process.
4991
5572
  """
4992
5573
  import _cctally_store
4993
5574
 
5575
+ # Read-only, and first: a short disk must abort while the destination is
5576
+ # still untouched, not part-way through a publication.
5577
+ _preflight_publication_space(scratch, destination)
5578
+
4994
5579
  # A marker already beside the destination may still owe a verdict on bytes
4995
5580
  # that are live right now. Settle it BEFORE Phase 1 overwrites the only
4996
5581
  # marker slot, while the destination is still exactly what that publication
4997
5582
  # left there.
4998
5583
  prior = _settle_prior_publication_verdict(destination)
4999
5584
 
5585
+ # The `before_swap` seam fires ONCE, for either mechanism: it is the
5586
+ # `db rederive` crash seam, and a fallback must not re-enter it.
5587
+ fired = []
5588
+
5589
+ def fire_before_swap() -> None:
5590
+ if before_swap is not None and not fired:
5591
+ fired.append(True)
5592
+ before_swap()
5593
+
5594
+ if pathlib.Path(destination).exists():
5595
+ published = _publish_stats_index_in_place(
5596
+ scratch=scratch, destination=destination, context=context,
5597
+ high_water=high_water, record=record,
5598
+ fire_before_swap=fire_before_swap, prior=prior,
5599
+ )
5600
+ if published is not _FALL_BACK:
5601
+ return published
5602
+
5000
5603
  family_exists = any(
5001
5604
  pathlib.Path(str(destination) + suffix).exists()
5002
5605
  for suffix in ("", "-wal", "-shm")
@@ -5050,8 +5653,7 @@ def _publish_rebuilt_stats_index(
5050
5653
  _remove_db_sidecars_strict(destination)
5051
5654
  _cctally_store._stats_storm_test_pause("stats_replace_sidecars_removed")
5052
5655
 
5053
- if before_swap is not None:
5054
- before_swap()
5656
+ fire_before_swap()
5055
5657
 
5056
5658
  # Phase 1 of the publication transaction: the record and then the marker,
5057
5659
  # each fsynced, BEFORE the replacement becomes visible.
@@ -5064,12 +5666,13 @@ def _publish_rebuilt_stats_index(
5064
5666
  "incidentPath": str(incident) if incident is not None else None,
5065
5667
  "damageShapeTokens": damage_tokens,
5066
5668
  "postPublicationValidation": None,
5669
+ "publicationMechanism": "replace",
5067
5670
  })
5068
5671
  record_path = pathlib.Path(context.record_path)
5069
5672
  _write_rebuild_record(record_path, record)
5070
5673
  _write_publication_marker(
5071
5674
  destination, record_path, started_at=started_at, scratch_path=scratch,
5072
- prior=prior,
5675
+ prior=prior, mechanism="replace",
5073
5676
  )
5074
5677
 
5075
5678
  _stats_rebuild_test_pause("rebuild_before_cutover")
@@ -5101,6 +5704,7 @@ def _publish_rebuilt_stats_index(
5101
5704
  _write_publication_marker(
5102
5705
  destination, record_path, started_at=started_at,
5103
5706
  scratch_path=scratch, status="failed", error=post_error,
5707
+ mechanism="replace",
5104
5708
  )
5105
5709
  raise JournalError(
5106
5710
  "published stats index failed post-publication validation: "
@@ -5397,6 +6001,17 @@ def rebuild_stats_index(
5397
6001
  os.fsync(handle.fileno())
5398
6002
  _fsync_dir(scratch.parent)
5399
6003
 
6004
+ # Extract the compact result data and RELEASE the replay structures before
6005
+ # publication begins (#496 S3 §4.2). The in-place attempt adds a
6006
+ # database-sized WAL while the scratch still exists, so the measured
6007
+ # multi-gigabyte replay peak must not still be resident on top of it.
6008
+ segments_read = len(segments)
6009
+ conflicts = effective.conflicts
6010
+ protocol_violations = effective.protocol_violations
6011
+ acknowledged = effective.acknowledged_protocol_violations
6012
+ decoded = effective = stream = structural = tail = None
6013
+ segments = protocol_evidence = None
6014
+
5400
6015
  # First fresh-connection validation (#496 S1 F1). A failure here raises
5401
6016
  # BEFORE any preservation, so no incident is created and the old family
5402
6017
  # stays live — the existing contract is preserved exactly.
@@ -5427,7 +6042,7 @@ def rebuild_stats_index(
5427
6042
  "highWater": [hw[0], hw[1]] if hw is not None else None,
5428
6043
  "destination": str(dest),
5429
6044
  "targetPath": str(target_path) if target_path is not None else None,
5430
- "segmentsRead": len(segments),
6045
+ "segmentsRead": segments_read,
5431
6046
  "linesFolded": lines_folded,
5432
6047
  "malformed": malformed,
5433
6048
  "rowsByTable": rows_by_table,
@@ -5438,12 +6053,10 @@ def rebuild_stats_index(
5438
6053
 
5439
6054
  return RebuildResult(
5440
6055
  rows_by_table=rows_by_table, malformed=malformed,
5441
- duration_s=time.monotonic() - start, segments_read=len(segments),
5442
- lines_folded=lines_folded, conflicts=effective.conflicts,
5443
- protocol_violations=effective.protocol_violations,
5444
- acknowledged_protocol_violations=(
5445
- effective.acknowledged_protocol_violations
5446
- ),
6056
+ duration_s=time.monotonic() - start, segments_read=segments_read,
6057
+ lines_folded=lines_folded, conflicts=conflicts,
6058
+ protocol_violations=protocol_violations,
6059
+ acknowledged_protocol_violations=acknowledged,
5447
6060
  quarantine_dir=incident,
5448
6061
  )
5449
6062