cctally 1.60.0 → 1.61.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 +14 -0
- package/bin/_cctally_cache.py +323 -48
- package/bin/_cctally_core.py +8 -0
- package/bin/_cctally_dashboard.py +866 -141
- package/bin/_cctally_doctor.py +2 -0
- package/bin/_cctally_forecast.py +24 -2
- package/bin/_cctally_parser.py +28 -2
- package/bin/_cctally_tui.py +643 -16
- package/bin/_cctally_update.py +49 -9
- package/bin/_cctally_weekrefs.py +92 -2
- package/bin/_lib_doctor.py +10 -0
- package/bin/_lib_snapshot_cache.py +988 -0
- package/bin/_lib_view_models.py +107 -12
- package/bin/cctally +57 -0
- package/dashboard/static/assets/index-0jzYm75p.css +1 -0
- package/dashboard/static/assets/{index-DQH6UPc_.js → index-D_Ylyqsf.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-uGwZSssU.css +0 -1
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
"""Snapshot-rebuild cache infrastructure for the dashboard/TUI (#268).
|
|
2
|
+
|
|
3
|
+
Pure, unit-testable cache primitives for the per-tick `DataSnapshot`
|
|
4
|
+
rebuild. The dashboard's background sync thread rebuilds the whole
|
|
5
|
+
snapshot every `--sync-interval`; on a large instance a from-scratch
|
|
6
|
+
rebuild re-aggregates the entire history each tick and pegs a CPU core.
|
|
7
|
+
This module holds the cheap change-signals + immutable-aggregate caches
|
|
8
|
+
that let the rebuild recompute only the current/dirty slice and serve
|
|
9
|
+
immutable past periods from memory.
|
|
10
|
+
|
|
11
|
+
Design spec: docs/superpowers/specs/2026-07-04-dashboard-rebuild-perf-design.md
|
|
12
|
+
|
|
13
|
+
What lives here (M0):
|
|
14
|
+
- `SnapshotSignature` + `compute_signature` — a composite data-version
|
|
15
|
+
signature over EVERY table the cached surfaces read (spec §3). Cheap
|
|
16
|
+
`MAX(id)` b-tree descents + a `(count, max-rowid)` change-signal over
|
|
17
|
+
the reset-event tables, plus a monotonic generation counter. When the
|
|
18
|
+
signature is unchanged the rebuild can take the idle path.
|
|
19
|
+
- `new_min_timestamp` — the per-builder timestamp watermark (spec §3,
|
|
20
|
+
Codex F1): the earliest EVENT time among genuinely-new rows, so a
|
|
21
|
+
late/backfilled entry (new `id`, OLD `timestamp_utc`) forces recompute
|
|
22
|
+
of the affected PAST bucket, not just the current one.
|
|
23
|
+
- `bump_generation` / `current_generation` — a monotonic counter bumped
|
|
24
|
+
by any path that deletes/rewrites history in place (orphan prune,
|
|
25
|
+
`cache-sync --rebuild`); part of the signature so a deletion that
|
|
26
|
+
leaves `MAX(id)` unchanged still invalidates (spec §7, Codex F4).
|
|
27
|
+
- `BucketCache` — immutable per-past-bucket `BucketUsage` cache for the
|
|
28
|
+
Group A calendar builders (daily / monthly / weekly), spec §5.1.
|
|
29
|
+
- `SessionCache` — immutable per-session aggregate cache over the FULL
|
|
30
|
+
window for the Group B sessions builder, spec §5.2 / Codex F5.
|
|
31
|
+
|
|
32
|
+
Design invariants (spec §7):
|
|
33
|
+
- Every cached value is treated as IMMUTABLE — callers store finished
|
|
34
|
+
aggregates and never mutate them in place (SSE client threads read the
|
|
35
|
+
published snapshot concurrently).
|
|
36
|
+
- These functions take explicit `sqlite3.Connection` objects and plain
|
|
37
|
+
values; no dashboard/TUI imports, no DB opening of their own.
|
|
38
|
+
|
|
39
|
+
`session_entries` / `codex_session_entries` live in `cache.db`;
|
|
40
|
+
`weekly_usage_snapshots` / `weekly_cost_snapshots` / `week_reset_events`
|
|
41
|
+
/ `weekly_credit_floors` live in `stats.db` (bin/_cctally_db.py,
|
|
42
|
+
bin/_cctally_core.py). `compute_signature` takes both conns for that
|
|
43
|
+
reason.
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import datetime as dt
|
|
48
|
+
import os
|
|
49
|
+
import sqlite3
|
|
50
|
+
import threading
|
|
51
|
+
from typing import TYPE_CHECKING, Callable, NamedTuple
|
|
52
|
+
|
|
53
|
+
from _cctally_core import parse_iso_datetime
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING: # type hints only — no runtime coupling to the aggregators
|
|
56
|
+
from _lib_aggregators import BucketUsage, ClaudeSessionUsage
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# === Task 0.1 — composite data-version signature ===========================
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SnapshotSignature(NamedTuple):
|
|
63
|
+
"""Cheap composite change-signal over every source table (spec §3).
|
|
64
|
+
|
|
65
|
+
Equality is value-equality (NamedTuple), so an unchanged signature
|
|
66
|
+
across two ticks means no cached surface's inputs moved → idle path.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
max_entry_id: int
|
|
70
|
+
max_wus_id: int
|
|
71
|
+
max_wcs_id: int
|
|
72
|
+
reset_sig: tuple[int, int]
|
|
73
|
+
max_codex_id: int
|
|
74
|
+
generation: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _max_id(conn: sqlite3.Connection, table: str) -> int:
|
|
78
|
+
"""`MAX(id)` on an autoincrement table, 0 on empty or missing table.
|
|
79
|
+
|
|
80
|
+
A single rowid b-tree descent (O(1)-ish). Returns 0 on a fresh /
|
|
81
|
+
partially-migrated DB where the table does not yet exist, so the
|
|
82
|
+
signature never raises.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
row = conn.execute(f"SELECT COALESCE(MAX(id), 0) FROM {table}").fetchone()
|
|
86
|
+
return int(row[0])
|
|
87
|
+
except sqlite3.Error:
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _reset_sig(conn: sqlite3.Connection) -> tuple[int, int]:
|
|
92
|
+
"""Change-signal over the two reset-event tables combined (spec §3).
|
|
93
|
+
|
|
94
|
+
A credit / reset re-shapes a PAST weekly bucket with NO new
|
|
95
|
+
`session_entries` row, so the composite signature must cover it.
|
|
96
|
+
Uses `(COUNT(*), MAX(rowid))` over `week_reset_events` +
|
|
97
|
+
`weekly_credit_floors`: the count catches inserts, the max-rowid
|
|
98
|
+
catches the (rare) case where a delete+insert keeps the count level.
|
|
99
|
+
`rowid` aliases the tables' `INTEGER PRIMARY KEY id`. Returns (0, 0)
|
|
100
|
+
on a fresh DB where the tables are absent.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
row = conn.execute(
|
|
104
|
+
"SELECT (SELECT COUNT(*) FROM week_reset_events)"
|
|
105
|
+
" + (SELECT COUNT(*) FROM weekly_credit_floors),"
|
|
106
|
+
" (SELECT COALESCE(MAX(rowid), 0) FROM week_reset_events)"
|
|
107
|
+
" + (SELECT COALESCE(MAX(rowid), 0) FROM weekly_credit_floors)"
|
|
108
|
+
).fetchone()
|
|
109
|
+
return (int(row[0]), int(row[1]))
|
|
110
|
+
except sqlite3.Error:
|
|
111
|
+
return (0, 0)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def compute_signature(
|
|
115
|
+
cache_conn: sqlite3.Connection,
|
|
116
|
+
stats_conn: sqlite3.Connection,
|
|
117
|
+
*,
|
|
118
|
+
generation: int,
|
|
119
|
+
) -> SnapshotSignature:
|
|
120
|
+
"""Composite data-version signature across cache.db + stats.db (spec §3).
|
|
121
|
+
|
|
122
|
+
``cache_conn`` reads ``session_entries`` / ``codex_session_entries``
|
|
123
|
+
(cache.db); ``stats_conn`` reads ``weekly_usage_snapshots`` /
|
|
124
|
+
``weekly_cost_snapshots`` / the reset-event tables (stats.db).
|
|
125
|
+
``generation`` is the current cache-generation counter, folded in so
|
|
126
|
+
an in-place history deletion (which need not lower any ``MAX(id)``)
|
|
127
|
+
still advances the signature.
|
|
128
|
+
"""
|
|
129
|
+
return SnapshotSignature(
|
|
130
|
+
max_entry_id=_max_id(cache_conn, "session_entries"),
|
|
131
|
+
max_wus_id=_max_id(stats_conn, "weekly_usage_snapshots"),
|
|
132
|
+
max_wcs_id=_max_id(stats_conn, "weekly_cost_snapshots"),
|
|
133
|
+
reset_sig=_reset_sig(stats_conn),
|
|
134
|
+
max_codex_id=_max_id(cache_conn, "codex_session_entries"),
|
|
135
|
+
generation=int(generation),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# === Task 0.2 — new-entry timestamp watermark ==============================
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def new_min_timestamp(
|
|
143
|
+
cache_conn: sqlite3.Connection,
|
|
144
|
+
last_seen_max_id: int,
|
|
145
|
+
) -> "dt.datetime | None":
|
|
146
|
+
"""Earliest EVENT time among genuinely-new session_entries rows.
|
|
147
|
+
|
|
148
|
+
Returns ``MIN(timestamp_utc)`` over rows with ``id > last_seen_max_id``
|
|
149
|
+
as an aware UTC datetime, or ``None`` when there are no such rows.
|
|
150
|
+
|
|
151
|
+
This is the per-builder dirty-bucket watermark (spec §3, Codex F1).
|
|
152
|
+
``session_entries.id`` is INGEST order, not event time — a resumed or
|
|
153
|
+
late-ingested file produces a NEW ``id`` carrying an OLD
|
|
154
|
+
``timestamp_utc``. So when the signature advances, the affected time
|
|
155
|
+
window starts at the earliest event time among the new rows, which may
|
|
156
|
+
reach back into a PAST calendar bucket; each builder recomputes every
|
|
157
|
+
one of its buckets whose window ends after this watermark and serves
|
|
158
|
+
strictly-older buckets from cache.
|
|
159
|
+
|
|
160
|
+
Returns ``None`` on a missing table (fresh DB) so callers can treat it
|
|
161
|
+
as "no new rows".
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
row = cache_conn.execute(
|
|
165
|
+
"SELECT MIN(timestamp_utc) FROM session_entries WHERE id > ?",
|
|
166
|
+
(last_seen_max_id,),
|
|
167
|
+
).fetchone()
|
|
168
|
+
except sqlite3.Error:
|
|
169
|
+
return None
|
|
170
|
+
if row is None or row[0] is None:
|
|
171
|
+
return None
|
|
172
|
+
# Parse via the repo's canonical ISO helper (handles both `...Z` and
|
|
173
|
+
# `...+00:00` stored forms), then normalize to a UTC-tzinfo instant so
|
|
174
|
+
# downstream comparisons against UTC bucket boundaries are exact.
|
|
175
|
+
parsed = parse_iso_datetime(row[0], "session_entries.timestamp_utc")
|
|
176
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# === Task 0.3 — cache-generation counter ===================================
|
|
180
|
+
#
|
|
181
|
+
# A monotonic counter bumped by any path that deletes/rewrites history in
|
|
182
|
+
# place (orphan prune, `cache-sync --rebuild`). Folded into the composite
|
|
183
|
+
# signature (§3) so a deletion that leaves MAX(id) unchanged still
|
|
184
|
+
# invalidates the caches (spec §7, Codex F4). Guarded by a lock: the
|
|
185
|
+
# counter is bumped from the sync thread and read during signature compute
|
|
186
|
+
# on the same thread, but the lock makes any future off-thread bump safe.
|
|
187
|
+
|
|
188
|
+
_GENERATION_LOCK = threading.Lock()
|
|
189
|
+
_GENERATION = 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def bump_generation() -> int:
|
|
193
|
+
"""Monotonically advance the cache-generation counter; return the new value."""
|
|
194
|
+
global _GENERATION
|
|
195
|
+
with _GENERATION_LOCK:
|
|
196
|
+
_GENERATION += 1
|
|
197
|
+
return _GENERATION
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def current_generation() -> int:
|
|
201
|
+
"""Return the current cache-generation counter."""
|
|
202
|
+
with _GENERATION_LOCK:
|
|
203
|
+
return _GENERATION
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# === Task 0.4 — Group A bucket cache holder ================================
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class BucketCache:
|
|
210
|
+
"""Immutable per-past-bucket `BucketUsage` cache for the calendar builders.
|
|
211
|
+
|
|
212
|
+
Stores the raw immutable aggregate (a `BucketUsage`) per past bucket,
|
|
213
|
+
keyed by ``(builder_key, bucket_label)`` where ``builder_key`` is one
|
|
214
|
+
of ``"daily"`` / ``"monthly"`` / ``"weekly"`` and ``bucket_label`` is
|
|
215
|
+
that builder's bucket identifier (``"2026-06-30"`` daily, ``"2026-06"``
|
|
216
|
+
monthly, the SubWeek key for weekly).
|
|
217
|
+
|
|
218
|
+
Spec §5.1: the cache holds the RAW aggregate, never the final
|
|
219
|
+
presentation row, and values are treated IMMUTABLE — a recomputed
|
|
220
|
+
bucket is put whole (never mutated in place), so an SSE client thread
|
|
221
|
+
reading a previously-published snapshot's rows can never observe a
|
|
222
|
+
torn value (spec §7 / Codex F7).
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def __init__(self) -> None:
|
|
226
|
+
self._store: "dict[tuple[str, str], BucketUsage]" = {}
|
|
227
|
+
|
|
228
|
+
def get(self, builder_key: str, bucket_label: str) -> "BucketUsage | None":
|
|
229
|
+
"""Return the cached aggregate for this bucket, or None on a miss."""
|
|
230
|
+
return self._store.get((builder_key, bucket_label))
|
|
231
|
+
|
|
232
|
+
def put(self, builder_key: str, bucket_label: str, bucket: "BucketUsage") -> None:
|
|
233
|
+
"""Store the (immutable) aggregate for this bucket."""
|
|
234
|
+
self._store[(builder_key, bucket_label)] = bucket
|
|
235
|
+
|
|
236
|
+
def drop_from(
|
|
237
|
+
self, builder_key: str, predicate: "Callable[[str], bool]"
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Evict cached buckets for ``builder_key`` whose label matches ``predicate``.
|
|
240
|
+
|
|
241
|
+
Used to invalidate the dirty tail: pass a predicate that is True for
|
|
242
|
+
every bucket label at/after the watermark (or otherwise known dirty).
|
|
243
|
+
Only the given ``builder_key``'s namespace is touched.
|
|
244
|
+
"""
|
|
245
|
+
doomed = [
|
|
246
|
+
key
|
|
247
|
+
for key in self._store
|
|
248
|
+
if key[0] == builder_key and predicate(key[1])
|
|
249
|
+
]
|
|
250
|
+
for key in doomed:
|
|
251
|
+
del self._store[key]
|
|
252
|
+
|
|
253
|
+
def clear(self) -> None:
|
|
254
|
+
"""Drop every cached bucket across all builders (full invalidation)."""
|
|
255
|
+
self._store.clear()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# === Task 0.5 — Group B session cache holder ===============================
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class SessionCache:
|
|
262
|
+
"""Immutable per-session aggregate cache over the FULL sessions window.
|
|
263
|
+
|
|
264
|
+
Holds ALL sessions in the builder's window (spec §5.2 / Codex F5),
|
|
265
|
+
keyed by the resolved session identity — NOT just the visible top 100.
|
|
266
|
+
Sorting/truncating for the 100-row view is done over ``get_all()`` each
|
|
267
|
+
tick, so a session that was previously below the cut can promote into
|
|
268
|
+
view once it gets new activity; caching only the visible slice would
|
|
269
|
+
make that impossible.
|
|
270
|
+
|
|
271
|
+
Values are aggregated ``ClaudeSessionUsage`` rows, treated immutable: a
|
|
272
|
+
changed session is fully re-aggregated and ``put`` whole (a resumed /
|
|
273
|
+
straddling session re-aggregates from its entire entry set, so there is
|
|
274
|
+
no split-row bug), never mutated in place (spec §7 / Codex F7).
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def __init__(self) -> None:
|
|
278
|
+
self._store: "dict[str, ClaudeSessionUsage]" = {}
|
|
279
|
+
|
|
280
|
+
def get_all(self) -> "dict[str, ClaudeSessionUsage]":
|
|
281
|
+
"""Return a shallow COPY of every cached session, keyed by identity.
|
|
282
|
+
|
|
283
|
+
A copy so a caller's sort/truncate over the candidate set can never
|
|
284
|
+
mutate the module-level store (the immutable-cache discipline). The
|
|
285
|
+
aggregate values are shared (they are themselves immutable).
|
|
286
|
+
"""
|
|
287
|
+
return dict(self._store)
|
|
288
|
+
|
|
289
|
+
def put(self, session_key: str, session: "ClaudeSessionUsage") -> None:
|
|
290
|
+
"""Store (or replace) the aggregate for one session identity."""
|
|
291
|
+
self._store[session_key] = session
|
|
292
|
+
|
|
293
|
+
def drop(self, session_keys: "set[str]") -> None:
|
|
294
|
+
"""Remove the given session identities; absent keys are ignored."""
|
|
295
|
+
for key in session_keys:
|
|
296
|
+
self._store.pop(key, None)
|
|
297
|
+
|
|
298
|
+
def clear(self) -> None:
|
|
299
|
+
"""Drop every cached session (full invalidation)."""
|
|
300
|
+
self._store.clear()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# === Task 2.1 — Group A cached-bucket recompute helper =====================
|
|
304
|
+
#
|
|
305
|
+
# The shared per-past-bucket aggregate cache for the three calendar
|
|
306
|
+
# builders (daily / monthly / weekly), plus the two entry points the
|
|
307
|
+
# dashboard builders call:
|
|
308
|
+
#
|
|
309
|
+
# - ``cached_buckets`` — the PURE per-bucket assembly loop: recompute the
|
|
310
|
+
# current + caller-marked-dirty buckets whole (from a timestamp-ordered
|
|
311
|
+
# fetch, spec §5.1 / Codex F3), serve clean past buckets from the
|
|
312
|
+
# ``BucketCache``, and recompute (cold-miss) any label the cache lacks.
|
|
313
|
+
# - ``build_cached_group_a`` — the STATEFUL wrapper: each Group A builder
|
|
314
|
+
# is self-caching and independently byte-correct (M2 key decision — no
|
|
315
|
+
# dependency on the not-yet-built M5 dispatch). It tracks THIS builder's
|
|
316
|
+
# own last-seen ``(MAX(session_entries.id), extra_signature)`` alongside
|
|
317
|
+
# the builder's ``BucketCache`` namespace, derives the dirty predicate
|
|
318
|
+
# from the new-entry timestamp watermark (``new_min_timestamp``), and
|
|
319
|
+
# full-invalidates the namespace on an ``extra_signature`` change (weekly
|
|
320
|
+
# snapshot/reset legs, or the daily/monthly display-tz flip) or a
|
|
321
|
+
# ``MAX(id)`` regression (cache.db rebuilt). Cold, warm, and invalidated
|
|
322
|
+
# ticks are all byte-identical to a from-scratch aggregation.
|
|
323
|
+
#
|
|
324
|
+
# Module-level state follows the ``_PROJECTS_ENV_MEMO`` precedent (spec §7):
|
|
325
|
+
# a single shared ``BucketCache`` namespaced by ``builder_key`` and a
|
|
326
|
+
# per-builder last-seen dict. Every cached value is an IMMUTABLE
|
|
327
|
+
# ``BucketUsage``; the builders build FRESH presentation rows over the
|
|
328
|
+
# assembled list each tick and never mutate a cached aggregate (Codex F7).
|
|
329
|
+
|
|
330
|
+
_GROUP_A_CACHE = BucketCache()
|
|
331
|
+
_GROUP_A_LAST_SEEN: "dict[str, dict]" = {}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def group_a_cache() -> BucketCache:
|
|
335
|
+
"""Return the shared Group A ``BucketCache`` (daily/monthly/weekly)."""
|
|
336
|
+
return _GROUP_A_CACHE
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def reset_group_a_state() -> None:
|
|
340
|
+
"""Clear the Group A bucket cache AND every builder's last-seen state.
|
|
341
|
+
|
|
342
|
+
Test hook + the M5 orphan-prune / ``cache-sync --rebuild`` invalidation
|
|
343
|
+
entry point: after history is deleted or rewritten in place, drop every
|
|
344
|
+
cached bucket and reset the watermarks so the next rebuild recomputes
|
|
345
|
+
from scratch (spec §7 / Codex F4).
|
|
346
|
+
"""
|
|
347
|
+
_GROUP_A_CACHE.clear()
|
|
348
|
+
_GROUP_A_LAST_SEEN.clear()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def cached_buckets(
|
|
352
|
+
builder_key: str,
|
|
353
|
+
*,
|
|
354
|
+
cache: BucketCache,
|
|
355
|
+
all_bucket_labels: "list[str]",
|
|
356
|
+
current_label: "str | None",
|
|
357
|
+
dirty_predicate: "Callable[[str], bool]",
|
|
358
|
+
fetch_bucket_entries: "Callable[[str], list]",
|
|
359
|
+
aggregate_one: "Callable[[str, list], object | None]",
|
|
360
|
+
) -> "list[object]":
|
|
361
|
+
"""Assemble one Group A builder's per-bucket aggregates (spec §5.1).
|
|
362
|
+
|
|
363
|
+
For each label in ``all_bucket_labels`` (caller order — pass oldest→newest
|
|
364
|
+
so the assembled list matches ``_aggregate_*``'s ascending-key output):
|
|
365
|
+
|
|
366
|
+
- If the label is ``current_label`` (the open bucket) or ``dirty_predicate``
|
|
367
|
+
returns True (the watermark reached it, or a forced recompute), recompute
|
|
368
|
+
it WHOLE via ``aggregate_one(label, fetch_bucket_entries(label))`` — a
|
|
369
|
+
timestamp-ordered fetch, so ``_aggregate_buckets`` first-seen model order
|
|
370
|
+
reproduces the full-history pass byte-for-byte (Codex F3).
|
|
371
|
+
- Otherwise serve the cached ``BucketUsage``; on a cache MISS (cold start /
|
|
372
|
+
post-invalidation) recompute it the same way.
|
|
373
|
+
|
|
374
|
+
``aggregate_one`` returns ``None`` for a label with no data (a gap
|
|
375
|
+
day/month/week): the bucket is omitted from the result and any stale cache
|
|
376
|
+
entry for that label is evicted. The returned list therefore contains only
|
|
377
|
+
buckets-with-data — exactly what ``_aggregate_daily/_monthly/_weekly`` over
|
|
378
|
+
the full entry set returns — in ``all_bucket_labels`` order.
|
|
379
|
+
|
|
380
|
+
Values are stored/served as-is (immutable ``BucketUsage``); this loop never
|
|
381
|
+
mutates a bucket in place (spec §7).
|
|
382
|
+
"""
|
|
383
|
+
result: "list[object]" = []
|
|
384
|
+
for label in all_bucket_labels:
|
|
385
|
+
recompute = (label == current_label) or dirty_predicate(label)
|
|
386
|
+
bucket = None
|
|
387
|
+
if not recompute:
|
|
388
|
+
bucket = cache.get(builder_key, label)
|
|
389
|
+
if bucket is None: # forced recompute OR cold miss
|
|
390
|
+
bucket = aggregate_one(label, fetch_bucket_entries(label))
|
|
391
|
+
if bucket is None:
|
|
392
|
+
# Gap bucket (no data): drop any stale cache entry, omit from output.
|
|
393
|
+
cache.drop_from(builder_key, lambda lbl, _t=label: lbl == _t)
|
|
394
|
+
else:
|
|
395
|
+
cache.put(builder_key, label, bucket)
|
|
396
|
+
result.append(bucket)
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def build_cached_group_a(
|
|
401
|
+
builder_key: str,
|
|
402
|
+
*,
|
|
403
|
+
cache_conn: sqlite3.Connection,
|
|
404
|
+
all_bucket_labels: "list[str]",
|
|
405
|
+
current_label: "str | None",
|
|
406
|
+
bucket_end_of: "Callable[[str], dt.datetime | None]",
|
|
407
|
+
fetch_bucket_entries: "Callable[[str], list]",
|
|
408
|
+
aggregate_one: "Callable[[str, list], object | None]",
|
|
409
|
+
extra_signature: object = None,
|
|
410
|
+
) -> "list[object]":
|
|
411
|
+
"""Stateful Group A assembly: invalidation + watermark + ``cached_buckets``.
|
|
412
|
+
|
|
413
|
+
``cache_conn`` is a ``cache.db`` connection (reads
|
|
414
|
+
``MAX(session_entries.id)`` + the new-entry watermark). ``bucket_end_of``
|
|
415
|
+
maps a label to an aware-UTC datetime that is at-or-after that bucket's
|
|
416
|
+
window end — a bucket is watermark-dirty when its end is after
|
|
417
|
+
``new_min_timestamp`` (over-estimating the end only over-marks buckets
|
|
418
|
+
dirty, which is safe: it never serves stale past data). ``extra_signature``
|
|
419
|
+
is any hashable value whose change forces a full namespace invalidation
|
|
420
|
+
(the weekly snapshot/reset legs, or the daily/monthly display-tz label) —
|
|
421
|
+
e.g. a weekly bucket still transitioning through a credit rides the
|
|
422
|
+
weekly builder's ``extra_signature`` full-invalidate rather than a
|
|
423
|
+
per-label recompute flag.
|
|
424
|
+
|
|
425
|
+
Returns the assembled ``BucketUsage`` list (cache hits for clean past
|
|
426
|
+
labels, fresh recompute for current + dirty), in ``all_bucket_labels``
|
|
427
|
+
order, and updates this builder's last-seen state.
|
|
428
|
+
"""
|
|
429
|
+
cur_max_id = _max_id(cache_conn, "session_entries")
|
|
430
|
+
state = _GROUP_A_LAST_SEEN.get(builder_key)
|
|
431
|
+
full_invalidate = state is not None and (
|
|
432
|
+
state.get("extra") != extra_signature
|
|
433
|
+
or cur_max_id < int(state.get("max_id", 0))
|
|
434
|
+
)
|
|
435
|
+
if full_invalidate:
|
|
436
|
+
_GROUP_A_CACHE.drop_from(builder_key, lambda _lbl: True)
|
|
437
|
+
|
|
438
|
+
if state is None or full_invalidate:
|
|
439
|
+
# Cold start OR post-invalidation: recompute every label. (Cold already
|
|
440
|
+
# cache-misses; being explicit also covers the boundary/signature-shift
|
|
441
|
+
# case where a stale label could otherwise collide with a fresh window.)
|
|
442
|
+
def dirty(_label: str) -> bool:
|
|
443
|
+
return True
|
|
444
|
+
else:
|
|
445
|
+
new_min_ts = new_min_timestamp(cache_conn, int(state.get("max_id", 0)))
|
|
446
|
+
|
|
447
|
+
def dirty(label: str) -> bool:
|
|
448
|
+
if new_min_ts is None:
|
|
449
|
+
return False
|
|
450
|
+
end = bucket_end_of(label)
|
|
451
|
+
return end is not None and end > new_min_ts
|
|
452
|
+
|
|
453
|
+
buckets = cached_buckets(
|
|
454
|
+
builder_key,
|
|
455
|
+
cache=_GROUP_A_CACHE,
|
|
456
|
+
all_bucket_labels=all_bucket_labels,
|
|
457
|
+
current_label=current_label,
|
|
458
|
+
dirty_predicate=dirty,
|
|
459
|
+
fetch_bucket_entries=fetch_bucket_entries,
|
|
460
|
+
aggregate_one=aggregate_one,
|
|
461
|
+
)
|
|
462
|
+
_GROUP_A_LAST_SEEN[builder_key] = {"max_id": cur_max_id, "extra": extra_signature}
|
|
463
|
+
return buckets
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# === Task 3.1 — changed-session resolution (join + filename-stem fallback) ==
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def affected_session_keys(
|
|
470
|
+
cache_conn: sqlite3.Connection,
|
|
471
|
+
last_seen_max_id: int,
|
|
472
|
+
) -> "set[str]":
|
|
473
|
+
"""Resolved session identities for entries with ``id > last_seen`` (spec §5.2).
|
|
474
|
+
|
|
475
|
+
Mirrors ``_aggregate_claude_sessions`` grouping EXACTLY: identity is
|
|
476
|
+
``session_files.session_id`` when the ``LEFT JOIN`` on ``source_path``
|
|
477
|
+
yields a non-null id, else the filename-stem of ``source_path``
|
|
478
|
+
(``os.path.splitext(os.path.basename(path))[0]``) — the same fallback
|
|
479
|
+
the aggregator applies when ``entry.session_id is None``
|
|
480
|
+
(``bin/_lib_aggregators.py``). ``<synthetic>``-model rows are skipped
|
|
481
|
+
(the aggregator skips them before the fallback), so a purely-synthetic
|
|
482
|
+
new row contributes no key.
|
|
483
|
+
|
|
484
|
+
``session_entries`` has NO ``session_id`` column — identity comes from
|
|
485
|
+
the join to ``session_files`` — so the returned keys key IDENTICALLY to
|
|
486
|
+
the aggregator's session grouping (Codex F5). Returns an empty set on a
|
|
487
|
+
missing table (fresh / partially-migrated DB) so callers never raise.
|
|
488
|
+
"""
|
|
489
|
+
try:
|
|
490
|
+
rows = cache_conn.execute(
|
|
491
|
+
"SELECT se.source_path, sf.session_id "
|
|
492
|
+
"FROM session_entries se "
|
|
493
|
+
"LEFT JOIN session_files sf ON sf.path = se.source_path "
|
|
494
|
+
"WHERE se.id > ? AND se.model != '<synthetic>'",
|
|
495
|
+
(last_seen_max_id,),
|
|
496
|
+
).fetchall()
|
|
497
|
+
except sqlite3.Error:
|
|
498
|
+
return set()
|
|
499
|
+
keys: "set[str]" = set()
|
|
500
|
+
for source_path, session_id in rows:
|
|
501
|
+
if session_id is not None:
|
|
502
|
+
keys.add(session_id)
|
|
503
|
+
else:
|
|
504
|
+
keys.add(os.path.splitext(os.path.basename(source_path))[0])
|
|
505
|
+
return keys
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# === Task 3.2 — Group B session aggregate cache over the FULL window =======
|
|
509
|
+
#
|
|
510
|
+
# Module-level state following the Group A precedent (spec §7): a single
|
|
511
|
+
# shared ``SessionCache`` holding ALL sessions in the 365-day window (NOT
|
|
512
|
+
# just the visible top 100 — Codex F5), plus this builder's own last-seen
|
|
513
|
+
# ``MAX(session_entries.id)`` watermark. Every cached value is an immutable
|
|
514
|
+
# ``ClaudeSessionUsage``; the builder builds FRESH ``TuiSessionRow`` objects
|
|
515
|
+
# from the assembled list each tick and never mutates a cached aggregate
|
|
516
|
+
# (Codex F7). Mutated ONLY on the sync thread (single-writer, like Group A).
|
|
517
|
+
|
|
518
|
+
_SESSION_CACHE = SessionCache()
|
|
519
|
+
_SESSION_LAST_SEEN: "dict" = {}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def session_cache() -> SessionCache:
|
|
523
|
+
"""Return the shared Group B ``SessionCache`` (all sessions in-window)."""
|
|
524
|
+
return _SESSION_CACHE
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def reset_session_cache_state() -> None:
|
|
528
|
+
"""Clear the session cache AND its last-seen watermark (full invalidation).
|
|
529
|
+
|
|
530
|
+
Test hook + the M5 orphan-prune / ``cache-sync --rebuild`` invalidation
|
|
531
|
+
entry point: after history is deleted or rewritten in place, drop every
|
|
532
|
+
cached session and reset the watermark so the next rebuild re-aggregates
|
|
533
|
+
the whole window from scratch (spec §7 / Codex F4).
|
|
534
|
+
"""
|
|
535
|
+
_SESSION_CACHE.clear()
|
|
536
|
+
_SESSION_LAST_SEEN.clear()
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def build_cached_sessions(
|
|
540
|
+
*,
|
|
541
|
+
cache_conn: sqlite3.Connection,
|
|
542
|
+
aggregate_all: "Callable[[], list]",
|
|
543
|
+
reaggregate: "Callable[[int, set], list]",
|
|
544
|
+
extra_signature: object = None,
|
|
545
|
+
) -> "list":
|
|
546
|
+
"""Stateful Group B assembly: cold full-aggregate / warm affected-only (spec §5.2).
|
|
547
|
+
|
|
548
|
+
``cache_conn`` is a ``cache.db`` connection (reads
|
|
549
|
+
``MAX(session_entries.id)`` + the affected-session set). ``aggregate_all``
|
|
550
|
+
returns the full ``list[ClaudeSessionUsage]`` for the window (cold path).
|
|
551
|
+
``reaggregate(last_seen, affected_keys)`` returns the re-aggregated
|
|
552
|
+
``ClaudeSessionUsage`` for exactly the sessions touched since ``last_seen``
|
|
553
|
+
(warm path) — a straddling/resumed session re-aggregates WHOLE from its
|
|
554
|
+
own full in-window entry set, so no split-row bug. ``extra_signature`` is
|
|
555
|
+
any hashable whose change forces a full cold rebuild.
|
|
556
|
+
|
|
557
|
+
Cold when: no prior state, ``extra_signature`` changed, or a
|
|
558
|
+
``MAX(id)`` regression (cache.db rebuilt). Warm otherwise, re-aggregating
|
|
559
|
+
only the affected sessions and updating their cache rows in place of the
|
|
560
|
+
stale ones. Returns the FULL cached session list (UNSORTED — the caller
|
|
561
|
+
window-filters, sorts by ``last_activity`` desc, and truncates to the
|
|
562
|
+
view limit, which is what preserves correct eviction/**promotion** at the
|
|
563
|
+
100-row boundary, Codex F5).
|
|
564
|
+
|
|
565
|
+
Each returned/stored value is an immutable ``ClaudeSessionUsage`` keyed by
|
|
566
|
+
its resolved ``session_id`` (which the aggregator sets to the stem for
|
|
567
|
+
fallback sessions), so the cache keys match ``affected_session_keys``.
|
|
568
|
+
"""
|
|
569
|
+
cur_max_id = _max_id(cache_conn, "session_entries")
|
|
570
|
+
state = _SESSION_LAST_SEEN
|
|
571
|
+
cold = (
|
|
572
|
+
not state
|
|
573
|
+
or state.get("extra") != extra_signature
|
|
574
|
+
or cur_max_id < int(state.get("max_id", 0))
|
|
575
|
+
)
|
|
576
|
+
if cold:
|
|
577
|
+
_SESSION_CACHE.clear()
|
|
578
|
+
for sess in aggregate_all():
|
|
579
|
+
_SESSION_CACHE.put(sess.session_id, sess)
|
|
580
|
+
else:
|
|
581
|
+
last_seen = int(state.get("max_id", 0))
|
|
582
|
+
if cur_max_id > last_seen:
|
|
583
|
+
affected = affected_session_keys(cache_conn, last_seen)
|
|
584
|
+
if affected:
|
|
585
|
+
for sess in reaggregate(last_seen, affected):
|
|
586
|
+
_SESSION_CACHE.put(sess.session_id, sess)
|
|
587
|
+
_SESSION_LAST_SEEN.clear()
|
|
588
|
+
_SESSION_LAST_SEEN["max_id"] = cur_max_id
|
|
589
|
+
_SESSION_LAST_SEEN["extra"] = extra_signature
|
|
590
|
+
return list(_SESSION_CACHE.get_all().values())
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
# === Task 4.2 — doctor payload TTL memo (spec §6) ==========================
|
|
594
|
+
#
|
|
595
|
+
# The dashboard envelope used to re-fork the `security` keychain subprocess
|
|
596
|
+
# (via `doctor_gather_state`) once PER SSE CLIENT PER TICK. §6 moves the
|
|
597
|
+
# doctor gather onto the sync-thread `DataSnapshot` (precomputed once per
|
|
598
|
+
# rebuild). This short-TTL memo further guards against back-to-back WARM
|
|
599
|
+
# rebuilds re-forking `security` every tick — the keychain/symlink/log state
|
|
600
|
+
# it reads changes rarely. The `compute` callable is INJECTED so this module
|
|
601
|
+
# stays decoupled from the doctor I/O layer (no `_cctally_doctor` import).
|
|
602
|
+
# The lazy `GET /api/doctor` endpoint deliberately does NOT route through
|
|
603
|
+
# this memo — an explicit user refresh must be live.
|
|
604
|
+
|
|
605
|
+
DOCTOR_MEMO_TTL_S = 30.0
|
|
606
|
+
|
|
607
|
+
_DOCTOR_MEMO_LOCK = threading.Lock()
|
|
608
|
+
_DOCTOR_MEMO: "dict" = {}
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def doctor_payload_memo(
|
|
612
|
+
now_utc: dt.datetime,
|
|
613
|
+
runtime_bind: "str | None",
|
|
614
|
+
*,
|
|
615
|
+
ttl_s: float,
|
|
616
|
+
compute: "Callable[[dt.datetime, str | None], dict]",
|
|
617
|
+
) -> dict:
|
|
618
|
+
"""Return the doctor envelope payload, recomputing via ``compute`` only
|
|
619
|
+
when the memo is cold — never computed, older than ``ttl_s``, a clock
|
|
620
|
+
regression (``now_utc`` before the cached instant), or a ``runtime_bind``
|
|
621
|
+
change (the bind feeds ``safety.dashboard_bind``).
|
|
622
|
+
|
|
623
|
+
``compute(now_utc, runtime_bind) -> dict`` is the injected
|
|
624
|
+
gather→checks→envelope-dict step; it runs OUTSIDE the lock so the
|
|
625
|
+
`security` subprocess fork never serializes other readers. In practice
|
|
626
|
+
only the sync thread calls this, so the (harmless) double-compute a
|
|
627
|
+
concurrent caller could trigger never happens.
|
|
628
|
+
"""
|
|
629
|
+
with _DOCTOR_MEMO_LOCK:
|
|
630
|
+
cached = _DOCTOR_MEMO
|
|
631
|
+
computed_at = cached.get("computed_at")
|
|
632
|
+
fresh = (
|
|
633
|
+
bool(cached)
|
|
634
|
+
and cached.get("runtime_bind") == runtime_bind
|
|
635
|
+
and computed_at is not None
|
|
636
|
+
and now_utc >= computed_at
|
|
637
|
+
and (now_utc - computed_at).total_seconds() < ttl_s
|
|
638
|
+
)
|
|
639
|
+
if fresh:
|
|
640
|
+
return cached["payload"]
|
|
641
|
+
payload = compute(now_utc, runtime_bind)
|
|
642
|
+
with _DOCTOR_MEMO_LOCK:
|
|
643
|
+
_DOCTOR_MEMO.clear()
|
|
644
|
+
_DOCTOR_MEMO["payload"] = payload
|
|
645
|
+
_DOCTOR_MEMO["computed_at"] = now_utc
|
|
646
|
+
_DOCTOR_MEMO["runtime_bind"] = runtime_bind
|
|
647
|
+
return payload
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def reset_doctor_memo() -> None:
|
|
651
|
+
"""Drop the memoized doctor payload (test hook + M5 invalidation entry)."""
|
|
652
|
+
with _DOCTOR_MEMO_LOCK:
|
|
653
|
+
_DOCTOR_MEMO.clear()
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# === Task 5.1 — idle-path dispatch state (last signature + snapshot) ========
|
|
657
|
+
#
|
|
658
|
+
# The dashboard sync-thread rebuild computes the composite ``SnapshotSignature``
|
|
659
|
+
# at the top of every tick; when it is UNCHANGED versus the last published
|
|
660
|
+
# rebuild AND no wall-clock day/week/month boundary has rolled over, the rebuild
|
|
661
|
+
# takes the IDLE path (spec §3): it reuses the last published snapshot's heavy
|
|
662
|
+
# period/session rows and re-patches only the time-derived fields, skipping ALL
|
|
663
|
+
# re-aggregation — so an idle dashboard sits near 0% CPU. This module holds that
|
|
664
|
+
# last ``(signature, snapshot)`` pair.
|
|
665
|
+
#
|
|
666
|
+
# Sync-thread-only, single-writer — same discipline as the Group A / session
|
|
667
|
+
# caches (spec §7). The snapshot is stored as an OPAQUE object: this module
|
|
668
|
+
# never introspects it, keeping the "no dashboard/TUI import" design (the caller
|
|
669
|
+
# in bin/_cctally_tui.py owns the ``DataSnapshot`` type and all patching).
|
|
670
|
+
|
|
671
|
+
_LAST_DISPATCH_KEY: object = None
|
|
672
|
+
_LAST_PUBLISHED_SNAPSHOT: object = None
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def dispatch_state() -> "tuple[object, object]":
|
|
676
|
+
"""Return the ``(last dispatch key, last published snapshot)`` pair (spec §3).
|
|
677
|
+
|
|
678
|
+
``(None, None)`` before the first rebuild or after a reset. The dispatch key
|
|
679
|
+
is the caller's opaque hashable — the composite ``SnapshotSignature`` bundled
|
|
680
|
+
with a render key (resolved display-tz + config) covering the render inputs
|
|
681
|
+
the DB signature does not; the caller compares it whole. The snapshot is an
|
|
682
|
+
opaque ``DataSnapshot`` reference the caller owns.
|
|
683
|
+
"""
|
|
684
|
+
return (_LAST_DISPATCH_KEY, _LAST_PUBLISHED_SNAPSHOT)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def store_dispatch_state(dispatch_key: object, snapshot: object) -> None:
|
|
688
|
+
"""Record the dispatch key + published snapshot for the next idle short-circuit.
|
|
689
|
+
|
|
690
|
+
Called once per rebuild (idle or full) so the next tick compares against the
|
|
691
|
+
key the just-published snapshot was built from.
|
|
692
|
+
"""
|
|
693
|
+
global _LAST_DISPATCH_KEY, _LAST_PUBLISHED_SNAPSHOT
|
|
694
|
+
_LAST_DISPATCH_KEY = dispatch_key
|
|
695
|
+
_LAST_PUBLISHED_SNAPSHOT = snapshot
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def reset_dispatch_state() -> None:
|
|
699
|
+
"""Drop the idle-path ``(key, snapshot)`` memo (test hook + isolation).
|
|
700
|
+
|
|
701
|
+
A fresh process starts with no memo; tests reset it between rebuilds so a
|
|
702
|
+
prior test's leftover snapshot can't be idle-served under a matching key. Not
|
|
703
|
+
part of the M5.2 prune invalidation — the generation bump (a signature leg)
|
|
704
|
+
already forces the next rebuild off the idle path.
|
|
705
|
+
"""
|
|
706
|
+
global _LAST_DISPATCH_KEY, _LAST_PUBLISHED_SNAPSHOT
|
|
707
|
+
_LAST_DISPATCH_KEY = None
|
|
708
|
+
_LAST_PUBLISHED_SNAPSHOT = None
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# === #269 M0 — shared per-weekref immutable-cost cache (B1 trend + B3 forecast)
|
|
712
|
+
#
|
|
713
|
+
# A closed subscription week's cost is IMMUTABLE, so it is computed once and
|
|
714
|
+
# reused until a signal invalidates it (spec §4). Both `build_trend_view`'s
|
|
715
|
+
# reset-event weeks (`_compute_cost_for_weekref`) and forecast's trailing-4-week
|
|
716
|
+
# fallback (`_select_dollars_per_percent` → `_sum_cost_for_range`) call the same
|
|
717
|
+
# per-closed-week cost primitive from two sites; one shared cache keyed by the
|
|
718
|
+
# week's `(week_start_at, week_end_at)` boundaries serves both. The OPEN (current)
|
|
719
|
+
# week is never cached — it is decided per call from `week_end_at > now_utc`, so a
|
|
720
|
+
# just-closed week caches on the next tick and the newly-opened week always
|
|
721
|
+
# recomputes (no `_snapshot_period_rolled_over` dependence).
|
|
722
|
+
#
|
|
723
|
+
# Module-level state follows the Group A / session-cache precedent (spec §7): a
|
|
724
|
+
# plain dict of immutable floats + a per-cache last-seen dict, mutated only on the
|
|
725
|
+
# dashboard sync thread (single-writer). Every cached value is an immutable float;
|
|
726
|
+
# each rebuild builds FRESH trend / forecast presentation objects and never
|
|
727
|
+
# mutates a cached value (Codex F7).
|
|
728
|
+
|
|
729
|
+
_WEEKREF_COST_CACHE: dict = {} # {(week_start_iso, week_end_iso): cost_usd}
|
|
730
|
+
_WEEKREF_COST_LAST_SEEN: dict = {} # {"max_id": int, "reset_sig": tuple}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _weekref_key(week_start_at, week_end_at):
|
|
734
|
+
"""Canonical UTC-ISO key for a subscription week's cost.
|
|
735
|
+
|
|
736
|
+
Normalizes both boundaries to UTC before serializing, so two callers that
|
|
737
|
+
resolve the same physical week in different tzinfos key identically.
|
|
738
|
+
"""
|
|
739
|
+
return (
|
|
740
|
+
week_start_at.astimezone(dt.timezone.utc).isoformat(),
|
|
741
|
+
week_end_at.astimezone(dt.timezone.utc).isoformat(),
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def reset_weekref_cost_state():
|
|
746
|
+
"""Clear the weekref-cost cache + its watermark (full invalidation).
|
|
747
|
+
|
|
748
|
+
Called from the orphan-prune site (a prune deletes ``session_entries``
|
|
749
|
+
possibly WITHOUT lowering ``MAX(id)``, so the reconcile's max-id-regression
|
|
750
|
+
check cannot catch it — the explicit clear must) and as a test hook.
|
|
751
|
+
"""
|
|
752
|
+
_WEEKREF_COST_CACHE.clear()
|
|
753
|
+
_WEEKREF_COST_LAST_SEEN.clear()
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def cached_weekref_cost(*, week_start_at, week_end_at, now_utc, compute):
|
|
757
|
+
"""Get-or-compute a subscription week's cost (spec §4).
|
|
758
|
+
|
|
759
|
+
The OPEN week (``week_end_at > now_utc``) is always recomputed and never
|
|
760
|
+
cached — open-vs-closed is decided per call so a just-closed week caches on
|
|
761
|
+
the next tick and the newly-opened week always recomputes. A CLOSED week is
|
|
762
|
+
served from ``_WEEKREF_COST_CACHE`` on a hit, else computed via ``compute``
|
|
763
|
+
and stored. ``compute`` is the caller's from-scratch closure
|
|
764
|
+
(``_compute_cost_for_weekref`` for B1, ``_sum_cost_for_range`` for B3), so
|
|
765
|
+
the returned float is bit-identical to today's.
|
|
766
|
+
"""
|
|
767
|
+
if week_end_at > now_utc:
|
|
768
|
+
return compute()
|
|
769
|
+
key = _weekref_key(week_start_at, week_end_at)
|
|
770
|
+
hit = _WEEKREF_COST_CACHE.get(key)
|
|
771
|
+
if hit is not None: # 0.0 is a legitimate cached value, not a miss
|
|
772
|
+
return hit
|
|
773
|
+
val = compute()
|
|
774
|
+
_WEEKREF_COST_CACHE[key] = val
|
|
775
|
+
return val
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def reconcile_weekref_cache(cache_conn, *, max_entry_id, reset_sig):
|
|
779
|
+
"""Once-per-non-idle-rebuild invalidation for the weekref-cost cache (spec §4).
|
|
780
|
+
|
|
781
|
+
Driven by ``_tui_build_snapshot`` after the idle-path check, before the
|
|
782
|
+
builders run, using the already-computed dispatch-signature legs
|
|
783
|
+
(``max_entry_id`` + ``reset_sig`` are passed in — no extra query for those):
|
|
784
|
+
|
|
785
|
+
- Cold (no last-seen): record last-seen, return — no eviction.
|
|
786
|
+
- ``reset_sig`` changed OR ``max_entry_id < last_seen`` (cache.db rebuilt
|
|
787
|
+
out-of-process): full ``clear()``. A credit/reset re-shapes a past week's
|
|
788
|
+
cost; reset events are rare, so a conservative full clear is correct and
|
|
789
|
+
cheap. A max-id regression means the ids no longer map to the same rows.
|
|
790
|
+
- ``max_entry_id > last_seen``: evict cached weeks whose ``week_end_at`` is
|
|
791
|
+
``>= new_min_timestamp(cache_conn, last_seen)`` — a genuinely-new row
|
|
792
|
+
could fall inside them (F1 late-ingest). The bound is ``>=``, NOT ``>``,
|
|
793
|
+
because ``_sum_cost_for_range`` / ``iter_entries`` sum an inclusive
|
|
794
|
+
``[start, end]`` window, so a row whose timestamp lands exactly on
|
|
795
|
+
``week_end_at`` belongs to that week (Codex-1). Over-eviction is byte-safe
|
|
796
|
+
(forces a recompute). Normally ``wm`` is recent and no closed week drops.
|
|
797
|
+
|
|
798
|
+
Idempotent within a tick: after the first call updates last-seen, a later
|
|
799
|
+
call in the same tick sees no delta (``max_entry_id == last_seen``,
|
|
800
|
+
``reset_sig`` unchanged) and no-ops — never re-running the watermark query.
|
|
801
|
+
|
|
802
|
+
Connection lifecycle (Codex-4): the ``new_min_timestamp`` watermark query is
|
|
803
|
+
the only use of ``cache_conn`` and runs only on the
|
|
804
|
+
``max_entry_id > last_seen`` branch; the caller passes a short-lived cache
|
|
805
|
+
connection, opened for that query.
|
|
806
|
+
"""
|
|
807
|
+
ls = _WEEKREF_COST_LAST_SEEN
|
|
808
|
+
if not ls: # cold
|
|
809
|
+
ls["max_id"] = max_entry_id
|
|
810
|
+
ls["reset_sig"] = reset_sig
|
|
811
|
+
return
|
|
812
|
+
if reset_sig != ls["reset_sig"] or max_entry_id < ls["max_id"]:
|
|
813
|
+
_WEEKREF_COST_CACHE.clear() # reset/credit, or cache.db rebuilt
|
|
814
|
+
ls["max_id"] = max_entry_id
|
|
815
|
+
ls["reset_sig"] = reset_sig
|
|
816
|
+
return
|
|
817
|
+
if max_entry_id > ls["max_id"]:
|
|
818
|
+
wm = new_min_timestamp(cache_conn, ls["max_id"])
|
|
819
|
+
if wm is not None:
|
|
820
|
+
for key in list(_WEEKREF_COST_CACHE):
|
|
821
|
+
# key = (start_iso, end_iso); inclusive [start,end] window, so
|
|
822
|
+
# evict when the week's end >= the earliest new event time.
|
|
823
|
+
if dt.datetime.fromisoformat(key[1]) >= wm:
|
|
824
|
+
del _WEEKREF_COST_CACHE[key]
|
|
825
|
+
ls["max_id"] = max_entry_id
|
|
826
|
+
ls["reset_sig"] = reset_sig
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
# === #269 M4 — projects-envelope per-(project, week) incremental cache =======
|
|
830
|
+
#
|
|
831
|
+
# `_build_projects_envelope` re-iterates all ~190K window entries every warm
|
|
832
|
+
# tick, doing a per-entry `_resolve_project_key` + cost + per-(project, week)
|
|
833
|
+
# aggregation. At scale that builder DOMINATES the warm rebuild (spec §13). A
|
|
834
|
+
# CLOSED week's per-project aggregate is IMMUTABLE, so cache it and recompute
|
|
835
|
+
# only the CURRENT week each warm tick (spec §14 Win 2).
|
|
836
|
+
#
|
|
837
|
+
# Storage (opaque — this module never introspects the aggregate object, keeping
|
|
838
|
+
# the "no dashboard/TUI import" design, exactly like `BucketCache`):
|
|
839
|
+
# - `_PROJECTS_ENV_WEEK_CACHE`: `{(bucket_path, week_iso) -> agg}` per closed
|
|
840
|
+
# week. The dashboard packs a `(cost_usd, sessions_count, first_seen,
|
|
841
|
+
# last_seen, first_order, first_id, first_key)` record; here it is opaque.
|
|
842
|
+
# - `_PROJECTS_ENV_WEEK_TOTALS`: `{week_iso -> total_cost}` cached as its OWN
|
|
843
|
+
# entry-order aggregate (spec §14(a) — never re-summed from project buckets,
|
|
844
|
+
# which would re-associate the float sum). ALSO the "week computed" registry:
|
|
845
|
+
# a week is a cache HIT iff its `week_iso` is present here (an empty computed
|
|
846
|
+
# week is stored with total 0.0 and no bucket rows, so cold empty weeks are
|
|
847
|
+
# not re-queried every tick).
|
|
848
|
+
# - `_PROJECTS_ENV_LAST_SEEN`: `{max_id, max_wus_id, sf_sig}` this cache last
|
|
849
|
+
# reconciled against.
|
|
850
|
+
#
|
|
851
|
+
# Single-writer (sync thread only), immutable values, fresh presentation each
|
|
852
|
+
# tick — the Group A / weekref discipline (spec §6, Codex F7).
|
|
853
|
+
|
|
854
|
+
_PROJECTS_ENV_WEEK_CACHE: dict = {} # {(bucket_path, week_iso): agg}
|
|
855
|
+
_PROJECTS_ENV_WEEK_TOTALS: dict = {} # {week_iso: total_cost} (also the registry)
|
|
856
|
+
_PROJECTS_ENV_LAST_SEEN: dict = {} # {"max_id", "max_wus_id", "sf_sig"}
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def projects_env_week_key(week_start):
|
|
860
|
+
"""Canonical UTC-ISO key for a Monday-anchored subscription week start.
|
|
861
|
+
|
|
862
|
+
The dashboard resolves week starts as aware-UTC datetimes; normalizing to
|
|
863
|
+
UTC before serializing keeps the key stable and parseable back by
|
|
864
|
+
``reconcile_projects_env_cache`` (for the ``week_end`` watermark compare).
|
|
865
|
+
"""
|
|
866
|
+
return week_start.astimezone(dt.timezone.utc).isoformat()
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def reset_projects_env_state():
|
|
870
|
+
"""Clear the projects-envelope week cache + totals + watermark.
|
|
871
|
+
|
|
872
|
+
Called from the orphan-prune site (a prune deletes ``session_entries``
|
|
873
|
+
possibly WITHOUT lowering ``MAX(id)``, so the reconcile's regression check
|
|
874
|
+
cannot catch it — the explicit clear must) and as a test hook.
|
|
875
|
+
"""
|
|
876
|
+
_PROJECTS_ENV_WEEK_CACHE.clear()
|
|
877
|
+
_PROJECTS_ENV_WEEK_TOTALS.clear()
|
|
878
|
+
_PROJECTS_ENV_LAST_SEEN.clear()
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def session_files_sig(cache_conn) -> "tuple[int, int]":
|
|
882
|
+
"""`(COUNT(*), COALESCE(MAX(rowid), 0))` over ``session_files`` (Codex-M4 P2).
|
|
883
|
+
|
|
884
|
+
``sync_cache`` lazily backfills ``session_files.session_id`` /
|
|
885
|
+
``project_path`` for OLD entries — moving a closed week's row from
|
|
886
|
+
``(unknown)`` to a project, or changing a per-week session count — WITHOUT
|
|
887
|
+
advancing ``MAX(session_entries.id)`` / ``MAX(weekly_usage_snapshots.id)``.
|
|
888
|
+
So the envelope cache keys this cheap change-signal and full-clears when it
|
|
889
|
+
moves. Returns ``(0, 0)`` on a missing table (fresh DB) so callers never
|
|
890
|
+
raise.
|
|
891
|
+
"""
|
|
892
|
+
try:
|
|
893
|
+
row = cache_conn.execute(
|
|
894
|
+
"SELECT COUNT(*), COALESCE(MAX(rowid), 0) FROM session_files"
|
|
895
|
+
).fetchone()
|
|
896
|
+
return (int(row[0]), int(row[1]))
|
|
897
|
+
except sqlite3.Error:
|
|
898
|
+
return (0, 0)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def projects_env_week_get(week_iso):
|
|
902
|
+
"""Return ``(buckets_by_bucket_path, total_cost)`` for a cached week, or
|
|
903
|
+
``None`` on a miss.
|
|
904
|
+
|
|
905
|
+
``_PROJECTS_ENV_WEEK_TOTALS`` is the presence registry: a week is a HIT iff
|
|
906
|
+
its key is present (an empty computed week is stored with total 0.0 and no
|
|
907
|
+
bucket rows, so it is a HIT with an empty bucket map). The bucket rows are
|
|
908
|
+
reassembled by scanning ``_PROJECTS_ENV_WEEK_CACHE`` for this week's keys —
|
|
909
|
+
the distinct-bucket_path count is small (project count), so the scan is
|
|
910
|
+
cheap.
|
|
911
|
+
"""
|
|
912
|
+
if week_iso not in _PROJECTS_ENV_WEEK_TOTALS:
|
|
913
|
+
return None
|
|
914
|
+
total = _PROJECTS_ENV_WEEK_TOTALS[week_iso]
|
|
915
|
+
by_bp = {
|
|
916
|
+
bp: agg
|
|
917
|
+
for (bp, wk), agg in _PROJECTS_ENV_WEEK_CACHE.items()
|
|
918
|
+
if wk == week_iso
|
|
919
|
+
}
|
|
920
|
+
return by_bp, total
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def projects_env_week_put(week_iso, buckets_by_bp, total) -> None:
|
|
924
|
+
"""Store one CLOSED week's per-bucket aggregates + entry-order total.
|
|
925
|
+
|
|
926
|
+
``total`` is registered even when ``buckets_by_bp`` is empty, so a
|
|
927
|
+
computed-empty week is a HIT (not a perpetual miss). Values are stored
|
|
928
|
+
as-is (immutable); this never mutates a stored aggregate in place.
|
|
929
|
+
"""
|
|
930
|
+
_PROJECTS_ENV_WEEK_TOTALS[week_iso] = total
|
|
931
|
+
for bp, agg in buckets_by_bp.items():
|
|
932
|
+
_PROJECTS_ENV_WEEK_CACHE[(bp, week_iso)] = agg
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def reconcile_projects_env_cache(cache_conn, *, max_entry_id, max_wus_id, sf_sig):
|
|
936
|
+
"""Once-per-non-idle-rebuild invalidation for the projects-envelope cache.
|
|
937
|
+
|
|
938
|
+
Driven by ``_tui_build_snapshot`` after the idle-path check, before the
|
|
939
|
+
envelope builder runs, using the dispatch-signature legs (``max_entry_id``,
|
|
940
|
+
``max_wus_id``) + a ``session_files`` signal (``sf_sig``) passed in:
|
|
941
|
+
|
|
942
|
+
- Cold (no last-seen): record last-seen, return — no eviction.
|
|
943
|
+
- ``max_wus_id`` changed (attribution denominator), ``sf_sig`` changed
|
|
944
|
+
(attribution backfill, Codex-M4 P2), OR ``max_entry_id < last_seen``
|
|
945
|
+
(cache.db rebuilt): full clear. Conservative but byte-safe (recompute).
|
|
946
|
+
- ``max_entry_id > last_seen``: evict cached weeks (and their bucket rows +
|
|
947
|
+
week total) whose ``week_end (= parse(week_iso) + 7d)`` is
|
|
948
|
+
``>= new_min_timestamp(cache_conn, last_seen)`` — a genuinely-new row
|
|
949
|
+
could fall inside them (F1 late-ingest). The bound is ``>=`` (Codex-1);
|
|
950
|
+
over-eviction is byte-safe.
|
|
951
|
+
|
|
952
|
+
Idempotent within a tick: after the first call updates last-seen, a later
|
|
953
|
+
call with the same signature sees no delta and no-ops (never re-running the
|
|
954
|
+
watermark query). The short-lived ``cache_conn`` is used only for the
|
|
955
|
+
watermark query on the ``max_entry_id > last_seen`` branch (Codex-4).
|
|
956
|
+
"""
|
|
957
|
+
ls = _PROJECTS_ENV_LAST_SEEN
|
|
958
|
+
if not ls: # cold
|
|
959
|
+
ls["max_id"] = max_entry_id
|
|
960
|
+
ls["max_wus_id"] = max_wus_id
|
|
961
|
+
ls["sf_sig"] = sf_sig
|
|
962
|
+
return
|
|
963
|
+
if (
|
|
964
|
+
max_wus_id != ls["max_wus_id"]
|
|
965
|
+
or sf_sig != ls["sf_sig"]
|
|
966
|
+
or max_entry_id < ls["max_id"]
|
|
967
|
+
):
|
|
968
|
+
_PROJECTS_ENV_WEEK_CACHE.clear()
|
|
969
|
+
_PROJECTS_ENV_WEEK_TOTALS.clear()
|
|
970
|
+
ls["max_id"] = max_entry_id
|
|
971
|
+
ls["max_wus_id"] = max_wus_id
|
|
972
|
+
ls["sf_sig"] = sf_sig
|
|
973
|
+
return
|
|
974
|
+
if max_entry_id > ls["max_id"]:
|
|
975
|
+
wm = new_min_timestamp(cache_conn, ls["max_id"])
|
|
976
|
+
if wm is not None:
|
|
977
|
+
dirty = [
|
|
978
|
+
wk
|
|
979
|
+
for wk in list(_PROJECTS_ENV_WEEK_TOTALS)
|
|
980
|
+
if dt.datetime.fromisoformat(wk) + dt.timedelta(days=7) >= wm
|
|
981
|
+
]
|
|
982
|
+
for wk in dirty:
|
|
983
|
+
_PROJECTS_ENV_WEEK_TOTALS.pop(wk, None)
|
|
984
|
+
for key in [k for k in _PROJECTS_ENV_WEEK_CACHE if k[1] == wk]:
|
|
985
|
+
del _PROJECTS_ENV_WEEK_CACHE[key]
|
|
986
|
+
ls["max_id"] = max_entry_id
|
|
987
|
+
ls["max_wus_id"] = max_wus_id
|
|
988
|
+
ls["sf_sig"] = sf_sig
|