cctally 1.94.2 → 1.95.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.
@@ -4900,10 +4900,11 @@ def _incremental_selection(conn, records, entries, cursor, covered):
4900
4900
 
4901
4901
  There are TWO read snapshots rather than one, and the gap re-fold sits
4902
4902
  between them deliberately: it is arbitrary journal file I/O, and holding a
4903
- stats.db read snapshot open across it pins the WAL against checkpointing for
4904
- the whole read, which is the bloat #297 documents. The second snapshot
4905
- re-reads the one state row and refuses on any difference, which is what
4906
- keeps the row groups and the state row from coming out of two generations.
4903
+ stats.db read snapshot open across it would keep a rollback-journal SHARED
4904
+ lock for the whole read and unnecessarily delay a writer. The second
4905
+ snapshot re-reads the one state row and refuses on any difference, which is
4906
+ what keeps the row groups and the state row from coming out of two
4907
+ generations.
4907
4908
 
4908
4909
  That re-read covers in-place publication and any live delta writer, because
4909
4910
  both mutate the state row this connection can see. It does NOT cover
@@ -5668,12 +5669,10 @@ def _run_stats_ingest_once(
5668
5669
  if own_conn and conn is not None:
5669
5670
  conn.close()
5670
5671
  finally:
5671
- # §9.2 (#496 S6 F23): the routine stats write. The `-wal`/`-shm`
5672
- # sidecars are materialized by the FIRST write, not by the connect, so
5673
- # hardening at open time alone would leave a 0644 WAL behind every
5674
- # ingest cycle — the exact shape of the cache.db defect #150 fixed.
5675
- # This runs while the ingest lock is still held, so the sidecars it
5676
- # inspects are the ones this cycle produced.
5672
+ # §9.2 (#496 S6 F23): harden the routine stats family again after the
5673
+ # write so a transient rollback journal created during the transaction
5674
+ # cannot retain a permissive mode. This runs while the ingest lock is
5675
+ # still held, so any family member it inspects belongs to this cycle.
5677
5676
  #
5678
5677
  # Guarded because it is the FIRST statement of this block and the two
5679
5678
  # lock releases are the last two: anything raised here — including the
@@ -5800,7 +5799,7 @@ def _correction_scratch_mains() -> set[pathlib.Path]:
5800
5799
  return {
5801
5800
  member
5802
5801
  for member in path.parent.glob(prefix + "*")
5803
- if not member.name.endswith(("-wal", "-shm"))
5802
+ if not member.name.endswith(("-journal", "-wal", "-shm"))
5804
5803
  }
5805
5804
 
5806
5805
 
@@ -6164,25 +6163,28 @@ class RebuildResult:
6164
6163
  stats_quota_projection_incomplete: bool = False
6165
6164
 
6166
6165
 
6167
- def _remove_db_sidecars_strict(path) -> None:
6168
- """Remove both sidecars or fail before publishing a replacement main file."""
6169
- for suffix in ("-wal", "-shm"):
6170
- candidate = pathlib.Path(str(path) + suffix)
6171
- try:
6172
- candidate.unlink()
6173
- except FileNotFoundError:
6174
- pass
6175
- _fsync_dir(pathlib.Path(path).parent)
6176
-
6177
-
6178
6166
  def _remove_db_family(path) -> None:
6179
- for suffix in ("", "-wal", "-shm"):
6167
+ for suffix in ("", "-journal", "-wal", "-shm"):
6180
6168
  try:
6181
6169
  pathlib.Path(str(path) + suffix).unlink()
6182
6170
  except OSError:
6183
6171
  pass
6184
6172
 
6185
6173
 
6174
+ def _assert_stats_wal_sidecars_absent(path, *, phase: str) -> None:
6175
+ """Refuse a rollback stats generation that somehow materialized WAL/SHM."""
6176
+ present = [
6177
+ pathlib.Path(f"{path}{suffix}").name
6178
+ for suffix in ("-wal", "-shm")
6179
+ if pathlib.Path(f"{path}{suffix}").exists()
6180
+ ]
6181
+ if present:
6182
+ raise JournalError(
6183
+ f"stats rollback-journal contract failed during {phase}: "
6184
+ f"unexpected sidecar(s) {present!r}"
6185
+ )
6186
+
6187
+
6186
6188
  def _stats_rebuild_test_pause(point: str) -> None:
6187
6189
  """Private process-control seam for the #388 interrupted-rebuild tests."""
6188
6190
  if os.environ.get("CCTALLY_TEST_STATS_REBUILD_PAUSE_AT") != point:
@@ -6624,58 +6626,6 @@ def stats_index_matches_journal_prefix(
6624
6626
  return False
6625
6627
 
6626
6628
 
6627
- def _prepare_existing_stats_for_cutover(path: pathlib.Path) -> str:
6628
- """Checkpoint a readable old index so removing its sidecars is kill-safe.
6629
-
6630
- Returns what it actually did, so the incident manifest can say whether the
6631
- explicit checkpoint ran (#496 S1 F8). Failure still RAISES rather than
6632
- returning an outcome — the caller records `failed` and re-raises, because
6633
- proceeding past an undrained WAL would pair stale sidecars with the
6634
- replacement main file.
6635
- """
6636
- import _cctally_db
6637
- import _lib_stats_wal
6638
-
6639
- wal_index = _lib_stats_wal.inspect_wal_index_family(path)
6640
- wal_verdict = wal_index.get("verdict")
6641
- if _lib_stats_wal.is_incoherent_wal_index(wal_index):
6642
- # The caller has already proved whole-family drain and, for a heal,
6643
- # preserved the complete pre-checkpoint family. Opening SQLite here
6644
- # would let a stale aPgno[] map direct valid WAL frames to wrong main
6645
- # pages before quarantine records the original bytes.
6646
- return "skipped_incoherent_wal_index"
6647
- if wal_verdict in {"capture_raced", "analysis_truncated"}:
6648
- raise JournalError(
6649
- "old stats index WAL/SHM coherence could not be established "
6650
- f"before cutover ({wal_verdict})"
6651
- )
6652
- if wal_verdict not in {"coherent", "wal_absent", "wal_empty"}:
6653
- # A malformed/non-empty WAL, missing SHM, or another unrecognized raw
6654
- # shape is not permission to let SQLite reconstruct or checkpoint it.
6655
- # The caller has already preserved the complete family and can publish
6656
- # the independently rebuilt index without mutating these old bytes.
6657
- return "skipped_unproven_wal_index"
6658
-
6659
- try:
6660
- conn = sqlite3.connect(str(path), timeout=15.0)
6661
- try:
6662
- conn.execute("PRAGMA schema_version").fetchone()
6663
- checkpoint = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
6664
- if checkpoint is not None and int(checkpoint[0]) != 0:
6665
- raise JournalError(
6666
- "old stats index WAL could not be drained before cutover"
6667
- )
6668
- finally:
6669
- conn.close()
6670
- except sqlite3.DatabaseError as exc:
6671
- # Auto-heal necessarily starts from an unreadable family. Preserve its
6672
- # exact bytes below, then publish the already-validated replacement.
6673
- if _cctally_db._is_sqlite_corruption_error(exc):
6674
- return "skipped_corrupt"
6675
- raise
6676
- return "checkpointed"
6677
-
6678
-
6679
6629
  def _utc_iso_now() -> str:
6680
6630
  return dt.datetime.now(dt.timezone.utc).isoformat(
6681
6631
  timespec="seconds"
@@ -7011,6 +6961,19 @@ def _remove_publication_marker(destination) -> None:
7011
6961
  _fsync_dir(pathlib.Path(destination).parent)
7012
6962
 
7013
6963
 
6964
+ def _restore_prior_publication_marker(destination, prior) -> None:
6965
+ """Undo this run's pre-commit marker without dropping an older verdict."""
6966
+ if isinstance(prior, dict) and prior:
6967
+ import _cctally_db
6968
+
6969
+ _cctally_db._atomic_write_private_json(
6970
+ _publication_marker_path(destination), prior
6971
+ )
6972
+ _fsync_dir(pathlib.Path(destination).parent)
6973
+ return
6974
+ _remove_publication_marker(destination)
6975
+
6976
+
7014
6977
  def _write_rebuild_record(path, payload: dict) -> None:
7015
6978
  import _cctally_db
7016
6979
 
@@ -7046,35 +7009,6 @@ def _scan_stats_damage(path) -> dict:
7046
7009
  }
7047
7010
 
7048
7011
 
7049
- def _record_post_checkpoint_damage(
7050
- incident: pathlib.Path, destination: pathlib.Path, outcome: str,
7051
- ) -> "dict | None":
7052
- """Add the post-checkpoint scan to an already-written incident manifest.
7053
-
7054
- A second `_atomic_write_private_json` to the same path is safe: the write
7055
- is atomic and nothing references the incident yet. It has to be a second
7056
- write because preservation runs BEFORE the explicit checkpoint, so the
7057
- outcome this records does not exist when the manifest is first written.
7058
- """
7059
- import _cctally_db
7060
-
7061
- try:
7062
- manifest_path = incident / "manifest.json"
7063
- manifest = json.loads(manifest_path.read_text())
7064
- damage = manifest.get("damage") or {}
7065
- damage["postCheckpoint"] = _scan_stats_damage(destination)
7066
- damage["checkpointOutcome"] = outcome
7067
- manifest["damage"] = damage
7068
- _cctally_db._atomic_write_private_json(manifest_path, manifest)
7069
- return damage
7070
- except Exception as exc: # noqa: BLE001 — enrichment never breaks a rebuild
7071
- print(
7072
- f"[rebuild] post-checkpoint damage scan failed: {exc}",
7073
- file=sys.stderr,
7074
- )
7075
- return None
7076
-
7077
-
7078
7012
  def _binary_version() -> "str | None":
7079
7013
  """The running binary's released version, or None when it cannot be read.
7080
7014
 
@@ -7089,54 +7023,37 @@ def _binary_version() -> "str | None":
7089
7023
  def _preserve_stats_family_for_cutover(
7090
7024
  path: pathlib.Path, *, context: RebuildContext,
7091
7025
  ) -> pathlib.Path:
7092
- """Durably copy the old family into quarantine without removing the main."""
7026
+ """Durably cold-quarantine the old family before physical replacement."""
7093
7027
  import _cctally_db
7094
7028
 
7095
- root = _cctally_core.APP_DIR / "quarantine"
7096
- root.mkdir(parents=True, exist_ok=True)
7097
- # The quarantine entry itself must survive power loss before any old
7098
- # sidecar can be removed. fsyncing only the new root/incident cannot make
7099
- # the root's directory entry durable in APP_DIR.
7100
- _fsync_dir(_cctally_core.APP_DIR)
7101
- try:
7102
- os.chmod(root, 0o700)
7103
- except OSError:
7104
- pass
7105
- stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%S_%f")
7106
- incident = root / f"{path.name}-{stamp}"
7107
- incident.mkdir(mode=0o700)
7108
- destination = incident / path.name
7029
+ path = pathlib.Path(path)
7109
7030
  members = [
7110
7031
  pathlib.Path(str(path) + suffix).name
7111
- for suffix in ("", "-wal", "-shm")
7032
+ for suffix in ("-journal", "-wal", "-shm", "")
7112
7033
  if pathlib.Path(str(path) + suffix).exists()
7113
7034
  ]
7114
7035
  if not members:
7115
7036
  raise OSError(f"no database family exists to preserve at {path}")
7116
- # Observed sizes are read BEFORE the copy, so the empty-WAL case in the
7117
- # routine corruption heal is evidenced rather than assumed (#496 S1 F2).
7118
7037
  family_sizes = {}
7119
7038
  for name in members:
7120
7039
  try:
7121
7040
  family_sizes[name] = path.with_name(name).stat().st_size
7122
7041
  except OSError:
7123
7042
  family_sizes[name] = None
7124
- # Read from the raw header rather than by opening the file: the file this
7125
- # is asked about is typically one SQLite refuses to open, which is exactly
7126
- # when the epoch it carried is worth recording (#496 S1).
7127
7043
  preserved_user_version = _cctally_db._read_user_version_header(path)
7128
- _cctally_db._copy_db_family(path, destination)
7129
- manifest = {
7130
- "schemaVersion": 2,
7131
- "quarantinedAtUtc": dt.datetime.now(dt.timezone.utc).isoformat(
7132
- timespec="seconds"
7133
- ).replace("+00:00", "Z"),
7134
- "originalPath": str(path),
7135
- "movedFiles": members,
7136
- "complete": True,
7137
- "cutoverProtocol": "preserve-then-atomic-replace-v1",
7138
- # #496 S1 additive fields. Every key above keeps its v1 name and
7139
- # meaning, so a v1 reader is unaffected by the bump.
7044
+ incident = _cctally_db.quarantine_db_family(
7045
+ path,
7046
+ strict=True,
7047
+ context=_cctally_db.quarantine_context(
7048
+ trigger=context.trigger,
7049
+ trigger_error=context.trigger_error,
7050
+ forensics_path=context.forensics_path,
7051
+ ),
7052
+ )
7053
+ manifest_path = incident / "manifest.json"
7054
+ manifest = json.loads(manifest_path.read_text())
7055
+ manifest.update({
7056
+ "cutoverProtocol": "cold-quarantine-then-replace-v2",
7140
7057
  "trigger": context.trigger,
7141
7058
  "triggerError": context.trigger_error,
7142
7059
  "forensicsPath": context.forensics_path,
@@ -7146,19 +7063,14 @@ def _preserve_stats_family_for_cutover(
7146
7063
  "sqliteRuntimeVersion": sqlite3.sqlite_version,
7147
7064
  "preservedUserVersion": preserved_user_version,
7148
7065
  "familySizes": family_sizes,
7149
- # The retained COPY is described, not the live file, because the copy
7150
- # is the artifact that actually survives. `postCheckpoint` and
7151
- # `checkpointOutcome` are filled in by the caller once the explicit
7152
- # checkpoint has run (#496 S1 F8 section 6.3).
7153
7066
  "damage": {
7154
- "preserved": _scan_stats_damage(destination),
7067
+ "preserved": _scan_stats_damage(incident / path.name),
7155
7068
  "postCheckpoint": None,
7156
- "checkpointOutcome": None,
7069
+ "checkpointOutcome": "not_applicable_cold_quarantine",
7157
7070
  },
7158
- }
7159
- _cctally_db._atomic_write_private_json(incident / "manifest.json", manifest)
7071
+ })
7072
+ _cctally_db._atomic_write_private_json(manifest_path, manifest)
7160
7073
  _fsync_dir(incident)
7161
- _fsync_dir(root)
7162
7074
  return incident
7163
7075
 
7164
7076
 
@@ -7183,7 +7095,7 @@ def _open_publication_connection(destination) -> sqlite3.Connection:
7183
7095
  disk right now and is not an interruption to recover from.
7184
7096
 
7185
7097
  `stats_open_guarded` does NOT apply connection policy — `open_db` does that
7186
- separately — so the busy timeout, journal mode and WAL size limit are
7098
+ separately — so the busy timeout, rollback journal mode and durability are
7187
7099
  applied here rather than assumed.
7188
7100
 
7189
7101
  The connection is opened with `uri=True` because the publisher ATTACHes the
@@ -7391,58 +7303,10 @@ def _publish_generation_in_place(
7391
7303
  return phase
7392
7304
 
7393
7305
 
7394
- def _checkpoint_after_publication(conn: sqlite3.Connection) -> str:
7395
- """Drain the WAL after a committed in-place publish — BEST EFFORT.
7396
-
7397
- `wal_checkpoint(TRUNCATE)` returns a busy ROW rather than raising, and this
7398
- repository has measured it taking about 16 seconds against a 15-second
7399
- `busy_timeout` under a pinned reader. Its result is recorded and never
7400
- interpreted as a transaction failure, and it is never a reason to fall back
7401
- after a commit.
7402
- """
7403
- try:
7404
- row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
7405
- except sqlite3.Error as exc:
7406
- return f"error:{type(exc).__name__}"
7407
- if row is None:
7408
- return "unknown"
7409
- return "checkpointed" if int(row[0]) == 0 else "busy"
7410
-
7411
-
7412
- # An in-place publication used to unlink the live `-wal` and `-shm` here once
7413
- # the TRUNCATE checkpoint had emptied the WAL, to reach a sidecar-free end
7414
- # state. That is issue #516 and the call is gone: unlinking the sidecars of a
7415
- # database other connections still hold open is outside SQLite's contract, and
7416
- # what it cost is a data-correctness fault and a crash, NOT a cosmetic end
7417
- # state. Two conditions have to combine, and an earlier thirteen-arrangement
7418
- # run never combined them: something must WRITE after the unlink, and the
7419
- # reader must already have READ before it. Re-measured on both LAN runners
7420
- # (macOS, Python 3.13.14, SQLite 3.53.4, byte-identical on each):
7421
- #
7422
- # - later writer in the SAME process — a reader that had read before the
7423
- # unlink, read-write or `mode=ro`, raises `OperationalError: disk I/O
7424
- # error`; and a connection that keeps writing through the unlinked inodes
7425
- # breaks the NEXT connection opened in that process the same way;
7426
- # - later writer in a CHILD process — that reader silently reads a STALE
7427
- # generation, and so does a freshly opened connection in the parent;
7428
- # - a reader that had NOT read before the unlink reads the current value,
7429
- # which is why the earlier run observed nothing;
7430
- # - a reader holding a pinned read transaction makes the checkpoint busy, so
7431
- # the unlink is refused before it can happen.
7432
- #
7433
- # One mechanism seen from three ends: whichever connection holds an fd on the
7434
- # stale `-wal` inode while the shared wal-index describes frames in the other
7435
- # `-wal` inode takes the short read. A clean last close removes both sidecars
7436
- # by itself, so nothing is leaked; only the case where a handle is open, which
7437
- # is exactly the case that must not be unlinked, now leaves a zero-length WAL
7438
- # behind.
7439
-
7440
-
7441
- #: Probe 2 measured the live WAL peaking at 1.01x the main file across five
7442
- #: successive in-place publishes, so the projection is that plus a margin.
7443
- _PUBLICATION_WAL_PROJECTION = 1.05
7444
- #: Headroom for the rollback journal and freelist churn of an abandoned attempt.
7445
- _PUBLICATION_ROLLBACK_MARGIN = 0.25
7306
+ #: A DELETE-mode in-place publication can temporarily hold a rollback journal
7307
+ #: roughly the size of the live generation. Keep additional headroom for
7308
+ #: freelist churn and for cold-quarantining a corrupt destination.
7309
+ _PUBLICATION_ROLLBACK_PROJECTION = 1.25
7446
7310
 
7447
7311
 
7448
7312
  def _free_disk_bytes(directory) -> int:
@@ -7454,7 +7318,7 @@ def _free_disk_bytes(directory) -> int:
7454
7318
 
7455
7319
  def _db_family_bytes(path) -> int:
7456
7320
  total = 0
7457
- for suffix in ("", "-wal", "-shm"):
7321
+ for suffix in ("", "-journal", "-wal", "-shm"):
7458
7322
  try:
7459
7323
  total += pathlib.Path(str(path) + suffix).stat().st_size
7460
7324
  except OSError:
@@ -7465,14 +7329,15 @@ def _db_family_bytes(path) -> int:
7465
7329
  def _publication_required_free_bytes(scratch, destination) -> int:
7466
7330
  """Conservative free-space floor for publishing ``scratch``.
7467
7331
 
7468
- The live attempt adds a database-sized WAL while the scratch still exists,
7469
- and a physical fallback would then add a full quarantine copy of the old
7470
- family on top of that. All three are counted, because the run cannot know
7471
- at this point which mechanism it will end up using.
7332
+ The live attempt can add a database-sized rollback journal while the
7333
+ scratch still exists, and a corrupt destination may then require a cold
7334
+ quarantine move before replacement.
7472
7335
  """
7473
7336
  scratch_bytes = _db_family_bytes(scratch)
7474
- projected = _PUBLICATION_WAL_PROJECTION + _PUBLICATION_ROLLBACK_MARGIN
7475
- return int(scratch_bytes * projected) + _db_family_bytes(destination)
7337
+ return (
7338
+ int(scratch_bytes * _PUBLICATION_ROLLBACK_PROJECTION)
7339
+ + _db_family_bytes(destination)
7340
+ )
7476
7341
 
7477
7342
 
7478
7343
  def _preflight_publication_space(scratch, destination) -> None:
@@ -7528,6 +7393,8 @@ def _publish_stats_index_in_place(
7528
7393
  file=sys.stderr,
7529
7394
  )
7530
7395
  return _FALL_BACK
7396
+ _remove_db_family(scratch)
7397
+ _fsync_dir(pathlib.Path(destination).parent)
7531
7398
  raise
7532
7399
 
7533
7400
  # Readability is not structural health. An integrity failure may consist
@@ -7557,6 +7424,8 @@ def _publish_stats_index_in_place(
7557
7424
  "error": f"{type(exc).__name__}: {exc}"[:500],
7558
7425
  }
7559
7426
  return _FALL_BACK
7427
+ _remove_db_family(scratch)
7428
+ _fsync_dir(pathlib.Path(destination).parent)
7560
7429
  raise
7561
7430
  if destination_integrity != ["ok"]:
7562
7431
  try:
@@ -7611,28 +7480,31 @@ def _publish_stats_index_in_place(
7611
7480
  conn.close()
7612
7481
  except Exception:
7613
7482
  pass
7483
+ # A healthy readable destination is never physically replaced merely
7484
+ # because its transactional publication was busy or failed. Physical
7485
+ # replacement is reserved for an absent or integrity-corrupt main.
7614
7486
  phase = publication_phase_of(exc)
7615
- if phase == sp.PRE_COMMIT and sp.may_fall_back_to_replacement(exc):
7616
- print(
7617
- "[rebuild] the in-place stats publication rolled back "
7618
- f"({exc}); publishing by replacement instead",
7619
- file=sys.stderr,
7620
- )
7621
- record["inPlaceAttempt"] = {
7622
- "phase": phase, "error": f"{type(exc).__name__}: {exc}"[:500],
7623
- }
7624
- return _FALL_BACK
7487
+ if phase is None or phase == sp.PRE_COMMIT:
7488
+ live["status"] = "failed"
7489
+ live["completedAtUtc"] = _utc_iso_now()
7490
+ live["publicationError"] = f"{type(exc).__name__}: {exc}"[:500]
7491
+ _write_rebuild_record(record_path, live)
7492
+ # The live generation did not change. Remove this run's marker, but
7493
+ # restore any verdict it was carrying for an earlier generation;
7494
+ # otherwise a busy/pre-commit failure would erase the load-bearing
7495
+ # publication failure that this run settled before Phase 1.
7496
+ _restore_prior_publication_marker(destination, prior)
7497
+ _remove_db_family(scratch)
7498
+ _fsync_dir(pathlib.Path(destination).parent)
7625
7499
  raise
7626
7500
 
7627
- checkpoint_outcome = _checkpoint_after_publication(conn)
7628
7501
  try:
7629
7502
  conn.close()
7630
7503
  except Exception:
7631
7504
  pass
7632
7505
  # §9.2 (#496 S6 F23): the in-place publisher never touches the destination
7633
7506
  # file's mode — it mutates objects inside it — so a family that was 0644
7634
- # before the publication is still 0644 after it. The checkpoint above is
7635
- # also the last thing that can re-materialize a sidecar.
7507
+ # before the publication is still 0644 after it.
7636
7508
  _cctally_store._harden_stats_family(destination)
7637
7509
  _stats_rebuild_test_pause("rebuild_after_publication_replace")
7638
7510
 
@@ -7644,7 +7516,10 @@ def _publish_stats_index_in_place(
7644
7516
  destination, high_water, expected_record_path=str(record_path),
7645
7517
  )
7646
7518
 
7647
- live["publicationCheckpoint"] = checkpoint_outcome
7519
+ # Kept as an additive compatibility field in retained rebuild records. The
7520
+ # live protocol no longer calls a WAL checkpoint; DELETE/FULL commits are
7521
+ # durable before control returns and SQLite owns any transient `-journal`.
7522
+ live["publicationCheckpoint"] = "not_applicable_rollback_journal"
7648
7523
  live["postPublicationValidation"] = {
7649
7524
  "ok": post_error is None, "error": post_error,
7650
7525
  }
@@ -7722,7 +7597,33 @@ def _publish_rebuilt_stats_index(
7722
7597
  fired.append(True)
7723
7598
  before_swap()
7724
7599
 
7725
- if pathlib.Path(destination).exists():
7600
+ legacy_wal = _cctally_store._stats_legacy_wal_family_present(destination)
7601
+ skip_in_place = False
7602
+ if legacy_wal:
7603
+ blocked = _cctally_store._stats_family_drained(destination)
7604
+ if blocked is not None:
7605
+ raise JournalError(
7606
+ "stats.db rollback-journal transition declined: "
7607
+ f"{blocked}. Stop and restart the dashboard and every older "
7608
+ "cctally process, then retry; the legacy WAL family is "
7609
+ "untouched."
7610
+ )
7611
+ import _lib_stats_wal
7612
+
7613
+ legacy_evidence = _lib_stats_wal.inspect_wal_index_family(destination)
7614
+ verdict = legacy_evidence.get("verdict")
7615
+ skip_in_place = _lib_stats_wal.is_incoherent_wal_index(legacy_evidence)
7616
+ if not skip_in_place and verdict not in {
7617
+ "coherent", "wal_absent", "wal_empty"
7618
+ }:
7619
+ raise JournalError(
7620
+ "stats.db rollback-journal transition could not prove the "
7621
+ f"legacy WAL family safe ({verdict or 'unknown'}). The family "
7622
+ "is untouched; stop and restart the dashboard and every older "
7623
+ "cctally process, then retry."
7624
+ )
7625
+
7626
+ if pathlib.Path(destination).exists() and not skip_in_place:
7726
7627
  published = _publish_stats_index_in_place(
7727
7628
  scratch=scratch, destination=destination, context=context,
7728
7629
  high_water=high_water, record=record,
@@ -7733,7 +7634,7 @@ def _publish_rebuilt_stats_index(
7733
7634
 
7734
7635
  family_exists = any(
7735
7636
  pathlib.Path(str(destination) + suffix).exists()
7736
- for suffix in ("", "-wal", "-shm")
7637
+ for suffix in ("", "-journal", "-wal", "-shm")
7737
7638
  )
7738
7639
  incident = None
7739
7640
  damage_tokens = None
@@ -7742,49 +7643,34 @@ def _publish_rebuilt_stats_index(
7742
7643
  if blocked is not None:
7743
7644
  raise JournalError(f"stats.db cutover declined: {blocked}")
7744
7645
  _cctally_store._stats_storm_test_pause("stats_replace_drained")
7646
+ fire_before_swap()
7647
+ # Close the exact TOCTOU seam #538 reproduced: the first scan is not a
7648
+ # proof once a pre-transition dashboard can open after it. Re-check at
7649
+ # the destructive edge, before moving even one family member.
7650
+ blocked = _cctally_store._stats_family_drained(destination)
7651
+ if blocked is not None:
7652
+ raise JournalError(
7653
+ "stats.db cold recovery declined after a new reader appeared: "
7654
+ f"{blocked}. Stop and restart the dashboard and every older "
7655
+ "cctally process, then retry; no family member was moved."
7656
+ )
7745
7657
  if preserve_existing:
7746
- # Preserve the exact pre-cutover family, including a committed WAL
7747
- # and SHM, before checkpointing mutates or removes those sidecars.
7748
7658
  incident = _preserve_stats_family_for_cutover(
7749
7659
  destination, context=context
7750
7660
  )
7751
- checkpoint_outcome = "skipped_absent"
7752
- if destination.exists():
7753
- try:
7754
- checkpoint_outcome = _prepare_existing_stats_for_cutover(
7755
- destination
7756
- )
7757
- except BaseException:
7758
- if incident is not None:
7759
- _record_post_checkpoint_damage(
7760
- incident, destination, "failed"
7761
- )
7762
- raise
7763
- if incident is not None:
7764
- # Scanned BEFORE the sidecars are removed, so this and the
7765
- # preserved scan bracket the explicit checkpoint.
7766
- damage = _record_post_checkpoint_damage(
7767
- incident, destination, checkpoint_outcome
7768
- )
7769
- if damage:
7770
- damage_tokens = {
7771
- "forensics": _forensics_shape_token(context.forensics_path),
7772
- "preserved": (damage.get("preserved") or {}).get(
7773
- "shapeToken"
7774
- ),
7775
- "postCheckpoint": (damage.get("postCheckpoint") or {}).get(
7776
- "shapeToken"
7777
- ),
7778
- "checkpointOutcome": damage.get("checkpointOutcome"),
7779
- }
7780
- # The old main stays present and, when it was readable, fully
7781
- # checkpointed. A kill from here until os.replace therefore still
7782
- # leaves a usable old destination while preventing stale sidecars from
7783
- # being paired with the replacement main.
7784
- _remove_db_sidecars_strict(destination)
7785
- _cctally_store._stats_storm_test_pause("stats_replace_sidecars_removed")
7786
-
7787
- fire_before_swap()
7661
+ damage = json.loads(
7662
+ (incident / "manifest.json").read_text()
7663
+ ).get("damage") or {}
7664
+ damage_tokens = {
7665
+ "forensics": _forensics_shape_token(context.forensics_path),
7666
+ "preserved": (damage.get("preserved") or {}).get("shapeToken"),
7667
+ "postCheckpoint": None,
7668
+ "checkpointOutcome": damage.get("checkpointOutcome"),
7669
+ }
7670
+ else:
7671
+ _remove_db_family(destination)
7672
+ else:
7673
+ fire_before_swap()
7788
7674
 
7789
7675
  # Phase 1 of the publication transaction: the record and then the marker,
7790
7676
  # each fsynced, BEFORE the replacement becomes visible.
@@ -7820,15 +7706,10 @@ def _publish_rebuilt_stats_index(
7820
7706
  # Phase 2: validate the bytes that are now live, on a connection that never
7821
7707
  # saw them being written.
7822
7708
  post_error = validate_published_stats_index(destination, high_water)
7823
- # The validation opened the family read-only, which materializes sidecars
7824
- # the removal below deletes; harden the destination itself while they are
7825
- # still present so neither the main nor a surviving sidecar stays 0644.
7826
7709
  _cctally_store._harden_stats_family(destination)
7827
- # The read-only open above creates a zero-byte WAL and a 32 KiB SHM.
7828
- # Remove them so the documented no-post-publication-stale-sidecar end state
7829
- # still holds; an empty WAL is consistent with the freshly published main,
7830
- # so a crash between validation and removal is harmless.
7831
- _remove_db_sidecars_strict(destination)
7710
+ _assert_stats_wal_sidecars_absent(
7711
+ destination, phase="post-publication validation"
7712
+ )
7832
7713
 
7833
7714
  record["postPublicationValidation"] = {
7834
7715
  "ok": post_error is None,
@@ -8989,11 +8870,9 @@ def rebuild_stats_index(
8989
8870
  ) -> RebuildResult:
8990
8871
  """Rebuild the stats index under a SHARED `artifact-retention.lock` hold.
8991
8872
 
8992
- #496 S6 §5.3. A rebuild publishes three artifacts that reclamation must not
8993
- mark while they are being written: the preserved-family incident manifest,
8994
- the second manifest write `_record_post_checkpoint_damage` performs after
8995
- the explicit checkpoint, and the rebuild record that names both. The hold
8996
- spans all three, so no observer ever sees the incident half-described.
8873
+ #496 S6 §5.3. A rebuild holds retention SHARED while publishing the
8874
+ cold-quarantine incident manifest and the rebuild record that names it, so
8875
+ no observer sees the incident half-described.
8997
8876
 
8998
8877
  The hold is taken here rather than only at the producer call sites because
8999
8878
  `db rebuild` and the auto-heal worker are not the only callers — the epoch
@@ -9519,10 +9398,6 @@ def _rebuild_stats_index_locked(
9519
9398
  f"SELECT COUNT(*) FROM {tbl}").fetchone()[0]
9520
9399
  except sqlite3.Error:
9521
9400
  rows_by_table[tbl] = 0
9522
- # Drain the WAL into the main file so the atomic rename carries all data.
9523
- checkpoint = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
9524
- if checkpoint is not None and int(checkpoint[0]) != 0:
9525
- raise JournalError("rebuilt stats index WAL could not be drained")
9526
9401
  _validate_rebuilt_stats_index(conn, hw)
9527
9402
  # The SEMANTIC half (#496 S5b §6.2). The structural checks above cover
9528
9403
  # the new tables' existence and definition; without this, a scratch
@@ -9548,23 +9423,25 @@ def _rebuild_stats_index_locked(
9548
9423
  _close_coverage_snapshot(_retained)
9549
9424
  quota_snapshot = []
9550
9425
 
9551
- # Closed, drained, validated, and durable before the old family is touched.
9552
- _remove_db_sidecars_strict(scratch)
9426
+ # Closed, committed, validated, and durable before the old family is
9427
+ # touched. DELETE/FULL has no WAL drain step; WAL/SHM presence is a contract
9428
+ # failure, never something application code repairs by unlinking.
9429
+ _assert_stats_wal_sidecars_absent(scratch, phase="scratch validation")
9553
9430
  with scratch.open("rb") as handle:
9554
9431
  os.fsync(handle.fileno())
9555
9432
  _fsync_dir(scratch.parent)
9556
9433
 
9557
9434
  # Extract the compact result data and RELEASE the replay structures before
9558
- # publication begins (#496 S3 §4.2). The in-place attempt adds a
9559
- # database-sized WAL while the scratch still exists, so the measured
9435
+ # publication begins (#496 S3 §4.2). The in-place attempt can add a
9436
+ # database-sized rollback journal while the scratch still exists, so the
9560
9437
  # multi-gigabyte replay peak must not still be resident on top of it.
9561
9438
  segments_read = len(segments)
9562
9439
  conflicts = effective.conflicts
9563
9440
  protocol_violations = effective.protocol_violations
9564
9441
  acknowledged = effective.acknowledged_protocol_violations
9565
9442
  # The pre-publication window is what F9's memory acceptance is measured
9566
- # over: everything after this point is publication, whose own WAL cost S3
9567
- # already accounts for.
9443
+ # over: everything after this point is publication, whose rollback-journal
9444
+ # cost S3 already accounts for through projected headroom.
9568
9445
  peak_heap_bytes = (
9569
9446
  tracemalloc.get_traced_memory()[1] if tracing else 0)
9570
9447
  decoded = effective = stream = structural = tail = None
@@ -9578,10 +9455,9 @@ def _rebuild_stats_index_locked(
9578
9455
  raise JournalError(
9579
9456
  f"rebuilt stats index failed pre-publication validation: {pre_error}"
9580
9457
  )
9581
- # That read-only open recreated the scratch sidecars; publication requires
9582
- # a sidecar-free scratch, and a leftover pair would also survive the
9583
- # `os.replace` as a stray artifact.
9584
- _remove_db_sidecars_strict(scratch)
9458
+ _assert_stats_wal_sidecars_absent(
9459
+ scratch, phase="fresh pre-publication validation"
9460
+ )
9585
9461
 
9586
9462
  publication_started = time.monotonic()
9587
9463
  incident = _publish_rebuilt_stats_index(