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.
- package/CHANGELOG.md +13 -1
- package/README.md +4 -2
- package/bin/_cctally_cache.py +44 -4
- package/bin/_cctally_core.py +56 -7
- package/bin/_cctally_dashboard.py +71 -14
- package/bin/_cctally_dashboard_conversation.py +8 -4
- package/bin/_cctally_db.py +111 -11
- package/bin/_cctally_journal.py +651 -38
- package/bin/_cctally_parser.py +20 -0
- package/bin/_cctally_quota.py +2 -2
- package/bin/_cctally_statusline.py +6 -6
- package/bin/_cctally_store.py +783 -67
- package/bin/_cctally_tui.py +54 -6
- package/bin/_lib_codex_conversation_query.py +30 -5
- package/bin/_lib_codex_find_projection.py +147 -0
- package/bin/_lib_conversation_dispatch.py +15 -1
- package/bin/_lib_conversation_query.py +62 -2
- package/bin/_lib_stats_publish.py +243 -0
- package/bin/cctally +12 -3
- package/dashboard/static/assets/{index-BEzzJtUd.js → index-Dat-mza6.js} +51 -51
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +2 -1
package/bin/_cctally_journal.py
CHANGED
|
@@ -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
|
-
"
|
|
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
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
the
|
|
4749
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
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
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
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
|
-
|
|
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":
|
|
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=
|
|
5442
|
-
lines_folded=lines_folded, conflicts=
|
|
5443
|
-
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
|
|