cctally 1.80.0 → 1.80.2

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 CHANGED
@@ -5,6 +5,17 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.80.2] - 2026-07-23
9
+
10
+ ### Fixed
11
+ - The dashboard no longer crashes with a native SQLite `SIGBUS` when a corrupt `cache.db` is detected while another dashboard or hook reader is active. Cache recovery now blocks new opens, refuses to replace a file family with live handles, preserves the complete family in quarantine, and retries recreation only after replacement is safe.
12
+ - Re-reading the same retained Codex quota observation now produces a byte-identical journal record instead of a new command-time identity. A compact natural-key index also recognizes records already written by v1.80.1, preventing cache recovery from multiplying historical quota records and making subsequent rebuilds progressively slower.
13
+
14
+ ## [1.80.1] - 2026-07-23
15
+
16
+ ### Fixed
17
+ - Corrupted `stats.db` indexes now auto-heal correctly on Linux: the locked re-check reads the SQLite header instead of accepting a constant-only query that could succeed without touching the damaged file.
18
+
8
19
  ## [1.80.0] - 2026-07-23
9
20
 
10
21
  ### Added
@@ -1211,26 +1211,25 @@ def _append_codex_quota_obs(quota_rows: list) -> None:
1211
1211
  lock is a LEAF — legal to take inside a provider flock, spec §4.3) and BEFORE
1212
1212
  the cache write / offset advance, so a crash re-reads the same bytes and
1213
1213
  re-appends (idempotent at the QUOTA_APPLIER's natural-key INSERT OR IGNORE)
1214
- rather than losing the observation. ``at`` is the detection clock
1215
- (``_command_as_of()``-aware, honoring the CCTALLY_AS_OF test hook); the raw
1216
- row (incl. its own ``captured_at_utc``) rides the payload (spec §4.2 raw
1217
- capture no derivation baked into the stored line). $CODEX_HOME multi-root
1218
- scoping is preserved: each row carries its own ``source_root_key`` and roots
1219
- are never combined."""
1214
+ rather than losing the observation. ``at`` is the observation's retained
1215
+ ``captured_at_utc`` rather than the later sync clock, so re-reading the same
1216
+ rollout bytes emits the same content id and byte-identical journal record.
1217
+ $CODEX_HOME multi-root scoping is preserved: each row carries its own
1218
+ ``source_root_key`` and roots are never combined."""
1220
1219
  if not quota_rows:
1221
1220
  return
1222
1221
  import _cctally_journal as _jr
1223
1222
  import _lib_journal as _jl
1224
- at = (
1225
- _cctally_core._command_as_of()
1226
- .isoformat(timespec="seconds")
1227
- .replace("+00:00", "Z")
1228
- )
1229
1223
  for row in quota_rows:
1230
1224
  (source, source_root_key, source_path, line_offset, captured_at_utc,
1231
1225
  observed_slot, logical_limit_key, limit_id, limit_name, window_minutes,
1232
1226
  used_percent, resets_at_utc, plan_type, individual_limit_json,
1233
1227
  reached_type, observed_model) = row
1228
+ at = captured_at_utc or (
1229
+ _cctally_core._command_as_of()
1230
+ .isoformat(timespec="seconds")
1231
+ .replace("+00:00", "Z")
1232
+ )
1234
1233
  try:
1235
1234
  _jr.append_record(_jl.make_obs(
1236
1235
  at=at, src="codex-quota", provider="codex",
@@ -1246,7 +1245,7 @@ def _append_codex_quota_obs(quota_rows: list) -> None:
1246
1245
  "plan_type": plan_type,
1247
1246
  "individual_limit_json": individual_limit_json,
1248
1247
  "reached_type": reached_type, "observed_model": observed_model,
1249
- }))
1248
+ }), dedupe_codex_quota=True)
1250
1249
  except Exception as exc: # best-effort; a journal append must not break sync
1251
1250
  eprint(f"[codex-cache] quota obs journal append failed: {exc}")
1252
1251
 
@@ -4789,12 +4788,113 @@ def _harden_cache_sidecars() -> None:
4789
4788
  # === Region 6: open_cache_db (was bin/cctally:9040-9155) ===
4790
4789
 
4791
4790
 
4791
+ def _cache_open_guarded() -> sqlite3.Connection:
4792
+ """Open cache.db while excluding a destructive recovery handshake.
4793
+
4794
+ The shared maintenance flock covers both marker checks and the SQLite
4795
+ connect/probe. A repair owns the exclusive side; after it claims the marker,
4796
+ no new cctally opener can escape into the live family while it verifies that
4797
+ all pre-marker handles have drained.
4798
+ """
4799
+ path = pathlib.Path(_cctally_core.CACHE_DB_PATH)
4800
+ marker = _cctally_db_sib._repair_marker_path(path)
4801
+ lock_path = pathlib.Path(_cctally_core.CACHE_LOCK_MAINTENANCE_PATH)
4802
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
4803
+ lock_fh = open(lock_path, "a+")
4804
+ conn = None
4805
+ try:
4806
+ fcntl.flock(lock_fh, fcntl.LOCK_SH)
4807
+ if marker.exists():
4808
+ raise sqlite3.DatabaseError(
4809
+ f"cache.db maintenance is in progress ({marker})"
4810
+ )
4811
+ conn = _cctally_store.open_index("cache")
4812
+ # Force a real header/schema read. SELECT 1 is constant-folded and can
4813
+ # report success without touching a malformed database file.
4814
+ conn.execute("PRAGMA schema_version").fetchone()
4815
+ if marker.exists():
4816
+ conn.close()
4817
+ conn = None
4818
+ raise sqlite3.DatabaseError(
4819
+ f"cache.db maintenance is in progress ({marker})"
4820
+ )
4821
+ return conn
4822
+ except Exception:
4823
+ if conn is not None:
4824
+ try:
4825
+ conn.close()
4826
+ except Exception:
4827
+ pass
4828
+ raise
4829
+ finally:
4830
+ try:
4831
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
4832
+ finally:
4833
+ lock_fh.close()
4834
+
4835
+
4836
+ def _recover_corrupt_cache(exc: sqlite3.DatabaseError) -> bool:
4837
+ """Quarantine a corrupt cache family only after every reader has drained.
4838
+
4839
+ Returns True after a safe quarantine, so the caller may create a fresh
4840
+ re-derivable cache. Raises a guided DatabaseError when recovery cannot prove
4841
+ exclusivity; callers then use their established direct-JSONL fallback.
4842
+ """
4843
+ if not _cctally_db_sib._is_sqlite_corruption_error(exc):
4844
+ return False
4845
+
4846
+ path = pathlib.Path(_cctally_core.CACHE_DB_PATH)
4847
+ try:
4848
+ claimed, reason = _cctally_db_sib._claim_repair_marker(path)
4849
+ except OSError as marker_exc:
4850
+ raise sqlite3.DatabaseError(
4851
+ f"cache.db recovery could not claim maintenance: {marker_exc}"
4852
+ ) from exc
4853
+ if not claimed:
4854
+ raise sqlite3.DatabaseError(
4855
+ f"cache.db maintenance is in progress: {reason}"
4856
+ ) from exc
4857
+
4858
+ lock_path = pathlib.Path(_cctally_core.CACHE_LOCK_MAINTENANCE_PATH)
4859
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
4860
+ lock_fh = open(lock_path, "a+")
4861
+ try:
4862
+ fcntl.flock(lock_fh, fcntl.LOCK_EX)
4863
+ open_pids = _cctally_db_sib._db_family_open_pids(path)
4864
+ if open_pids is None:
4865
+ raise sqlite3.DatabaseError(
4866
+ "cache.db recovery cannot verify that the database family has "
4867
+ "no open handles; leaving it untouched"
4868
+ ) from exc
4869
+ if open_pids:
4870
+ raise sqlite3.DatabaseError(
4871
+ "cache.db is still open in process(es) "
4872
+ + ", ".join(str(pid) for pid in sorted(open_pids))
4873
+ + "; leaving the live family untouched"
4874
+ ) from exc
4875
+
4876
+ _cctally_db_sib.write_corruption_forensics(path, db_label="cache")
4877
+ incident = _cctally_db_sib.quarantine_db_family(path)
4878
+ eprint(
4879
+ f"[cache] corrupt cache DB ({exc}); quarantined its file family "
4880
+ f"under {incident} and recreating from source JSONL"
4881
+ )
4882
+ return True
4883
+ finally:
4884
+ try:
4885
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
4886
+ finally:
4887
+ lock_fh.close()
4888
+ _cctally_db_sib._release_repair_marker(path)
4889
+
4890
+
4792
4891
  def open_cache_db() -> sqlite3.Connection:
4793
4892
  """Open (or create) the session-entry cache DB.
4794
4893
 
4795
4894
  Enables WAL mode so queries can run concurrently with an in-progress
4796
- ingest. On sqlite3.DatabaseError (corruption) the file is unlinked and
4797
- recreated the cache is fully re-derivable from JSONL, so this is safe.
4895
+ ingest. On positively classified corruption, the whole file family is
4896
+ quarantined and recreated only after a marker + maintenance flock + active
4897
+ handle check prove replacement cannot invalidate a live WAL reader.
4798
4898
  """
4799
4899
  c = _cctally()
4800
4900
  _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
@@ -4807,22 +4907,13 @@ def open_cache_db() -> sqlite3.Connection:
4807
4907
  except OSError as exc:
4808
4908
  eprint(f"[cache] could not chmod data dir 0700 ({exc}); continuing")
4809
4909
  try:
4810
- # Connect + install row factory / test trace hook via the unified
4811
- # opener (spec §6.1). PRAGMAs are applied AFTER the corruption probe so
4812
- # a mode-change never writes a WAL onto a DB we are about to unlink.
4813
- conn = _cctally_store.open_index("cache")
4814
- conn.execute("SELECT 1").fetchone()
4910
+ conn = _cache_open_guarded()
4815
4911
  except sqlite3.DatabaseError as exc:
4816
- eprint(f"[cache] corrupt cache DB ({exc}); recreating")
4817
- try:
4818
- conn.close()
4819
- except Exception:
4820
- pass
4821
- try:
4822
- _cctally_core.CACHE_DB_PATH.unlink()
4823
- except FileNotFoundError:
4824
- pass
4825
- conn = _cctally_store.open_index("cache")
4912
+ if not _recover_corrupt_cache(exc):
4913
+ raise
4914
+ # One retry only. A second failure surfaces to the existing direct-JSONL
4915
+ # fallback instead of looping through destructive recovery.
4916
+ conn = _cache_open_guarded()
4826
4917
 
4827
4918
  # Best-effort 0600 on cache.db itself (the 0700 dir above backstops the
4828
4919
  # sidecars until the first write hardens them in sync_cache).
@@ -6695,7 +6695,7 @@ def _db_backup_timestamp() -> str:
6695
6695
 
6696
6696
 
6697
6697
  def _repair_marker_path(path: pathlib.Path) -> pathlib.Path:
6698
- return path.with_name("stats.db.repairing")
6698
+ return path.with_name(f"{path.name}.repairing")
6699
6699
 
6700
6700
 
6701
6701
  def _pid_is_alive(pid: int) -> bool:
@@ -6722,7 +6722,9 @@ def _claim_repair_marker(path: pathlib.Path) -> "tuple[bool, str]":
6722
6722
  except (OSError, ValueError):
6723
6723
  owner = -1
6724
6724
  if _pid_is_alive(owner):
6725
- return False, f"another stats.db repair owns {marker} (pid {owner})"
6725
+ return False, (
6726
+ f"another {path.name} repair owns {marker} (pid {owner})"
6727
+ )
6726
6728
  try:
6727
6729
  marker.unlink()
6728
6730
  except FileNotFoundError:
@@ -45,6 +45,15 @@ import _lib_record
45
45
  _TAIL_WINDOW = 64 * 1024
46
46
  _MAX_LINE_BYTES = _TAIL_WINDOW
47
47
 
48
+ # Process-local acceleration for the durable Codex-quota dedupe index. The
49
+ # compact index is re-derivable from the journal and lets short-lived hook
50
+ # processes load ~20-byte natural-key digests instead of rescanning every full
51
+ # JSONL segment.
52
+ _QUOTA_DEDUP_INDEX_NAME = ".quota-observation-keys"
53
+ _QUOTA_DEDUP_DIR: str | None = None
54
+ _QUOTA_DEDUP_KEYS: set[str] = set()
55
+ _QUOTA_DEDUP_LOADED = False
56
+
48
57
 
49
58
  class JournalError(Exception):
50
59
  """A structural journal-append failure (line too long, unrepairable tail)."""
@@ -134,17 +143,136 @@ def _repair_torn_tail(fd: int) -> None:
134
143
  # public append surface
135
144
  # --------------------------------------------------------------------------
136
145
 
137
- def append_record(record: dict, *, now_utc: dt.datetime | None = None) -> tuple[str, int]:
146
+ def _is_codex_quota_obs(record: dict) -> bool:
147
+ return (
148
+ record.get("t") == "obs"
149
+ and record.get("provider") == "codex"
150
+ and (record.get("payload") or {}).get("kind") == "quota_window_snapshot"
151
+ )
152
+
153
+
154
+ def _codex_quota_natural_key(record: dict) -> str | None:
155
+ """Digest the cache table's stable UNIQUE key for one quota observation."""
156
+ if not _is_codex_quota_obs(record):
157
+ return None
158
+ payload = record.get("payload") or {}
159
+ source = payload.get("source")
160
+ source_path = payload.get("source_path")
161
+ line_offset = payload.get("line_offset")
162
+ logical_limit_key = payload.get("logical_limit_key")
163
+ if (
164
+ not isinstance(source, str)
165
+ or not isinstance(source_path, str)
166
+ or not isinstance(line_offset, int)
167
+ or not isinstance(logical_limit_key, str)
168
+ ):
169
+ return None
170
+ return _lib_journal.content_id({
171
+ "t": "quota-natural-key",
172
+ "payload": {
173
+ "source": source,
174
+ "source_path": source_path,
175
+ "line_offset": line_offset,
176
+ "logical_limit_key": logical_limit_key,
177
+ },
178
+ })
179
+
180
+
181
+ def _load_quota_dedup_keys() -> None:
182
+ """Load or atomically rebuild the re-derivable quota natural-key index.
183
+
184
+ Caller owns journal.lock, so an initial full scan observes one stable
185
+ journal prefix and only one process can publish the compact index. Normal
186
+ quota appends fsync the journal first and this index second; a crash in
187
+ between can cause at most one harmless duplicate on retry, never a skipped
188
+ durable observation.
189
+ """
190
+ global _QUOTA_DEDUP_DIR, _QUOTA_DEDUP_LOADED
191
+ journal_dir = _cctally_core.JOURNAL_DIR
192
+ dir_key = str(journal_dir)
193
+ if _QUOTA_DEDUP_DIR != dir_key:
194
+ _QUOTA_DEDUP_DIR = dir_key
195
+ _QUOTA_DEDUP_KEYS.clear()
196
+ _QUOTA_DEDUP_LOADED = False
197
+ if _QUOTA_DEDUP_LOADED:
198
+ return
199
+
200
+ index_path = journal_dir / _QUOTA_DEDUP_INDEX_NAME
201
+ try:
202
+ raw_keys = index_path.read_text(encoding="ascii").splitlines()
203
+ if any(not item.startswith("o:") or len(item) != 18 for item in raw_keys):
204
+ raise ValueError("invalid quota dedupe index")
205
+ _QUOTA_DEDUP_KEYS.update(raw_keys)
206
+ _QUOTA_DEDUP_LOADED = True
207
+ return
208
+ except (FileNotFoundError, OSError, UnicodeError, ValueError):
209
+ _QUOTA_DEDUP_KEYS.clear()
210
+
211
+ for name in list_segments():
212
+ with (journal_dir / name).open("rb") as fh:
213
+ for raw in fh:
214
+ if not raw.endswith(b"\n"):
215
+ break
216
+ decoded = _lib_journal.decode_line(raw)
217
+ if decoded is not None:
218
+ natural_key = _codex_quota_natural_key(decoded)
219
+ if natural_key is not None:
220
+ _QUOTA_DEDUP_KEYS.add(natural_key)
221
+
222
+ tmp = index_path.with_name(
223
+ f"{index_path.name}.tmp-{os.getpid()}-{time.monotonic_ns()}"
224
+ )
225
+ try:
226
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
227
+ try:
228
+ payload = "".join(
229
+ f"{natural_key}\n" for natural_key in sorted(_QUOTA_DEDUP_KEYS)
230
+ ).encode("ascii")
231
+ _write_all(fd, payload)
232
+ os.fsync(fd)
233
+ finally:
234
+ os.close(fd)
235
+ os.replace(str(tmp), str(index_path))
236
+ _fsync_dir(journal_dir)
237
+ finally:
238
+ try:
239
+ tmp.unlink()
240
+ except FileNotFoundError:
241
+ pass
242
+ _QUOTA_DEDUP_LOADED = True
243
+
244
+
245
+ def _append_quota_dedup_key(natural_key: str) -> None:
246
+ """Journal-first second leg: append+fsync one key to the compact index."""
247
+ index_path = _cctally_core.JOURNAL_DIR / _QUOTA_DEDUP_INDEX_NAME
248
+ fd = os.open(str(index_path), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
249
+ try:
250
+ _write_all(fd, f"{natural_key}\n".encode("ascii"))
251
+ os.fsync(fd)
252
+ finally:
253
+ os.close(fd)
254
+ _QUOTA_DEDUP_KEYS.add(natural_key)
255
+
256
+
257
+ def append_record(
258
+ record: dict,
259
+ *,
260
+ now_utc: dt.datetime | None = None,
261
+ dedupe_codex_quota: bool = False,
262
+ ) -> tuple[str, int] | None:
138
263
  """Append one encoded line to the current UTC month's segment.
139
264
 
140
265
  Implements spec §4.3 exactly: ``O_RDWR|O_APPEND|O_CREAT`` (0o600) → blocking
141
266
  leaf flock → torn-tail repair → loop-write the full line → ``fsync(fd)`` →
142
267
  parent-dir fsync on first segment/dir creation → unlock.
143
268
 
144
- ``now_utc`` selects the segment (defaults to the current UTC time). Returns
145
- ``(segment_basename, end_offset)`` where ``end_offset`` is the file size
146
- just past the appended line the byte position the ingest cursor advances
147
- to when it consumes this line."""
269
+ ``now_utc`` selects the segment (defaults to the current UTC time).
270
+ ``dedupe_codex_quota`` skips a retained Codex quota obs whose table natural
271
+ key already exists in any segment; this is the cache-recovery replay path
272
+ and returns ``None`` on a skip. Otherwise returns ``(segment_basename,
273
+ end_offset)`` where ``end_offset`` is the file size just past the appended
274
+ line — the byte position the ingest cursor advances to when it consumes
275
+ this line."""
148
276
  if now_utc is None:
149
277
  now_utc = dt.datetime.now(dt.timezone.utc)
150
278
  data = _lib_journal.encode_line(record)
@@ -167,6 +295,20 @@ def append_record(record: dict, *, now_utc: dt.datetime | None = None) -> tuple[
167
295
 
168
296
  lock_fd = _acquire_leaf_lock()
169
297
  try:
298
+ if dedupe_codex_quota:
299
+ if not _is_codex_quota_obs(record):
300
+ raise ValueError(
301
+ "dedupe_codex_quota is valid only for Codex quota obs"
302
+ )
303
+ _load_quota_dedup_keys()
304
+ natural_key = _codex_quota_natural_key(record)
305
+ if natural_key is None:
306
+ raise ValueError(
307
+ "dedupe_codex_quota requires a complete quota natural key"
308
+ )
309
+ if natural_key in _QUOTA_DEDUP_KEYS:
310
+ return None
311
+
170
312
  seg_created = not seg_path.exists()
171
313
  fd = os.open(str(seg_path), os.O_RDWR | os.O_APPEND | os.O_CREAT, 0o600)
172
314
  try:
@@ -181,6 +323,8 @@ def append_record(record: dict, *, now_utc: dt.datetime | None = None) -> tuple[
181
323
  end_offset = os.fstat(fd).st_size
182
324
  finally:
183
325
  os.close(fd)
326
+ if dedupe_codex_quota:
327
+ _append_quota_dedup_key(natural_key)
184
328
  # Durably record new directory entries (spec §4.3: fsync the parent
185
329
  # directory on first creation of a segment or the journal dir).
186
330
  if seg_created:
@@ -363,7 +363,10 @@ def _probe_stats_ok(path) -> bool:
363
363
  try:
364
364
  c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
365
365
  try:
366
- c.execute("SELECT 1").fetchone()
366
+ # Force SQLite to read the database header. A constant-only
367
+ # ``SELECT 1`` can succeed on Linux without touching a corrupt file,
368
+ # falsely reporting that a sibling already healed the index.
369
+ c.execute("PRAGMA schema_version").fetchone()
367
370
  finally:
368
371
  c.close()
369
372
  return True
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.80.0",
3
+ "version": "1.80.2",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {