cctally 1.38.0 → 1.38.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 CHANGED
@@ -5,6 +5,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.38.1] - 2026-06-12
9
+
10
+ ### Fixed
11
+ - Dashboard: stop the steady-state one-core CPU peg on large caches. The background sync thread rebuilds the full snapshot every few seconds, and several panels read wide historical ranges that re-parsed the per-row `usage_extra_json` JSON blob on every tick — on a ~260K-entry cache a single rebuild cost roughly a core-second, so rebuilds ran back-to-back and one core stayed pinned. The only non-token field any reader consumes (`speed`, for the `<model>-fast` label) is now materialized into a real `session_entries.speed` column, so the hot cache read paths reconstruct usage with zero JSON parsing; a one-time cache migration backfills the column from the existing blobs, and new ingests write the column directly (the now-unused blob is written `NULL` and reclaimed on the next `cache-sync --rebuild`). Output is byte-identical everywhere `speed` matters; on a 250K-row synthetic cache the wide read dropped from ~1.6s to ~0.35s per call (#181).
12
+
8
13
  ## [1.38.0] - 2026-06-12
9
14
 
10
15
  ### Added
@@ -928,14 +928,12 @@ def sync_cache(
928
928
  out = int(usage.get("output_tokens", 0) or 0)
929
929
  cc = int(usage.get("cache_creation_input_tokens", 0) or 0)
930
930
  cr = int(usage.get("cache_read_input_tokens", 0) or 0)
931
- extras = {
932
- k: v for k, v in usage.items()
933
- if k not in (
934
- "input_tokens", "output_tokens",
935
- "cache_creation_input_tokens",
936
- "cache_read_input_tokens",
937
- )
938
- }
931
+ # #181: `speed` is the ONLY non-token usage key any
932
+ # consumer reads, so materialize just that scalar and
933
+ # write NULL into usage_extra_json — no more
934
+ # serializing the deeply-nested blob the read paths
935
+ # used to json.loads per row.
936
+ speed = usage.get("speed")
939
937
  rows.append((
940
938
  path_str,
941
939
  offset,
@@ -944,7 +942,8 @@ def sync_cache(
944
942
  msg_id,
945
943
  req_id,
946
944
  inp, out, cc, cr,
947
- json.dumps(extras, sort_keys=True) if extras else None,
945
+ None, # usage_extra_json bloat no longer written (#181)
946
+ speed, # materialized speed column
948
947
  entry.cost_usd,
949
948
  ))
950
949
  if mrow is not None:
@@ -1005,8 +1004,8 @@ def sync_cache(
1005
1004
  (source_path, line_offset, timestamp_utc, model,
1006
1005
  msg_id, req_id, input_tokens, output_tokens,
1007
1006
  cache_create_tokens, cache_read_tokens,
1008
- usage_extra_json, cost_usd_raw)
1009
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
1007
+ usage_extra_json, speed, cost_usd_raw)
1008
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
1010
1009
  ON CONFLICT(msg_id, req_id)
1011
1010
  WHERE msg_id IS NOT NULL AND req_id IS NOT NULL
1012
1011
  DO UPDATE SET
@@ -1017,6 +1016,7 @@ def sync_cache(
1017
1016
  cache_create_tokens = excluded.cache_create_tokens,
1018
1017
  cache_read_tokens = excluded.cache_read_tokens,
1019
1018
  usage_extra_json = excluded.usage_extra_json,
1019
+ speed = excluded.speed,
1020
1020
  cost_usd_raw = excluded.cost_usd_raw
1021
1021
  WHERE
1022
1022
  (excluded.input_tokens + excluded.output_tokens
@@ -1030,8 +1030,8 @@ def sync_cache(
1030
1030
  =
1031
1031
  (session_entries.input_tokens + session_entries.output_tokens
1032
1032
  + session_entries.cache_create_tokens + session_entries.cache_read_tokens)
1033
- AND json_extract(excluded.usage_extra_json, '$.speed') IS NOT NULL
1034
- AND json_extract(session_entries.usage_extra_json, '$.speed') IS NULL
1033
+ AND excluded.speed IS NOT NULL
1034
+ AND session_entries.speed IS NULL
1035
1035
  )""",
1036
1036
  rows,
1037
1037
  )
@@ -1266,7 +1266,7 @@ def iter_entries(
1266
1266
 
1267
1267
  sql = (
1268
1268
  "SELECT timestamp_utc, model, input_tokens, output_tokens, "
1269
- "cache_create_tokens, cache_read_tokens, usage_extra_json, "
1269
+ "cache_create_tokens, cache_read_tokens, speed, "
1270
1270
  "cost_usd_raw, source_path "
1271
1271
  "FROM session_entries "
1272
1272
  "WHERE timestamp_utc >= ? AND timestamp_utc <= ?"
@@ -1292,12 +1292,12 @@ def iter_entries(
1292
1292
  "cache_creation_input_tokens": row[4],
1293
1293
  "cache_read_input_tokens": row[5],
1294
1294
  }
1295
- if row[6]:
1296
- # Safe because sync_cache strips the four token keys from
1297
- # extras before storing them in usage_extra_json. If that
1298
- # write-side invariant ever changes, extras could shadow
1299
- # the int-normalized token columns.
1300
- usage.update(json.loads(row[6]))
1295
+ # speed is the only non-token usage key any consumer reads (#181);
1296
+ # materialized into its own column so this hot path never parses JSON.
1297
+ # `is not None` (not truthiness) so an empty-string speed still surfaces,
1298
+ # mirroring the SQL `json_extract(...) IS NOT NULL` parity.
1299
+ if row[6] is not None:
1300
+ usage["speed"] = row[6]
1301
1301
  entries.append(UsageEntry(
1302
1302
  timestamp=dt.datetime.fromisoformat(row[0]),
1303
1303
  model=row[1],
@@ -1430,7 +1430,7 @@ def get_claude_session_entries(
1430
1430
  " se.cache_create_tokens, se.cache_read_tokens, "
1431
1431
  " se.source_path, "
1432
1432
  " sf.session_id, sf.project_path, "
1433
- " se.cost_usd_raw, se.usage_extra_json "
1433
+ " se.cost_usd_raw, se.speed "
1434
1434
  "FROM session_entries se "
1435
1435
  "LEFT JOIN session_files sf ON sf.path = se.source_path "
1436
1436
  "WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ?"
@@ -1458,7 +1458,10 @@ def get_claude_session_entries(
1458
1458
  session_id=row[7],
1459
1459
  project_path=row[8],
1460
1460
  cost_usd=row[9],
1461
- usage_extra=(json.loads(row[10]) if row[10] else None),
1461
+ # speed materialized into its own column (#181); reconstruct the
1462
+ # {"speed": …} shape _usage_entry_from_joined already merges, with
1463
+ # zero JSON parsing. `is not None` so an empty-string speed surfaces.
1464
+ usage_extra=({"speed": row[10]} if row[10] is not None else None),
1462
1465
  )
1463
1466
  for row in rows
1464
1467
  ]
@@ -2304,7 +2304,8 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2304
2304
  cache_create_tokens INTEGER NOT NULL DEFAULT 0,
2305
2305
  cache_read_tokens INTEGER NOT NULL DEFAULT 0,
2306
2306
  usage_extra_json TEXT,
2307
- cost_usd_raw REAL
2307
+ cost_usd_raw REAL,
2308
+ speed TEXT
2308
2309
  );
2309
2310
  CREATE INDEX IF NOT EXISTS idx_entries_timestamp
2310
2311
  ON session_entries(timestamp_utc);
@@ -2384,6 +2385,14 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2384
2385
  # populated lazily in sync_cache() / _ensure_session_files_row().
2385
2386
  add_column_if_missing(conn, "session_files", "session_id", "TEXT")
2386
2387
  add_column_if_missing(conn, "session_files", "project_path", "TEXT")
2388
+ # #181: materialize the only-ever-consumed extra `usage` key (`speed`) into
2389
+ # a real session_entries column so the hot cache read paths (iter_entries /
2390
+ # get_claude_session_entries) stop json.loads-ing the deeply-nested
2391
+ # usage_extra_json blob per row — that per-tick parse was pegging a core in
2392
+ # the dashboard. Idempotent column-add (no marker, no version), appended
2393
+ # after cost_usd_raw to match the CREATE TABLE order; cache migration 008
2394
+ # then backfills it from the legacy blob on existing rows.
2395
+ add_column_if_missing(conn, "session_entries", "speed", "TEXT")
2387
2396
  # Existing-DB guard for the skill-content fold link (cctally-dev
2388
2397
  # skill-content-nesting): the message-level sourceToolUseID. Idempotent
2389
2398
  # column-add (no marker, no version); cache migration 006 then re-ingests
@@ -3171,6 +3180,33 @@ def _007_conversation_reingest_enrichment(conn: sqlite3.Connection) -> None:
3171
3180
  conn.commit()
3172
3181
 
3173
3182
 
3183
+ @cache_migration("008_session_entries_speed_backfill")
3184
+ def _008_session_entries_speed_backfill(conn: sqlite3.Connection) -> None:
3185
+ """Backfill the materialized ``session_entries.speed`` column from the
3186
+ legacy ``usage_extra_json`` blob (#181). ``speed`` is the ONLY non-token
3187
+ ``usage`` key any consumer reads (``<model>-fast`` rendering in
3188
+ _lib_aggregators + the _should_replace dedup tiebreak); materializing it
3189
+ lets the hot read paths (iter_entries / get_claude_session_entries) stop
3190
+ ``json.loads``-ing the deeply-nested blob per row — the per-tick dashboard
3191
+ rebuild was pegging a core on a ~261K-row cache.
3192
+
3193
+ The column is added by _apply_cache_schema's add_column_if_missing (which
3194
+ runs before this dispatcher), so it always exists here. This handler only
3195
+ backfills existing rows; new ingests write the column directly and write
3196
+ NULL to usage_extra_json. ``WHERE speed IS NULL`` self-guards re-runs.
3197
+ We do NOT rewrite/NULL usage_extra_json on existing rows or VACUUM — the
3198
+ stale blob has no reader and is reclaimed on the next cache-sync --rebuild.
3199
+ json_extract returns NULL when '$.speed' is absent, so speed-less rows stay
3200
+ NULL. Central stamp via the dispatcher (#140); a fresh install stamps it
3201
+ without effect (empty table)."""
3202
+ conn.execute(
3203
+ "UPDATE session_entries "
3204
+ "SET speed = json_extract(usage_extra_json, '$.speed') "
3205
+ "WHERE speed IS NULL AND usage_extra_json IS NOT NULL"
3206
+ )
3207
+ conn.commit()
3208
+
3209
+
3174
3210
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3175
3211
 
3176
3212
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -3323,6 +3359,10 @@ def _008_recompute_weekly_cost_snapshots_dedup_fix(
3323
3359
  "cache_creation_input_tokens": cc,
3324
3360
  "cache_read_input_tokens": cr,
3325
3361
  }
3362
+ # #181: usage_extra_json is cost-irrelevant (cost is
3363
+ # token-only); parsed here only for pre-008 rows that may
3364
+ # still carry the legacy blob — NOT a speed reader, so the
3365
+ # write-side NULL going forward is safe.
3326
3366
  if extras_json:
3327
3367
  usage.update(json.loads(extras_json))
3328
3368
  total += _calculate_entry_cost(
@@ -3637,6 +3677,10 @@ def _009_recompute_five_hour_blocks_dedup_fix(
3637
3677
  "cache_creation_input_tokens": cc_t,
3638
3678
  "cache_read_input_tokens": cr_t,
3639
3679
  }
3680
+ # #181: usage_extra_json is cost-irrelevant (cost is
3681
+ # token-only); parsed here only for pre-008 rows that may
3682
+ # still carry the legacy blob — NOT a speed reader, so the
3683
+ # write-side NULL going forward is safe.
3640
3684
  if extras_json:
3641
3685
  usage.update(json.loads(extras_json))
3642
3686
  cost = _calculate_entry_cost(
@@ -3909,6 +3953,10 @@ def _010_recompute_percent_milestones_dedup_fix(
3909
3953
  "cache_creation_input_tokens": cc,
3910
3954
  "cache_read_input_tokens": cr,
3911
3955
  }
3956
+ # #181: usage_extra_json is cost-irrelevant (cost is
3957
+ # token-only); parsed here only for pre-008 rows that may
3958
+ # still carry the legacy blob — NOT a speed reader, so the
3959
+ # write-side NULL going forward is safe.
3912
3960
  if extras_json:
3913
3961
  usage.update(json.loads(extras_json))
3914
3962
  cumulative += _calculate_entry_cost(
@@ -345,7 +345,7 @@ def _build_statusline_injections(warn_once):
345
345
  "SELECT se.timestamp_utc, se.model, "
346
346
  " se.input_tokens, se.output_tokens, "
347
347
  " se.cache_create_tokens, se.cache_read_tokens, "
348
- " se.cost_usd_raw, se.usage_extra_json "
348
+ " se.cost_usd_raw "
349
349
  "FROM session_entries se "
350
350
  "LEFT JOIN session_files sf ON sf.path = se.source_path "
351
351
  "WHERE sf.session_id = ?"
@@ -368,13 +368,10 @@ def _build_statusline_injections(warn_once):
368
368
  "cache_creation_input_tokens": r[4] or 0,
369
369
  "cache_read_input_tokens": r[5] or 0,
370
370
  }
371
- try:
372
- if r[7]:
373
- extras = json.loads(r[7])
374
- if isinstance(extras, dict):
375
- usage.update(extras)
376
- except Exception:
377
- pass
371
+ # #181: cost is token-only (_calculate_entry_cost ignores `speed`),
372
+ # so the statusline cost path no longer selects or json.loads the
373
+ # usage_extra_json blob — output is byte-identical. r[6] is still
374
+ # cost_usd_raw (the dropped column was the trailing slot).
378
375
  try:
379
376
  total += c._calculate_entry_cost(
380
377
  r[1], usage, mode="auto", cost_usd=r[6],
package/bin/_lib_jsonl.py CHANGED
@@ -85,8 +85,10 @@ def _should_replace(
85
85
  finalization row carries `speed`; streaming intermediates don't).
86
86
 
87
87
  The `usage.get("speed") is not None` check matches the SQL UPDATE WHERE
88
- clause's `json_extract(..., '$.speed') IS NOT NULL` in `sync_cache`'s
89
- INSERT … ON CONFLICT … DO UPDATE, keeping the direct-parse fallback and
88
+ clause's `excluded.speed IS NOT NULL` in `sync_cache`'s INSERT … ON
89
+ CONFLICT … DO UPDATE `speed` is materialized into its own
90
+ `session_entries.speed` column (#181), so the tiebreak no longer
91
+ `json_extract`s the blob — keeping the direct-parse fallback and
90
92
  cache-ingest paths in lockstep on the rare-but-possible "explicit JSON
91
93
  null" payload.
92
94
  """
package/bin/cctally CHANGED
@@ -1614,13 +1614,14 @@ def _usage_entry_from_joined(je) -> "UsageEntry":
1614
1614
  and the per-token integers; this adapter is pure shape conversion
1615
1615
  with no cache re-read.
1616
1616
 
1617
- Non-token ``usage`` extras (``je.usage_extra``) — notably ``speed`` —
1617
+ Non-token ``usage`` extras (``je.usage_extra``) — now just ``speed`` —
1618
1618
  are merged AFTER the four token keys, mirroring ``iter_entries``'
1619
- ``usage.update(json.loads(...))``. Without this, the project-axis
1619
+ reconstruction of ``usage["speed"]`` from the materialized
1620
+ ``session_entries.speed`` column (#181). Without this, the project-axis
1620
1621
  ``daily`` path (and the diff/report joined-entry consumers) would
1621
1622
  drop the fast-tier flag and render ``<model>`` where the normal path
1622
- renders ``<model>-fast``. The write-side strips the four token keys
1623
- from ``usage_extra_json`` so the merge never shadows the integers.
1623
+ renders ``<model>-fast``. ``je.usage_extra`` is ``{"speed": …}`` or
1624
+ ``None``, so the merge never shadows the token integers.
1624
1625
  """
1625
1626
  usage = {
1626
1627
  "input_tokens": je.input_tokens,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.38.0",
3
+ "version": "1.38.1",
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": {