cctally 1.94.1 → 1.95.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.
- package/CHANGELOG.md +27 -0
- package/bin/_cctally_core.py +28 -24
- package/bin/_cctally_dashboard_share.py +6 -0
- package/bin/_cctally_db.py +166 -68
- package/bin/_cctally_journal.py +164 -288
- package/bin/_cctally_store.py +460 -292
- package/bin/_lib_doctor.py +2 -2
- package/bin/_lib_pricing.py +26 -7
- package/dashboard/static/assets/index-BvCbpJJA.css +1 -0
- package/dashboard/static/assets/{index-CaUQziq_.js → index-CRZMxeYI.js} +55 -55
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-DUQjFSX7.css +0 -1
package/bin/_cctally_journal.py
CHANGED
|
@@ -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
|
|
4904
|
-
the whole read
|
|
4905
|
-
re-reads the one state row and refuses on any difference, which is
|
|
4906
|
-
keeps the row groups and the state row from coming out of two
|
|
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
|
|
5672
|
-
#
|
|
5673
|
-
#
|
|
5674
|
-
#
|
|
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
|
|
7026
|
+
"""Durably cold-quarantine the old family before physical replacement."""
|
|
7093
7027
|
import _cctally_db
|
|
7094
7028
|
|
|
7095
|
-
|
|
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.
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
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(
|
|
7067
|
+
"preserved": _scan_stats_damage(incident / path.name),
|
|
7155
7068
|
"postCheckpoint": None,
|
|
7156
|
-
"checkpointOutcome":
|
|
7069
|
+
"checkpointOutcome": "not_applicable_cold_quarantine",
|
|
7157
7070
|
},
|
|
7158
|
-
}
|
|
7159
|
-
_cctally_db._atomic_write_private_json(
|
|
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
|
|
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
|
-
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
|
|
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
|
|
7469
|
-
and a
|
|
7470
|
-
|
|
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
|
-
|
|
7475
|
-
|
|
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
|
|
7616
|
-
|
|
7617
|
-
|
|
7618
|
-
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
|
|
7623
|
-
|
|
7624
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
)
|
|
7757
|
-
|
|
7758
|
-
|
|
7759
|
-
|
|
7760
|
-
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
|
|
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
|
-
|
|
7828
|
-
|
|
7829
|
-
|
|
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
|
|
8993
|
-
|
|
8994
|
-
|
|
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,
|
|
9552
|
-
|
|
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
|
|
9559
|
-
# database-sized
|
|
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
|
|
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
|
-
|
|
9582
|
-
|
|
9583
|
-
|
|
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(
|