cctally 1.113.0 → 1.115.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 +47 -0
- package/README.md +4 -6
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +6035 -722
- package/bin/_cctally_completion.py +1287 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +400 -2
- package/bin/_cctally_dashboard.py +98 -20
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +16 -3
- package/bin/_cctally_dashboard_perf.py +128 -9
- package/bin/_cctally_dashboard_sources.py +285 -213
- package/bin/_cctally_db.py +1177 -23
- package/bin/_cctally_diagnosis_sources.py +22 -4
- package/bin/_cctally_doctor.py +572 -45
- package/bin/_cctally_five_hour.py +84 -33
- package/bin/_cctally_forecast.py +108 -15
- package/bin/_cctally_journal.py +3950 -116
- package/bin/_cctally_journal_repair.py +101 -12
- package/bin/_cctally_milestone_history.py +82 -9
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_percent_breakdown.py +3 -2
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +652 -0
- package/bin/_cctally_quota.py +227 -37
- package/bin/_cctally_quota_model.py +360 -83
- package/bin/_cctally_record.py +691 -149
- package/bin/_cctally_rederive.py +320 -41
- package/bin/_cctally_refresh.py +84 -13
- package/bin/_cctally_source_analytics.py +83 -17
- package/bin/_cctally_statusline.py +6154 -517
- package/bin/_cctally_store.py +367 -53
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +61 -56
- package/bin/_cctally_weekrefs.py +128 -21
- package/bin/_lib_account_binding.py +152 -0
- package/bin/_lib_account_protocol.py +386 -0
- package/bin/_lib_aggregators.py +4 -0
- package/bin/_lib_cache_coverage.py +13 -1
- package/bin/_lib_cache_report.py +35 -6
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +142 -0
- package/bin/_lib_codex_conversation.py +32 -0
- package/bin/_lib_codex_conversation_query.py +297 -49
- package/bin/_lib_codex_conversation_watch.py +59 -14
- package/bin/_lib_codex_drain_admission.py +214 -0
- package/bin/_lib_codex_metadata.py +18 -1
- package/bin/_lib_codex_publication_barrier.py +277 -0
- package/bin/_lib_codex_rollout_container.py +502 -0
- package/bin/_lib_conversation_dispatch.py +3 -0
- package/bin/_lib_conversation_query.py +106 -59
- package/bin/_lib_conversation_retention.py +1605 -640
- package/bin/_lib_cost_provenance.py +53 -2
- package/bin/_lib_detector_access_registry.py +3817 -0
- package/bin/_lib_doctor.py +714 -69
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_fmt.py +11 -0
- package/bin/_lib_ingest_frontier.py +414 -20
- package/bin/_lib_journal.py +141 -10
- package/bin/_lib_jsonl.py +5 -4
- package/bin/_lib_legacy_spool.py +126 -0
- package/bin/_lib_legacy_writer_evidence.py +136 -0
- package/bin/_lib_merge_gate.py +231 -86
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_perf.py +27 -1
- package/bin/_lib_pricing.py +199 -9
- package/bin/_lib_pricing_check.py +20 -4
- package/bin/_lib_quarantine.py +154 -0
- package/bin/_lib_quota_calibration.py +53 -8
- package/bin/_lib_quota_copy.py +2 -1
- package/bin/_lib_quota_model.py +288 -46
- package/bin/_lib_reclaim_planner.py +792 -0
- package/bin/_lib_rederive.py +319 -82
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_snapshot_cache.py +21 -0
- package/bin/_lib_sqlite_close.py +81 -0
- package/bin/_lib_statusline_candidates.py +292 -23
- package/bin/_lib_subscription_weeks.py +100 -17
- package/bin/_lib_tick_stats.py +217 -17
- package/bin/_lib_wal_checkpoint.py +1077 -0
- package/bin/_lib_write_budget.py +483 -0
- package/bin/_lib_write_io.py +703 -0
- package/bin/cctally +31 -1
- package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
- package/dashboard/static/assets/index-BKITk1CT.css +1 -0
- package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +19 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
- package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
package/bin/_cctally_db.py
CHANGED
|
@@ -60,6 +60,7 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
|
60
60
|
from __future__ import annotations
|
|
61
61
|
|
|
62
62
|
import argparse
|
|
63
|
+
import contextlib
|
|
63
64
|
import contextvars
|
|
64
65
|
import datetime as dt
|
|
65
66
|
import enum
|
|
@@ -76,7 +77,7 @@ import tempfile
|
|
|
76
77
|
import time
|
|
77
78
|
import traceback
|
|
78
79
|
from dataclasses import dataclass
|
|
79
|
-
from typing import Any, Callable, NamedTuple
|
|
80
|
+
from typing import Any, Callable, Iterator, NamedTuple
|
|
80
81
|
|
|
81
82
|
|
|
82
83
|
def _cctally():
|
|
@@ -800,7 +801,7 @@ def _would_block_prod_migration(conn: sqlite3.Connection) -> bool:
|
|
|
800
801
|
test-suite's CCTALLY_DISABLE_DEV_AUTODETECT. Escape: CCTALLY_ALLOW_PROD_MIGRATION."""
|
|
801
802
|
if _cctally_core._truthy_env("CCTALLY_ALLOW_PROD_MIGRATION"):
|
|
802
803
|
return False
|
|
803
|
-
if not
|
|
804
|
+
if not _cctally_core._has_git_checkout_marker():
|
|
804
805
|
return False
|
|
805
806
|
db_dir = _conn_db_dir(conn)
|
|
806
807
|
if db_dir is None:
|
|
@@ -856,7 +857,7 @@ def _would_block_prod_stats(path: pathlib.Path) -> bool:
|
|
|
856
857
|
``CCTALLY_ALLOW_PROD_MIGRATION``."""
|
|
857
858
|
if _cctally_core._truthy_env("CCTALLY_ALLOW_PROD_MIGRATION"):
|
|
858
859
|
return False
|
|
859
|
-
if not
|
|
860
|
+
if not _cctally_core._has_git_checkout_marker():
|
|
860
861
|
return False
|
|
861
862
|
try:
|
|
862
863
|
return (
|
|
@@ -1699,6 +1700,10 @@ def cmd_db_rebuild(args: argparse.Namespace) -> int:
|
|
|
1699
1700
|
)
|
|
1700
1701
|
forensics = None
|
|
1701
1702
|
incident = None
|
|
1703
|
+
import _cctally_completion
|
|
1704
|
+
# Fix round K11 (K9K10-R7): a rebuild that published but could not record
|
|
1705
|
+
# its residual obligation; reported once every lock is released.
|
|
1706
|
+
obligation_failure = None
|
|
1702
1707
|
try:
|
|
1703
1708
|
fcntl.flock(maint_fd, fcntl.LOCK_EX)
|
|
1704
1709
|
# #386: record the hold so a nested live `open_db()` does not request
|
|
@@ -1750,9 +1755,12 @@ def cmd_db_rebuild(args: argparse.Namespace) -> int:
|
|
|
1750
1755
|
forensics_path=(
|
|
1751
1756
|
str(forensics) if forensics is not None else None
|
|
1752
1757
|
),
|
|
1753
|
-
)
|
|
1758
|
+
),
|
|
1759
|
+
complete_derivation=True,
|
|
1754
1760
|
)
|
|
1755
1761
|
incident = result.quarantine_dir
|
|
1762
|
+
except _cctally_completion.ResidualObligationNotDurable as exc:
|
|
1763
|
+
obligation_failure = exc
|
|
1756
1764
|
except Exception as exc:
|
|
1757
1765
|
eprint(f"cctally: stats.db rebuild failed: {exc}")
|
|
1758
1766
|
return 3
|
|
@@ -1766,6 +1774,20 @@ def cmd_db_rebuild(args: argparse.Namespace) -> int:
|
|
|
1766
1774
|
finally:
|
|
1767
1775
|
os.close(maint_fd)
|
|
1768
1776
|
|
|
1777
|
+
if obligation_failure is not None:
|
|
1778
|
+
# The rebuilt index IS published (spec 4.7), so its completion wake
|
|
1779
|
+
# is posted as for any publication; only then is the obligation
|
|
1780
|
+
# failure reported. The inputs past the rebuilt prefix stay in the
|
|
1781
|
+
# journal for the next ingest.
|
|
1782
|
+
_cctally_completion.post_publication_wake()
|
|
1783
|
+
eprint(
|
|
1784
|
+
"cctally: stats.db was rebuilt, but the record that the inputs "
|
|
1785
|
+
"written during the rebuild still need deriving could not be "
|
|
1786
|
+
f"saved ({obligation_failure}). Those inputs stay in the journal "
|
|
1787
|
+
"and the next ingest derives them; check that the data directory "
|
|
1788
|
+
"is writable.")
|
|
1789
|
+
return 3
|
|
1790
|
+
|
|
1769
1791
|
total_rows = sum(result.rows_by_table.values())
|
|
1770
1792
|
if as_json:
|
|
1771
1793
|
payload = {
|
|
@@ -1869,6 +1891,11 @@ def cmd_db_rebuild(args: argparse.Namespace) -> int:
|
|
|
1869
1891
|
print(f" previous stats.db quarantined -> {incident}")
|
|
1870
1892
|
if forensics is not None:
|
|
1871
1893
|
print(f" forensics -> {forensics}")
|
|
1894
|
+
# #825/#832 spec 4.3 revision 2: a completed rebuild is a live index
|
|
1895
|
+
# publication; its completion wake (posted with every lock released)
|
|
1896
|
+
# dispatches the detached continuation, so this command never waits on
|
|
1897
|
+
# it. A file-only no-op when nothing is pending.
|
|
1898
|
+
_cctally_completion.post_publication_wake()
|
|
1872
1899
|
return 0
|
|
1873
1900
|
|
|
1874
1901
|
|
|
@@ -2355,6 +2382,24 @@ def _run_pending_migrations(
|
|
|
2355
2382
|
conn.commit()
|
|
2356
2383
|
|
|
2357
2384
|
|
|
2385
|
+
def _accounts_cutover_value_or_defer(what: str):
|
|
2386
|
+
"""`find_accounts_cutover_op` for an account backfill migration.
|
|
2387
|
+
|
|
2388
|
+
#825/#832 fix round K11 (K9K10-R9): a journal the lookup cannot list,
|
|
2389
|
+
size or read — including the account-bound boundary lookup it applies,
|
|
2390
|
+
`AccountBoundCutoverUnavailable` — proves neither the op nor its
|
|
2391
|
+
absence. That is transient, so the migration defers
|
|
2392
|
+
(`MigrationGateNotMet`: no error log, no banner, retried on the next
|
|
2393
|
+
open) instead of failing hard and stopping the registry."""
|
|
2394
|
+
import _cctally_journal
|
|
2395
|
+
try:
|
|
2396
|
+
return _cctally_journal.find_accounts_cutover_op()
|
|
2397
|
+
except OSError as exc:
|
|
2398
|
+
raise MigrationGateNotMet(
|
|
2399
|
+
f"the journal could not be read to find the accounts cutover op "
|
|
2400
|
+
f"({exc}); deferring the {what}") from exc
|
|
2401
|
+
|
|
2402
|
+
|
|
2358
2403
|
# === Region 3: 001 handler (was bin/cctally:11232-11344) ===
|
|
2359
2404
|
|
|
2360
2405
|
@stats_migration("001_five_hour_block_models_backfill_v1")
|
|
@@ -3876,13 +3921,29 @@ def _codex_quota_ledger_ddl() -> tuple[str, ...]:
|
|
|
3876
3921
|
#: because SQLite only uses an expression index when the query's expression
|
|
3877
3922
|
#: matches the indexed one — indexing the bare ``COALESCE`` while the reader
|
|
3878
3923
|
#: wraps it in ``unixepoch`` silently buys nothing.
|
|
3924
|
+
#:
|
|
3925
|
+
#: #901 Q11 (spec §5.3a, 901-PA-001 a; cache migration 048): the four order
|
|
3926
|
+
#: columns after the five equality members are the loader's ``ORDER BY
|
|
3927
|
+
#: source_root_key, captured_at_utc, resets_at_utc, source_path, line_offset``
|
|
3928
|
+
#: minus its constant first term, so each physical-group shard streams from the
|
|
3929
|
+
#: seek. Without them every shard re-sorted its group's whole history ("USE TEMP
|
|
3930
|
+
#: B-TREE FOR LAST 4 TERMS OF ORDER BY"; 3.3, 12.5 and 5.4 MB of temp in three
|
|
3931
|
+
#: measured calls), and one window group is a week of activity, not a bound.
|
|
3879
3932
|
_QUOTA_GROUP_INDEX_DDL = (
|
|
3880
3933
|
"CREATE INDEX IF NOT EXISTS idx_qws_physical_group "
|
|
3881
3934
|
"ON quota_window_snapshots("
|
|
3882
3935
|
" source_root_key, logical_limit_key, observed_slot, window_minutes,"
|
|
3883
|
-
" unixepoch(COALESCE(canonical_resets_at_utc, resets_at_utc))
|
|
3936
|
+
" unixepoch(COALESCE(canonical_resets_at_utc, resets_at_utc)),"
|
|
3937
|
+
" captured_at_utc, resets_at_utc, source_path, line_offset)"
|
|
3884
3938
|
" WHERE source='codex'"
|
|
3885
3939
|
)
|
|
3940
|
+
#: The key columns ``PRAGMA index_xinfo`` reports for the current definition
|
|
3941
|
+
#: (``None`` is the expression member). An index of the same name with any
|
|
3942
|
+
#: other key list is a pre-048 definition and is replaced.
|
|
3943
|
+
_QUOTA_GROUP_INDEX_KEY_COLUMNS = (
|
|
3944
|
+
"source_root_key", "logical_limit_key", "observed_slot", "window_minutes",
|
|
3945
|
+
None, "captured_at_utc", "resets_at_utc", "source_path", "line_offset",
|
|
3946
|
+
)
|
|
3886
3947
|
|
|
3887
3948
|
|
|
3888
3949
|
def _apply_codex_quota_group_index(conn: sqlite3.Connection) -> None:
|
|
@@ -3916,6 +3977,16 @@ def _apply_codex_quota_group_index(conn: sqlite3.Connection) -> None:
|
|
|
3916
3977
|
Guarded on ``canonical_resets_at_utc`` for the same reason the ledger is: a
|
|
3917
3978
|
legacy-shape cache whose schema apply took the FTS early-return before that
|
|
3918
3979
|
column add would raise ``no such column`` here.
|
|
3980
|
+
|
|
3981
|
+
#901 Q11 (cache migration 048) extends the key list with the loader's order
|
|
3982
|
+
columns (see ``_QUOTA_GROUP_INDEX_DDL``). ``CREATE INDEX IF NOT EXISTS``
|
|
3983
|
+
never reshapes an existing index, so an index of this name with any other
|
|
3984
|
+
key list is dropped and recreated, and the two statements commit together
|
|
3985
|
+
(its own ``BEGIN IMMEDIATE``, or a savepoint inside a caller's
|
|
3986
|
+
transaction): the loader probes for the index by name and then pins it with
|
|
3987
|
+
``INDEXED BY``, so a reader must never find it missing in between. A
|
|
3988
|
+
current index is left alone, so the helper is idempotent and costs one
|
|
3989
|
+
``PRAGMA`` on a current store.
|
|
3919
3990
|
"""
|
|
3920
3991
|
cols = {
|
|
3921
3992
|
str(row[1]) for row in conn.execute(
|
|
@@ -3923,8 +3994,34 @@ def _apply_codex_quota_group_index(conn: sqlite3.Connection) -> None:
|
|
|
3923
3994
|
}
|
|
3924
3995
|
if not cols or "canonical_resets_at_utc" not in cols:
|
|
3925
3996
|
return
|
|
3926
|
-
|
|
3927
|
-
|
|
3997
|
+
current = tuple(
|
|
3998
|
+
row[2] for row in conn.execute(
|
|
3999
|
+
"PRAGMA index_xinfo(idx_qws_physical_group)") if row[5])
|
|
4000
|
+
retired = conn.execute(
|
|
4001
|
+
"SELECT 1 FROM sqlite_master WHERE type='index'"
|
|
4002
|
+
" AND name='idx_qws_window_ident'").fetchone() is not None
|
|
4003
|
+
if current == _QUOTA_GROUP_INDEX_KEY_COLUMNS and not retired:
|
|
4004
|
+
return
|
|
4005
|
+
own_transaction = not conn.in_transaction
|
|
4006
|
+
conn.execute(
|
|
4007
|
+
"BEGIN IMMEDIATE" if own_transaction
|
|
4008
|
+
else "SAVEPOINT codex_quota_group_index")
|
|
4009
|
+
try:
|
|
4010
|
+
if current and current != _QUOTA_GROUP_INDEX_KEY_COLUMNS:
|
|
4011
|
+
conn.execute("DROP INDEX IF EXISTS idx_qws_physical_group")
|
|
4012
|
+
conn.execute(_QUOTA_GROUP_INDEX_DDL)
|
|
4013
|
+
conn.execute("DROP INDEX IF EXISTS idx_qws_window_ident")
|
|
4014
|
+
except BaseException:
|
|
4015
|
+
if own_transaction:
|
|
4016
|
+
conn.rollback()
|
|
4017
|
+
else:
|
|
4018
|
+
conn.execute("ROLLBACK TO codex_quota_group_index")
|
|
4019
|
+
conn.execute("RELEASE codex_quota_group_index")
|
|
4020
|
+
raise
|
|
4021
|
+
if own_transaction:
|
|
4022
|
+
conn.commit()
|
|
4023
|
+
else:
|
|
4024
|
+
conn.execute("RELEASE codex_quota_group_index")
|
|
3928
4025
|
|
|
3929
4026
|
|
|
3930
4027
|
#: Partial index over the rows the standing `observed_model` resolution can
|
|
@@ -4283,9 +4380,532 @@ def _apply_codex_accounting_change_ledger(conn: sqlite3.Connection) -> None:
|
|
|
4283
4380
|
conn.execute(statement)
|
|
4284
4381
|
|
|
4285
4382
|
|
|
4383
|
+
# ── #901 W1: the maintained raw-partition maxima (spec §5.1, T1) ─────────────
|
|
4384
|
+
#
|
|
4385
|
+
# `load_codex_quota_observations(latest_per_identity=True)` computed
|
|
4386
|
+
# `MAX(unixepoch(captured_at_utc)) OVER (PARTITION BY <nine raw columns>)` across
|
|
4387
|
+
# every retained Codex quota row on every doctor refresh; on the measured store
|
|
4388
|
+
# that sorted 565K rows through ~1.1 GB of temp files per call (spec §1.2). The
|
|
4389
|
+
# table below holds exactly that window's per-partition maximum, maintained by
|
|
4390
|
+
# triggers in the same transaction as every mutation, so the latest read becomes
|
|
4391
|
+
# a scan of ~10^3 summary rows plus one index seek per partition for the rows
|
|
4392
|
+
# tied at its maximum second (tests/test_901_partition_latest_maintenance.py
|
|
4393
|
+
# holds it to the frozen window query after every writer class).
|
|
4394
|
+
#
|
|
4395
|
+
# Keys are `quote(<raw column>)`: NOT NULL, collision-free across NULL, '' and
|
|
4396
|
+
# storage classes, and equal exactly when the window's PARTITION BY puts two
|
|
4397
|
+
# rows in one partition (no collation on these columns; TEXT affinity stores
|
|
4398
|
+
# numbers as text and INTEGER affinity integral reals as integers, so value
|
|
4399
|
+
# equality and quote() equality coincide). The key columns are declared BLOB on
|
|
4400
|
+
# purpose: a TEXT key column gives `quote(q.col) = s.key` TEXT comparison
|
|
4401
|
+
# affinity, and SQLite then refuses the expression index (measured: the tie
|
|
4402
|
+
# fetch fell back to walking the whole `source` autoindex). The seek index leads
|
|
4403
|
+
# with `source` for the same measured reason.
|
|
4404
|
+
#
|
|
4405
|
+
# The triggers use built-in functions only (quote, trim, coalesce, unixepoch),
|
|
4406
|
+
# so an older binary that opens the store keeps them firing. They never upsert:
|
|
4407
|
+
# an outer statement's conflict policy propagates into trigger-body statements,
|
|
4408
|
+
# so every repair is a DELETE followed by an INSERT that cannot conflict. A
|
|
4409
|
+
# REPLACE against the snapshots table would delete rows without firing the
|
|
4410
|
+
# delete trigger (recursive_triggers is off); no writer issues one, and
|
|
4411
|
+
# test_no_writer_replaces_quota_rows fails if one appears.
|
|
4412
|
+
#
|
|
4413
|
+
# `unixepoch()` in an index expression throws on the literal 'now', so the seek
|
|
4414
|
+
# index refuses a Codex row whose capture is 'now' — the same property
|
|
4415
|
+
# idx_qws_physical_group already has for the reset. Ingest writes only
|
|
4416
|
+
# `_utc_iso` instants.
|
|
4417
|
+
|
|
4418
|
+
#: Must equal `_cctally_quota._CODEX_QUOTA_REQUIRED_TEXT` (#500: the loader and
|
|
4419
|
+
#: every population derived for it agree on one required-text set);
|
|
4420
|
+
#: tests/test_901_partition_latest_maintenance.py pins the equality.
|
|
4421
|
+
_CODEX_QUOTA_LATEST_REQUIRED_TEXT = (
|
|
4422
|
+
"source", "source_root_key", "source_path", "captured_at_utc",
|
|
4423
|
+
"observed_slot", "logical_limit_key", "resets_at_utc",
|
|
4424
|
+
)
|
|
4425
|
+
|
|
4426
|
+
#: (summary key column, raw column; None means the coalesced reset anchor), in
|
|
4427
|
+
#: the window query's PARTITION BY order.
|
|
4428
|
+
_CODEX_QUOTA_LATEST_KEYS = (
|
|
4429
|
+
("k_source_root_key", "source_root_key"),
|
|
4430
|
+
("k_logical_limit_key", "logical_limit_key"),
|
|
4431
|
+
("k_observed_slot", "observed_slot"),
|
|
4432
|
+
("k_window_minutes", "window_minutes"),
|
|
4433
|
+
("k_limit_id", "limit_id"),
|
|
4434
|
+
("k_limit_name", "limit_name"),
|
|
4435
|
+
("k_observed_model", "observed_model"),
|
|
4436
|
+
("k_account_key", "account_key"),
|
|
4437
|
+
("k_reset_anchor", None),
|
|
4438
|
+
)
|
|
4439
|
+
_CODEX_QUOTA_LATEST_KEY_COLUMNS = tuple(
|
|
4440
|
+
key for key, _column in _CODEX_QUOTA_LATEST_KEYS)
|
|
4441
|
+
|
|
4442
|
+
#: Every column a partition key or a validity predicate reads.
|
|
4443
|
+
_CODEX_QUOTA_LATEST_UPDATE_OF = (
|
|
4444
|
+
"source", "source_root_key", "source_path", "captured_at_utc",
|
|
4445
|
+
"observed_slot", "logical_limit_key", "resets_at_utc",
|
|
4446
|
+
"canonical_resets_at_utc", "window_minutes", "limit_id", "limit_name",
|
|
4447
|
+
"observed_model", "account_key",
|
|
4448
|
+
)
|
|
4449
|
+
|
|
4450
|
+
#: The five objects whose joint presence means the summary has been maintained
|
|
4451
|
+
#: since it was bootstrapped. The reader uses it only when all five exist.
|
|
4452
|
+
_CODEX_QUOTA_LATEST_OBJECTS = (
|
|
4453
|
+
("table", "codex_quota_partition_latest"),
|
|
4454
|
+
("index", "idx_qws_partition_capture"),
|
|
4455
|
+
("trigger", "trg_qws_latest_ins"),
|
|
4456
|
+
("trigger", "trg_qws_latest_del"),
|
|
4457
|
+
("trigger", "trg_qws_latest_upd"),
|
|
4458
|
+
)
|
|
4459
|
+
|
|
4460
|
+
|
|
4461
|
+
def _codex_quota_latest_raw(column, prefix: str) -> str:
|
|
4462
|
+
if column is None:
|
|
4463
|
+
return (f"COALESCE({prefix}canonical_resets_at_utc, "
|
|
4464
|
+
f"{prefix}resets_at_utc)")
|
|
4465
|
+
return f"{prefix}{column}"
|
|
4466
|
+
|
|
4467
|
+
|
|
4468
|
+
def _codex_quota_latest_validity_sql(prefix: str = "") -> str:
|
|
4469
|
+
"""The loader's SQL-statable validity predicates, for one row image."""
|
|
4470
|
+
terms = [f"{prefix}source='codex'", f"{prefix}source_root_key IS NOT NULL"]
|
|
4471
|
+
terms += [f"trim(coalesce({prefix}{column}, '')) <> ''"
|
|
4472
|
+
for column in _CODEX_QUOTA_LATEST_REQUIRED_TEXT]
|
|
4473
|
+
terms += [f"unixepoch({prefix}captured_at_utc) IS NOT NULL",
|
|
4474
|
+
f"unixepoch({prefix}resets_at_utc) IS NOT NULL"]
|
|
4475
|
+
return " AND ".join(terms)
|
|
4476
|
+
|
|
4477
|
+
|
|
4478
|
+
def _codex_quota_latest_key_exprs(prefix: str = "") -> "tuple[str, ...]":
|
|
4479
|
+
return tuple(
|
|
4480
|
+
f"quote({_codex_quota_latest_raw(column, prefix)})"
|
|
4481
|
+
for _key, column in _CODEX_QUOTA_LATEST_KEYS)
|
|
4482
|
+
|
|
4483
|
+
|
|
4484
|
+
def _codex_quota_latest_summary_match(image: str) -> str:
|
|
4485
|
+
return " AND ".join(
|
|
4486
|
+
f"{key} = {expr}" for key, expr in zip(
|
|
4487
|
+
_CODEX_QUOTA_LATEST_KEY_COLUMNS,
|
|
4488
|
+
_codex_quota_latest_key_exprs(f"{image}.")))
|
|
4489
|
+
|
|
4490
|
+
|
|
4491
|
+
def _codex_quota_latest_base_match(image: str) -> str:
|
|
4492
|
+
return " AND ".join(
|
|
4493
|
+
f"{base} = {expr}" for base, expr in zip(
|
|
4494
|
+
_codex_quota_latest_key_exprs(""),
|
|
4495
|
+
_codex_quota_latest_key_exprs(f"{image}.")))
|
|
4496
|
+
|
|
4497
|
+
|
|
4498
|
+
def _codex_quota_latest_recompute_sql(image: str, *, only_if_absent: bool) -> str:
|
|
4499
|
+
"""Re-derive one partition's maximum from the physical rows (one seek)."""
|
|
4500
|
+
absent_clause = (
|
|
4501
|
+
" AND NOT EXISTS (SELECT 1 FROM codex_quota_partition_latest"
|
|
4502
|
+
f" WHERE {_codex_quota_latest_summary_match(image)})"
|
|
4503
|
+
if only_if_absent else ""
|
|
4504
|
+
)
|
|
4505
|
+
return (
|
|
4506
|
+
"INSERT INTO codex_quota_partition_latest ("
|
|
4507
|
+
+ ", ".join(_CODEX_QUOTA_LATEST_KEY_COLUMNS)
|
|
4508
|
+
+ ", latest_capture_epoch)"
|
|
4509
|
+
f" SELECT {', '.join(_codex_quota_latest_key_exprs(image + '.'))},"
|
|
4510
|
+
" latest FROM (SELECT MAX(unixepoch(captured_at_utc)) AS latest"
|
|
4511
|
+
" FROM quota_window_snapshots"
|
|
4512
|
+
f" WHERE {_codex_quota_latest_validity_sql('')}"
|
|
4513
|
+
f" AND {_codex_quota_latest_base_match(image)})"
|
|
4514
|
+
f" WHERE latest IS NOT NULL{absent_clause}"
|
|
4515
|
+
)
|
|
4516
|
+
|
|
4517
|
+
|
|
4518
|
+
def _codex_quota_latest_bootstrap_select() -> str:
|
|
4519
|
+
"""Every valid raw partition's maximum second, streamed off the seek index."""
|
|
4520
|
+
keys = ", ".join(_codex_quota_latest_key_exprs(""))
|
|
4521
|
+
return (
|
|
4522
|
+
f"SELECT {keys}, MAX(unixepoch(captured_at_utc))"
|
|
4523
|
+
" FROM quota_window_snapshots INDEXED BY idx_qws_partition_capture"
|
|
4524
|
+
f" WHERE {_codex_quota_latest_validity_sql('')}"
|
|
4525
|
+
f" GROUP BY source, {keys}"
|
|
4526
|
+
)
|
|
4527
|
+
|
|
4528
|
+
|
|
4529
|
+
def _codex_quota_latest_bootstrap_sql() -> str:
|
|
4530
|
+
return (
|
|
4531
|
+
"INSERT INTO codex_quota_partition_latest ("
|
|
4532
|
+
+ ", ".join(_CODEX_QUOTA_LATEST_KEY_COLUMNS)
|
|
4533
|
+
+ ", latest_capture_epoch) "
|
|
4534
|
+
+ _codex_quota_latest_bootstrap_select()
|
|
4535
|
+
)
|
|
4536
|
+
|
|
4537
|
+
|
|
4538
|
+
def _codex_quota_latest_ddl() -> "tuple[str, ...]":
|
|
4539
|
+
"""DDL for the summary, its seek index and its three triggers.
|
|
4540
|
+
|
|
4541
|
+
The triggers are dropped and re-created on every apply, like the change
|
|
4542
|
+
ledger's, so a definition change never leaves an older body behind; the
|
|
4543
|
+
table keeps IF NOT EXISTS and its rows survive.
|
|
4544
|
+
"""
|
|
4545
|
+
columns = ",\n".join(
|
|
4546
|
+
f" {key} BLOB NOT NULL"
|
|
4547
|
+
for key in _CODEX_QUOTA_LATEST_KEY_COLUMNS)
|
|
4548
|
+
keys = ", ".join(_CODEX_QUOTA_LATEST_KEY_COLUMNS)
|
|
4549
|
+
index_columns = ", ".join((
|
|
4550
|
+
"source", *_codex_quota_latest_key_exprs(""),
|
|
4551
|
+
"unixepoch(captured_at_utc)"))
|
|
4552
|
+
insert_new = (
|
|
4553
|
+
f"INSERT INTO codex_quota_partition_latest ({keys}, latest_capture_epoch)"
|
|
4554
|
+
f" SELECT {', '.join(_codex_quota_latest_key_exprs('NEW.'))},"
|
|
4555
|
+
" unixepoch(NEW.captured_at_utc)"
|
|
4556
|
+
" WHERE NOT EXISTS (SELECT 1 FROM codex_quota_partition_latest"
|
|
4557
|
+
f" WHERE {_codex_quota_latest_summary_match('NEW')})"
|
|
4558
|
+
)
|
|
4559
|
+
return (
|
|
4560
|
+
"CREATE TABLE IF NOT EXISTS codex_quota_partition_latest (\n"
|
|
4561
|
+
f"{columns},\n"
|
|
4562
|
+
" latest_capture_epoch BLOB NOT NULL,\n"
|
|
4563
|
+
f" PRIMARY KEY ({keys})\n"
|
|
4564
|
+
" ) WITHOUT ROWID",
|
|
4565
|
+
"CREATE INDEX IF NOT EXISTS idx_qws_partition_capture "
|
|
4566
|
+
f"ON quota_window_snapshots({index_columns}) "
|
|
4567
|
+
f"WHERE {_codex_quota_latest_validity_sql('')}",
|
|
4568
|
+
"DROP TRIGGER IF EXISTS trg_qws_latest_ins",
|
|
4569
|
+
"CREATE TRIGGER trg_qws_latest_ins\n"
|
|
4570
|
+
" AFTER INSERT ON quota_window_snapshots\n"
|
|
4571
|
+
f" WHEN {_codex_quota_latest_validity_sql('NEW.')}\n"
|
|
4572
|
+
" BEGIN\n"
|
|
4573
|
+
" DELETE FROM codex_quota_partition_latest"
|
|
4574
|
+
f" WHERE {_codex_quota_latest_summary_match('NEW')}"
|
|
4575
|
+
" AND latest_capture_epoch < unixepoch(NEW.captured_at_utc);\n"
|
|
4576
|
+
f" {insert_new};\n"
|
|
4577
|
+
" END",
|
|
4578
|
+
"DROP TRIGGER IF EXISTS trg_qws_latest_del",
|
|
4579
|
+
"CREATE TRIGGER trg_qws_latest_del\n"
|
|
4580
|
+
" AFTER DELETE ON quota_window_snapshots\n"
|
|
4581
|
+
f" WHEN {_codex_quota_latest_validity_sql('OLD.')}\n"
|
|
4582
|
+
" BEGIN\n"
|
|
4583
|
+
" DELETE FROM codex_quota_partition_latest"
|
|
4584
|
+
f" WHERE {_codex_quota_latest_summary_match('OLD')}"
|
|
4585
|
+
" AND latest_capture_epoch = unixepoch(OLD.captured_at_utc);\n"
|
|
4586
|
+
" "
|
|
4587
|
+
f"{_codex_quota_latest_recompute_sql('OLD', only_if_absent=True)};\n"
|
|
4588
|
+
" END",
|
|
4589
|
+
"DROP TRIGGER IF EXISTS trg_qws_latest_upd",
|
|
4590
|
+
"CREATE TRIGGER trg_qws_latest_upd\n"
|
|
4591
|
+
f" AFTER UPDATE OF {', '.join(_CODEX_QUOTA_LATEST_UPDATE_OF)}"
|
|
4592
|
+
" ON quota_window_snapshots\n"
|
|
4593
|
+
" WHEN OLD.source = 'codex' OR NEW.source = 'codex'\n"
|
|
4594
|
+
" BEGIN\n"
|
|
4595
|
+
" DELETE FROM codex_quota_partition_latest"
|
|
4596
|
+
f" WHERE {_codex_quota_latest_summary_match('OLD')};\n"
|
|
4597
|
+
" "
|
|
4598
|
+
f"{_codex_quota_latest_recompute_sql('OLD', only_if_absent=False)};\n"
|
|
4599
|
+
" DELETE FROM codex_quota_partition_latest"
|
|
4600
|
+
f" WHERE {_codex_quota_latest_summary_match('NEW')};\n"
|
|
4601
|
+
" "
|
|
4602
|
+
f"{_codex_quota_latest_recompute_sql('NEW', only_if_absent=False)};\n"
|
|
4603
|
+
" END",
|
|
4604
|
+
)
|
|
4605
|
+
|
|
4606
|
+
|
|
4607
|
+
def _apply_codex_quota_latest_summary(
|
|
4608
|
+
conn: sqlite3.Connection, *, rebootstrap: bool = False,
|
|
4609
|
+
) -> None:
|
|
4610
|
+
"""Install the latest-capture summary and keep it equal to the physical rows.
|
|
4611
|
+
|
|
4612
|
+
Atomic: the table, its seek index, the three triggers and, when needed, the
|
|
4613
|
+
bootstrap commit together — as a savepoint inside the caller's open
|
|
4614
|
+
transaction, or as an IMMEDIATE transaction of their own. The bootstrap runs
|
|
4615
|
+
when any of the five objects was missing before this call (so a store that
|
|
4616
|
+
carries all five has been maintained by the triggers since they were
|
|
4617
|
+
created) or when ``rebootstrap`` asks for it, which migration 047 always
|
|
4618
|
+
does, so a re-upgrade after an older binary trimmed its marker re-derives
|
|
4619
|
+
the summary from the physical rows.
|
|
4620
|
+
|
|
4621
|
+
Guarded on the three columns the partition reads: a legacy-shape cache whose
|
|
4622
|
+
schema apply took the FTS early-return before they existed keeps the
|
|
4623
|
+
window-query fallback, which is correct, just not spill-free.
|
|
4624
|
+
"""
|
|
4625
|
+
columns = {
|
|
4626
|
+
str(row[1]) for row in conn.execute(
|
|
4627
|
+
"PRAGMA table_info(quota_window_snapshots)")
|
|
4628
|
+
}
|
|
4629
|
+
if not {"canonical_resets_at_utc", "observed_model", "account_key"} <= columns:
|
|
4630
|
+
return
|
|
4631
|
+
own_transaction = not conn.in_transaction
|
|
4632
|
+
conn.execute(
|
|
4633
|
+
"BEGIN IMMEDIATE" if own_transaction
|
|
4634
|
+
else "SAVEPOINT codex_quota_latest_apply")
|
|
4635
|
+
try:
|
|
4636
|
+
present = {
|
|
4637
|
+
(str(row[0]), str(row[1])) for row in conn.execute(
|
|
4638
|
+
"SELECT type, name FROM sqlite_master "
|
|
4639
|
+
"WHERE type IN ('table', 'index', 'trigger')")
|
|
4640
|
+
}
|
|
4641
|
+
complete = all(item in present for item in _CODEX_QUOTA_LATEST_OBJECTS)
|
|
4642
|
+
for statement in _codex_quota_latest_ddl():
|
|
4643
|
+
conn.execute(statement)
|
|
4644
|
+
if rebootstrap or not complete:
|
|
4645
|
+
conn.execute("DELETE FROM codex_quota_partition_latest")
|
|
4646
|
+
conn.execute(_codex_quota_latest_bootstrap_sql())
|
|
4647
|
+
except BaseException:
|
|
4648
|
+
if own_transaction:
|
|
4649
|
+
conn.rollback()
|
|
4650
|
+
else:
|
|
4651
|
+
conn.execute("ROLLBACK TO codex_quota_latest_apply")
|
|
4652
|
+
conn.execute("RELEASE codex_quota_latest_apply")
|
|
4653
|
+
raise
|
|
4654
|
+
if own_transaction:
|
|
4655
|
+
conn.commit()
|
|
4656
|
+
else:
|
|
4657
|
+
conn.execute("RELEASE codex_quota_latest_apply")
|
|
4658
|
+
|
|
4659
|
+
|
|
4660
|
+
def _apply_codex_quota_load_order_index(conn: sqlite3.Connection) -> None:
|
|
4661
|
+
"""The general loader's ORDER BY, served by an index (#901 §5.1, T1/T3).
|
|
4662
|
+
|
|
4663
|
+
Adoption (W3a, every Codex ingest including the hook), breakdown evidence,
|
|
4664
|
+
the projection's root loads and the CLI commands all read complete
|
|
4665
|
+
populations in ``source_root_key, captured_at_utc, resets_at_utc,
|
|
4666
|
+
source_path, line_offset`` order; without this index each one sorted its
|
|
4667
|
+
whole population through a temp b-tree. ``canonical_resets_at_utc`` rides
|
|
4668
|
+
at the end so the adoption pass's canonical-reset bound is evaluated on the
|
|
4669
|
+
index entry before any table row is fetched. Guarded on that column like
|
|
4670
|
+
idx_qws_physical_group.
|
|
4671
|
+
"""
|
|
4672
|
+
columns = {
|
|
4673
|
+
str(row[1]) for row in conn.execute(
|
|
4674
|
+
"PRAGMA table_info(quota_window_snapshots)")
|
|
4675
|
+
}
|
|
4676
|
+
if "canonical_resets_at_utc" not in columns:
|
|
4677
|
+
return
|
|
4678
|
+
conn.execute(
|
|
4679
|
+
"CREATE INDEX IF NOT EXISTS idx_qws_codex_load_order "
|
|
4680
|
+
"ON quota_window_snapshots(source_root_key, captured_at_utc, "
|
|
4681
|
+
"resets_at_utc, source_path, line_offset, canonical_resets_at_utc) "
|
|
4682
|
+
"WHERE source='codex'"
|
|
4683
|
+
)
|
|
4684
|
+
|
|
4685
|
+
|
|
4686
|
+
def _apply_codex_metadata_lookup_indexes(conn: sqlite3.Connection) -> None:
|
|
4687
|
+
"""The W2 correlated lookups' indexes (#901 §5.2, T2).
|
|
4688
|
+
|
|
4689
|
+
``idx_codex_entries_root_path_time`` answers each file's and each thread's
|
|
4690
|
+
first accounting instant with one covering seek (the existing
|
|
4691
|
+
``idx_codex_entries_root_path`` keeps serving the minimum ``id``, because
|
|
4692
|
+
the rowid trails its two columns). ``idx_codex_files_alias_recent`` and
|
|
4693
|
+
``idx_codex_threads_recent`` hold the two queries' final orders so neither
|
|
4694
|
+
sorts. Each is guarded on its columns, because a legacy-shape cache can
|
|
4695
|
+
lack the ALTER-added ones.
|
|
4696
|
+
"""
|
|
4697
|
+
def _columns(table: str) -> "set[str]":
|
|
4698
|
+
return {
|
|
4699
|
+
str(row[0]) for row in conn.execute(
|
|
4700
|
+
"SELECT name FROM pragma_table_info(?)", (table,))
|
|
4701
|
+
}
|
|
4702
|
+
|
|
4703
|
+
if {"source_root_key", "source_path", "timestamp_utc"} <= _columns(
|
|
4704
|
+
"codex_session_entries"):
|
|
4705
|
+
conn.execute(
|
|
4706
|
+
"CREATE INDEX IF NOT EXISTS idx_codex_entries_root_path_time "
|
|
4707
|
+
"ON codex_session_entries(source_root_key, source_path, "
|
|
4708
|
+
"timestamp_utc)"
|
|
4709
|
+
)
|
|
4710
|
+
if {"last_ingested_at", "path", "last_native_thread_id"} <= _columns(
|
|
4711
|
+
"codex_session_files"):
|
|
4712
|
+
conn.execute(
|
|
4713
|
+
"CREATE INDEX IF NOT EXISTS idx_codex_files_alias_recent "
|
|
4714
|
+
"ON codex_session_files(last_ingested_at, path) "
|
|
4715
|
+
"WHERE last_native_thread_id IS NOT NULL "
|
|
4716
|
+
"AND last_native_thread_id != ''"
|
|
4717
|
+
)
|
|
4718
|
+
if {"last_seen_utc", "conversation_key"} <= _columns(
|
|
4719
|
+
"codex_conversation_threads"):
|
|
4720
|
+
conn.execute(
|
|
4721
|
+
"CREATE INDEX IF NOT EXISTS idx_codex_threads_recent "
|
|
4722
|
+
"ON codex_conversation_threads(last_seen_utc, conversation_key)"
|
|
4723
|
+
)
|
|
4724
|
+
|
|
4725
|
+
|
|
4726
|
+
def _apply_pricing_model_indexes(conn: sqlite3.Connection) -> None:
|
|
4727
|
+
"""Model-first covering indexes for ``_pricing_observed_models`` (#901 §5.3).
|
|
4728
|
+
|
|
4729
|
+
Both of its modes — doctor's trailing 30 days and pricing-check's whole
|
|
4730
|
+
history — group by model over one entries table. Covering the token columns
|
|
4731
|
+
lets SQLite stream the GROUP BY off the index with the timestamp filter
|
|
4732
|
+
evaluated on the entry; a narrower ``(model, timestamp_utc)`` index streams
|
|
4733
|
+
too but pays one table lookup per row, a latency regression the §6.3
|
|
4734
|
+
per-call-site gate would catch.
|
|
4735
|
+
"""
|
|
4736
|
+
def _columns(table: str) -> "set[str]":
|
|
4737
|
+
return {
|
|
4738
|
+
str(row[0]) for row in conn.execute(
|
|
4739
|
+
"SELECT name FROM pragma_table_info(?)", (table,))
|
|
4740
|
+
}
|
|
4741
|
+
|
|
4742
|
+
if {"model", "timestamp_utc", "input_tokens", "output_tokens",
|
|
4743
|
+
"cache_create_tokens", "cache_read_tokens"} <= _columns(
|
|
4744
|
+
"session_entries"):
|
|
4745
|
+
conn.execute(
|
|
4746
|
+
"CREATE INDEX IF NOT EXISTS idx_entries_model_time "
|
|
4747
|
+
"ON session_entries(model, timestamp_utc, input_tokens, "
|
|
4748
|
+
"output_tokens, cache_create_tokens, cache_read_tokens)"
|
|
4749
|
+
)
|
|
4750
|
+
if {"model", "timestamp_utc", "total_tokens"} <= _columns(
|
|
4751
|
+
"codex_session_entries"):
|
|
4752
|
+
conn.execute(
|
|
4753
|
+
"CREATE INDEX IF NOT EXISTS idx_codex_entries_model_time "
|
|
4754
|
+
"ON codex_session_entries(model, timestamp_utc, total_tokens)"
|
|
4755
|
+
)
|
|
4756
|
+
|
|
4757
|
+
|
|
4758
|
+
def _apply_codex_window_attribution_read_index(conn: sqlite3.Connection) -> None:
|
|
4759
|
+
"""The window-attribution read's order, served by an index (#901 I1/G1).
|
|
4760
|
+
|
|
4761
|
+
``_cctally_cache.load_active_window_attributions`` (every Codex ingest's
|
|
4762
|
+
spend reconciliation, so the dashboard and the Codex hook, and the
|
|
4763
|
+
attribution overlay of every quota load) returns every asserted or
|
|
4764
|
+
retracted weekly attribution in ``asserted_at_utc, op_id`` order; without
|
|
4765
|
+
this index it sorted all of them through a temp b-tree, a population that
|
|
4766
|
+
grows by one assertion per attributed window. ``window_minutes`` leads
|
|
4767
|
+
because the read's one equality filter is on it; the root, retraction and
|
|
4768
|
+
account predicates are evaluated along the ordered scan. ``source_root_key``
|
|
4769
|
+
is deliberately NOT ahead of the ordering columns: several roots share one
|
|
4770
|
+
global assertion order (Amendment 1 item 1, ``c901-design-2``). Guarded on
|
|
4771
|
+
the table's columns, because the conversations store drops the table and a
|
|
4772
|
+
legacy-shape cache can predate it.
|
|
4773
|
+
"""
|
|
4774
|
+
columns = {
|
|
4775
|
+
str(row[0]) for row in conn.execute(
|
|
4776
|
+
"SELECT name FROM pragma_table_info(?)",
|
|
4777
|
+
("codex_window_attributions",))
|
|
4778
|
+
}
|
|
4779
|
+
if {"window_minutes", "asserted_at_utc", "op_id"} <= columns:
|
|
4780
|
+
conn.execute(
|
|
4781
|
+
"CREATE INDEX IF NOT EXISTS idx_codex_window_attributions_read_order "
|
|
4782
|
+
"ON codex_window_attributions(window_minutes ASC, asserted_at_utc ASC, "
|
|
4783
|
+
"op_id ASC)"
|
|
4784
|
+
)
|
|
4785
|
+
|
|
4786
|
+
|
|
4286
4787
|
# === Region 7b2: Eager cache-migration trigger (V4 — same-invocation 008 apply) ===
|
|
4287
4788
|
|
|
4288
4789
|
|
|
4790
|
+
# #968 spec 5.3: container evidence beside each Codex cursor row. Every
|
|
4791
|
+
# persisted key stays the LOGICAL ``.jsonl`` path; these columns record which
|
|
4792
|
+
# physical container's content the cursor accepted. Defaults describe what an
|
|
4793
|
+
# existing row already is: a plain file whose content was accepted. No evidence
|
|
4794
|
+
# column is backfilled by statting a file now, for the reason migration 046
|
|
4795
|
+
# gives for device_id/inode.
|
|
4796
|
+
_CODEX_CONTAINER_CURSOR_COLUMNS: "tuple[tuple[str, str], ...]" = (
|
|
4797
|
+
("container_kind", "TEXT NOT NULL DEFAULT 'plain'"),
|
|
4798
|
+
("container_path", "TEXT"),
|
|
4799
|
+
("container_size_bytes", "INTEGER"),
|
|
4800
|
+
("decoded_size_bytes", "INTEGER"),
|
|
4801
|
+
("content_anchor_version", "INTEGER"),
|
|
4802
|
+
("content_anchor", "BLOB"),
|
|
4803
|
+
("content_anchor_offset", "INTEGER"),
|
|
4804
|
+
("container_state", "TEXT NOT NULL DEFAULT 'accepted'"),
|
|
4805
|
+
("container_state_reason", "TEXT"),
|
|
4806
|
+
("pending_container_fingerprint", "TEXT"),
|
|
4807
|
+
)
|
|
4808
|
+
_CODEX_CONTAINER_CACHE_ONLY_COLUMNS: "tuple[tuple[str, str], ...]" = (
|
|
4809
|
+
("container_resume_strikes", "INTEGER NOT NULL DEFAULT 0"),
|
|
4810
|
+
)
|
|
4811
|
+
_CODEX_968_CACHE_TABLES = (
|
|
4812
|
+
"codex_prune_debt", "codex_barrier_membership", "codex_barrier_targets",
|
|
4813
|
+
"codex_barrier_population", "codex_barrier_certificates",
|
|
4814
|
+
"codex_barrier_repairs", "codex_barrier_entry_threads",
|
|
4815
|
+
)
|
|
4816
|
+
_CODEX_968_CONVERSATIONS_TABLES = (
|
|
4817
|
+
"codex_conversation_prune_debt", "codex_conversation_publications",
|
|
4818
|
+
"codex_conversation_published_threads",
|
|
4819
|
+
)
|
|
4820
|
+
#: The column list of `codex_conversation_threads`, shared by the two
|
|
4821
|
+
#: byte-preserving thread snapshots the publication barrier keeps: a thread
|
|
4822
|
+
#: row is copied with INSERT ... SELECT, never through JSON, so an undecodable
|
|
4823
|
+
#: cwd/git_json (#845) survives exactly as stored.
|
|
4824
|
+
_CODEX_THREAD_SNAPSHOT_COLUMNS_DDL = (
|
|
4825
|
+
" conversation_key TEXT NOT NULL PRIMARY KEY,"
|
|
4826
|
+
" source_root_key TEXT NOT NULL,"
|
|
4827
|
+
" native_thread_id TEXT NOT NULL,"
|
|
4828
|
+
" root_thread_id TEXT NOT NULL,"
|
|
4829
|
+
" parent_thread_id TEXT,"
|
|
4830
|
+
" source_path TEXT NOT NULL,"
|
|
4831
|
+
" cwd TEXT,"
|
|
4832
|
+
" git_json TEXT,"
|
|
4833
|
+
" source_kind TEXT,"
|
|
4834
|
+
" thread_source_json TEXT,"
|
|
4835
|
+
" model_provider TEXT,"
|
|
4836
|
+
" context_window INTEGER,"
|
|
4837
|
+
" first_seen_utc TEXT,"
|
|
4838
|
+
" last_seen_utc TEXT)"
|
|
4839
|
+
)
|
|
4840
|
+
_CODEX_968_CACHE_DDL = (
|
|
4841
|
+
"CREATE TABLE IF NOT EXISTS codex_prune_debt ("
|
|
4842
|
+
" source_path TEXT NOT NULL,"
|
|
4843
|
+
" source_root_key TEXT NOT NULL DEFAULT '',"
|
|
4844
|
+
" recorded_at_utc TEXT NOT NULL,"
|
|
4845
|
+
" PRIMARY KEY (source_path, source_root_key)) WITHOUT ROWID",
|
|
4846
|
+
"CREATE TABLE IF NOT EXISTS codex_barrier_membership ("
|
|
4847
|
+
" file_identity TEXT NOT NULL,"
|
|
4848
|
+
" generation_token TEXT NOT NULL,"
|
|
4849
|
+
" conversation_key TEXT NOT NULL,"
|
|
4850
|
+
" source_path TEXT NOT NULL,"
|
|
4851
|
+
" metadata_json TEXT,"
|
|
4852
|
+
" metadata_offset INTEGER,"
|
|
4853
|
+
" PRIMARY KEY (file_identity, generation_token, conversation_key))"
|
|
4854
|
+
" WITHOUT ROWID",
|
|
4855
|
+
"CREATE INDEX IF NOT EXISTS idx_codex_barrier_membership_conversation"
|
|
4856
|
+
" ON codex_barrier_membership(conversation_key)",
|
|
4857
|
+
"CREATE TABLE IF NOT EXISTS codex_barrier_targets ("
|
|
4858
|
+
" file_identity TEXT PRIMARY KEY,"
|
|
4859
|
+
" source_path TEXT NOT NULL,"
|
|
4860
|
+
" source_root_key TEXT,"
|
|
4861
|
+
" generation_token TEXT NOT NULL,"
|
|
4862
|
+
" scan_target INTEGER NOT NULL,"
|
|
4863
|
+
" complete_line_end INTEGER,"
|
|
4864
|
+
" state TEXT NOT NULL CHECK (state IN ('required','ready','suspended')),"
|
|
4865
|
+
" reason TEXT,"
|
|
4866
|
+
" accepted_generation_token TEXT,"
|
|
4867
|
+
" accepted_complete_line_end INTEGER)",
|
|
4868
|
+
"CREATE TABLE IF NOT EXISTS codex_barrier_population ("
|
|
4869
|
+
" conversation_key TEXT PRIMARY KEY,"
|
|
4870
|
+
" required_revision INTEGER NOT NULL DEFAULT 1,"
|
|
4871
|
+
" entered_at_utc TEXT NOT NULL)",
|
|
4872
|
+
"CREATE TABLE IF NOT EXISTS codex_barrier_entry_threads ("
|
|
4873
|
+
+ _CODEX_THREAD_SNAPSHOT_COLUMNS_DDL,
|
|
4874
|
+
"CREATE INDEX IF NOT EXISTS idx_codex_barrier_entry_threads_parent"
|
|
4875
|
+
" ON codex_barrier_entry_threads"
|
|
4876
|
+
"(source_root_key, native_thread_id, conversation_key)",
|
|
4877
|
+
"CREATE TABLE IF NOT EXISTS codex_barrier_certificates ("
|
|
4878
|
+
" conversation_key TEXT NOT NULL,"
|
|
4879
|
+
" revision INTEGER NOT NULL,"
|
|
4880
|
+
" certificate_json TEXT NOT NULL,"
|
|
4881
|
+
" sealed_at_utc TEXT NOT NULL,"
|
|
4882
|
+
" PRIMARY KEY (conversation_key, revision)) WITHOUT ROWID",
|
|
4883
|
+
"CREATE TABLE IF NOT EXISTS codex_barrier_repairs ("
|
|
4884
|
+
" conversation_key TEXT PRIMARY KEY,"
|
|
4885
|
+
" revision INTEGER NOT NULL,"
|
|
4886
|
+
" reason TEXT NOT NULL)",
|
|
4887
|
+
)
|
|
4888
|
+
_CODEX_968_CONVERSATIONS_DDL = (
|
|
4889
|
+
"CREATE TABLE IF NOT EXISTS codex_conversation_prune_debt ("
|
|
4890
|
+
" source_path TEXT NOT NULL,"
|
|
4891
|
+
" source_root_key TEXT NOT NULL DEFAULT '',"
|
|
4892
|
+
" recorded_at_utc TEXT NOT NULL,"
|
|
4893
|
+
" PRIMARY KEY (source_path, source_root_key)) WITHOUT ROWID",
|
|
4894
|
+
"CREATE TABLE IF NOT EXISTS codex_conversation_publications ("
|
|
4895
|
+
" conversation_key TEXT PRIMARY KEY,"
|
|
4896
|
+
" required_revision INTEGER NOT NULL DEFAULT 0,"
|
|
4897
|
+
" ready_certificate_revision INTEGER,"
|
|
4898
|
+
" published_revision INTEGER NOT NULL DEFAULT 0,"
|
|
4899
|
+
" legacy_uncertified INTEGER NOT NULL DEFAULT 0,"
|
|
4900
|
+
" updated_at_utc TEXT NOT NULL)",
|
|
4901
|
+
"CREATE TABLE IF NOT EXISTS codex_conversation_published_threads ("
|
|
4902
|
+
+ _CODEX_THREAD_SNAPSHOT_COLUMNS_DDL,
|
|
4903
|
+
"CREATE INDEX IF NOT EXISTS idx_codex_published_threads_parent"
|
|
4904
|
+
" ON codex_conversation_published_threads"
|
|
4905
|
+
"(source_root_key, native_thread_id, conversation_key)",
|
|
4906
|
+
)
|
|
4907
|
+
|
|
4908
|
+
|
|
4289
4909
|
class SchemaDeliveryObject(NamedTuple):
|
|
4290
4910
|
"""One re-derivable schema object declared by a version-gated apply.
|
|
4291
4911
|
|
|
@@ -4451,7 +5071,7 @@ CACHE_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
|
|
|
4451
5071
|
"031_codex_file_account_map"),
|
|
4452
5072
|
SchemaDeliveryObject(
|
|
4453
5073
|
"index", "idx_qws_physical_group", "_apply_codex_quota_group_index",
|
|
4454
|
-
"
|
|
5074
|
+
"048_codex_quota_physical_group_order"),
|
|
4455
5075
|
SchemaDeliveryObject(
|
|
4456
5076
|
"index", "idx_qws_unresolved_model",
|
|
4457
5077
|
"_apply_codex_quota_unresolved_model_index",
|
|
@@ -4471,6 +5091,21 @@ CACHE_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
|
|
|
4471
5091
|
"column", "codex_session_files.inode",
|
|
4472
5092
|
"_apply_codex_session_file_identity",
|
|
4473
5093
|
"046_codex_source_file_identity"),
|
|
5094
|
+
*(SchemaDeliveryObject(
|
|
5095
|
+
"column", f"codex_session_files.{column}",
|
|
5096
|
+
"_apply_codex_container_cache_schema", "049_codex_rollout_containers")
|
|
5097
|
+
for column, _decl in (_CODEX_CONTAINER_CURSOR_COLUMNS
|
|
5098
|
+
+ _CODEX_CONTAINER_CACHE_ONLY_COLUMNS)),
|
|
5099
|
+
*(SchemaDeliveryObject(
|
|
5100
|
+
"table", table, "_apply_codex_container_cache_schema",
|
|
5101
|
+
"049_codex_rollout_containers")
|
|
5102
|
+
for table in _CODEX_968_CACHE_TABLES),
|
|
5103
|
+
SchemaDeliveryObject(
|
|
5104
|
+
"index", "idx_codex_barrier_membership_conversation",
|
|
5105
|
+
"_apply_codex_container_cache_schema", "049_codex_rollout_containers"),
|
|
5106
|
+
SchemaDeliveryObject(
|
|
5107
|
+
"index", "idx_codex_barrier_entry_threads_parent",
|
|
5108
|
+
"_apply_codex_container_cache_schema", "049_codex_rollout_containers"),
|
|
4474
5109
|
SchemaDeliveryObject(
|
|
4475
5110
|
"index", "idx_codex_accounting_change_mutation",
|
|
4476
5111
|
"_apply_codex_accounting_change_ledger",
|
|
@@ -4537,9 +5172,119 @@ CACHE_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
|
|
|
4537
5172
|
"trigger", "trg_codex_accounting_upd",
|
|
4538
5173
|
"_apply_codex_accounting_change_ledger",
|
|
4539
5174
|
"044_codex_accounting_change_ledger"),
|
|
5175
|
+
SchemaDeliveryObject(
|
|
5176
|
+
"table", "codex_quota_partition_latest",
|
|
5177
|
+
"_apply_codex_quota_latest_summary", "047_spill_free_read_paths"),
|
|
5178
|
+
SchemaDeliveryObject(
|
|
5179
|
+
"index", "idx_qws_partition_capture",
|
|
5180
|
+
"_apply_codex_quota_latest_summary", "047_spill_free_read_paths"),
|
|
5181
|
+
SchemaDeliveryObject(
|
|
5182
|
+
"trigger", "trg_qws_latest_del",
|
|
5183
|
+
"_apply_codex_quota_latest_summary", "047_spill_free_read_paths"),
|
|
5184
|
+
SchemaDeliveryObject(
|
|
5185
|
+
"trigger", "trg_qws_latest_ins",
|
|
5186
|
+
"_apply_codex_quota_latest_summary", "047_spill_free_read_paths"),
|
|
5187
|
+
SchemaDeliveryObject(
|
|
5188
|
+
"trigger", "trg_qws_latest_upd",
|
|
5189
|
+
"_apply_codex_quota_latest_summary", "047_spill_free_read_paths"),
|
|
5190
|
+
SchemaDeliveryObject(
|
|
5191
|
+
"index", "idx_qws_codex_load_order",
|
|
5192
|
+
"_apply_codex_quota_load_order_index", "047_spill_free_read_paths"),
|
|
5193
|
+
SchemaDeliveryObject(
|
|
5194
|
+
"index", "idx_codex_entries_root_path_time",
|
|
5195
|
+
"_apply_codex_metadata_lookup_indexes", "047_spill_free_read_paths"),
|
|
5196
|
+
SchemaDeliveryObject(
|
|
5197
|
+
"index", "idx_codex_files_alias_recent",
|
|
5198
|
+
"_apply_codex_metadata_lookup_indexes", "047_spill_free_read_paths"),
|
|
5199
|
+
SchemaDeliveryObject(
|
|
5200
|
+
"index", "idx_codex_threads_recent",
|
|
5201
|
+
"_apply_codex_metadata_lookup_indexes", "047_spill_free_read_paths"),
|
|
5202
|
+
SchemaDeliveryObject(
|
|
5203
|
+
"index", "idx_entries_model_time",
|
|
5204
|
+
"_apply_pricing_model_indexes", "047_spill_free_read_paths"),
|
|
5205
|
+
SchemaDeliveryObject(
|
|
5206
|
+
"index", "idx_codex_entries_model_time",
|
|
5207
|
+
"_apply_pricing_model_indexes", "047_spill_free_read_paths"),
|
|
5208
|
+
SchemaDeliveryObject(
|
|
5209
|
+
"index", "idx_codex_window_attributions_read_order",
|
|
5210
|
+
"_apply_codex_window_attribution_read_index",
|
|
5211
|
+
"047_spill_free_read_paths"),
|
|
4540
5212
|
)
|
|
4541
5213
|
|
|
4542
5214
|
|
|
5215
|
+
def _apply_codex_container_cache_schema(conn: sqlite3.Connection) -> None:
|
|
5216
|
+
"""#968 spec 5.3: container columns and the cache-side maintenance state.
|
|
5217
|
+
|
|
5218
|
+
Shared by ``_apply_cache_schema`` and migration 049 so both shapes
|
|
5219
|
+
converge. Columns go through ``add_column_if_missing``; the cursor table is
|
|
5220
|
+
checked first because that helper raises on a missing table, and a table
|
|
5221
|
+
created later is created by the schema apply that calls this helper anyway.
|
|
5222
|
+
"""
|
|
5223
|
+
# Literal column names keep the schema-delivery AST guard complete.
|
|
5224
|
+
if _table_exists(conn, "codex_session_files"):
|
|
5225
|
+
add_column_if_missing(
|
|
5226
|
+
conn, "codex_session_files", "container_kind",
|
|
5227
|
+
"TEXT NOT NULL DEFAULT 'plain'")
|
|
5228
|
+
add_column_if_missing(
|
|
5229
|
+
conn, "codex_session_files", "container_path", "TEXT")
|
|
5230
|
+
add_column_if_missing(
|
|
5231
|
+
conn, "codex_session_files", "container_size_bytes", "INTEGER")
|
|
5232
|
+
add_column_if_missing(
|
|
5233
|
+
conn, "codex_session_files", "decoded_size_bytes", "INTEGER")
|
|
5234
|
+
add_column_if_missing(
|
|
5235
|
+
conn, "codex_session_files", "content_anchor_version", "INTEGER")
|
|
5236
|
+
add_column_if_missing(
|
|
5237
|
+
conn, "codex_session_files", "content_anchor", "BLOB")
|
|
5238
|
+
add_column_if_missing(
|
|
5239
|
+
conn, "codex_session_files", "content_anchor_offset", "INTEGER")
|
|
5240
|
+
add_column_if_missing(
|
|
5241
|
+
conn, "codex_session_files", "container_state",
|
|
5242
|
+
"TEXT NOT NULL DEFAULT 'accepted'")
|
|
5243
|
+
add_column_if_missing(
|
|
5244
|
+
conn, "codex_session_files", "container_state_reason", "TEXT")
|
|
5245
|
+
add_column_if_missing(
|
|
5246
|
+
conn, "codex_session_files", "pending_container_fingerprint", "TEXT")
|
|
5247
|
+
add_column_if_missing(
|
|
5248
|
+
conn, "codex_session_files", "container_resume_strikes",
|
|
5249
|
+
"INTEGER NOT NULL DEFAULT 0")
|
|
5250
|
+
for statement in _CODEX_968_CACHE_DDL:
|
|
5251
|
+
conn.execute(statement)
|
|
5252
|
+
|
|
5253
|
+
|
|
5254
|
+
def _apply_codex_conversation_container_schema(conn: sqlite3.Connection) -> None:
|
|
5255
|
+
"""#968 spec 5.3: the conversations twin of the container evidence.
|
|
5256
|
+
|
|
5257
|
+
Shared by ``_apply_conversations_schema`` (fast path and full path) and
|
|
5258
|
+
conversations migration 012.
|
|
5259
|
+
"""
|
|
5260
|
+
# Keep these in the same order as _CODEX_CONTAINER_CURSOR_COLUMNS.
|
|
5261
|
+
if _table_exists(conn, "codex_conversation_source_files"):
|
|
5262
|
+
add_column_if_missing(
|
|
5263
|
+
conn, "codex_conversation_source_files", "container_kind",
|
|
5264
|
+
"TEXT NOT NULL DEFAULT 'plain'")
|
|
5265
|
+
add_column_if_missing(
|
|
5266
|
+
conn, "codex_conversation_source_files", "container_path", "TEXT")
|
|
5267
|
+
add_column_if_missing(
|
|
5268
|
+
conn, "codex_conversation_source_files", "container_size_bytes", "INTEGER")
|
|
5269
|
+
add_column_if_missing(
|
|
5270
|
+
conn, "codex_conversation_source_files", "decoded_size_bytes", "INTEGER")
|
|
5271
|
+
add_column_if_missing(
|
|
5272
|
+
conn, "codex_conversation_source_files", "content_anchor_version", "INTEGER")
|
|
5273
|
+
add_column_if_missing(
|
|
5274
|
+
conn, "codex_conversation_source_files", "content_anchor", "BLOB")
|
|
5275
|
+
add_column_if_missing(
|
|
5276
|
+
conn, "codex_conversation_source_files", "content_anchor_offset", "INTEGER")
|
|
5277
|
+
add_column_if_missing(
|
|
5278
|
+
conn, "codex_conversation_source_files", "container_state",
|
|
5279
|
+
"TEXT NOT NULL DEFAULT 'accepted'")
|
|
5280
|
+
add_column_if_missing(
|
|
5281
|
+
conn, "codex_conversation_source_files", "container_state_reason", "TEXT")
|
|
5282
|
+
add_column_if_missing(
|
|
5283
|
+
conn, "codex_conversation_source_files", "pending_container_fingerprint", "TEXT")
|
|
5284
|
+
for statement in _CODEX_968_CONVERSATIONS_DDL:
|
|
5285
|
+
conn.execute(statement)
|
|
5286
|
+
|
|
5287
|
+
|
|
4543
5288
|
def _apply_codex_session_file_identity(conn: sqlite3.Connection) -> None:
|
|
4544
5289
|
"""Deliver the Codex accounting cursor's file identity (#769 S6, #716 A).
|
|
4545
5290
|
|
|
@@ -5111,6 +5856,7 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
5111
5856
|
conn, "codex_session_files", "ingest_complete",
|
|
5112
5857
|
"INTEGER NOT NULL DEFAULT 1")
|
|
5113
5858
|
_apply_codex_session_file_identity(conn)
|
|
5859
|
+
_apply_codex_container_cache_schema(conn)
|
|
5114
5860
|
add_column_if_missing(conn, "quota_window_snapshots", "account_key", "TEXT")
|
|
5115
5861
|
# #195: the cache-write TTL split. NULLable with NO DEFAULT — NULL is the
|
|
5116
5862
|
# "split unknown" sentinel a pre-#195 row produces for free, and a real
|
|
@@ -5249,6 +5995,15 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
5249
5995
|
_apply_codex_quota_unresolved_model_index(conn)
|
|
5250
5996
|
_apply_codex_quota_change_ledger(conn)
|
|
5251
5997
|
_apply_codex_accounting_change_ledger(conn)
|
|
5998
|
+
# #901 (spec §5.1-§5.3): the latest-capture summary, the general loader's
|
|
5999
|
+
# ordering index, the W2 lookup indexes, the pricing model indexes and the
|
|
6000
|
+
# window-attribution read order (Amendment 1 item 1). BEFORE the
|
|
6001
|
+
# legacy-FTS early-return, so an old-shape cache.db gains them.
|
|
6002
|
+
_apply_codex_quota_latest_summary(conn)
|
|
6003
|
+
_apply_codex_quota_load_order_index(conn)
|
|
6004
|
+
_apply_codex_metadata_lookup_indexes(conn)
|
|
6005
|
+
_apply_pricing_model_indexes(conn)
|
|
6006
|
+
_apply_codex_window_attribution_read_index(conn)
|
|
5252
6007
|
conn.execute(
|
|
5253
6008
|
"CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
|
|
5254
6009
|
"ON session_files(session_id)"
|
|
@@ -5394,6 +6149,8 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
|
|
|
5394
6149
|
_apply_codex_find_projection_schema(conn)
|
|
5395
6150
|
_apply_conversation_generation_schema(conn)
|
|
5396
6151
|
_apply_codex_conversation_source_identity(conn)
|
|
6152
|
+
_apply_conversation_latest_meta_indexes(conn)
|
|
6153
|
+
_apply_codex_conversation_container_schema(conn)
|
|
5397
6154
|
return
|
|
5398
6155
|
|
|
5399
6156
|
_apply_cache_schema(conn)
|
|
@@ -5410,6 +6167,14 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
|
|
|
5410
6167
|
-- it), so this only clears the now-orphan ledger and keeps
|
|
5411
6168
|
-- conversations.db transcripts-only.
|
|
5412
6169
|
DROP TABLE IF EXISTS quota_window_change_log;
|
|
6170
|
+
-- #968: only the conversation twins belong in this store.
|
|
6171
|
+
DROP TABLE IF EXISTS codex_prune_debt;
|
|
6172
|
+
DROP TABLE IF EXISTS codex_barrier_membership;
|
|
6173
|
+
DROP TABLE IF EXISTS codex_barrier_targets;
|
|
6174
|
+
DROP TABLE IF EXISTS codex_barrier_population;
|
|
6175
|
+
DROP TABLE IF EXISTS codex_barrier_certificates;
|
|
6176
|
+
DROP TABLE IF EXISTS codex_barrier_repairs;
|
|
6177
|
+
DROP TABLE IF EXISTS codex_barrier_entry_threads;
|
|
5413
6178
|
DROP TABLE IF EXISTS codex_accounting_change_log;
|
|
5414
6179
|
DROP TABLE IF EXISTS codex_conversation_threads;
|
|
5415
6180
|
DROP TABLE IF EXISTS codex_source_roots;
|
|
@@ -5425,6 +6190,10 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
|
|
|
5425
6190
|
-- tests/test_conversations_transcripts_only.py, which derives its
|
|
5426
6191
|
-- expectation from COVERAGE_CACHE_FAMILIES rather than restating a list.
|
|
5427
6192
|
DROP TABLE IF EXISTS codex_window_attributions;
|
|
6193
|
+
-- #901: the latest-capture summary is a cache.db read model over
|
|
6194
|
+
-- quota_window_snapshots, which was dropped above together with its
|
|
6195
|
+
-- triggers and seek index. Drop the table as well.
|
|
6196
|
+
DROP TABLE IF EXISTS codex_quota_partition_latest;
|
|
5428
6197
|
|
|
5429
6198
|
CREATE TABLE IF NOT EXISTS conversation_source_files (
|
|
5430
6199
|
path TEXT PRIMARY KEY,
|
|
@@ -5478,6 +6247,8 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
|
|
|
5478
6247
|
_apply_codex_find_projection_schema(conn)
|
|
5479
6248
|
_apply_conversation_generation_schema(conn)
|
|
5480
6249
|
_apply_codex_conversation_source_identity(conn)
|
|
6250
|
+
_apply_conversation_latest_meta_indexes(conn)
|
|
6251
|
+
_apply_codex_conversation_container_schema(conn)
|
|
5481
6252
|
|
|
5482
6253
|
|
|
5483
6254
|
def _ensure_codex_session_meta_provenance_index(
|
|
@@ -5654,6 +6425,36 @@ def _apply_codex_conversation_source_identity(conn: sqlite3.Connection) -> None:
|
|
|
5654
6425
|
conn, "codex_conversation_source_files", "inode", "INTEGER")
|
|
5655
6426
|
|
|
5656
6427
|
|
|
6428
|
+
def _apply_conversation_latest_meta_indexes(conn: sqlite3.Connection) -> None:
|
|
6429
|
+
"""The latest-metadata lookups' partial indexes (#901 Q11, 901-PA-001 b).
|
|
6430
|
+
|
|
6431
|
+
``_lib_conversation_query._session_latest_meta_map`` resolves a session's
|
|
6432
|
+
latest non-NULL ``cwd`` and latest non-NULL ``git_branch`` with two
|
|
6433
|
+
``LIMIT 1`` lookups ordered ``timestamp_utc DESC, id DESC``. Each partial
|
|
6434
|
+
index holds exactly the rows its lookup may return, in that order, so the
|
|
6435
|
+
lookup is one seek however long the session is and however sparse its
|
|
6436
|
+
metadata. The window query they replace sorted every touched session's
|
|
6437
|
+
whole history through temp b-trees on each conversation-sync pass (9.56 MB
|
|
6438
|
+
of temp in one measured call over a long session).
|
|
6439
|
+
|
|
6440
|
+
Called by the fresh-install schema apply and by conversations migration
|
|
6441
|
+
011, so both shapes converge. Skipped when ``conversation_messages`` is
|
|
6442
|
+
absent (a reachable state, not a corrupt one).
|
|
6443
|
+
"""
|
|
6444
|
+
if not _table_exists(conn, "conversation_messages"):
|
|
6445
|
+
return
|
|
6446
|
+
conn.execute(
|
|
6447
|
+
"CREATE INDEX IF NOT EXISTS idx_conv_session_latest_cwd "
|
|
6448
|
+
"ON conversation_messages(session_id, timestamp_utc, id) "
|
|
6449
|
+
"WHERE cwd IS NOT NULL"
|
|
6450
|
+
)
|
|
6451
|
+
conn.execute(
|
|
6452
|
+
"CREATE INDEX IF NOT EXISTS idx_conv_session_latest_git_branch "
|
|
6453
|
+
"ON conversation_messages(session_id, timestamp_utc, id) "
|
|
6454
|
+
"WHERE git_branch IS NOT NULL"
|
|
6455
|
+
)
|
|
6456
|
+
|
|
6457
|
+
|
|
5657
6458
|
def _apply_codex_find_projection_schema(conn: sqlite3.Connection) -> None:
|
|
5658
6459
|
"""Create #482's disposable visible-text projection tables.
|
|
5659
6460
|
|
|
@@ -5817,6 +6618,27 @@ CONVERSATIONS_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
|
|
|
5817
6618
|
"column", "codex_conversation_source_files.inode",
|
|
5818
6619
|
"_apply_codex_conversation_source_identity",
|
|
5819
6620
|
"010_codex_conversation_source_file_identity"),
|
|
6621
|
+
SchemaDeliveryObject(
|
|
6622
|
+
"index", "idx_conv_session_latest_cwd",
|
|
6623
|
+
"_apply_conversation_latest_meta_indexes",
|
|
6624
|
+
"011_conversation_latest_meta_indexes"),
|
|
6625
|
+
SchemaDeliveryObject(
|
|
6626
|
+
"index", "idx_conv_session_latest_git_branch",
|
|
6627
|
+
"_apply_conversation_latest_meta_indexes",
|
|
6628
|
+
"011_conversation_latest_meta_indexes"),
|
|
6629
|
+
*(SchemaDeliveryObject(
|
|
6630
|
+
"column", f"codex_conversation_source_files.{column}",
|
|
6631
|
+
"_apply_codex_conversation_container_schema",
|
|
6632
|
+
"012_codex_conversation_containers")
|
|
6633
|
+
for column, _decl in _CODEX_CONTAINER_CURSOR_COLUMNS),
|
|
6634
|
+
*(SchemaDeliveryObject(
|
|
6635
|
+
"table", table, "_apply_codex_conversation_container_schema",
|
|
6636
|
+
"012_codex_conversation_containers")
|
|
6637
|
+
for table in _CODEX_968_CONVERSATIONS_TABLES),
|
|
6638
|
+
SchemaDeliveryObject(
|
|
6639
|
+
"index", "idx_codex_published_threads_parent",
|
|
6640
|
+
"_apply_codex_conversation_container_schema",
|
|
6641
|
+
"012_codex_conversation_containers"),
|
|
5820
6642
|
SchemaDeliveryObject(
|
|
5821
6643
|
"table", "claude_conversation_account_stamp_gaps",
|
|
5822
6644
|
"_apply_conversation_generation_schema",
|
|
@@ -6026,7 +6848,8 @@ def _conv_005_conversation_account_dimension(conn: sqlite3.Connection) -> None:
|
|
|
6026
6848
|
held += _acquire_conversations_db_codex_provider_flock(
|
|
6027
6849
|
conn, migration="conversations 005 account dimension"
|
|
6028
6850
|
)
|
|
6029
|
-
claude_key =
|
|
6851
|
+
claude_key = _accounts_cutover_value_or_defer(
|
|
6852
|
+
"Claude conversation backfill")
|
|
6030
6853
|
if claude_key is None:
|
|
6031
6854
|
pending = conn.execute(
|
|
6032
6855
|
"SELECT 1 FROM conversation_messages "
|
|
@@ -6453,6 +7276,50 @@ def _conv_010_codex_conversation_source_file_identity(
|
|
|
6453
7276
|
conn.commit()
|
|
6454
7277
|
|
|
6455
7278
|
|
|
7279
|
+
@conversations_migration("011_conversation_latest_meta_indexes")
|
|
7280
|
+
def _conv_011_conversation_latest_meta_indexes(
|
|
7281
|
+
conn: sqlite3.Connection,
|
|
7282
|
+
) -> None:
|
|
7283
|
+
"""Deliver the latest-metadata partial indexes (#901 Q11, 901-PA-001 b).
|
|
7284
|
+
|
|
7285
|
+
``_session_latest_meta_map`` now resolves each touched session's latest
|
|
7286
|
+
``cwd`` and ``git_branch`` with two index-served ``LIMIT 1`` lookups; the
|
|
7287
|
+
indexes and the reasoning live on ``_apply_conversation_latest_meta_indexes``,
|
|
7288
|
+
the helper the fresh-install apply also calls. The schema apply that would
|
|
7289
|
+
create them is version-gated, so this registration is what reaches an
|
|
7290
|
+
already-current store.
|
|
7291
|
+
|
|
7292
|
+
Takes the Claude provider flock and defers cleanly on contention, like 009:
|
|
7293
|
+
building the indexes holds SQLite's write lock for a sort of the Claude
|
|
7294
|
+
transcript rows (one-time setup work), and a concurrent Claude transcript
|
|
7295
|
+
writer then reports contention instead of exhausting its busy timeout.
|
|
7296
|
+
|
|
7297
|
+
Re-running is a no-op (``IF NOT EXISTS``). NO self-stamp — the dispatcher
|
|
7298
|
+
central-stamps on a clean return (#140).
|
|
7299
|
+
"""
|
|
7300
|
+
held = _acquire_conversations_db_claude_provider_flock(
|
|
7301
|
+
conn, migration="conversations 011 latest metadata indexes")
|
|
7302
|
+
try:
|
|
7303
|
+
_apply_conversation_latest_meta_indexes(conn)
|
|
7304
|
+
conn.commit()
|
|
7305
|
+
finally:
|
|
7306
|
+
_release_cache_db_writer_flocks(held)
|
|
7307
|
+
|
|
7308
|
+
|
|
7309
|
+
@conversations_migration("012_codex_conversation_containers")
|
|
7310
|
+
def _conv_012_codex_conversation_containers(conn: sqlite3.Connection) -> None:
|
|
7311
|
+
"""#968 spec 5.3: the conversations twin of cache migration 049.
|
|
7312
|
+
|
|
7313
|
+
Delivers the transcript cursor's container columns, the conversation-side
|
|
7314
|
+
prune debt and the last-good publication records to an already-current
|
|
7315
|
+
store. Same version-gate reason and the same no-backfill rule as 049; no
|
|
7316
|
+
provider flock for the same reason as 010. Re-running is a no-op. NO
|
|
7317
|
+
self-stamp — the dispatcher central-stamps on a clean return (#140).
|
|
7318
|
+
"""
|
|
7319
|
+
_apply_codex_conversation_container_schema(conn)
|
|
7320
|
+
conn.commit()
|
|
7321
|
+
|
|
7322
|
+
|
|
6456
7323
|
# #177 S6: the consolidated multi-column external-content FTS5 table that
|
|
6457
7324
|
# replaces the old conversation_fts(text) + conversation_fts_aux(search_aux)
|
|
6458
7325
|
# pair. The three column names MUST match the conversation_messages columns BY
|
|
@@ -8435,7 +9302,7 @@ def _029_backfill_claude_account(conn: sqlite3.Connection) -> None:
|
|
|
8435
9302
|
import _cctally_journal as _jr
|
|
8436
9303
|
import _lib_accounts as _acc
|
|
8437
9304
|
|
|
8438
|
-
op_value =
|
|
9305
|
+
op_value = _accounts_cutover_value_or_defer("Claude account backfill")
|
|
8439
9306
|
if op_value is None:
|
|
8440
9307
|
# Cutover hasn't appended its op yet. Defer only if there is legacy Claude
|
|
8441
9308
|
# history to attribute; a fresh install (no such rows) proceeds as a no-op.
|
|
@@ -9277,6 +10144,89 @@ def _046_codex_source_file_identity(conn: sqlite3.Connection) -> None:
|
|
|
9277
10144
|
conn.commit()
|
|
9278
10145
|
|
|
9279
10146
|
|
|
10147
|
+
@cache_migration("047_spill_free_read_paths")
|
|
10148
|
+
def _047_spill_free_read_paths(conn: sqlite3.Connection) -> None:
|
|
10149
|
+
"""#901 (spec §5.1-§5.3): deliver the spill-free read paths to existing stores.
|
|
10150
|
+
|
|
10151
|
+
``_apply_cache_schema`` creates every object below for a new store, but that
|
|
10152
|
+
pass is VERSION-GATED: ``open_cache_db`` runs it only when ``user_version``
|
|
10153
|
+
differs from ``len(_CACHE_MIGRATIONS)``. Registering here bumps the head,
|
|
10154
|
+
the mechanism migrations 029, 031, 044, 045 and 046 rely on.
|
|
10155
|
+
|
|
10156
|
+
The handler ALWAYS re-derives the latest-capture summary from the physical
|
|
10157
|
+
rows (``rebootstrap=True``), so a re-upgrade after an older binary trimmed
|
|
10158
|
+
this marker converges on the rows actually stored whatever happened to the
|
|
10159
|
+
summary meanwhile. On a first upgrade the schema apply has already
|
|
10160
|
+
bootstrapped it once; the second derivation is one ordered scan of the new
|
|
10161
|
+
seek index, paid once.
|
|
10162
|
+
|
|
10163
|
+
It also delivers ``idx_codex_window_attributions_read_order`` (Amendment 1
|
|
10164
|
+
item 1, ``c901-design-2``), the window-attribution read's assertion order.
|
|
10165
|
+
|
|
10166
|
+
Takes the Codex provider flock like handlers 024-027, 034 and 043, because
|
|
10167
|
+
the summary is Codex-derived, and DEFERS (``MigrationGateNotMet``) before
|
|
10168
|
+
any DDL when a Codex sync holds it. Idempotent: every object uses IF NOT
|
|
10169
|
+
EXISTS or drop-then-create, and a re-derivation over its own output writes
|
|
10170
|
+
the same rows. NO self-stamp — the dispatcher central-stamps on a clean
|
|
10171
|
+
return (#140).
|
|
10172
|
+
"""
|
|
10173
|
+
held = _acquire_cache_db_codex_provider_flock(
|
|
10174
|
+
conn, migration="047 spill-free read paths")
|
|
10175
|
+
try:
|
|
10176
|
+
_apply_codex_quota_latest_summary(conn, rebootstrap=True)
|
|
10177
|
+
_apply_codex_quota_load_order_index(conn)
|
|
10178
|
+
_apply_codex_metadata_lookup_indexes(conn)
|
|
10179
|
+
_apply_pricing_model_indexes(conn)
|
|
10180
|
+
_apply_codex_window_attribution_read_index(conn)
|
|
10181
|
+
conn.commit()
|
|
10182
|
+
finally:
|
|
10183
|
+
_release_cache_db_writer_flocks(held)
|
|
10184
|
+
|
|
10185
|
+
|
|
10186
|
+
@cache_migration("048_codex_quota_physical_group_order")
|
|
10187
|
+
def _048_codex_quota_physical_group_order(conn: sqlite3.Connection) -> None:
|
|
10188
|
+
"""#901 Q11 (spec §5.3a, 901-PA-001 a): stream every physical-group shard.
|
|
10189
|
+
|
|
10190
|
+
Replaces ``idx_qws_physical_group``'s five-member definition with the same
|
|
10191
|
+
name, partial predicate and five equality members followed by the loader's
|
|
10192
|
+
order columns ``captured_at_utc, resets_at_utc, source_path,
|
|
10193
|
+
line_offset``. The shard SQL, its population and its order are unchanged;
|
|
10194
|
+
only the sorter goes. The reasoning lives on
|
|
10195
|
+
``_apply_codex_quota_group_index``, which this delegates to so the
|
|
10196
|
+
migration and the schema apply cannot drift, and which replaces the index
|
|
10197
|
+
atomically.
|
|
10198
|
+
|
|
10199
|
+
Same version-gate reason as 040: the schema apply that converges the index
|
|
10200
|
+
is skipped by a steady-state open, so registering here is what makes an
|
|
10201
|
+
already-current install pick it up. The rebuild is a one-time sort of the
|
|
10202
|
+
Codex quota rows (setup work, not a recurring path).
|
|
10203
|
+
|
|
10204
|
+
Re-running is a no-op: a current index is left alone. NO self-stamp — the
|
|
10205
|
+
dispatcher central-stamps on a clean return (#140).
|
|
10206
|
+
"""
|
|
10207
|
+
_apply_codex_quota_group_index(conn)
|
|
10208
|
+
conn.commit()
|
|
10209
|
+
|
|
10210
|
+
|
|
10211
|
+
@cache_migration("049_codex_rollout_containers")
|
|
10212
|
+
def _049_codex_rollout_containers(conn: sqlite3.Connection) -> None:
|
|
10213
|
+
"""#968 spec 5.3: deliver container evidence and maintenance state.
|
|
10214
|
+
|
|
10215
|
+
The schema apply that declares these objects is version-gated, so only a
|
|
10216
|
+
registered migration reaches an already-current store (the reason 046
|
|
10217
|
+
gives). Existing cursor rows read as plain and accepted, which is exactly
|
|
10218
|
+
what they are; no container evidence is backfilled from a stat taken now.
|
|
10219
|
+
The seven tables start empty: prune debt, verification state and the
|
|
10220
|
+
publication barrier are written only by the runtime paths that own them.
|
|
10221
|
+
|
|
10222
|
+
Takes no provider flock: ADD COLUMN with a constant default and CREATE of
|
|
10223
|
+
an empty table rewrite no rows. Re-running is a no-op. NO self-stamp — the
|
|
10224
|
+
dispatcher central-stamps on a clean return (#140).
|
|
10225
|
+
"""
|
|
10226
|
+
_apply_codex_container_cache_schema(conn)
|
|
10227
|
+
conn.commit()
|
|
10228
|
+
|
|
10229
|
+
|
|
9280
10230
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
9281
10231
|
|
|
9282
10232
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
|
@@ -10867,6 +11817,13 @@ def _repair_marker_path(path: pathlib.Path) -> pathlib.Path:
|
|
|
10867
11817
|
return path.with_name(f"{path.name}.repairing")
|
|
10868
11818
|
|
|
10869
11819
|
|
|
11820
|
+
def _keeper_yield_request_path(path: pathlib.Path) -> pathlib.Path:
|
|
11821
|
+
"""#901 W9 (Q15): the store-scoped request that makes every process's
|
|
11822
|
+
idle checkpoint keeper (`_lib_wal_checkpoint`) close. Its owner record is
|
|
11823
|
+
the repair marker's, written, read and checked for liveness the same way."""
|
|
11824
|
+
return path.with_name(f"{path.name}.keeper-yield")
|
|
11825
|
+
|
|
11826
|
+
|
|
10870
11827
|
_REPAIR_OWNER_VERSION = 1
|
|
10871
11828
|
_REPAIR_OWNER_UNKNOWN_IDENTITY_LEASE_SECONDS = 30 * 60
|
|
10872
11829
|
|
|
@@ -11019,10 +11976,30 @@ def _claim_repair_marker(
|
|
|
11019
11976
|
path: pathlib.Path,
|
|
11020
11977
|
) -> "tuple[RepairMarkerClaim | None, str]":
|
|
11021
11978
|
"""Atomically publish a complete owner record; reclaim stale ownership."""
|
|
11022
|
-
|
|
11979
|
+
return _claim_owner_record(
|
|
11980
|
+
_repair_marker_path(path),
|
|
11981
|
+
lambda: _remove_stale_repair_marker(path),
|
|
11982
|
+
owner="repair-owner",
|
|
11983
|
+
record="repair marker",
|
|
11984
|
+
)
|
|
11985
|
+
|
|
11986
|
+
|
|
11987
|
+
def _claim_owner_record(
|
|
11988
|
+
marker: pathlib.Path,
|
|
11989
|
+
remove_stale: "Callable[[], tuple[bool, str]]",
|
|
11990
|
+
*,
|
|
11991
|
+
owner: str,
|
|
11992
|
+
record: str,
|
|
11993
|
+
) -> "tuple[RepairMarkerClaim | None, str]":
|
|
11994
|
+
"""Publish ``marker`` with a complete owner record by exclusive create.
|
|
11995
|
+
|
|
11996
|
+
The repair marker's discipline, shared with #901's keeper-yield request:
|
|
11997
|
+
the record is written and fsynced under a private name and linked into
|
|
11998
|
+
place, so it is complete or absent; an existing record is replaced only
|
|
11999
|
+
when ``remove_stale`` proves its owner dead or its pid reused."""
|
|
11023
12000
|
process_start = _process_start_identity(os.getpid())
|
|
11024
12001
|
if process_start is None:
|
|
11025
|
-
raise OSError("could not establish
|
|
12002
|
+
raise OSError(f"could not establish {owner} process identity")
|
|
11026
12003
|
for _attempt in range(3):
|
|
11027
12004
|
claim_id = f"{os.getpid()}-{time.time_ns()}-{os.urandom(8).hex()}"
|
|
11028
12005
|
temp = marker.with_name(f".{marker.name}.{claim_id}.tmp")
|
|
@@ -11041,7 +12018,7 @@ def _claim_repair_marker(
|
|
|
11041
12018
|
try:
|
|
11042
12019
|
os.link(temp, marker)
|
|
11043
12020
|
except FileExistsError:
|
|
11044
|
-
removed, reason =
|
|
12021
|
+
removed, reason = remove_stale()
|
|
11045
12022
|
if not removed:
|
|
11046
12023
|
return None, reason
|
|
11047
12024
|
continue
|
|
@@ -11052,7 +12029,7 @@ def _claim_repair_marker(
|
|
|
11052
12029
|
temp.unlink()
|
|
11053
12030
|
except FileNotFoundError:
|
|
11054
12031
|
pass
|
|
11055
|
-
return None, f"could not claim
|
|
12032
|
+
return None, f"could not claim {record} {marker}"
|
|
11056
12033
|
|
|
11057
12034
|
|
|
11058
12035
|
def _release_repair_marker(path: pathlib.Path, claim: RepairMarkerClaim) -> None:
|
|
@@ -11067,8 +12044,135 @@ def _release_repair_marker(path: pathlib.Path, claim: RepairMarkerClaim) -> None
|
|
|
11067
12044
|
_fsync_directory(marker.parent)
|
|
11068
12045
|
|
|
11069
12046
|
|
|
12047
|
+
#: #901 W9 (Q15): the one monotonic deadline a process that needs a
|
|
12048
|
+
#: cache.db or conversations.db family exclusive or drained waits for the
|
|
12049
|
+
#: other processes' idle checkpoint keepers to close, per store: twice the
|
|
12050
|
+
#: keepers' 10 s timer interval. Never renewed per keeper, scan or retry.
|
|
12051
|
+
_KEEPER_YIELD_DEADLINE_SECONDS = 20.0
|
|
12052
|
+
#: The pause between two handle checks, or two exclusive acquisitions, while
|
|
12053
|
+
#: a requester waits under that deadline.
|
|
12054
|
+
_KEEPER_YIELD_RETRY_SECONDS = 0.25
|
|
12055
|
+
|
|
12056
|
+
|
|
12057
|
+
def _remove_stale_keeper_yield_request(
|
|
12058
|
+
path: pathlib.Path,
|
|
12059
|
+
) -> "tuple[bool, str]":
|
|
12060
|
+
"""Remove a provably stale keeper-yield request (dead or reused-pid
|
|
12061
|
+
owner), as `_remove_stale_repair_marker` does for the repair marker."""
|
|
12062
|
+
request = _keeper_yield_request_path(path)
|
|
12063
|
+
if not request.exists():
|
|
12064
|
+
return True, ""
|
|
12065
|
+
live, reason = _repair_marker_is_live(request)
|
|
12066
|
+
if live:
|
|
12067
|
+
return False, f"another {path.name} keeper-yield request owns {request} ({reason})"
|
|
12068
|
+
try:
|
|
12069
|
+
request.unlink()
|
|
12070
|
+
except FileNotFoundError:
|
|
12071
|
+
return True, ""
|
|
12072
|
+
except OSError as exc:
|
|
12073
|
+
return False, f"could not remove stale keeper-yield request {request}: {exc}"
|
|
12074
|
+
_fsync_directory(request.parent)
|
|
12075
|
+
return True, reason
|
|
12076
|
+
|
|
12077
|
+
|
|
12078
|
+
def _claim_keeper_yield_request(
|
|
12079
|
+
path: pathlib.Path,
|
|
12080
|
+
) -> "tuple[RepairMarkerClaim | None, str]":
|
|
12081
|
+
"""Publish ``<store>.keeper-yield`` with this process as its owner."""
|
|
12082
|
+
return _claim_owner_record(
|
|
12083
|
+
_keeper_yield_request_path(path),
|
|
12084
|
+
lambda: _remove_stale_keeper_yield_request(path),
|
|
12085
|
+
owner="keeper-yield-owner",
|
|
12086
|
+
record="keeper-yield request",
|
|
12087
|
+
)
|
|
12088
|
+
|
|
12089
|
+
|
|
12090
|
+
def _release_keeper_yield_request(
|
|
12091
|
+
path: pathlib.Path, claim: RepairMarkerClaim,
|
|
12092
|
+
) -> None:
|
|
12093
|
+
"""Remove the request; only its owner does, and a record someone else now
|
|
12094
|
+
owns is left alone. Best effort: a request left behind fences keepers
|
|
12095
|
+
only while this process lives, and it is stale once the process exits."""
|
|
12096
|
+
request = _keeper_yield_request_path(path)
|
|
12097
|
+
owner = _read_repair_owner(request)
|
|
12098
|
+
if owner is not None and owner.get("claim_id") != claim.claim_id:
|
|
12099
|
+
return
|
|
12100
|
+
try:
|
|
12101
|
+
request.unlink()
|
|
12102
|
+
except OSError:
|
|
12103
|
+
return
|
|
12104
|
+
try:
|
|
12105
|
+
_fsync_directory(request.parent)
|
|
12106
|
+
except OSError:
|
|
12107
|
+
pass
|
|
12108
|
+
|
|
12109
|
+
|
|
12110
|
+
@contextlib.contextmanager
|
|
12111
|
+
def _keeper_yield_request(path) -> "Iterator[float]":
|
|
12112
|
+
"""Ask every process's idle checkpoint keeper on the store to close (#901
|
|
12113
|
+
W9, Q15) and yield the one monotonic deadline to wait for them.
|
|
12114
|
+
|
|
12115
|
+
The caller already holds its maintenance and provider locks, taken as
|
|
12116
|
+
before and failing as promptly as before; the request grants no authority
|
|
12117
|
+
of its own. It is published with the repair marker's owner record, this
|
|
12118
|
+
process's own keeper is closed at once, and the request is removed when
|
|
12119
|
+
the caller finishes or fails. The other processes observe it at their next
|
|
12120
|
+
`arm`, timer round or exit and close their keepers without a checkpoint.
|
|
12121
|
+
When it cannot be published the caller still waits, because a sync or a
|
|
12122
|
+
reader may close in time; it then refuses at the deadline as before."""
|
|
12123
|
+
path = pathlib.Path(path)
|
|
12124
|
+
try:
|
|
12125
|
+
claim, _reason = _claim_keeper_yield_request(path)
|
|
12126
|
+
except OSError:
|
|
12127
|
+
claim = None
|
|
12128
|
+
try:
|
|
12129
|
+
try:
|
|
12130
|
+
import _lib_wal_checkpoint
|
|
12131
|
+
|
|
12132
|
+
_lib_wal_checkpoint.release(path)
|
|
12133
|
+
except Exception: # noqa: BLE001 — the drain check below decides
|
|
12134
|
+
pass
|
|
12135
|
+
yield time.monotonic() + _KEEPER_YIELD_DEADLINE_SECONDS
|
|
12136
|
+
finally:
|
|
12137
|
+
if claim is not None:
|
|
12138
|
+
_release_keeper_yield_request(path, claim)
|
|
12139
|
+
|
|
12140
|
+
|
|
12141
|
+
def _await_family_drained(
|
|
12142
|
+
path: pathlib.Path, deadline: float,
|
|
12143
|
+
) -> "set[int] | None":
|
|
12144
|
+
"""Repeat the family's handle check until it finds no open handle or the
|
|
12145
|
+
deadline passes (#901 W9, Q15); return the last answer.
|
|
12146
|
+
|
|
12147
|
+
``None`` (unknown) returns at once, as a refusal. Nothing is killed,
|
|
12148
|
+
ignored or exempted: at the deadline the caller refuses with today's
|
|
12149
|
+
message for whatever handle remains — a keeper that did not yield, a sync
|
|
12150
|
+
in progress, a reader."""
|
|
12151
|
+
while True:
|
|
12152
|
+
open_pids = _db_family_open_pids(path)
|
|
12153
|
+
if not open_pids:
|
|
12154
|
+
return open_pids
|
|
12155
|
+
remaining = deadline - time.monotonic()
|
|
12156
|
+
if remaining <= 0:
|
|
12157
|
+
return open_pids
|
|
12158
|
+
time.sleep(min(_KEEPER_YIELD_RETRY_SECONDS, remaining))
|
|
12159
|
+
|
|
12160
|
+
|
|
11070
12161
|
def _db_family_open_pids(path: pathlib.Path) -> "set[int] | None":
|
|
11071
|
-
"""Return processes with any SQLite family member open; None if unknown.
|
|
12162
|
+
"""Return processes with any SQLite family member open; None if unknown.
|
|
12163
|
+
|
|
12164
|
+
#901 W9 (Q14): this process's idle keeper for the store is released first,
|
|
12165
|
+
without a checkpoint. It exists only to defer checkpoints, and every
|
|
12166
|
+
caller asks this question before replacing or quarantining the family, so
|
|
12167
|
+
the keeper must never be the handle that refuses this process's own store
|
|
12168
|
+
recovery; the next sync's arm reopens it on whatever file is then there.
|
|
12169
|
+
"""
|
|
12170
|
+
try:
|
|
12171
|
+
import _lib_wal_checkpoint
|
|
12172
|
+
|
|
12173
|
+
_lib_wal_checkpoint.release(path)
|
|
12174
|
+
except Exception: # noqa: BLE001 — the drain check below still decides
|
|
12175
|
+
pass
|
|
11072
12176
|
family = [
|
|
11073
12177
|
pathlib.Path(str(path) + suffix)
|
|
11074
12178
|
for suffix in ("", "-journal", "-wal", "-shm")
|
|
@@ -11094,6 +12198,8 @@ def _db_family_open_pids(path: pathlib.Path) -> "set[int] | None":
|
|
|
11094
12198
|
)
|
|
11095
12199
|
except OSError:
|
|
11096
12200
|
return None
|
|
12201
|
+
if result.returncode not in (0, 1):
|
|
12202
|
+
return None
|
|
11097
12203
|
return {
|
|
11098
12204
|
int(line[1:])
|
|
11099
12205
|
for line in result.stdout.splitlines()
|
|
@@ -12065,13 +13171,51 @@ def _vacuum_required_free_bytes(path) -> int:
|
|
|
12065
13171
|
return 2 * db_bytes + wal_bytes
|
|
12066
13172
|
|
|
12067
13173
|
|
|
12068
|
-
def
|
|
13174
|
+
def _acquire_vacuum_exclusion(
|
|
13175
|
+
conn: sqlite3.Connection, deadline: "float | None",
|
|
13176
|
+
) -> None:
|
|
13177
|
+
"""Take a WAL store's EXCLUSIVE lock and keep it (#901 W9, Q15).
|
|
13178
|
+
|
|
13179
|
+
Under ``locking_mode=EXCLUSIVE`` a write transaction takes the database
|
|
13180
|
+
file's EXCLUSIVE lock and the connection keeps it after the transaction
|
|
13181
|
+
ends, so ``BEGIN IMMEDIATE`` then ``ROLLBACK`` acquires the exclusion
|
|
13182
|
+
without writing anything, and the checkpoint and VACUUM that follow run
|
|
13183
|
+
under one continuous hold (#386). Only this acquisition is retried — each
|
|
13184
|
+
attempt under the existing busy timeout, a short pause between attempts —
|
|
13185
|
+
until ``deadline``, the keepers' drain grace; the VACUUM is never retried.
|
|
13186
|
+
``deadline=None`` makes one attempt. The last busy error is raised at the
|
|
13187
|
+
deadline, so the caller's refusal is today's."""
|
|
13188
|
+
while True:
|
|
13189
|
+
try:
|
|
13190
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
13191
|
+
except sqlite3.OperationalError as exc:
|
|
13192
|
+
text = str(exc).lower()
|
|
13193
|
+
if (
|
|
13194
|
+
deadline is None
|
|
13195
|
+
or ("lock" not in text and "busy" not in text)
|
|
13196
|
+
or time.monotonic() >= deadline
|
|
13197
|
+
):
|
|
13198
|
+
raise
|
|
13199
|
+
time.sleep(max(0.0, min(_KEEPER_YIELD_RETRY_SECONDS,
|
|
13200
|
+
deadline - time.monotonic())))
|
|
13201
|
+
continue
|
|
13202
|
+
conn.execute("ROLLBACK")
|
|
13203
|
+
return
|
|
13204
|
+
|
|
13205
|
+
|
|
13206
|
+
def _run_vacuum_exclusive(
|
|
13207
|
+
path, label: str, *, deadline: "float | None" = None,
|
|
13208
|
+
) -> int:
|
|
12069
13209
|
"""VACUUM ``path`` under EXCLUSIVE; checkpoint WAL-backed stores first.
|
|
12070
13210
|
|
|
12071
13211
|
``locking_mode=EXCLUSIVE`` + a short ``busy_timeout`` make a concurrent
|
|
12072
|
-
reader/writer
|
|
12073
|
-
lock, which the advisory flocks do not provide against dashboard
|
|
12074
|
-
|
|
13212
|
+
reader/writer fail rather than hang (no TOCTOU gap — the exclusion is the
|
|
13213
|
+
DB's own lock, which the advisory flocks do not provide against dashboard
|
|
13214
|
+
readers). On a WAL store (#901 W9, Q15) the exclusive acquisition comes
|
|
13215
|
+
first and is retried until ``deadline`` while other processes' idle
|
|
13216
|
+
checkpoint keepers yield to the caller's request; the checkpoint and the
|
|
13217
|
+
VACUUM then run under that one hold. Exit 0 on success, 3 when the DB is
|
|
13218
|
+
in use."""
|
|
12075
13219
|
conn = sqlite3.connect(f"file:{path}?mode=rw", uri=True)
|
|
12076
13220
|
try:
|
|
12077
13221
|
conn.execute(f"PRAGMA busy_timeout={_VACUUM_BUSY_TIMEOUT_MS}")
|
|
@@ -12080,6 +13224,7 @@ def _run_vacuum_exclusive(path, label: str) -> int:
|
|
|
12080
13224
|
try:
|
|
12081
13225
|
conn.execute("PRAGMA locking_mode=EXCLUSIVE")
|
|
12082
13226
|
if label != "stats.db":
|
|
13227
|
+
_acquire_vacuum_exclusion(conn, deadline)
|
|
12083
13228
|
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
12084
13229
|
conn.execute("VACUUM")
|
|
12085
13230
|
except sqlite3.OperationalError as exc:
|
|
@@ -12189,7 +13334,13 @@ def _vacuum_one_db(path, label: str, provider_locked: bool) -> int:
|
|
|
12189
13334
|
"before retrying VACUUM."
|
|
12190
13335
|
)
|
|
12191
13336
|
return 3
|
|
12192
|
-
|
|
13337
|
+
# stats.db has no checkpoint keeper: one attempt, as before.
|
|
13338
|
+
return _run_vacuum_exclusive(path, label)
|
|
13339
|
+
# #901 W9 (Q15): with both locks held, ask the other processes'
|
|
13340
|
+
# idle checkpoint keepers to close, close this process's own, and
|
|
13341
|
+
# retry only the exclusive acquisition within the drain grace.
|
|
13342
|
+
with _keeper_yield_request(path) as deadline:
|
|
13343
|
+
return _run_vacuum_exclusive(path, label, deadline=deadline)
|
|
12193
13344
|
finally:
|
|
12194
13345
|
for fh in held:
|
|
12195
13346
|
try:
|
|
@@ -12212,9 +13363,12 @@ def cmd_db_vacuum(args: argparse.Namespace) -> int:
|
|
|
12212
13363
|
|
|
12213
13364
|
NEVER automatic. Holds the maintenance flock + (for cache.db) the provider
|
|
12214
13365
|
flocks, then runs a checkpoint + VACUUM under a real SQLite EXCLUSIVE lock so
|
|
12215
|
-
a concurrent dashboard reader
|
|
12216
|
-
|
|
12217
|
-
|
|
13366
|
+
a concurrent dashboard reader makes it refuse instead of racing. For
|
|
13367
|
+
cache.db and conversations.db it first asks other processes' idle
|
|
13368
|
+
checkpoint keepers to close (#901 W9, Q15) and retries the exclusive
|
|
13369
|
+
acquisition for at most 20 s; a handle still open then gets the same
|
|
13370
|
+
refusal. Refuses when free disk is below ~2x the file + WAL. Exit 0 on
|
|
13371
|
+
success, 3 when a target is in use or disk is short."""
|
|
12218
13372
|
which = getattr(args, "db", "cache")
|
|
12219
13373
|
targets = []
|
|
12220
13374
|
if which in ("cache", "all"):
|