cctally 1.82.1 → 1.83.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 +63 -0
- package/README.md +12 -5
- package/bin/_cctally_alerts.py +8 -1
- package/bin/_cctally_cache.py +912 -149
- package/bin/_cctally_config.py +43 -4
- package/bin/_cctally_core.py +933 -759
- package/bin/_cctally_dashboard.py +157 -47
- package/bin/_cctally_dashboard_cache_report.py +13 -6
- package/bin/_cctally_dashboard_conversation.py +1 -0
- package/bin/_cctally_dashboard_envelope.py +116 -8
- package/bin/_cctally_dashboard_share.py +50 -19
- package/bin/_cctally_dashboard_sources.py +223 -48
- package/bin/_cctally_db.py +605 -128
- package/bin/_cctally_doctor.py +417 -28
- package/bin/_cctally_five_hour.py +12 -5
- package/bin/_cctally_journal.py +2050 -156
- package/bin/_cctally_journal_repair.py +519 -0
- package/bin/_cctally_milestone_history.py +142 -56
- package/bin/_cctally_milestones.py +179 -111
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +24 -18
- package/bin/_cctally_quota.py +139 -25
- package/bin/_cctally_record.py +279 -108
- package/bin/_cctally_rederive.py +1052 -0
- package/bin/_cctally_reporting.py +58 -53
- package/bin/_cctally_setup.py +1 -0
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_statusline.py +11 -11
- package/bin/_cctally_store.py +1039 -31
- package/bin/_cctally_sync_week.py +17 -8
- package/bin/_cctally_tui.py +350 -44
- package/bin/_cctally_update.py +133 -8
- package/bin/_cctally_weekrefs.py +14 -0
- package/bin/_lib_aggregators.py +10 -6
- package/bin/_lib_cache_report.py +101 -9
- package/bin/_lib_codex_pools.py +82 -0
- package/bin/_lib_conversation_query.py +81 -33
- package/bin/_lib_dashboard_sources.py +75 -0
- package/bin/_lib_diff_kernel.py +28 -15
- package/bin/_lib_doctor.py +342 -4
- package/bin/_lib_journal.py +924 -2
- package/bin/_lib_jsonl.py +43 -14
- package/bin/_lib_pricing.py +140 -21
- package/bin/_lib_rederive.py +395 -0
- package/bin/_lib_share.py +58 -2
- package/bin/cctally +56 -8
- package/dashboard/static/assets/{index-BKM43pxK.js → index-3bgCMVHb.js} +52 -52
- package/dashboard/static/assets/index-D27EIHEI.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
package/bin/_cctally_cache.py
CHANGED
|
@@ -103,11 +103,12 @@ import fcntl
|
|
|
103
103
|
import json
|
|
104
104
|
import os
|
|
105
105
|
import pathlib
|
|
106
|
+
import select
|
|
106
107
|
import signal
|
|
107
108
|
import sqlite3
|
|
108
109
|
import sys
|
|
109
110
|
import time
|
|
110
|
-
from dataclasses import dataclass, field
|
|
111
|
+
from dataclasses import asdict, dataclass, field
|
|
111
112
|
from typing import Any, Callable, Iterator, NamedTuple
|
|
112
113
|
|
|
113
114
|
|
|
@@ -197,6 +198,10 @@ _perf = _load_lib("_lib_perf")
|
|
|
197
198
|
# _arm_rollup_backfill_on_pricing_change so a test may monkeypatch it.
|
|
198
199
|
PRICING_SNAPSHOT_DATE = _load_lib("_lib_pricing").PRICING_SNAPSHOT_DATE
|
|
199
200
|
|
|
201
|
+
# #195: the single construction point for every cost-feeding usage dict. Bound
|
|
202
|
+
# from the same circular-safe stdlib leaf as PRICING_SNAPSHOT_DATE above.
|
|
203
|
+
claude_usage_dict = _load_lib("_lib_pricing").claude_usage_dict
|
|
204
|
+
|
|
200
205
|
# Shared by the fused per-file walk AND backfill_conversation_messages so the
|
|
201
206
|
# column list, placeholders, and tuple order live in ONE place — a column
|
|
202
207
|
# add/reorder can't silently desync the two ingest paths (which would land
|
|
@@ -223,6 +228,129 @@ _AI_TITLE_UPSERT_SQL = (
|
|
|
223
228
|
"ai_title=excluded.ai_title, source_path=excluded.source_path, byte_offset=excluded.byte_offset"
|
|
224
229
|
)
|
|
225
230
|
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# session_entries upsert (#195: extracted from the inline string in sync_cache
|
|
233
|
+
# so the steady-state and re-walk variants share ONE body).
|
|
234
|
+
#
|
|
235
|
+
# ccusage-parity ON CONFLICT DO UPDATE: higher-token total wins on conflict;
|
|
236
|
+
# speed-set breaks ties. The partial UNIQUE index `idx_entries_dedup` restricts
|
|
237
|
+
# the conflict target to (msg_id IS NOT NULL AND req_id IS NOT NULL), so the
|
|
238
|
+
# WHERE clause on the conflict target MUST repeat that predicate verbatim —
|
|
239
|
+
# bare `ON CONFLICT(msg_id, req_id)` raises OperationalError. NULL-keyed rows
|
|
240
|
+
# fall through to a plain INSERT, unchanged.
|
|
241
|
+
#
|
|
242
|
+
# `source_path` is INTENTIONALLY OMITTED from the DO UPDATE SET clause: it
|
|
243
|
+
# stays pinned to whichever JSONL FIRST INSERTed the (msg_id, req_id) row. The
|
|
244
|
+
# downstream `LEFT JOIN session_files ON sf.path = se.source_path` uses
|
|
245
|
+
# source_path to attribute tokens to a `project_path`. If a later UPSERT from a
|
|
246
|
+
# different file flipped source_path, the row's project attribution would move
|
|
247
|
+
# with the winner — `cctally project` would mis-aggregate. Sticky source_path
|
|
248
|
+
# matches pre-dedup INSERT OR IGNORE behavior and the operator's mental model.
|
|
249
|
+
# (`line_offset` is similarly sticky for the same reason — the offset only
|
|
250
|
+
# makes sense within the file that originally wrote the row.)
|
|
251
|
+
#
|
|
252
|
+
# `account_key` is DELIBERATELY OMITTED from DO UPDATE SET too (#341,
|
|
253
|
+
# first-stamp-wins): a resumed session replaying identical bytes under a
|
|
254
|
+
# different account is the SAME message and keeps the first observed stamp.
|
|
255
|
+
_SESSION_ENTRY_SET = """
|
|
256
|
+
timestamp_utc = excluded.timestamp_utc,
|
|
257
|
+
model = excluded.model,
|
|
258
|
+
input_tokens = excluded.input_tokens,
|
|
259
|
+
output_tokens = excluded.output_tokens,
|
|
260
|
+
cache_create_tokens = excluded.cache_create_tokens,
|
|
261
|
+
cache_read_tokens = excluded.cache_read_tokens,
|
|
262
|
+
cache_create_1h_tokens = excluded.cache_create_1h_tokens,
|
|
263
|
+
cache_create_5m_tokens = excluded.cache_create_5m_tokens,
|
|
264
|
+
usage_extra_json = excluded.usage_extra_json,
|
|
265
|
+
speed = excluded.speed,
|
|
266
|
+
cost_usd_raw = excluded.cost_usd_raw,
|
|
267
|
+
-- #270: stamp the change. mutation_seq advances
|
|
268
|
+
-- exactly when this guarded UPSERT's WHERE passes
|
|
269
|
+
-- (incl. the equal-tokens speed-tiebreak branch,
|
|
270
|
+
-- Codex-2d). mutation_min_ts accumulates the
|
|
271
|
+
-- EARLIEST event time the row has held —
|
|
272
|
+
-- session_entries.mutation_min_ts is the OLD
|
|
273
|
+
-- (pre-update) value, excluded.timestamp_utc the
|
|
274
|
+
-- finalization's new time — so a finalization
|
|
275
|
+
-- that moves the row across a bucket boundary
|
|
276
|
+
-- still lets the closed-bucket watermark reach
|
|
277
|
+
-- the OLD bucket (spec §6/§7b). The SET reads
|
|
278
|
+
-- pre-update column values, unaffected by the
|
|
279
|
+
-- sibling timestamp_utc = excluded.timestamp_utc.
|
|
280
|
+
-- COALESCE(mutation_min_ts, timestamp_utc) guards
|
|
281
|
+
-- a LEGACY row (written before these columns
|
|
282
|
+
-- existed: mutation_min_ts NULL): SQLite scalar
|
|
283
|
+
-- MIN(NULL, x) is NULL, which would strand the
|
|
284
|
+
-- watermark; the pre-update timestamp_utc is that
|
|
285
|
+
-- legacy row's old event time, so both its old
|
|
286
|
+
-- and new buckets stay reachable. No-op for
|
|
287
|
+
-- non-legacy rows (mutation_min_ts already set).
|
|
288
|
+
mutation_seq = excluded.mutation_seq,
|
|
289
|
+
mutation_min_ts = MIN(COALESCE(session_entries.mutation_min_ts,
|
|
290
|
+
session_entries.timestamp_utc),
|
|
291
|
+
excluded.timestamp_utc)"""
|
|
292
|
+
|
|
293
|
+
# The third guard branch (#195) mirrors the existing `speed` tiebreak: a replay
|
|
294
|
+
# of IDENTICAL bytes has an EQUAL token sum, so without it the enrichment can
|
|
295
|
+
# never land on an existing row.
|
|
296
|
+
_SESSION_ENTRY_GUARD = """
|
|
297
|
+
WHERE
|
|
298
|
+
(excluded.input_tokens + excluded.output_tokens
|
|
299
|
+
+ excluded.cache_create_tokens + excluded.cache_read_tokens)
|
|
300
|
+
>
|
|
301
|
+
(session_entries.input_tokens + session_entries.output_tokens
|
|
302
|
+
+ session_entries.cache_create_tokens + session_entries.cache_read_tokens)
|
|
303
|
+
OR (
|
|
304
|
+
(excluded.input_tokens + excluded.output_tokens
|
|
305
|
+
+ excluded.cache_create_tokens + excluded.cache_read_tokens)
|
|
306
|
+
=
|
|
307
|
+
(session_entries.input_tokens + session_entries.output_tokens
|
|
308
|
+
+ session_entries.cache_create_tokens + session_entries.cache_read_tokens)
|
|
309
|
+
AND excluded.speed IS NOT NULL
|
|
310
|
+
AND session_entries.speed IS NULL
|
|
311
|
+
)
|
|
312
|
+
OR (
|
|
313
|
+
(excluded.input_tokens + excluded.output_tokens
|
|
314
|
+
+ excluded.cache_create_tokens + excluded.cache_read_tokens)
|
|
315
|
+
=
|
|
316
|
+
(session_entries.input_tokens + session_entries.output_tokens
|
|
317
|
+
+ session_entries.cache_create_tokens + session_entries.cache_read_tokens)
|
|
318
|
+
AND excluded.cache_create_1h_tokens IS NOT NULL
|
|
319
|
+
AND session_entries.cache_create_1h_tokens IS NULL
|
|
320
|
+
)"""
|
|
321
|
+
|
|
322
|
+
# Column order is the bind order of the tuples built in `sync_cache` (13 walk
|
|
323
|
+
# columns, then the two #195 split columns, then the three #270/#341 stamps
|
|
324
|
+
# appended by `stamped_rows`). Keep the two in lockstep.
|
|
325
|
+
_SESSION_ENTRY_HEAD = """INSERT INTO session_entries
|
|
326
|
+
(source_path, line_offset, timestamp_utc, model,
|
|
327
|
+
msg_id, req_id, input_tokens, output_tokens,
|
|
328
|
+
cache_create_tokens, cache_read_tokens,
|
|
329
|
+
usage_extra_json, speed, cost_usd_raw,
|
|
330
|
+
cache_create_1h_tokens, cache_create_5m_tokens,
|
|
331
|
+
mutation_seq, mutation_min_ts, account_key)
|
|
332
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
333
|
+
ON CONFLICT(msg_id, req_id)
|
|
334
|
+
WHERE msg_id IS NOT NULL AND req_id IS NOT NULL
|
|
335
|
+
DO UPDATE SET"""
|
|
336
|
+
|
|
337
|
+
# Steady state: ONE conflict target. A duplicate physical key stays a LOUD
|
|
338
|
+
# IntegrityError — migration 020 calls those "strictly ingest-bug artifacts"
|
|
339
|
+
# and that backstop must not be silently converted into an update.
|
|
340
|
+
SESSION_ENTRY_UPSERT_SQL = _SESSION_ENTRY_HEAD + _SESSION_ENTRY_SET + _SESSION_ENTRY_GUARD
|
|
341
|
+
|
|
342
|
+
# Re-walk only (#195 migration 030): rows are NOT wiped first, so a row the
|
|
343
|
+
# partial dedup index does not cover (NULL msg_id and/or req_id) collides on
|
|
344
|
+
# idx_entries_physical instead. SQLite does not route that through the first
|
|
345
|
+
# target's handler, so it needs its own clause or the whole per-file
|
|
346
|
+
# transaction rolls back and that file is silently skipped forever.
|
|
347
|
+
SESSION_ENTRY_UPSERT_SQL_REWALK = (
|
|
348
|
+
SESSION_ENTRY_UPSERT_SQL
|
|
349
|
+
+ """
|
|
350
|
+
ON CONFLICT(source_path, line_offset)
|
|
351
|
+
DO UPDATE SET"""
|
|
352
|
+
+ _SESSION_ENTRY_SET + _SESSION_ENTRY_GUARD)
|
|
353
|
+
|
|
226
354
|
|
|
227
355
|
def _conv_row_tuple(m, path_str):
|
|
228
356
|
"""Flatten a ``MessageRow`` into the ``_CONV_INSERT_SQL`` column order.
|
|
@@ -1714,6 +1842,15 @@ def _ensure_session_files_row(conn: sqlite3.Connection, source_path: str) -> Non
|
|
|
1714
1842
|
# Read at call time in cmd_cache_sync so tests can monkeypatch it low.
|
|
1715
1843
|
_REBUILD_LOCK_TIMEOUT_SECONDS = 30.0
|
|
1716
1844
|
|
|
1845
|
+
# #395: an explicit transcript rebuild runs each provider in a disposable child
|
|
1846
|
+
# process. A provider phase may legitimately be large, so the production bound
|
|
1847
|
+
# measures time without a phase/file progress event, not total wall time. The
|
|
1848
|
+
# important contract is that a truly stuck phase is finite and process-level
|
|
1849
|
+
# (SQLite/Python work is never unsafely cancelled in the parent). Tests patch
|
|
1850
|
+
# this module constant to exercise the real timeout path quickly.
|
|
1851
|
+
_TRANSCRIPT_REBUILD_PHASE_TIMEOUT_SECONDS = 30.0 * 60.0
|
|
1852
|
+
_TRANSCRIPT_REBUILD_KILL_GRACE_SECONDS = 1.0
|
|
1853
|
+
|
|
1717
1854
|
|
|
1718
1855
|
# Orphan-warning throttle: warn only when the detected orphan set CHANGES,
|
|
1719
1856
|
# so a long-lived dashboard doesn't re-spam the "[cache] N tracked file(s) no
|
|
@@ -2604,6 +2741,19 @@ def sync_cache(
|
|
|
2604
2741
|
# "walk" phase (never per-row — Section 2 rule: volume is a count, not
|
|
2605
2742
|
# N timed phases). Opened via the context-manager protocol so the hot
|
|
2606
2743
|
# loop body below is not reindented; counts recorded after the loop.
|
|
2744
|
+
# #195: is the cache-write-split re-walk armed? Computed ONCE per
|
|
2745
|
+
# sync_cache call, before the file loop, so every file in this walk uses
|
|
2746
|
+
# one statement. Cache migration 030 sets this flag and zeroes the
|
|
2747
|
+
# per-file cursors; the end-of-walk block below clears it after a clean,
|
|
2748
|
+
# non-targeted full walk. While armed, the chained-conflict variant is
|
|
2749
|
+
# used so a replayed NULL-key row updates in place instead of raising
|
|
2750
|
+
# IntegrityError and rolling back its whole file. Scoped to THIS flag
|
|
2751
|
+
# (not marker-absence) so migration 020's loud duplicate-physical-key
|
|
2752
|
+
# backstop stays intact on every other ingest path.
|
|
2753
|
+
rewalk_armed = conn.execute(
|
|
2754
|
+
"SELECT 1 FROM cache_meta WHERE key=?",
|
|
2755
|
+
(_cctally_db_sib.CACHE_CREATION_SPLIT_REWALK_KEY,),
|
|
2756
|
+
).fetchone() is not None
|
|
2607
2757
|
_p_walk = _perf.phase("walk")
|
|
2608
2758
|
_p_walk.__enter__()
|
|
2609
2759
|
for jp in paths:
|
|
@@ -2679,6 +2829,13 @@ def sync_cache(
|
|
|
2679
2829
|
# serializing the deeply-nested blob the read paths
|
|
2680
2830
|
# used to json.loads per row.
|
|
2681
2831
|
speed = usage.get("speed")
|
|
2832
|
+
# #195: the cache-write TTL split, normalized out of
|
|
2833
|
+
# the nested `usage.cache_creation` by
|
|
2834
|
+
# `_classify_cost_entry`. Absent keys stay None,
|
|
2835
|
+
# which stores NULL — the "split unknown" sentinel
|
|
2836
|
+
# the pricing kernel branches on.
|
|
2837
|
+
h = usage.get("cache_creation_1h_input_tokens")
|
|
2838
|
+
m = usage.get("cache_creation_5m_input_tokens")
|
|
2682
2839
|
rows.append((
|
|
2683
2840
|
path_str,
|
|
2684
2841
|
offset,
|
|
@@ -2690,6 +2847,7 @@ def sync_cache(
|
|
|
2690
2847
|
None, # usage_extra_json — bloat no longer written (#181)
|
|
2691
2848
|
speed, # materialized speed column
|
|
2692
2849
|
entry.cost_usd,
|
|
2850
|
+
h, m, # #195 cache-write TTL split
|
|
2693
2851
|
))
|
|
2694
2852
|
if mrow is not None:
|
|
2695
2853
|
conv_rows.append(_conv_row_tuple(mrow, path_str))
|
|
@@ -2770,68 +2928,14 @@ def sync_cache(
|
|
|
2770
2928
|
# (`line_offset` is similarly sticky for the same
|
|
2771
2929
|
# reason — the offset only makes sense within the
|
|
2772
2930
|
# file that originally wrote the row.)
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
ON CONFLICT(msg_id, req_id)
|
|
2782
|
-
WHERE msg_id IS NOT NULL AND req_id IS NOT NULL
|
|
2783
|
-
DO UPDATE SET
|
|
2784
|
-
timestamp_utc = excluded.timestamp_utc,
|
|
2785
|
-
model = excluded.model,
|
|
2786
|
-
input_tokens = excluded.input_tokens,
|
|
2787
|
-
output_tokens = excluded.output_tokens,
|
|
2788
|
-
cache_create_tokens = excluded.cache_create_tokens,
|
|
2789
|
-
cache_read_tokens = excluded.cache_read_tokens,
|
|
2790
|
-
usage_extra_json = excluded.usage_extra_json,
|
|
2791
|
-
speed = excluded.speed,
|
|
2792
|
-
cost_usd_raw = excluded.cost_usd_raw,
|
|
2793
|
-
-- #270: stamp the change. mutation_seq advances
|
|
2794
|
-
-- exactly when this guarded UPSERT's WHERE passes
|
|
2795
|
-
-- (incl. the equal-tokens speed-tiebreak branch,
|
|
2796
|
-
-- Codex-2d). mutation_min_ts accumulates the
|
|
2797
|
-
-- EARLIEST event time the row has held —
|
|
2798
|
-
-- session_entries.mutation_min_ts is the OLD
|
|
2799
|
-
-- (pre-update) value, excluded.timestamp_utc the
|
|
2800
|
-
-- finalization's new time — so a finalization
|
|
2801
|
-
-- that moves the row across a bucket boundary
|
|
2802
|
-
-- still lets the closed-bucket watermark reach
|
|
2803
|
-
-- the OLD bucket (spec §6/§7b). The SET reads
|
|
2804
|
-
-- pre-update column values, unaffected by the
|
|
2805
|
-
-- sibling timestamp_utc = excluded.timestamp_utc.
|
|
2806
|
-
-- COALESCE(mutation_min_ts, timestamp_utc) guards
|
|
2807
|
-
-- a LEGACY row (written before these columns
|
|
2808
|
-
-- existed: mutation_min_ts NULL): SQLite scalar
|
|
2809
|
-
-- MIN(NULL, x) is NULL, which would strand the
|
|
2810
|
-
-- watermark; the pre-update timestamp_utc is that
|
|
2811
|
-
-- legacy row's old event time, so both its old
|
|
2812
|
-
-- and new buckets stay reachable. No-op for
|
|
2813
|
-
-- non-legacy rows (mutation_min_ts already set).
|
|
2814
|
-
mutation_seq = excluded.mutation_seq,
|
|
2815
|
-
mutation_min_ts = MIN(COALESCE(session_entries.mutation_min_ts,
|
|
2816
|
-
session_entries.timestamp_utc),
|
|
2817
|
-
excluded.timestamp_utc)
|
|
2818
|
-
WHERE
|
|
2819
|
-
(excluded.input_tokens + excluded.output_tokens
|
|
2820
|
-
+ excluded.cache_create_tokens + excluded.cache_read_tokens)
|
|
2821
|
-
>
|
|
2822
|
-
(session_entries.input_tokens + session_entries.output_tokens
|
|
2823
|
-
+ session_entries.cache_create_tokens + session_entries.cache_read_tokens)
|
|
2824
|
-
OR (
|
|
2825
|
-
(excluded.input_tokens + excluded.output_tokens
|
|
2826
|
-
+ excluded.cache_create_tokens + excluded.cache_read_tokens)
|
|
2827
|
-
=
|
|
2828
|
-
(session_entries.input_tokens + session_entries.output_tokens
|
|
2829
|
-
+ session_entries.cache_create_tokens + session_entries.cache_read_tokens)
|
|
2830
|
-
AND excluded.speed IS NOT NULL
|
|
2831
|
-
AND session_entries.speed IS NULL
|
|
2832
|
-
)""",
|
|
2833
|
-
stamped_rows,
|
|
2834
|
-
)
|
|
2931
|
+
# #195: while the re-walk is armed, rows are NOT wiped
|
|
2932
|
+
# first, so a row the partial dedup index does not cover
|
|
2933
|
+
# collides on idx_entries_physical and would roll back the
|
|
2934
|
+
# whole per-file transaction. Steady state keeps the
|
|
2935
|
+
# single-target SQL and its LOUD physical-key backstop.
|
|
2936
|
+
_sql = (SESSION_ENTRY_UPSERT_SQL_REWALK if rewalk_armed
|
|
2937
|
+
else SESSION_ENTRY_UPSERT_SQL)
|
|
2938
|
+
conn.executemany(_sql, stamped_rows)
|
|
2835
2939
|
stats.rows_changed += conn.total_changes - before
|
|
2836
2940
|
# Conversation message ingest (Plan 1). Lands in the SAME
|
|
2837
2941
|
# per-file write transaction as session_entries so the cost
|
|
@@ -2964,6 +3068,14 @@ def sync_cache(
|
|
|
2964
3068
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
2965
3069
|
(dt.datetime.now(dt.timezone.utc).isoformat(),),
|
|
2966
3070
|
)
|
|
3071
|
+
# #195: the same clean-full-walk condition retires the split
|
|
3072
|
+
# re-walk arming flag, so steady state goes back to the
|
|
3073
|
+
# single-target UPSERT and its loud physical-key backstop. An
|
|
3074
|
+
# unclean or targeted walk leaves it armed and retries next time.
|
|
3075
|
+
conn.execute(
|
|
3076
|
+
"DELETE FROM cache_meta WHERE key=?",
|
|
3077
|
+
(_cctally_db_sib.CACHE_CREATION_SPLIT_REWALK_KEY,),
|
|
3078
|
+
)
|
|
2967
3079
|
conn.commit()
|
|
2968
3080
|
# #279 S2 F1: rolling parse-health record. Anomaly-delta-gated so
|
|
2969
3081
|
# steady-state (incl. targeted live-tail) syncs stay zero-write;
|
|
@@ -3664,7 +3776,7 @@ def iter_entries(
|
|
|
3664
3776
|
sql = (
|
|
3665
3777
|
"SELECT timestamp_utc, model, input_tokens, output_tokens, "
|
|
3666
3778
|
"cache_create_tokens, cache_read_tokens, speed, "
|
|
3667
|
-
"cost_usd_raw, source_path "
|
|
3779
|
+
"cost_usd_raw, source_path, cache_create_1h_tokens "
|
|
3668
3780
|
"FROM session_entries "
|
|
3669
3781
|
"WHERE timestamp_utc >= ? AND timestamp_utc <= ?"
|
|
3670
3782
|
)
|
|
@@ -3698,18 +3810,16 @@ def iter_entries(
|
|
|
3698
3810
|
|
|
3699
3811
|
entries: list[UsageEntry] = []
|
|
3700
3812
|
for row in conn.execute(sql, params):
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
if row[6] is not None:
|
|
3712
|
-
usage["speed"] = row[6]
|
|
3813
|
+
# #195: one construction point for every cost-feeding usage dict.
|
|
3814
|
+
# `cache_1h_tokens` is a REQUIRED keyword — see claude_usage_dict.
|
|
3815
|
+
usage: dict[str, Any] = claude_usage_dict(
|
|
3816
|
+
input_tokens=row[2],
|
|
3817
|
+
output_tokens=row[3],
|
|
3818
|
+
cache_creation_tokens=row[4],
|
|
3819
|
+
cache_read_tokens=row[5],
|
|
3820
|
+
cache_1h_tokens=row[9],
|
|
3821
|
+
speed=row[6],
|
|
3822
|
+
)
|
|
3713
3823
|
entries.append(UsageEntry(
|
|
3714
3824
|
timestamp=dt.datetime.fromisoformat(row[0]),
|
|
3715
3825
|
model=row[1],
|
|
@@ -3753,7 +3863,8 @@ def iter_entries_with_id(
|
|
|
3753
3863
|
end_iso = range_end.astimezone(dt.timezone.utc).isoformat()
|
|
3754
3864
|
sql = (
|
|
3755
3865
|
"SELECT id, timestamp_utc, model, input_tokens, output_tokens, "
|
|
3756
|
-
"cache_create_tokens, cache_read_tokens, speed, cost_usd_raw, source_path "
|
|
3866
|
+
"cache_create_tokens, cache_read_tokens, speed, cost_usd_raw, source_path, "
|
|
3867
|
+
"cache_create_1h_tokens "
|
|
3757
3868
|
"FROM session_entries "
|
|
3758
3869
|
"WHERE timestamp_utc >= ? AND timestamp_utc <= ?"
|
|
3759
3870
|
)
|
|
@@ -3770,14 +3881,14 @@ def iter_entries_with_id(
|
|
|
3770
3881
|
|
|
3771
3882
|
out: list[tuple[int, UsageEntry]] = []
|
|
3772
3883
|
for row in conn.execute(sql, params):
|
|
3773
|
-
usage: dict[str, Any] =
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3884
|
+
usage: dict[str, Any] = claude_usage_dict( # #195 chokepoint
|
|
3885
|
+
input_tokens=row[3],
|
|
3886
|
+
output_tokens=row[4],
|
|
3887
|
+
cache_creation_tokens=row[5],
|
|
3888
|
+
cache_read_tokens=row[6],
|
|
3889
|
+
cache_1h_tokens=row[10],
|
|
3890
|
+
speed=row[7],
|
|
3891
|
+
)
|
|
3781
3892
|
out.append((row[0], UsageEntry(
|
|
3782
3893
|
timestamp=dt.datetime.fromisoformat(row[1]),
|
|
3783
3894
|
model=row[2],
|
|
@@ -3870,6 +3981,18 @@ class _JoinedClaudeEntry:
|
|
|
3870
3981
|
# them (else `daily -i`/`-p` lose fast-tier model labels). None when
|
|
3871
3982
|
# the row has no extras.
|
|
3872
3983
|
usage_extra: dict | None = None
|
|
3984
|
+
# #195: the 1-hour portion of `cache_creation_tokens`, or None when the
|
|
3985
|
+
# split is unknown (a pre-#195 cache row, or a JSONL entry with no nested
|
|
3986
|
+
# `cache_creation` breakdown). None is the sentinel the pricing kernel
|
|
3987
|
+
# branches on to reproduce pre-#195 behavior byte-identically.
|
|
3988
|
+
cache_1h_tokens: int | None = None
|
|
3989
|
+
|
|
3990
|
+
@property
|
|
3991
|
+
def speed(self):
|
|
3992
|
+
"""Authoritative effective tier retained from ``message.usage.speed``."""
|
|
3993
|
+
if self.usage_extra is None:
|
|
3994
|
+
return None
|
|
3995
|
+
return self.usage_extra.get("speed")
|
|
3873
3996
|
|
|
3874
3997
|
|
|
3875
3998
|
def get_claude_session_entries(
|
|
@@ -3911,7 +4034,9 @@ def get_claude_session_entries(
|
|
|
3911
4034
|
|
|
3912
4035
|
if not skip_sync:
|
|
3913
4036
|
stats, conn = _run_cache_operation_with_recovery(
|
|
3914
|
-
conn,
|
|
4037
|
+
conn,
|
|
4038
|
+
lambda active_conn: sync_cache(active_conn),
|
|
4039
|
+
origin="claude.session_entries.sync",
|
|
3915
4040
|
)
|
|
3916
4041
|
if stats.lock_contended:
|
|
3917
4042
|
# Partial cache window: a concurrent ingest may have committed some
|
|
@@ -3919,6 +4044,7 @@ def get_claude_session_entries(
|
|
|
3919
4044
|
# JSONL parse — same rationale as `get_entries`.
|
|
3920
4045
|
# #341: fail closed on an account-scoped read — the direct-JSONL
|
|
3921
4046
|
# fallback carries no account identity (exit 3, not a mislabel).
|
|
4047
|
+
conn.close()
|
|
3922
4048
|
_guard_account_attribution(account_key, "concurrent ingest")
|
|
3923
4049
|
eprint(
|
|
3924
4050
|
"[cache] concurrent ingest in progress; "
|
|
@@ -3938,7 +4064,7 @@ def get_claude_session_entries(
|
|
|
3938
4064
|
" se.cache_create_tokens, se.cache_read_tokens, "
|
|
3939
4065
|
" se.source_path, "
|
|
3940
4066
|
" sf.session_id, sf.project_path, "
|
|
3941
|
-
" se.cost_usd_raw, se.speed "
|
|
4067
|
+
" se.cost_usd_raw, se.speed, se.cache_create_1h_tokens "
|
|
3942
4068
|
"FROM session_entries se "
|
|
3943
4069
|
"LEFT JOIN session_files sf ON sf.path = se.source_path "
|
|
3944
4070
|
"WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ?"
|
|
@@ -3972,7 +4098,10 @@ def get_claude_session_entries(
|
|
|
3972
4098
|
# which plan SQLite picks for either window.
|
|
3973
4099
|
sql += " ORDER BY se.timestamp_utc ASC, se.id ASC"
|
|
3974
4100
|
|
|
3975
|
-
|
|
4101
|
+
try:
|
|
4102
|
+
rows = conn.execute(sql, params).fetchall()
|
|
4103
|
+
finally:
|
|
4104
|
+
conn.close()
|
|
3976
4105
|
|
|
3977
4106
|
return [
|
|
3978
4107
|
_JoinedClaudeEntry(
|
|
@@ -3990,6 +4119,9 @@ def get_claude_session_entries(
|
|
|
3990
4119
|
# {"speed": …} shape _usage_entry_from_joined already merges, with
|
|
3991
4120
|
# zero JSON parsing. `is not None` so an empty-string speed surfaces.
|
|
3992
4121
|
usage_extra=({"speed": row[10]} if row[10] is not None else None),
|
|
4122
|
+
# #195: NULL == split unknown; carried through so the pricing
|
|
4123
|
+
# kernel can price the 1h portion at 2x base input.
|
|
4124
|
+
cache_1h_tokens=row[11],
|
|
3993
4125
|
)
|
|
3994
4126
|
for row in rows
|
|
3995
4127
|
]
|
|
@@ -4104,6 +4236,9 @@ def _direct_parse_claude_session_entries(
|
|
|
4104
4236
|
_token_keys = {
|
|
4105
4237
|
"input_tokens", "output_tokens",
|
|
4106
4238
|
"cache_creation_input_tokens", "cache_read_input_tokens",
|
|
4239
|
+
# #195: the normalized TTL split rides its own dataclass field and its
|
|
4240
|
+
# own columns, so it must NOT double-ride into usage_extra.
|
|
4241
|
+
"cache_creation_1h_input_tokens", "cache_creation_5m_input_tokens",
|
|
4107
4242
|
}
|
|
4108
4243
|
for entry, source_path in flat:
|
|
4109
4244
|
usage = entry.usage
|
|
@@ -4127,6 +4262,7 @@ def _direct_parse_claude_session_entries(
|
|
|
4127
4262
|
project_path=cwd,
|
|
4128
4263
|
cost_usd=entry.cost_usd,
|
|
4129
4264
|
usage_extra=(extras or None),
|
|
4265
|
+
cache_1h_tokens=usage.get("cache_creation_1h_input_tokens"),
|
|
4130
4266
|
))
|
|
4131
4267
|
|
|
4132
4268
|
return results
|
|
@@ -4966,7 +5102,9 @@ def get_codex_entries(
|
|
|
4966
5102
|
# classified corruption closes the handle, quarantines once, and
|
|
4967
5103
|
# restarts on a fresh family (reassigning `conn`, closed in finally).
|
|
4968
5104
|
stats, conn = _run_cache_operation_with_recovery(
|
|
4969
|
-
conn,
|
|
5105
|
+
conn,
|
|
5106
|
+
lambda active_conn: sync_codex_cache(active_conn),
|
|
5107
|
+
origin="codex.entries.sync",
|
|
4970
5108
|
)
|
|
4971
5109
|
if stats.lock_contended:
|
|
4972
5110
|
# Sync commits file-by-file, so contention on the ingest lock
|
|
@@ -5086,7 +5224,9 @@ def get_entries(
|
|
|
5086
5224
|
try:
|
|
5087
5225
|
if not skip_sync:
|
|
5088
5226
|
stats, conn = _run_cache_operation_with_recovery(
|
|
5089
|
-
conn,
|
|
5227
|
+
conn,
|
|
5228
|
+
lambda active_conn: sync_cache(active_conn),
|
|
5229
|
+
origin="claude.entries.sync",
|
|
5090
5230
|
)
|
|
5091
5231
|
if stats.lock_contended:
|
|
5092
5232
|
# Sync commits file-by-file, so contention on the ingest lock
|
|
@@ -5108,10 +5248,7 @@ def get_entries(
|
|
|
5108
5248
|
return iter_entries(
|
|
5109
5249
|
conn, range_start, range_end, project=project, account_key=account_key)
|
|
5110
5250
|
finally:
|
|
5111
|
-
|
|
5112
|
-
conn.close()
|
|
5113
|
-
except Exception:
|
|
5114
|
-
pass
|
|
5251
|
+
conn.close()
|
|
5115
5252
|
|
|
5116
5253
|
|
|
5117
5254
|
def _harden_cache_sidecars() -> None:
|
|
@@ -5229,12 +5366,22 @@ def _cache_open_guarded() -> sqlite3.Connection:
|
|
|
5229
5366
|
f"cache.db maintenance is in progress ({marker})"
|
|
5230
5367
|
)
|
|
5231
5368
|
return conn
|
|
5232
|
-
except Exception:
|
|
5369
|
+
except Exception as exc:
|
|
5233
5370
|
if conn is not None:
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5371
|
+
if (
|
|
5372
|
+
isinstance(exc, sqlite3.DatabaseError)
|
|
5373
|
+
and _cctally_db_sib._is_sqlite_corruption_error(exc)
|
|
5374
|
+
):
|
|
5375
|
+
# Keep the triggering handle alive until recovery owns
|
|
5376
|
+
# marker + maintenance-EX. Closing it here would run
|
|
5377
|
+
# SQLite's last-close checkpoint before that boundary.
|
|
5378
|
+
setattr(exc, "_cctally_cache_connection", conn)
|
|
5379
|
+
conn = None
|
|
5380
|
+
else:
|
|
5381
|
+
try:
|
|
5382
|
+
conn.close()
|
|
5383
|
+
except Exception:
|
|
5384
|
+
pass
|
|
5238
5385
|
raise
|
|
5239
5386
|
finally:
|
|
5240
5387
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
@@ -5245,34 +5392,203 @@ def _cache_open_guarded() -> sqlite3.Connection:
|
|
|
5245
5392
|
lock_fh.close()
|
|
5246
5393
|
|
|
5247
5394
|
|
|
5248
|
-
def
|
|
5395
|
+
def _set_cache_no_checkpoint_on_close(
|
|
5396
|
+
conn: sqlite3.Connection, disabled: bool,
|
|
5397
|
+
) -> None:
|
|
5398
|
+
"""Set SQLite's per-connection checkpoint-on-close policy.
|
|
5399
|
+
|
|
5400
|
+
Python 3.12 added ``Connection.setconfig``. cctally still supports 3.11,
|
|
5401
|
+
so CPython 3.11 reaches the same SQLite API through its supported-version
|
|
5402
|
+
``pysqlite_Connection`` layout and the stdlib extension's linked SQLite
|
|
5403
|
+
symbol. This helper is reached only after a classified cache failure;
|
|
5404
|
+
ordinary opens never depend on the implementation-specific adapter.
|
|
5405
|
+
"""
|
|
5406
|
+
option = getattr(sqlite3, "SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE", None)
|
|
5407
|
+
setconfig = getattr(conn, "setconfig", None)
|
|
5408
|
+
if option is not None and setconfig is not None:
|
|
5409
|
+
setconfig(option, bool(disabled))
|
|
5410
|
+
return
|
|
5411
|
+
|
|
5412
|
+
_set_cache_no_checkpoint_on_close_cpython(conn, disabled)
|
|
5413
|
+
|
|
5414
|
+
|
|
5415
|
+
def _set_cache_no_checkpoint_on_close_cpython(
|
|
5416
|
+
conn: sqlite3.Connection, disabled: bool,
|
|
5417
|
+
) -> None:
|
|
5418
|
+
"""Python 3.11 compatibility adapter for sqlite3_db_config()."""
|
|
5419
|
+
if sys.implementation.name != "cpython":
|
|
5420
|
+
raise sqlite3.NotSupportedError(
|
|
5421
|
+
"cache recovery requires SQLite no-checkpoint-on-close support"
|
|
5422
|
+
)
|
|
5423
|
+
|
|
5424
|
+
# CPython 3.11's public sqlite3 module does not expose db_config(), but its
|
|
5425
|
+
# connection layout begins with PyObject_HEAD followed by ``sqlite3 *db``.
|
|
5426
|
+
# The layout and audit-visible handle are defined by Modules/_sqlite in
|
|
5427
|
+
# every supported CPython release. Load sqlite3_db_config from the same
|
|
5428
|
+
# extension dependency so we never bind a different SQLite instance.
|
|
5429
|
+
import _sqlite3
|
|
5430
|
+
import ctypes
|
|
5431
|
+
|
|
5432
|
+
sqlite_lib = ctypes.CDLL(_sqlite3.__file__)
|
|
5433
|
+
db_config = sqlite_lib.sqlite3_db_config
|
|
5434
|
+
db_config.argtypes = (ctypes.c_void_p, ctypes.c_int)
|
|
5435
|
+
db_config.restype = ctypes.c_int
|
|
5436
|
+
pointer_size = ctypes.sizeof(ctypes.c_void_p)
|
|
5437
|
+
db_pointer = ctypes.c_void_p.from_address(
|
|
5438
|
+
id(conn) + (2 * pointer_size)
|
|
5439
|
+
).value
|
|
5440
|
+
if not db_pointer:
|
|
5441
|
+
raise sqlite3.NotSupportedError(
|
|
5442
|
+
"cache recovery could not resolve the SQLite connection handle"
|
|
5443
|
+
)
|
|
5444
|
+
current = ctypes.c_int()
|
|
5445
|
+
rc = db_config(
|
|
5446
|
+
ctypes.c_void_p(db_pointer),
|
|
5447
|
+
1006, # SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE
|
|
5448
|
+
ctypes.c_int(1 if disabled else 0),
|
|
5449
|
+
ctypes.byref(current),
|
|
5450
|
+
)
|
|
5451
|
+
if rc != sqlite3.SQLITE_OK or current.value != int(bool(disabled)):
|
|
5452
|
+
raise sqlite3.NotSupportedError(
|
|
5453
|
+
"cache recovery could not configure SQLite close checkpointing"
|
|
5454
|
+
)
|
|
5455
|
+
|
|
5456
|
+
|
|
5457
|
+
@dataclass(frozen=True)
|
|
5458
|
+
class _CacheShmSnapshot:
|
|
5459
|
+
existed: bool
|
|
5460
|
+
data: bytes
|
|
5461
|
+
|
|
5462
|
+
|
|
5463
|
+
def _capture_cache_shm_snapshot(db_path: pathlib.Path) -> _CacheShmSnapshot:
|
|
5464
|
+
shm = pathlib.Path(f"{db_path}-shm")
|
|
5465
|
+
try:
|
|
5466
|
+
return _CacheShmSnapshot(existed=True, data=shm.read_bytes())
|
|
5467
|
+
except FileNotFoundError:
|
|
5468
|
+
return _CacheShmSnapshot(existed=False, data=b"")
|
|
5469
|
+
|
|
5470
|
+
|
|
5471
|
+
def _restore_cache_shm_snapshot(
|
|
5472
|
+
db_path: pathlib.Path, snapshot: _CacheShmSnapshot,
|
|
5473
|
+
) -> None:
|
|
5474
|
+
"""Undo read-mark changes made by the locked read-only probe.
|
|
5475
|
+
|
|
5476
|
+
The WAL index is transient, but Task A's preservation contract is stronger:
|
|
5477
|
+
a declined heal retains all three family members byte-for-byte. With every
|
|
5478
|
+
SQLite handle drained under maintenance-exclusive, restoring the exact
|
|
5479
|
+
pre-probe SHM bytes is safe and preserves its inode when it already existed.
|
|
5480
|
+
"""
|
|
5481
|
+
shm = pathlib.Path(f"{db_path}-shm")
|
|
5482
|
+
if not snapshot.existed:
|
|
5483
|
+
try:
|
|
5484
|
+
shm.unlink()
|
|
5485
|
+
except FileNotFoundError:
|
|
5486
|
+
pass
|
|
5487
|
+
return
|
|
5488
|
+
with shm.open("r+b") as fh:
|
|
5489
|
+
fh.seek(0)
|
|
5490
|
+
fh.write(snapshot.data)
|
|
5491
|
+
fh.truncate()
|
|
5492
|
+
fh.flush()
|
|
5493
|
+
os.fsync(fh.fileno())
|
|
5494
|
+
|
|
5495
|
+
|
|
5496
|
+
def _close_cache_trigger_connection(
|
|
5497
|
+
conn: sqlite3.Connection, db_path: pathlib.Path,
|
|
5498
|
+
) -> None:
|
|
5499
|
+
"""Drain the triggering handle without a last-close checkpoint.
|
|
5500
|
+
|
|
5501
|
+
Modern Python and CPython 3.11 use SQLite's native db_config option. An
|
|
5502
|
+
alternate Python 3.11 implementation falls back to a short-lived read-only
|
|
5503
|
+
keeper: with another connection present, closing the trigger is not the
|
|
5504
|
+
last read/write close. The keeper may update transient SHM read marks, so
|
|
5505
|
+
their exact pre-keeper bytes are restored while maintenance-EX excludes
|
|
5506
|
+
every other cache opener.
|
|
5507
|
+
"""
|
|
5508
|
+
try:
|
|
5509
|
+
_set_cache_no_checkpoint_on_close(conn, True)
|
|
5510
|
+
except sqlite3.NotSupportedError:
|
|
5511
|
+
snapshot = _capture_cache_shm_snapshot(db_path)
|
|
5512
|
+
keeper = None
|
|
5513
|
+
try:
|
|
5514
|
+
keeper = sqlite3.connect(
|
|
5515
|
+
db_path.resolve().as_uri() + "?mode=ro", uri=True,
|
|
5516
|
+
)
|
|
5517
|
+
keeper.execute("PRAGMA schema_version").fetchone()
|
|
5518
|
+
conn.close()
|
|
5519
|
+
finally:
|
|
5520
|
+
if keeper is not None:
|
|
5521
|
+
keeper.close()
|
|
5522
|
+
_restore_cache_shm_snapshot(db_path, snapshot)
|
|
5523
|
+
else:
|
|
5524
|
+
conn.close()
|
|
5525
|
+
|
|
5526
|
+
|
|
5527
|
+
def _close_cache_trigger_connection_best_effort(
|
|
5528
|
+
conn: sqlite3.Connection,
|
|
5529
|
+
) -> None:
|
|
5530
|
+
"""Close an unclaimed trigger handle without enabling destructive recovery."""
|
|
5531
|
+
try:
|
|
5532
|
+
_set_cache_no_checkpoint_on_close(conn, True)
|
|
5533
|
+
except Exception:
|
|
5534
|
+
pass
|
|
5535
|
+
try:
|
|
5536
|
+
conn.close()
|
|
5537
|
+
except Exception:
|
|
5538
|
+
pass
|
|
5539
|
+
|
|
5540
|
+
|
|
5541
|
+
def _recover_corrupt_cache(
|
|
5542
|
+
exc: sqlite3.DatabaseError,
|
|
5543
|
+
*,
|
|
5544
|
+
origin: str,
|
|
5545
|
+
active_conn: sqlite3.Connection | None = None,
|
|
5546
|
+
) -> bool:
|
|
5249
5547
|
"""Quarantine a corrupt cache family only after every reader has drained.
|
|
5250
5548
|
|
|
5251
|
-
Returns True after a
|
|
5252
|
-
|
|
5253
|
-
|
|
5549
|
+
Returns True only after a locked forensics probe confirms corruption and
|
|
5550
|
+
whole-family quarantine completes, so the caller may create a fresh
|
|
5551
|
+
re-derivable cache. An unconfirmed trigger returns False after preserving
|
|
5552
|
+
the family and emitting its incident path; the caller then propagates the
|
|
5553
|
+
original exception through its established direct-JSONL/error fallback.
|
|
5554
|
+
Raises a guided DatabaseError when recovery cannot prove exclusivity.
|
|
5254
5555
|
"""
|
|
5255
5556
|
if not _cctally_db_sib._is_sqlite_corruption_error(exc):
|
|
5256
5557
|
return False
|
|
5558
|
+
if not origin.strip():
|
|
5559
|
+
raise ValueError("cache recovery origin must be non-empty")
|
|
5257
5560
|
|
|
5258
5561
|
path = pathlib.Path(_cctally_core.CACHE_DB_PATH)
|
|
5259
5562
|
try:
|
|
5260
5563
|
claim, reason = _cctally_db_sib._claim_repair_marker(path)
|
|
5261
5564
|
except OSError as marker_exc:
|
|
5565
|
+
if active_conn is not None:
|
|
5566
|
+
_close_cache_trigger_connection_best_effort(active_conn)
|
|
5262
5567
|
raise sqlite3.DatabaseError(
|
|
5263
5568
|
f"cache.db recovery could not claim maintenance: {marker_exc}"
|
|
5264
5569
|
) from exc
|
|
5265
5570
|
if claim is None:
|
|
5571
|
+
if active_conn is not None:
|
|
5572
|
+
_close_cache_trigger_connection_best_effort(active_conn)
|
|
5266
5573
|
raise sqlite3.DatabaseError(
|
|
5267
5574
|
f"cache.db maintenance is in progress: {reason}"
|
|
5268
5575
|
) from exc
|
|
5269
5576
|
_cache_storm_test_pause("cache_repair_claimed")
|
|
5270
5577
|
|
|
5271
5578
|
lock_path = pathlib.Path(_cctally_core.CACHE_LOCK_MAINTENANCE_PATH)
|
|
5272
|
-
|
|
5273
|
-
|
|
5579
|
+
try:
|
|
5580
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
5581
|
+
lock_fh = open(lock_path, "a+")
|
|
5582
|
+
except OSError:
|
|
5583
|
+
if active_conn is not None:
|
|
5584
|
+
_close_cache_trigger_connection_best_effort(active_conn)
|
|
5585
|
+
_cctally_db_sib._release_repair_marker(path, claim)
|
|
5586
|
+
raise
|
|
5274
5587
|
try:
|
|
5275
5588
|
fcntl.flock(lock_fh, fcntl.LOCK_EX)
|
|
5589
|
+
if active_conn is not None:
|
|
5590
|
+
_close_cache_trigger_connection(active_conn, path)
|
|
5591
|
+
active_conn = None
|
|
5276
5592
|
open_pids = _cctally_db_sib._db_family_open_pids(path)
|
|
5277
5593
|
if open_pids is None:
|
|
5278
5594
|
raise sqlite3.DatabaseError(
|
|
@@ -5286,8 +5602,54 @@ def _recover_corrupt_cache(exc: sqlite3.DatabaseError) -> bool:
|
|
|
5286
5602
|
+ "; leaving the live family untouched"
|
|
5287
5603
|
) from exc
|
|
5288
5604
|
|
|
5289
|
-
|
|
5605
|
+
# Capture only after marker + maintenance-EX + handle drain. A writer
|
|
5606
|
+
# that completed before the marker is legitimate current state; taking
|
|
5607
|
+
# this snapshot earlier and restoring it after the probe would overwrite
|
|
5608
|
+
# that writer's newer WAL index.
|
|
5609
|
+
shm_snapshot = _capture_cache_shm_snapshot(path)
|
|
5610
|
+
try:
|
|
5611
|
+
forensics = _cctally_db_sib.write_corruption_forensics(
|
|
5612
|
+
path,
|
|
5613
|
+
db_label="cache",
|
|
5614
|
+
trigger_origin=origin,
|
|
5615
|
+
trigger_exception=exc,
|
|
5616
|
+
return_result=True,
|
|
5617
|
+
)
|
|
5618
|
+
except Exception as forensics_exc:
|
|
5619
|
+
if shm_snapshot is not None:
|
|
5620
|
+
_restore_cache_shm_snapshot(path, shm_snapshot)
|
|
5621
|
+
eprint(
|
|
5622
|
+
"[cache] destructive recovery declined for classified trigger "
|
|
5623
|
+
f"at {origin}: forensics was unavailable "
|
|
5624
|
+
f"({forensics_exc}; forensics: unavailable); leaving the "
|
|
5625
|
+
"cache.db file family untouched"
|
|
5626
|
+
)
|
|
5627
|
+
return False
|
|
5628
|
+
assert isinstance(
|
|
5629
|
+
forensics, _cctally_db_sib.CorruptionForensicsResult,
|
|
5630
|
+
)
|
|
5631
|
+
if shm_snapshot is not None:
|
|
5632
|
+
try:
|
|
5633
|
+
_restore_cache_shm_snapshot(path, shm_snapshot)
|
|
5634
|
+
except OSError as restore_exc:
|
|
5635
|
+
raise sqlite3.DatabaseError(
|
|
5636
|
+
"cache.db recovery could not restore the exact pre-probe "
|
|
5637
|
+
f"WAL-index bytes: {restore_exc}"
|
|
5638
|
+
) from exc
|
|
5290
5639
|
_cache_storm_test_pause("cache_repair_forensics")
|
|
5640
|
+
if (
|
|
5641
|
+
forensics.disposition
|
|
5642
|
+
is not _cctally_db_sib.CorruptionProbeDisposition.CONFIRMED
|
|
5643
|
+
or forensics.path is None
|
|
5644
|
+
):
|
|
5645
|
+
bundle = str(forensics.path) if forensics.path is not None else "unavailable"
|
|
5646
|
+
eprint(
|
|
5647
|
+
"[cache] destructive recovery declined for classified trigger "
|
|
5648
|
+
f"at {origin}: corruption was not confirmed "
|
|
5649
|
+
f"({forensics.reason}; forensics: {bundle}); leaving the "
|
|
5650
|
+
"cache.db file family untouched"
|
|
5651
|
+
)
|
|
5652
|
+
return False
|
|
5291
5653
|
try:
|
|
5292
5654
|
incident = _cctally_db_sib.quarantine_db_family(path, strict=True)
|
|
5293
5655
|
except OSError as quarantine_exc:
|
|
@@ -5302,6 +5664,8 @@ def _recover_corrupt_cache(exc: sqlite3.DatabaseError) -> bool:
|
|
|
5302
5664
|
)
|
|
5303
5665
|
return True
|
|
5304
5666
|
finally:
|
|
5667
|
+
if active_conn is not None:
|
|
5668
|
+
_close_cache_trigger_connection_best_effort(active_conn)
|
|
5305
5669
|
try:
|
|
5306
5670
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
5307
5671
|
finally:
|
|
@@ -5312,32 +5676,45 @@ def _recover_corrupt_cache(exc: sqlite3.DatabaseError) -> bool:
|
|
|
5312
5676
|
def _run_cache_operation_with_recovery(
|
|
5313
5677
|
conn: sqlite3.Connection,
|
|
5314
5678
|
operation: Callable[[sqlite3.Connection], Any],
|
|
5679
|
+
*,
|
|
5680
|
+
origin: str,
|
|
5315
5681
|
) -> "tuple[Any, sqlite3.Connection]":
|
|
5316
|
-
results, replacement = _run_cache_plan_with_recovery(
|
|
5682
|
+
results, replacement = _run_cache_plan_with_recovery(
|
|
5683
|
+
conn, (operation,), origins=(origin,),
|
|
5684
|
+
)
|
|
5317
5685
|
return results[0], replacement
|
|
5318
5686
|
|
|
5319
5687
|
|
|
5320
5688
|
def _run_cache_plan_with_recovery(
|
|
5321
5689
|
conn: sqlite3.Connection,
|
|
5322
5690
|
operations: "tuple[Callable[[sqlite3.Connection], Any], ...]",
|
|
5691
|
+
*,
|
|
5692
|
+
origins: "tuple[str, ...]",
|
|
5323
5693
|
) -> "tuple[tuple[Any, ...], sqlite3.Connection]":
|
|
5324
5694
|
"""Run a provider plan, recovering once and restarting from its first leg.
|
|
5325
5695
|
|
|
5326
|
-
The connection that observed corruption is
|
|
5327
|
-
maintenance
|
|
5696
|
+
The connection that observed corruption is drained only after the repair
|
|
5697
|
+
marker and maintenance-exclusive lock exclude new openers. Because cache.db
|
|
5698
|
+
is one shared physical family, a
|
|
5328
5699
|
recovery in a later provider leg invalidates every earlier result; the
|
|
5329
5700
|
complete requested plan therefore restarts against the replacement family.
|
|
5330
5701
|
A second classified failure closes the replacement and propagates without a
|
|
5331
5702
|
second quarantine attempt.
|
|
5332
5703
|
"""
|
|
5704
|
+
if len(operations) != len(origins):
|
|
5705
|
+
raise ValueError("cache recovery origins must match operation count")
|
|
5706
|
+
if any(not origin.strip() for origin in origins):
|
|
5707
|
+
raise ValueError("cache recovery origins must be non-empty")
|
|
5333
5708
|
if not operations:
|
|
5334
5709
|
return (), conn
|
|
5335
5710
|
active = conn
|
|
5336
5711
|
recovered = False
|
|
5337
5712
|
while True:
|
|
5338
5713
|
try:
|
|
5339
|
-
results =
|
|
5340
|
-
|
|
5714
|
+
results: list[Any] = []
|
|
5715
|
+
for operation, origin in zip(operations, origins):
|
|
5716
|
+
results.append(operation(active))
|
|
5717
|
+
return tuple(results), active
|
|
5341
5718
|
except sqlite3.DatabaseError as exc:
|
|
5342
5719
|
if (
|
|
5343
5720
|
recovered
|
|
@@ -5345,8 +5722,9 @@ def _run_cache_plan_with_recovery(
|
|
|
5345
5722
|
):
|
|
5346
5723
|
active.close()
|
|
5347
5724
|
raise
|
|
5348
|
-
|
|
5349
|
-
|
|
5725
|
+
if not _recover_corrupt_cache(
|
|
5726
|
+
exc, origin=origin, active_conn=active,
|
|
5727
|
+
):
|
|
5350
5728
|
raise
|
|
5351
5729
|
active = open_cache_db()
|
|
5352
5730
|
_cache_storm_test_pause("cache_repair_recreated")
|
|
@@ -5378,7 +5756,13 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
5378
5756
|
try:
|
|
5379
5757
|
conn = _cache_open_guarded()
|
|
5380
5758
|
except sqlite3.DatabaseError as exc:
|
|
5381
|
-
if not _recover_corrupt_cache(
|
|
5759
|
+
if not _recover_corrupt_cache(
|
|
5760
|
+
exc,
|
|
5761
|
+
origin="cache.open",
|
|
5762
|
+
active_conn=getattr(
|
|
5763
|
+
exc, "_cctally_cache_connection", None,
|
|
5764
|
+
),
|
|
5765
|
+
):
|
|
5382
5766
|
raise
|
|
5383
5767
|
# One retry only. A second failure surfaces to the existing direct-JSONL
|
|
5384
5768
|
# fallback instead of looping through destructive recovery.
|
|
@@ -5405,6 +5789,9 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
5405
5789
|
# only. Persistent/schema PRAGMAs and every DDL/DML migration path are
|
|
5406
5790
|
# reserved for the globally serialized branch below.
|
|
5407
5791
|
_cctally_store.apply_connection_policy(conn, "cache")
|
|
5792
|
+
_cctally_db_sib._reconcile_durable_applied_migration_errors(
|
|
5793
|
+
conn, _CACHE_MIGRATIONS, "cache.db",
|
|
5794
|
+
)
|
|
5408
5795
|
return conn
|
|
5409
5796
|
|
|
5410
5797
|
from _lib_cache_writer_lock import (
|
|
@@ -5823,12 +6210,23 @@ def _prepare_claude_conversation_maintenance(
|
|
|
5823
6210
|
_consume_file_touches(conn)
|
|
5824
6211
|
|
|
5825
6212
|
|
|
6213
|
+
def _report_conversation_progress(
|
|
6214
|
+
progress: "Callable[[str, Any], None] | None",
|
|
6215
|
+
phase: str,
|
|
6216
|
+
stats: "IngestStats | CodexIngestStats",
|
|
6217
|
+
) -> None:
|
|
6218
|
+
"""Emit one optional #395 transcript-rebuild phase observation."""
|
|
6219
|
+
if progress is not None:
|
|
6220
|
+
progress(phase, stats)
|
|
6221
|
+
|
|
6222
|
+
|
|
5826
6223
|
def sync_claude_conversations(
|
|
5827
6224
|
conn: sqlite3.Connection,
|
|
5828
6225
|
*,
|
|
5829
6226
|
rebuild: bool = False,
|
|
5830
6227
|
lock_timeout: "float | None" = None,
|
|
5831
6228
|
only_paths: "set[str] | None" = None,
|
|
6229
|
+
progress: "Callable[[str, IngestStats], None] | None" = None,
|
|
5832
6230
|
) -> IngestStats:
|
|
5833
6231
|
"""Delta-sync Claude transcript/search rows into conversations.db (#320).
|
|
5834
6232
|
|
|
@@ -5842,6 +6240,7 @@ def sync_claude_conversations(
|
|
|
5842
6240
|
_cctally_core.CONVERSATIONS_LOCK_PATH.touch()
|
|
5843
6241
|
lock_fh = open(_cctally_core.CONVERSATIONS_LOCK_PATH, "w")
|
|
5844
6242
|
try:
|
|
6243
|
+
_report_conversation_progress(progress, "lock", stats)
|
|
5845
6244
|
if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
|
|
5846
6245
|
stats.lock_contended = True
|
|
5847
6246
|
return stats
|
|
@@ -5863,7 +6262,17 @@ def sync_claude_conversations(
|
|
|
5863
6262
|
stats.deferred_reason = "pending_global_flags"
|
|
5864
6263
|
return stats
|
|
5865
6264
|
rebuild = rebuild or pending_rebuild
|
|
6265
|
+
if rebuild:
|
|
6266
|
+
# Commit the retry marker before the destructive clear. A killed
|
|
6267
|
+
# #395 worker therefore leaves a partial transcript store visibly
|
|
6268
|
+
# pending instead of advancing it to a false-complete state.
|
|
6269
|
+
conn.execute(
|
|
6270
|
+
"INSERT OR REPLACE INTO cache_meta(key,value) VALUES(?,?)",
|
|
6271
|
+
("conversation_rebuild_claude_pending", "1"),
|
|
6272
|
+
)
|
|
6273
|
+
conn.commit()
|
|
5866
6274
|
|
|
6275
|
+
_report_conversation_progress(progress, "prepare", stats)
|
|
5867
6276
|
_prepare_claude_conversation_maintenance(
|
|
5868
6277
|
conn, rebuild=rebuild, targeted=targeted
|
|
5869
6278
|
)
|
|
@@ -5886,6 +6295,7 @@ def sync_claude_conversations(
|
|
|
5886
6295
|
else list(_iter_claude_jsonl_files())
|
|
5887
6296
|
)
|
|
5888
6297
|
stats.files_total = len(paths)
|
|
6298
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
5889
6299
|
existing = {
|
|
5890
6300
|
row[0]: (row[1], row[2], row[3])
|
|
5891
6301
|
for row in conn.execute(
|
|
@@ -5919,11 +6329,13 @@ def sync_claude_conversations(
|
|
|
5919
6329
|
st = jp.stat()
|
|
5920
6330
|
except OSError:
|
|
5921
6331
|
stats.files_failed += 1
|
|
6332
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
5922
6333
|
continue
|
|
5923
6334
|
size, mtime_ns = st.st_size, st.st_mtime_ns
|
|
5924
6335
|
prev = existing.get(path_str)
|
|
5925
6336
|
if prev is not None and size == prev[0]:
|
|
5926
6337
|
stats.files_skipped_unchanged += 1
|
|
6338
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
5927
6339
|
continue
|
|
5928
6340
|
truncated = prev is not None and size < prev[0]
|
|
5929
6341
|
if targeted and truncated:
|
|
@@ -5951,6 +6363,7 @@ def sync_claude_conversations(
|
|
|
5951
6363
|
except OSError as exc:
|
|
5952
6364
|
eprint(f"[conversations] could not read {jp}: {exc}")
|
|
5953
6365
|
stats.files_failed += 1
|
|
6366
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
5954
6367
|
continue
|
|
5955
6368
|
|
|
5956
6369
|
try:
|
|
@@ -6004,11 +6417,14 @@ def sync_claude_conversations(
|
|
|
6004
6417
|
touched_sessions.update(
|
|
6005
6418
|
row[0] for row in conv_rows if row[0] is not None
|
|
6006
6419
|
)
|
|
6420
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
6007
6421
|
except sqlite3.DatabaseError as exc:
|
|
6008
6422
|
conn.rollback()
|
|
6009
6423
|
eprint(f"[conversations] db error on {jp}: {exc}")
|
|
6010
6424
|
stats.files_failed += 1
|
|
6425
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
6011
6426
|
|
|
6427
|
+
_report_conversation_progress(progress, "rollup", stats)
|
|
6012
6428
|
_arm_rollup_backfill_on_pricing_change(conn)
|
|
6013
6429
|
if _conversation_sessions_backfill_pending(conn):
|
|
6014
6430
|
_recompute_conversation_sessions(conn)
|
|
@@ -6026,6 +6442,7 @@ def sync_claude_conversations(
|
|
|
6026
6442
|
"WHERE key='conversation_rebuild_claude_pending'"
|
|
6027
6443
|
)
|
|
6028
6444
|
conn.commit()
|
|
6445
|
+
_report_conversation_progress(progress, "checkpoint", stats)
|
|
6029
6446
|
_harden_conversation_sidecars()
|
|
6030
6447
|
_maybe_truncate_wal(conn, _cctally_core.CONVERSATIONS_DB_PATH)
|
|
6031
6448
|
did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
|
|
@@ -6036,7 +6453,9 @@ def sync_claude_conversations(
|
|
|
6036
6453
|
pass
|
|
6037
6454
|
lock_fh.close()
|
|
6038
6455
|
if did_from_zero_replay:
|
|
6456
|
+
_report_conversation_progress(progress, "retention", stats)
|
|
6039
6457
|
_force_retention_prune_after_replay()
|
|
6458
|
+
_report_conversation_progress(progress, "complete", stats)
|
|
6040
6459
|
return stats
|
|
6041
6460
|
|
|
6042
6461
|
|
|
@@ -6053,6 +6472,7 @@ def sync_codex_conversations(
|
|
|
6053
6472
|
rebuild: bool = False,
|
|
6054
6473
|
lock_timeout: "float | None" = None,
|
|
6055
6474
|
only_paths: "set[str] | None" = None,
|
|
6475
|
+
progress: "Callable[[str, CodexIngestStats], None] | None" = None,
|
|
6056
6476
|
) -> CodexIngestStats:
|
|
6057
6477
|
"""Delta-sync Codex events/search rows into conversations.db (#320)."""
|
|
6058
6478
|
stats = CodexIngestStats()
|
|
@@ -6061,6 +6481,7 @@ def sync_codex_conversations(
|
|
|
6061
6481
|
_cctally_core.CONVERSATIONS_LOCK_CODEX_PATH.touch()
|
|
6062
6482
|
lock_fh = open(_cctally_core.CONVERSATIONS_LOCK_CODEX_PATH, "w")
|
|
6063
6483
|
try:
|
|
6484
|
+
_report_conversation_progress(progress, "lock", stats)
|
|
6064
6485
|
if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
|
|
6065
6486
|
stats.lock_contended = True
|
|
6066
6487
|
return stats
|
|
@@ -6089,6 +6510,12 @@ def sync_codex_conversations(
|
|
|
6089
6510
|
return stats
|
|
6090
6511
|
rebuild = rebuild or pending_rebuild or contract_rebuild
|
|
6091
6512
|
if rebuild:
|
|
6513
|
+
conn.execute(
|
|
6514
|
+
"INSERT OR REPLACE INTO cache_meta(key,value) VALUES(?,?)",
|
|
6515
|
+
("conversation_rebuild_codex_pending", "1"),
|
|
6516
|
+
)
|
|
6517
|
+
conn.commit()
|
|
6518
|
+
_report_conversation_progress(progress, "prepare", stats)
|
|
6092
6519
|
_clear_codex_conversation_store(conn)
|
|
6093
6520
|
conn.commit()
|
|
6094
6521
|
|
|
@@ -6102,6 +6529,7 @@ def sync_codex_conversations(
|
|
|
6102
6529
|
else _discover_codex_files_with_roots()
|
|
6103
6530
|
)
|
|
6104
6531
|
stats.files_total = len(files)
|
|
6532
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
6105
6533
|
existing = {
|
|
6106
6534
|
row[0]: tuple(row[1:])
|
|
6107
6535
|
for row in conn.execute(
|
|
@@ -6163,11 +6591,13 @@ def sync_codex_conversations(
|
|
|
6163
6591
|
st = jp.stat()
|
|
6164
6592
|
except OSError:
|
|
6165
6593
|
stats.files_failed += 1
|
|
6594
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
6166
6595
|
continue
|
|
6167
6596
|
size, mtime_ns = st.st_size, st.st_mtime_ns
|
|
6168
6597
|
prev = existing.get(path_str)
|
|
6169
6598
|
if prev is not None and size == prev[0] and prev[3] == discovered.source_root_key:
|
|
6170
6599
|
stats.files_skipped_unchanged += 1
|
|
6600
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
6171
6601
|
continue
|
|
6172
6602
|
reset_file = (
|
|
6173
6603
|
prev is not None
|
|
@@ -6250,6 +6680,7 @@ def sync_codex_conversations(
|
|
|
6250
6680
|
except OSError as exc:
|
|
6251
6681
|
eprint(f"[codex-conversations] could not read {jp}: {exc}")
|
|
6252
6682
|
stats.files_failed += 1
|
|
6683
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
6253
6684
|
continue
|
|
6254
6685
|
|
|
6255
6686
|
try:
|
|
@@ -6267,6 +6698,7 @@ def sync_codex_conversations(
|
|
|
6267
6698
|
f"[codex-conversations] normalization failed for {jp}: {exc}"
|
|
6268
6699
|
)
|
|
6269
6700
|
stats.files_failed += 1
|
|
6701
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
6270
6702
|
continue
|
|
6271
6703
|
affected_keys = {
|
|
6272
6704
|
row[0]
|
|
@@ -6356,11 +6788,14 @@ def sync_codex_conversations(
|
|
|
6356
6788
|
)
|
|
6357
6789
|
conn.commit()
|
|
6358
6790
|
stats.files_processed += 1
|
|
6791
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
6359
6792
|
except sqlite3.DatabaseError as exc:
|
|
6360
6793
|
conn.rollback()
|
|
6361
6794
|
eprint(f"[codex-conversations] db error on {jp}: {exc}")
|
|
6362
6795
|
stats.files_failed += 1
|
|
6796
|
+
_report_conversation_progress(progress, "ingest", stats)
|
|
6363
6797
|
|
|
6798
|
+
_report_conversation_progress(progress, "finalize", stats)
|
|
6364
6799
|
if only_paths is None and stats.files_failed == 0:
|
|
6365
6800
|
conn.execute(
|
|
6366
6801
|
"INSERT OR REPLACE INTO cache_meta(key,value) VALUES(?,?)",
|
|
@@ -6374,6 +6809,7 @@ def sync_codex_conversations(
|
|
|
6374
6809
|
"WHERE key='conversation_rebuild_codex_pending'"
|
|
6375
6810
|
)
|
|
6376
6811
|
conn.commit()
|
|
6812
|
+
_report_conversation_progress(progress, "checkpoint", stats)
|
|
6377
6813
|
_harden_conversation_sidecars()
|
|
6378
6814
|
_maybe_truncate_wal(conn, _cctally_core.CONVERSATIONS_DB_PATH)
|
|
6379
6815
|
did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
|
|
@@ -6384,10 +6820,269 @@ def sync_codex_conversations(
|
|
|
6384
6820
|
pass
|
|
6385
6821
|
lock_fh.close()
|
|
6386
6822
|
if did_from_zero_replay:
|
|
6823
|
+
_report_conversation_progress(progress, "retention", stats)
|
|
6387
6824
|
_force_retention_prune_after_replay()
|
|
6825
|
+
_report_conversation_progress(progress, "complete", stats)
|
|
6388
6826
|
return stats
|
|
6389
6827
|
|
|
6390
6828
|
|
|
6829
|
+
class _TranscriptRebuildOutcome(NamedTuple):
|
|
6830
|
+
stats: "IngestStats | CodexIngestStats | None"
|
|
6831
|
+
timed_out: bool
|
|
6832
|
+
phase: str
|
|
6833
|
+
error: "str | None"
|
|
6834
|
+
elapsed_seconds: float
|
|
6835
|
+
|
|
6836
|
+
|
|
6837
|
+
def _write_transcript_worker_event(fd: int, payload: dict[str, Any]) -> None:
|
|
6838
|
+
"""Write one PIPE_BUF-sized JSON event from the isolated #395 worker."""
|
|
6839
|
+
encoded = (
|
|
6840
|
+
json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n"
|
|
6841
|
+
).encode("utf-8", errors="replace")
|
|
6842
|
+
try:
|
|
6843
|
+
os.write(fd, encoded)
|
|
6844
|
+
except OSError:
|
|
6845
|
+
pass
|
|
6846
|
+
|
|
6847
|
+
|
|
6848
|
+
def _test_transcript_stall_requested(provider: str, phase: str) -> bool:
|
|
6849
|
+
"""Pytest-only real-subprocess fault seam for #395 containment evidence."""
|
|
6850
|
+
if not os.environ.get("PYTEST_CURRENT_TEST"):
|
|
6851
|
+
return False
|
|
6852
|
+
return os.environ.get("CCTALLY_TEST_CACHE_SYNC_STALL_PHASE") == (
|
|
6853
|
+
f"{provider}:{phase}"
|
|
6854
|
+
)
|
|
6855
|
+
|
|
6856
|
+
|
|
6857
|
+
def _transcript_rebuild_timeout_seconds() -> float:
|
|
6858
|
+
timeout = _TRANSCRIPT_REBUILD_PHASE_TIMEOUT_SECONDS
|
|
6859
|
+
if os.environ.get("PYTEST_CURRENT_TEST"):
|
|
6860
|
+
raw = os.environ.get("CCTALLY_TEST_CACHE_SYNC_PHASE_TIMEOUT_SECONDS")
|
|
6861
|
+
if raw is not None:
|
|
6862
|
+
try:
|
|
6863
|
+
timeout = float(raw)
|
|
6864
|
+
except ValueError:
|
|
6865
|
+
pass
|
|
6866
|
+
return max(0.01, float(timeout))
|
|
6867
|
+
|
|
6868
|
+
|
|
6869
|
+
def _terminate_transcript_worker(pid: int) -> int:
|
|
6870
|
+
"""Bounded SIGTERM -> SIGKILL reap for one explicit rebuild worker."""
|
|
6871
|
+
try:
|
|
6872
|
+
os.kill(pid, signal.SIGTERM)
|
|
6873
|
+
except ProcessLookupError:
|
|
6874
|
+
pass
|
|
6875
|
+
deadline = time.monotonic() + _TRANSCRIPT_REBUILD_KILL_GRACE_SECONDS
|
|
6876
|
+
while time.monotonic() < deadline:
|
|
6877
|
+
done, status = os.waitpid(pid, os.WNOHANG)
|
|
6878
|
+
if done == pid:
|
|
6879
|
+
return status
|
|
6880
|
+
time.sleep(0.02)
|
|
6881
|
+
try:
|
|
6882
|
+
os.kill(pid, signal.SIGKILL)
|
|
6883
|
+
except ProcessLookupError:
|
|
6884
|
+
pass
|
|
6885
|
+
_done, status = os.waitpid(pid, 0)
|
|
6886
|
+
return status
|
|
6887
|
+
|
|
6888
|
+
|
|
6889
|
+
def _run_transcript_rebuild_worker(
|
|
6890
|
+
provider: str,
|
|
6891
|
+
*,
|
|
6892
|
+
lock_timeout: "float | None",
|
|
6893
|
+
) -> _TranscriptRebuildOutcome:
|
|
6894
|
+
"""Run one destructive transcript provider leg in a kill-safe child.
|
|
6895
|
+
|
|
6896
|
+
Core cache connections are already closed before this boundary. The child
|
|
6897
|
+
owns its conversations.db connection and provider flock; SIGKILL therefore
|
|
6898
|
+
lets SQLite roll back only the active transaction while preserving prior
|
|
6899
|
+
per-file commits and the durable pending marker.
|
|
6900
|
+
"""
|
|
6901
|
+
read_fd, write_fd = os.pipe()
|
|
6902
|
+
started = time.monotonic()
|
|
6903
|
+
pid = os.fork()
|
|
6904
|
+
if pid == 0:
|
|
6905
|
+
os.close(read_fd)
|
|
6906
|
+
|
|
6907
|
+
def emit(payload: dict[str, Any]) -> None:
|
|
6908
|
+
_write_transcript_worker_event(write_fd, payload)
|
|
6909
|
+
|
|
6910
|
+
def progress(phase: str, stats: Any) -> None:
|
|
6911
|
+
emit({
|
|
6912
|
+
"event": "progress",
|
|
6913
|
+
"phase": phase,
|
|
6914
|
+
"filesDone": (
|
|
6915
|
+
stats.files_processed
|
|
6916
|
+
+ stats.files_skipped_unchanged
|
|
6917
|
+
+ stats.files_failed
|
|
6918
|
+
),
|
|
6919
|
+
"filesTotal": stats.files_total,
|
|
6920
|
+
})
|
|
6921
|
+
if _test_transcript_stall_requested(provider, phase):
|
|
6922
|
+
while True:
|
|
6923
|
+
time.sleep(0.05)
|
|
6924
|
+
|
|
6925
|
+
conn = None
|
|
6926
|
+
try:
|
|
6927
|
+
emit({"event": "progress", "phase": "open", "filesDone": 0,
|
|
6928
|
+
"filesTotal": 0})
|
|
6929
|
+
conn = open_conversations_db()
|
|
6930
|
+
emit({"event": "progress", "phase": "sync-start", "filesDone": 0,
|
|
6931
|
+
"filesTotal": 0})
|
|
6932
|
+
sync = (
|
|
6933
|
+
sync_claude_conversations
|
|
6934
|
+
if provider == "claude"
|
|
6935
|
+
else sync_codex_conversations
|
|
6936
|
+
)
|
|
6937
|
+
stats = sync(
|
|
6938
|
+
conn,
|
|
6939
|
+
rebuild=True,
|
|
6940
|
+
lock_timeout=lock_timeout,
|
|
6941
|
+
progress=progress,
|
|
6942
|
+
)
|
|
6943
|
+
emit({"event": "progress", "phase": "close", "filesDone": 0,
|
|
6944
|
+
"filesTotal": 0})
|
|
6945
|
+
conn.close()
|
|
6946
|
+
conn = None
|
|
6947
|
+
emit({
|
|
6948
|
+
"event": "result",
|
|
6949
|
+
"stats": asdict(stats),
|
|
6950
|
+
"statsType": type(stats).__name__,
|
|
6951
|
+
})
|
|
6952
|
+
except BaseException as exc: # child reports; parent owns CLI wording
|
|
6953
|
+
emit({
|
|
6954
|
+
"event": "error",
|
|
6955
|
+
"errorType": type(exc).__name__,
|
|
6956
|
+
"message": str(exc),
|
|
6957
|
+
})
|
|
6958
|
+
finally:
|
|
6959
|
+
if conn is not None:
|
|
6960
|
+
try:
|
|
6961
|
+
conn.close()
|
|
6962
|
+
except Exception:
|
|
6963
|
+
pass
|
|
6964
|
+
os.close(write_fd)
|
|
6965
|
+
os._exit(0)
|
|
6966
|
+
|
|
6967
|
+
os.close(write_fd)
|
|
6968
|
+
os.set_blocking(read_fd, False)
|
|
6969
|
+
buffer = b""
|
|
6970
|
+
last_phase = "spawn"
|
|
6971
|
+
result_payload: "dict[str, Any] | None" = None
|
|
6972
|
+
error_payload: "dict[str, Any] | None" = None
|
|
6973
|
+
last_reported_done = -1
|
|
6974
|
+
last_progress_at = started
|
|
6975
|
+
|
|
6976
|
+
def consume(chunk: bytes) -> None:
|
|
6977
|
+
nonlocal buffer, last_phase, result_payload, error_payload
|
|
6978
|
+
nonlocal last_reported_done, last_progress_at
|
|
6979
|
+
buffer += chunk
|
|
6980
|
+
while b"\n" in buffer:
|
|
6981
|
+
line, buffer = buffer.split(b"\n", 1)
|
|
6982
|
+
if not line:
|
|
6983
|
+
continue
|
|
6984
|
+
try:
|
|
6985
|
+
event = json.loads(line)
|
|
6986
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
6987
|
+
continue
|
|
6988
|
+
kind = event.get("event")
|
|
6989
|
+
if kind == "progress":
|
|
6990
|
+
last_progress_at = time.monotonic()
|
|
6991
|
+
phase = str(event.get("phase") or "unknown")
|
|
6992
|
+
elapsed = time.monotonic() - started
|
|
6993
|
+
if phase != last_phase:
|
|
6994
|
+
last_phase = phase
|
|
6995
|
+
eprint(
|
|
6996
|
+
f"[cache-sync] {provider} transcripts phase={phase} "
|
|
6997
|
+
f"(+{elapsed:.1f}s)"
|
|
6998
|
+
)
|
|
6999
|
+
done = int(event.get("filesDone") or 0)
|
|
7000
|
+
total = int(event.get("filesTotal") or 0)
|
|
7001
|
+
if (
|
|
7002
|
+
phase == "ingest"
|
|
7003
|
+
and done != last_reported_done
|
|
7004
|
+
and (done > 0 and (done % 200 == 0 or done == total))
|
|
7005
|
+
):
|
|
7006
|
+
last_reported_done = done
|
|
7007
|
+
eprint(
|
|
7008
|
+
f"[cache-sync] {provider} transcripts: "
|
|
7009
|
+
f"{done}/{total} files (+{elapsed:.1f}s)"
|
|
7010
|
+
)
|
|
7011
|
+
elif kind == "result":
|
|
7012
|
+
result_payload = event
|
|
7013
|
+
elif kind == "error":
|
|
7014
|
+
error_payload = event
|
|
7015
|
+
|
|
7016
|
+
timeout = _transcript_rebuild_timeout_seconds()
|
|
7017
|
+
status = None
|
|
7018
|
+
timed_out = False
|
|
7019
|
+
try:
|
|
7020
|
+
while True:
|
|
7021
|
+
ready, _writable, _exceptional = select.select(
|
|
7022
|
+
[read_fd], [], [], 0.05
|
|
7023
|
+
)
|
|
7024
|
+
if ready:
|
|
7025
|
+
try:
|
|
7026
|
+
chunk = os.read(read_fd, 65_536)
|
|
7027
|
+
except BlockingIOError:
|
|
7028
|
+
chunk = b""
|
|
7029
|
+
if chunk:
|
|
7030
|
+
consume(chunk)
|
|
7031
|
+
done, child_status = os.waitpid(pid, os.WNOHANG)
|
|
7032
|
+
if done == pid:
|
|
7033
|
+
status = child_status
|
|
7034
|
+
break
|
|
7035
|
+
if time.monotonic() - last_progress_at >= timeout:
|
|
7036
|
+
timed_out = True
|
|
7037
|
+
status = _terminate_transcript_worker(pid)
|
|
7038
|
+
break
|
|
7039
|
+
except BaseException:
|
|
7040
|
+
_terminate_transcript_worker(pid)
|
|
7041
|
+
raise
|
|
7042
|
+
finally:
|
|
7043
|
+
while True:
|
|
7044
|
+
try:
|
|
7045
|
+
chunk = os.read(read_fd, 65_536)
|
|
7046
|
+
except BlockingIOError:
|
|
7047
|
+
break
|
|
7048
|
+
if not chunk:
|
|
7049
|
+
break
|
|
7050
|
+
consume(chunk)
|
|
7051
|
+
os.close(read_fd)
|
|
7052
|
+
|
|
7053
|
+
elapsed = time.monotonic() - started
|
|
7054
|
+
if timed_out:
|
|
7055
|
+
return _TranscriptRebuildOutcome(
|
|
7056
|
+
None, True, last_phase, None, elapsed
|
|
7057
|
+
)
|
|
7058
|
+
if error_payload is not None:
|
|
7059
|
+
message = str(error_payload.get("message") or "unknown error")
|
|
7060
|
+
error_type = str(error_payload.get("errorType") or "Error")
|
|
7061
|
+
return _TranscriptRebuildOutcome(
|
|
7062
|
+
None, False, last_phase, f"{error_type}: {message}", elapsed
|
|
7063
|
+
)
|
|
7064
|
+
if status != 0 or result_payload is None:
|
|
7065
|
+
return _TranscriptRebuildOutcome(
|
|
7066
|
+
None,
|
|
7067
|
+
False,
|
|
7068
|
+
last_phase,
|
|
7069
|
+
f"worker exited without a result (status={status})",
|
|
7070
|
+
elapsed,
|
|
7071
|
+
)
|
|
7072
|
+
stats_type = result_payload.get("statsType")
|
|
7073
|
+
stats_data = result_payload.get("stats")
|
|
7074
|
+
if not isinstance(stats_data, dict):
|
|
7075
|
+
return _TranscriptRebuildOutcome(
|
|
7076
|
+
None, False, last_phase, "worker returned invalid stats", elapsed
|
|
7077
|
+
)
|
|
7078
|
+
stats = (
|
|
7079
|
+
IngestStats(**stats_data)
|
|
7080
|
+
if stats_type == "IngestStats"
|
|
7081
|
+
else CodexIngestStats(**stats_data)
|
|
7082
|
+
)
|
|
7083
|
+
return _TranscriptRebuildOutcome(stats, False, last_phase, None, elapsed)
|
|
7084
|
+
|
|
7085
|
+
|
|
6391
7086
|
# === Region 7: cmd_cache_sync (was bin/cctally:11563-11616) ===
|
|
6392
7087
|
|
|
6393
7088
|
|
|
@@ -6507,6 +7202,7 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
6507
7202
|
_p_root.__enter__()
|
|
6508
7203
|
|
|
6509
7204
|
plan: list[Callable[[sqlite3.Connection], Any]] = []
|
|
7205
|
+
plan_origins: list[str] = []
|
|
6510
7206
|
|
|
6511
7207
|
if source in ("claude", "all"):
|
|
6512
7208
|
def _sync_claude_leg(active_conn: sqlite3.Connection) -> IngestStats:
|
|
@@ -6519,6 +7215,7 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
6519
7215
|
)
|
|
6520
7216
|
|
|
6521
7217
|
plan.append(_sync_claude_leg)
|
|
7218
|
+
plan_origins.append("cache_sync.cli.claude")
|
|
6522
7219
|
|
|
6523
7220
|
if source in ("codex", "all"):
|
|
6524
7221
|
def _sync_codex_leg(
|
|
@@ -6533,9 +7230,12 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
6533
7230
|
)
|
|
6534
7231
|
|
|
6535
7232
|
plan.append(_sync_codex_leg)
|
|
7233
|
+
plan_origins.append("cache_sync.cli.codex")
|
|
6536
7234
|
|
|
6537
7235
|
try:
|
|
6538
|
-
plan_results, conn = _run_cache_plan_with_recovery(
|
|
7236
|
+
plan_results, conn = _run_cache_plan_with_recovery(
|
|
7237
|
+
conn, tuple(plan), origins=tuple(plan_origins),
|
|
7238
|
+
)
|
|
6539
7239
|
except (OSError, sqlite3.DatabaseError) as exc:
|
|
6540
7240
|
eprint(f"[cache-sync] failed: {exc}")
|
|
6541
7241
|
_p_root.__exit__(type(exc), exc, exc.__traceback__)
|
|
@@ -6600,53 +7300,116 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
6600
7300
|
# #320: transcript/search ingestion is a second physical database with its
|
|
6601
7301
|
# own cursors and flocks. Run it only after the core providers have
|
|
6602
7302
|
# committed so a slow/failed transcript pass can never roll back accounting
|
|
6603
|
-
# or quota state.
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
try:
|
|
6616
|
-
if source in ("claude", "all"):
|
|
6617
|
-
conv_stats = sync_claude_conversations(
|
|
6618
|
-
conversation_conn, rebuild=args.rebuild, lock_timeout=lt
|
|
7303
|
+
# or quota state. #395 contains each explicit provider rebuild in its own
|
|
7304
|
+
# process so a stuck SQLite/parser/normalization phase has a real finite
|
|
7305
|
+
# boundary without unsafe thread cancellation.
|
|
7306
|
+
if args.rebuild:
|
|
7307
|
+
providers = [
|
|
7308
|
+
provider
|
|
7309
|
+
for provider in ("claude", "codex")
|
|
7310
|
+
if source in (provider, "all")
|
|
7311
|
+
]
|
|
7312
|
+
for provider in providers:
|
|
7313
|
+
outcome = _run_transcript_rebuild_worker(
|
|
7314
|
+
provider, lock_timeout=lt
|
|
6619
7315
|
)
|
|
6620
|
-
|
|
7316
|
+
retry = (
|
|
7317
|
+
"Re-run `cctally cache-sync "
|
|
7318
|
+
f"--source {provider} --rebuild`."
|
|
7319
|
+
)
|
|
7320
|
+
if outcome.timed_out:
|
|
6621
7321
|
eprint(
|
|
6622
|
-
"[cache-sync] transcript
|
|
6623
|
-
"
|
|
7322
|
+
"[cache-sync] transcript rebuild timed out: "
|
|
7323
|
+
f"provider={provider} store=conversations.db "
|
|
7324
|
+
f"phase={outcome.phase} after "
|
|
7325
|
+
f"{_transcript_rebuild_timeout_seconds():.1f}s without "
|
|
7326
|
+
f"progress (+{outcome.elapsed_seconds:.1f}s total); "
|
|
7327
|
+
"core accounting/quota sync is complete; any partial "
|
|
7328
|
+
f"transcript state remains retry-safe and incomplete. {retry}"
|
|
6624
7329
|
)
|
|
6625
|
-
|
|
6626
|
-
|
|
7330
|
+
_p_root.__exit__(None, None, None)
|
|
7331
|
+
if _perf.enabled():
|
|
7332
|
+
_perf.flush_stderr(_perf.current_root())
|
|
7333
|
+
return 1
|
|
7334
|
+
if outcome.error is not None or outcome.stats is None:
|
|
6627
7335
|
eprint(
|
|
6628
|
-
|
|
6629
|
-
f"{
|
|
6630
|
-
f"{
|
|
7336
|
+
"[cache-sync] transcript rebuild failed: "
|
|
7337
|
+
f"provider={provider} store=conversations.db "
|
|
7338
|
+
f"phase={outcome.phase} ({outcome.error}); "
|
|
7339
|
+
f"core accounting/quota sync is complete. {retry}"
|
|
6631
7340
|
)
|
|
6632
|
-
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
7341
|
+
_p_root.__exit__(None, None, None)
|
|
7342
|
+
if _perf.enabled():
|
|
7343
|
+
_perf.flush_stderr(_perf.current_root())
|
|
7344
|
+
return 1
|
|
7345
|
+
conv_stats = outcome.stats
|
|
6636
7346
|
if conv_stats.lock_contended:
|
|
6637
7347
|
eprint(
|
|
6638
|
-
"[cache-sync] transcript
|
|
6639
|
-
"
|
|
7348
|
+
"[cache-sync] transcript rebuild incomplete: "
|
|
7349
|
+
f"provider={provider} store=conversations.db phase=lock "
|
|
7350
|
+
"(another process holds the conversations lock); "
|
|
7351
|
+
f"core accounting/quota sync is complete. {retry}"
|
|
6640
7352
|
)
|
|
6641
|
-
contended =
|
|
7353
|
+
contended = True
|
|
7354
|
+
elif conv_stats.files_failed:
|
|
7355
|
+
eprint(
|
|
7356
|
+
"[cache-sync] transcript rebuild incomplete: "
|
|
7357
|
+
f"provider={provider} store=conversations.db phase=ingest "
|
|
7358
|
+
f"({conv_stats.files_failed} file(s) failed); "
|
|
7359
|
+
f"core accounting/quota sync is complete. {retry}"
|
|
7360
|
+
)
|
|
7361
|
+
contended = True
|
|
6642
7362
|
else:
|
|
6643
7363
|
eprint(
|
|
6644
|
-
f"[cache-sync]
|
|
7364
|
+
f"[cache-sync] {provider} transcripts done: "
|
|
6645
7365
|
f"{conv_stats.files_processed} processed, "
|
|
6646
7366
|
f"{conv_stats.files_skipped_unchanged} skipped"
|
|
6647
7367
|
)
|
|
6648
|
-
|
|
6649
|
-
|
|
7368
|
+
else:
|
|
7369
|
+
try:
|
|
7370
|
+
conversation_conn = open_conversations_db()
|
|
7371
|
+
except (OSError, sqlite3.DatabaseError) as exc:
|
|
7372
|
+
eprint(
|
|
7373
|
+
f"[cache-sync] transcript store unavailable ({exc}); "
|
|
7374
|
+
"core accounting/quota sync is complete"
|
|
7375
|
+
)
|
|
7376
|
+
_p_root.__exit__(None, None, None)
|
|
7377
|
+
if _perf.enabled():
|
|
7378
|
+
_perf.flush_stderr(_perf.current_root())
|
|
7379
|
+
return 1 if contended else 0
|
|
7380
|
+
try:
|
|
7381
|
+
if source in ("claude", "all"):
|
|
7382
|
+
conv_stats = sync_claude_conversations(
|
|
7383
|
+
conversation_conn, rebuild=False, lock_timeout=lt
|
|
7384
|
+
)
|
|
7385
|
+
if conv_stats.lock_contended:
|
|
7386
|
+
eprint(
|
|
7387
|
+
"[cache-sync] transcript sync skipped (claude): "
|
|
7388
|
+
"another process holds the conversations lock"
|
|
7389
|
+
)
|
|
7390
|
+
else:
|
|
7391
|
+
eprint(
|
|
7392
|
+
f"[cache-sync] claude transcripts done: "
|
|
7393
|
+
f"{conv_stats.files_processed} processed, "
|
|
7394
|
+
f"{conv_stats.files_skipped_unchanged} skipped"
|
|
7395
|
+
)
|
|
7396
|
+
if source in ("codex", "all"):
|
|
7397
|
+
conv_stats = sync_codex_conversations(
|
|
7398
|
+
conversation_conn, rebuild=False, lock_timeout=lt
|
|
7399
|
+
)
|
|
7400
|
+
if conv_stats.lock_contended:
|
|
7401
|
+
eprint(
|
|
7402
|
+
"[cache-sync] transcript sync skipped (codex): "
|
|
7403
|
+
"another process holds the conversations lock"
|
|
7404
|
+
)
|
|
7405
|
+
else:
|
|
7406
|
+
eprint(
|
|
7407
|
+
f"[cache-sync] codex transcripts done: "
|
|
7408
|
+
f"{conv_stats.files_processed} processed, "
|
|
7409
|
+
f"{conv_stats.files_skipped_unchanged} skipped"
|
|
7410
|
+
)
|
|
7411
|
+
finally:
|
|
7412
|
+
conversation_conn.close()
|
|
6650
7413
|
|
|
6651
7414
|
_p_root.__exit__(None, None, None)
|
|
6652
7415
|
# #276 perf: when tracing is enabled, flush the completed "cache-sync"
|