cctally 1.103.0 → 1.105.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 +94 -0
- package/README.md +6 -6
- package/bin/_cctally_alerts.py +104 -10
- package/bin/_cctally_cache.py +193 -71
- package/bin/_cctally_config.py +62 -2
- package/bin/_cctally_core.py +62 -1
- package/bin/_cctally_dashboard.py +694 -70
- package/bin/_cctally_dashboard_conversation.py +400 -6
- package/bin/_cctally_dashboard_envelope.py +336 -14
- package/bin/_cctally_dashboard_perf.py +93 -0
- package/bin/_cctally_dashboard_share.py +56 -25
- package/bin/_cctally_dashboard_sources.py +988 -94
- package/bin/_cctally_db.py +30 -3
- package/bin/_cctally_diagnosis_sources.py +699 -186
- package/bin/_cctally_doctor.py +77 -0
- package/bin/_cctally_forecast.py +932 -51
- package/bin/_cctally_journal.py +430 -24
- package/bin/_cctally_parser.py +66 -0
- package/bin/_cctally_project.py +535 -10
- package/bin/_cctally_quota.py +28 -0
- package/bin/_cctally_quota_calibration.py +146 -0
- package/bin/_cctally_quota_model.py +2142 -0
- package/bin/_cctally_record.py +204 -30
- package/bin/_cctally_share.py +16 -8
- package/bin/_cctally_statusline.py +34 -0
- package/bin/_cctally_tui.py +531 -115
- package/bin/_lib_codex_conversation_query.py +258 -12
- package/bin/_lib_codex_hooks.py +26 -0
- package/bin/_lib_conversation_query.py +145 -15
- package/bin/_lib_dashboard_json.py +105 -0
- package/bin/_lib_dashboard_settings_contract.py +2 -0
- package/bin/_lib_diagnosis.py +21 -2
- package/bin/_lib_doctor.py +215 -1
- package/bin/_lib_forecast.py +337 -43
- package/bin/_lib_ingest_frontier.py +889 -0
- package/bin/_lib_meter_rate_change.py +360 -0
- package/bin/_lib_perf.py +22 -0
- package/bin/_lib_pricing.py +30 -3
- package/bin/_lib_quota_calibration.py +311 -0
- package/bin/_lib_quota_copy.py +157 -0
- package/bin/_lib_quota_model.py +2520 -0
- package/bin/_lib_rate_change_delivery.py +119 -0
- package/bin/_lib_record.py +50 -0
- package/bin/_lib_rederive.py +10 -0
- package/bin/_lib_render.py +6 -0
- package/bin/_lib_retained_size.py +187 -0
- package/bin/_lib_share_templates.py +37 -5
- package/bin/_lib_snapshot_cache.py +504 -17
- package/bin/_lib_statusline.py +200 -2
- package/bin/_lib_tick_stats.py +28 -4
- package/bin/_lib_view_models.py +30 -12
- package/bin/cctally +32 -0
- package/dashboard/static/assets/ConversationsView-DA4j7Yso.js +72 -0
- package/dashboard/static/assets/DoctorModal-D4PbVnbE.js +1 -0
- package/dashboard/static/assets/ModalRoot-D4oSPxPp.js +1 -0
- package/dashboard/static/assets/ProjectsDrillPanel-DkPlB9JH.js +1 -0
- package/dashboard/static/assets/SourceDetailModal-BE7FONsS.js +1 -0
- package/dashboard/static/assets/UpdateModal-BJuI8glf.js +7 -0
- package/dashboard/static/assets/index-klO46NcU.css +1 -0
- package/dashboard/static/assets/index-w_bINkJ8.js +13 -0
- package/dashboard/static/assets/outlineNavigation-zDVm6Hdd.js +9 -0
- package/dashboard/static/assets/useKeymap-CJ-Pi17D.js +1 -0
- package/dashboard/static/dashboard.html +3 -2
- package/package.json +10 -1
- package/dashboard/static/assets/index-Di2hljvB.css +0 -1
- package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
|
@@ -48,7 +48,9 @@ import datetime as dt
|
|
|
48
48
|
import hashlib
|
|
49
49
|
import os
|
|
50
50
|
import sqlite3
|
|
51
|
+
import sys
|
|
51
52
|
import threading
|
|
53
|
+
import time
|
|
52
54
|
from dataclasses import dataclass
|
|
53
55
|
from typing import TYPE_CHECKING, Callable, NamedTuple
|
|
54
56
|
|
|
@@ -61,6 +63,12 @@ from _cctally_core import parse_iso_datetime
|
|
|
61
63
|
# still a TYPE_CHECKING-only hint). _lib_aggregators imports only _cctally_core,
|
|
62
64
|
# so there is no import cycle.
|
|
63
65
|
from _lib_aggregators import _fold_entry, _finalize_bucket, _new_bucket_acc
|
|
66
|
+
from _lib_retained_size import (
|
|
67
|
+
RETAINED_SIZE_WORK_LOCK,
|
|
68
|
+
RETAINED_SIZE_WORKER_DUTY,
|
|
69
|
+
RetainedSizeCancelled,
|
|
70
|
+
retained_size_bytes,
|
|
71
|
+
)
|
|
64
72
|
|
|
65
73
|
if TYPE_CHECKING: # type hints only — no runtime coupling to the aggregators
|
|
66
74
|
from _lib_aggregators import BucketUsage, ClaudeSessionUsage
|
|
@@ -413,6 +421,12 @@ def reset_owner_thread() -> None:
|
|
|
413
421
|
_OWNER_THREAD_IDENT = None
|
|
414
422
|
|
|
415
423
|
|
|
424
|
+
def _restore_owner_thread(owner_ident: int | None) -> None:
|
|
425
|
+
"""Restore the owner that was active before a lock-scoped helper."""
|
|
426
|
+
global _OWNER_THREAD_IDENT
|
|
427
|
+
_OWNER_THREAD_IDENT = owner_ident
|
|
428
|
+
|
|
429
|
+
|
|
416
430
|
def _assert_owner() -> None:
|
|
417
431
|
if _OWNER_THREAD_IDENT is not None and threading.get_ident() != _OWNER_THREAD_IDENT:
|
|
418
432
|
raise RuntimeError(
|
|
@@ -421,6 +435,98 @@ def _assert_owner() -> None:
|
|
|
421
435
|
)
|
|
422
436
|
|
|
423
437
|
|
|
438
|
+
# #684: the retained-memory verifier keys work to actual cache mutation, not
|
|
439
|
+
# publisher ticks or wall-clock-derived data-version strings. The first write
|
|
440
|
+
# after a claimed generation advances the counter; subsequent writes coalesce
|
|
441
|
+
# until the publisher submits one shallow container snapshot. A later write
|
|
442
|
+
# advances it again and cooperatively cancels the now-superseded walk.
|
|
443
|
+
_SNAPSHOT_ACCELERATOR_DIRTY = True
|
|
444
|
+
_SNAPSHOT_ACCELERATOR_DIRTY_LOCK = threading.Lock()
|
|
445
|
+
_SNAPSHOT_ACCELERATOR_GENERATION = 0
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _mark_snapshot_accelerators_dirty() -> None:
|
|
449
|
+
global _SNAPSHOT_ACCELERATOR_DIRTY
|
|
450
|
+
global _SNAPSHOT_ACCELERATOR_GENERATION
|
|
451
|
+
if _SNAPSHOT_ACCELERATOR_DIRTY:
|
|
452
|
+
return
|
|
453
|
+
with _SNAPSHOT_ACCELERATOR_DIRTY_LOCK:
|
|
454
|
+
if not _SNAPSHOT_ACCELERATOR_DIRTY:
|
|
455
|
+
_SNAPSHOT_ACCELERATOR_DIRTY = True
|
|
456
|
+
_SNAPSHOT_ACCELERATOR_GENERATION += 1
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _take_snapshot_accelerator_dirty() -> int | None:
|
|
460
|
+
"""Claim the coalesced mutation generation for one background walk."""
|
|
461
|
+
global _SNAPSHOT_ACCELERATOR_DIRTY
|
|
462
|
+
with _SNAPSHOT_ACCELERATOR_DIRTY_LOCK:
|
|
463
|
+
if not _SNAPSHOT_ACCELERATOR_DIRTY:
|
|
464
|
+
return None
|
|
465
|
+
_SNAPSHOT_ACCELERATOR_DIRTY = False
|
|
466
|
+
return int(_SNAPSHOT_ACCELERATOR_GENERATION)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _snapshot_retained_shape(value) -> tuple:
|
|
470
|
+
"""Cheap replacement signature; deep truth is rechecked periodically."""
|
|
471
|
+
try:
|
|
472
|
+
length = len(value)
|
|
473
|
+
except (TypeError, AttributeError):
|
|
474
|
+
length = None
|
|
475
|
+
namespace = getattr(value, "__dict__", None)
|
|
476
|
+
children = ()
|
|
477
|
+
if isinstance(namespace, dict):
|
|
478
|
+
children = tuple(sorted(
|
|
479
|
+
(
|
|
480
|
+
str(key),
|
|
481
|
+
type(child).__name__,
|
|
482
|
+
sys.getsizeof(child),
|
|
483
|
+
len(child) if hasattr(child, "__len__") else None,
|
|
484
|
+
)
|
|
485
|
+
for key, child in namespace.items()
|
|
486
|
+
))
|
|
487
|
+
return (type(value).__name__, sys.getsizeof(value), length, children)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class _ObservedSnapshotDict(dict):
|
|
491
|
+
"""Dictionary whose successful mutations invalidate aggregate sizing."""
|
|
492
|
+
|
|
493
|
+
def __init__(self, *args, always_mark_replacements=False, **kwargs):
|
|
494
|
+
self._always_mark_replacements = bool(always_mark_replacements)
|
|
495
|
+
super().__init__(*args, **kwargs)
|
|
496
|
+
|
|
497
|
+
def __setitem__(self, key, value):
|
|
498
|
+
existed = key in self
|
|
499
|
+
prior = self.get(key)
|
|
500
|
+
super().__setitem__(key, value)
|
|
501
|
+
if (
|
|
502
|
+
not existed
|
|
503
|
+
or self._always_mark_replacements
|
|
504
|
+
or _snapshot_retained_shape(prior) != _snapshot_retained_shape(value)
|
|
505
|
+
):
|
|
506
|
+
_mark_snapshot_accelerators_dirty()
|
|
507
|
+
|
|
508
|
+
def __delitem__(self, key):
|
|
509
|
+
super().__delitem__(key)
|
|
510
|
+
_mark_snapshot_accelerators_dirty()
|
|
511
|
+
|
|
512
|
+
def clear(self):
|
|
513
|
+
if self:
|
|
514
|
+
super().clear()
|
|
515
|
+
_mark_snapshot_accelerators_dirty()
|
|
516
|
+
|
|
517
|
+
def pop(self, key, default=None):
|
|
518
|
+
existed = key in self
|
|
519
|
+
value = super().pop(key, default)
|
|
520
|
+
if existed:
|
|
521
|
+
_mark_snapshot_accelerators_dirty()
|
|
522
|
+
return value
|
|
523
|
+
|
|
524
|
+
def update(self, *args, **kwargs):
|
|
525
|
+
super().update(*args, **kwargs)
|
|
526
|
+
if args or kwargs:
|
|
527
|
+
_mark_snapshot_accelerators_dirty()
|
|
528
|
+
|
|
529
|
+
|
|
424
530
|
# === Task 0.4 — Group A bucket cache holder ================================
|
|
425
531
|
|
|
426
532
|
|
|
@@ -441,7 +547,8 @@ class BucketCache:
|
|
|
441
547
|
"""
|
|
442
548
|
|
|
443
549
|
def __init__(self) -> None:
|
|
444
|
-
self._store: "dict[tuple[str, str], BucketUsage]" =
|
|
550
|
+
self._store: "dict[tuple[str, str], BucketUsage]" = (
|
|
551
|
+
_ObservedSnapshotDict())
|
|
445
552
|
|
|
446
553
|
def get(self, builder_key: str, bucket_label: str) -> "BucketUsage | None":
|
|
447
554
|
"""Return the cached aggregate for this bucket, or None on a miss."""
|
|
@@ -493,7 +600,8 @@ class SessionCache:
|
|
|
493
600
|
"""
|
|
494
601
|
|
|
495
602
|
def __init__(self) -> None:
|
|
496
|
-
self._store: "dict[str, ClaudeSessionUsage]" =
|
|
603
|
+
self._store: "dict[str, ClaudeSessionUsage]" = _ObservedSnapshotDict(
|
|
604
|
+
always_mark_replacements=True)
|
|
497
605
|
|
|
498
606
|
def get_all(self) -> "dict[str, ClaudeSessionUsage]":
|
|
499
607
|
"""Return a shallow COPY of every cached session, keyed by identity.
|
|
@@ -546,7 +654,7 @@ class SessionCache:
|
|
|
546
654
|
# assembled list each tick and never mutate a cached aggregate (Codex F7).
|
|
547
655
|
|
|
548
656
|
_GROUP_A_CACHE = BucketCache()
|
|
549
|
-
_GROUP_A_LAST_SEEN: "dict[str, dict]" =
|
|
657
|
+
_GROUP_A_LAST_SEEN: "dict[str, dict]" = _ObservedSnapshotDict()
|
|
550
658
|
|
|
551
659
|
|
|
552
660
|
def group_a_cache() -> BucketCache:
|
|
@@ -597,7 +705,7 @@ class CurrentBucketAccumulator:
|
|
|
597
705
|
last_now: dt.datetime # now_utc upper bound used to clamp the last fold
|
|
598
706
|
|
|
599
707
|
|
|
600
|
-
_GROUP_A_CURRENT: "dict[str, CurrentBucketAccumulator]" =
|
|
708
|
+
_GROUP_A_CURRENT: "dict[str, CurrentBucketAccumulator]" = _ObservedSnapshotDict()
|
|
601
709
|
|
|
602
710
|
|
|
603
711
|
def reset_group_a_current_state() -> None:
|
|
@@ -947,7 +1055,7 @@ def affected_session_keys(
|
|
|
947
1055
|
# (Codex F7). Mutated ONLY on the sync thread (single-writer, like Group A).
|
|
948
1056
|
|
|
949
1057
|
_SESSION_CACHE = SessionCache()
|
|
950
|
-
_SESSION_LAST_SEEN: "dict" =
|
|
1058
|
+
_SESSION_LAST_SEEN: "dict" = _ObservedSnapshotDict()
|
|
951
1059
|
|
|
952
1060
|
|
|
953
1061
|
def session_cache() -> SessionCache:
|
|
@@ -983,7 +1091,7 @@ class CodexAccountingCacheResult:
|
|
|
983
1091
|
changed_new: tuple[object, ...] = ()
|
|
984
1092
|
|
|
985
1093
|
|
|
986
|
-
_CODEX_ACCOUNTING_CACHE_STATE: dict[str, object] =
|
|
1094
|
+
_CODEX_ACCOUNTING_CACHE_STATE: dict[str, object] = _ObservedSnapshotDict()
|
|
987
1095
|
_CODEX_ACCOUNTING_MAX_DIRTY_PATHS = 300
|
|
988
1096
|
|
|
989
1097
|
|
|
@@ -1182,6 +1290,10 @@ def build_cached_codex_accounting(
|
|
|
1182
1290
|
dirty_accounts = tuple(sorted(
|
|
1183
1291
|
{str(account_of(entry)) for entry in (*changed_old, *changed_new)}
|
|
1184
1292
|
))
|
|
1293
|
+
# A same-cardinality path replacement can change the deep retained graph
|
|
1294
|
+
# while keeping the tuple's shallow shape. Mark this semantic replacement
|
|
1295
|
+
# explicitly; the routine ``seq``/``end`` watermark updates below do not.
|
|
1296
|
+
_mark_snapshot_accelerators_dirty()
|
|
1185
1297
|
state["entries"] = entries
|
|
1186
1298
|
state["seq"] = current_seq
|
|
1187
1299
|
state["end"] = range_end
|
|
@@ -1248,7 +1360,8 @@ def build_cached_sessions(
|
|
|
1248
1360
|
# byte-identical to from-scratch even for an id-stable update.
|
|
1249
1361
|
for sess in reaggregate(last_seen_seq, affected):
|
|
1250
1362
|
_SESSION_CACHE.put(sess.session_id, sess)
|
|
1251
|
-
|
|
1363
|
+
# Fixed-key watermark update. Clearing first would falsely classify every
|
|
1364
|
+
# unchanged tick as retained-memory growth and cancel the deep verifier.
|
|
1252
1365
|
_SESSION_LAST_SEEN["max_id"] = cur_max_id
|
|
1253
1366
|
_SESSION_LAST_SEEN["max_seq"] = cur_max_seq
|
|
1254
1367
|
_SESSION_LAST_SEEN["extra"] = extra_signature
|
|
@@ -1270,6 +1383,9 @@ def build_cached_sessions(
|
|
|
1270
1383
|
DOCTOR_MEMO_TTL_S = 30.0
|
|
1271
1384
|
|
|
1272
1385
|
_DOCTOR_MEMO_LOCK = threading.Lock()
|
|
1386
|
+
# Tiny, fixed-key TTL payload. Its routine 30-second clear/repopulate must not
|
|
1387
|
+
# cancel a production-scale accounting walk forever; the five-minute aggregate
|
|
1388
|
+
# remeasurement backstop still includes and verifies it.
|
|
1273
1389
|
_DOCTOR_MEMO: "dict" = {}
|
|
1274
1390
|
|
|
1275
1391
|
|
|
@@ -1393,8 +1509,8 @@ def reset_dispatch_state() -> None:
|
|
|
1393
1509
|
# each rebuild builds FRESH trend / forecast presentation objects and never
|
|
1394
1510
|
# mutates a cached value (Codex F7).
|
|
1395
1511
|
|
|
1396
|
-
_WEEKREF_COST_CACHE: dict =
|
|
1397
|
-
_WEEKREF_COST_LAST_SEEN: dict =
|
|
1512
|
+
_WEEKREF_COST_CACHE: dict = _ObservedSnapshotDict()
|
|
1513
|
+
_WEEKREF_COST_LAST_SEEN: dict = _ObservedSnapshotDict()
|
|
1398
1514
|
|
|
1399
1515
|
|
|
1400
1516
|
def _weekref_key(week_start_at, week_end_at):
|
|
@@ -1532,9 +1648,9 @@ def reconcile_weekref_cache(cache_conn, *, max_entry_id, max_mutation_seq, reset
|
|
|
1532
1648
|
# Single-writer (sync thread only), immutable values, fresh presentation each
|
|
1533
1649
|
# tick — the Group A / weekref discipline (spec §6, Codex F7).
|
|
1534
1650
|
|
|
1535
|
-
_PROJECTS_ENV_WEEK_CACHE: dict =
|
|
1536
|
-
_PROJECTS_ENV_WEEK_TOTALS: dict =
|
|
1537
|
-
_PROJECTS_ENV_LAST_SEEN: dict =
|
|
1651
|
+
_PROJECTS_ENV_WEEK_CACHE: dict = _ObservedSnapshotDict()
|
|
1652
|
+
_PROJECTS_ENV_WEEK_TOTALS: dict = _ObservedSnapshotDict()
|
|
1653
|
+
_PROJECTS_ENV_LAST_SEEN: dict = _ObservedSnapshotDict()
|
|
1538
1654
|
|
|
1539
1655
|
# #271 M4 (spec §20): the CURRENT-week per-project aggregate is re-folded from
|
|
1540
1656
|
# scratch every warm tick (the closed weeks are already cache-served). This
|
|
@@ -1546,7 +1662,7 @@ _PROJECTS_ENV_LAST_SEEN: dict = {} # {"max_id", "max_wus_id", "sf_sig"}
|
|
|
1546
1662
|
# reachable from a published DataSnapshot (F7 — the snapshot holds the finalized
|
|
1547
1663
|
# buckets, not the running `mut`). The `mut` is opaque here (the dashboard
|
|
1548
1664
|
# packs/unpacks it — same "no dashboard import" discipline as `BucketCache`).
|
|
1549
|
-
_PROJECTS_ENV_CURRENT: dict = {"state": None}
|
|
1665
|
+
_PROJECTS_ENV_CURRENT: dict = _ObservedSnapshotDict({"state": None})
|
|
1550
1666
|
|
|
1551
1667
|
|
|
1552
1668
|
_PROJECTS_ENV_KEY_SEP = "|"
|
|
@@ -1973,8 +2089,8 @@ class BugKSegment(NamedTuple):
|
|
|
1973
2089
|
entry_count: int
|
|
1974
2090
|
|
|
1975
2091
|
|
|
1976
|
-
_BUGK_SEGMENT_CACHE: dict =
|
|
1977
|
-
_BUGK_SEGMENT_LAST_SEEN: dict =
|
|
2092
|
+
_BUGK_SEGMENT_CACHE: dict = _ObservedSnapshotDict()
|
|
2093
|
+
_BUGK_SEGMENT_LAST_SEEN: dict = _ObservedSnapshotDict()
|
|
1978
2094
|
|
|
1979
2095
|
|
|
1980
2096
|
def _bugk_key(original_start_at, effective_at):
|
|
@@ -2095,8 +2211,379 @@ def reconcile_bugk_cache(cache_conn, *, max_entry_id, max_mutation_seq, reset_si
|
|
|
2095
2211
|
# / `reset_sig` / `sf_sig` / display-tz signals, mirroring
|
|
2096
2212
|
# `reconcile_bugk_cache` + the projects-env `session_files_sig` leg.
|
|
2097
2213
|
|
|
2098
|
-
_CACHE_REPORT_DAY_CACHE: dict =
|
|
2099
|
-
_CACHE_REPORT_LAST_SEEN: dict =
|
|
2214
|
+
_CACHE_REPORT_DAY_CACHE: dict = _ObservedSnapshotDict()
|
|
2215
|
+
_CACHE_REPORT_LAST_SEEN: dict = _ObservedSnapshotDict()
|
|
2216
|
+
|
|
2217
|
+
|
|
2218
|
+
# #684: one admission owner for every cross-build accelerator in this module.
|
|
2219
|
+
# The opaque last-published snapshot is deliberately not in this set: it is the
|
|
2220
|
+
# product state shared with the SSE hub, not an accelerator, and counting it
|
|
2221
|
+
# here would double-count the same object. All historical bucket/session/
|
|
2222
|
+
# accounting/doctor caches and their invalidation watermarks are included.
|
|
2223
|
+
_SNAPSHOT_ACCELERATOR_MAX_BYTES = 384 * 1024 * 1024
|
|
2224
|
+
_SNAPSHOT_ACCELERATOR_MAX_ENTRIES = 500_000
|
|
2225
|
+
_SNAPSHOT_ACCELERATOR_STATS_LOCK = threading.Lock()
|
|
2226
|
+
_SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = 0
|
|
2227
|
+
_SNAPSHOT_ACCELERATOR_ENTRY_COUNT = 0
|
|
2228
|
+
_SNAPSHOT_ACCELERATOR_EVICTIONS = 0
|
|
2229
|
+
_SNAPSHOT_ACCELERATOR_FALLBACKS = 0
|
|
2230
|
+
_SNAPSHOT_ACCELERATOR_MEASUREMENT_ERRORS = 0
|
|
2231
|
+
_SNAPSHOT_ACCELERATOR_MEASURED_GENERATION = -1
|
|
2232
|
+
_SNAPSHOT_ACCELERATOR_LAST_MEASURED_MONOTONIC = 0.0
|
|
2233
|
+
_SNAPSHOT_ACCELERATOR_REMEASURE_SECONDS = 300.0
|
|
2234
|
+
_SNAPSHOT_MEMORY_COMPLETION_LOCK = None
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
class _SnapshotMemoryWorker:
|
|
2238
|
+
"""One cooperative latest-generation retained-size walk."""
|
|
2239
|
+
|
|
2240
|
+
def __init__(self) -> None:
|
|
2241
|
+
self._condition = threading.Condition()
|
|
2242
|
+
self._pending: tuple[int, tuple[dict, ...], int] | None = None
|
|
2243
|
+
self._result: tuple[int, int | None, str | None] | None = None
|
|
2244
|
+
self._latest_generation = -1
|
|
2245
|
+
self._active_generation: int | None = None
|
|
2246
|
+
self._stop = False
|
|
2247
|
+
self._thread: threading.Thread | None = None
|
|
2248
|
+
self._duty_wall = 0.0
|
|
2249
|
+
self._duty_cpu = 0.0
|
|
2250
|
+
|
|
2251
|
+
def submit(
|
|
2252
|
+
self, generation: int, roots: tuple[dict, ...], entry_count: int,
|
|
2253
|
+
) -> None:
|
|
2254
|
+
with self._condition:
|
|
2255
|
+
if self._stop:
|
|
2256
|
+
return
|
|
2257
|
+
self._latest_generation = int(generation)
|
|
2258
|
+
self._pending = (int(generation), roots, int(entry_count))
|
|
2259
|
+
if self._thread is None or not self._thread.is_alive():
|
|
2260
|
+
self._thread = threading.Thread(
|
|
2261
|
+
target=self._run,
|
|
2262
|
+
name="cctally-snapshot-memory",
|
|
2263
|
+
daemon=True,
|
|
2264
|
+
)
|
|
2265
|
+
self._thread.start()
|
|
2266
|
+
self._condition.notify()
|
|
2267
|
+
|
|
2268
|
+
def take_result(self) -> tuple[int, int | None, str | None] | None:
|
|
2269
|
+
with self._condition:
|
|
2270
|
+
result = self._result
|
|
2271
|
+
self._result = None
|
|
2272
|
+
return result
|
|
2273
|
+
|
|
2274
|
+
def _cooperate(self) -> bool:
|
|
2275
|
+
with self._condition:
|
|
2276
|
+
cancelled = (
|
|
2277
|
+
self._stop
|
|
2278
|
+
or self._active_generation != self._latest_generation
|
|
2279
|
+
)
|
|
2280
|
+
if cancelled:
|
|
2281
|
+
return True
|
|
2282
|
+
cpu_elapsed = time.thread_time() - self._duty_cpu
|
|
2283
|
+
wall_elapsed = time.monotonic() - self._duty_wall
|
|
2284
|
+
delay = (cpu_elapsed / RETAINED_SIZE_WORKER_DUTY) - wall_elapsed
|
|
2285
|
+
if delay > 0:
|
|
2286
|
+
time.sleep(min(delay, 0.01))
|
|
2287
|
+
with self._condition:
|
|
2288
|
+
return (
|
|
2289
|
+
self._stop
|
|
2290
|
+
or self._active_generation != self._latest_generation
|
|
2291
|
+
)
|
|
2292
|
+
|
|
2293
|
+
def _run(self) -> None:
|
|
2294
|
+
while True:
|
|
2295
|
+
with self._condition:
|
|
2296
|
+
while self._pending is None and not self._stop:
|
|
2297
|
+
self._condition.wait()
|
|
2298
|
+
if self._stop:
|
|
2299
|
+
return
|
|
2300
|
+
generation, roots, entry_count = self._pending
|
|
2301
|
+
self._pending = None
|
|
2302
|
+
self._active_generation = generation
|
|
2303
|
+
estimated: int | None = None
|
|
2304
|
+
error: str | None = None
|
|
2305
|
+
try:
|
|
2306
|
+
while not RETAINED_SIZE_WORK_LOCK.acquire(timeout=0.05):
|
|
2307
|
+
if self._cooperate():
|
|
2308
|
+
raise RetainedSizeCancelled()
|
|
2309
|
+
try:
|
|
2310
|
+
self._duty_wall = time.monotonic()
|
|
2311
|
+
self._duty_cpu = time.thread_time()
|
|
2312
|
+
estimated = retained_size_bytes(
|
|
2313
|
+
roots,
|
|
2314
|
+
stop_after=_SNAPSHOT_ACCELERATOR_MAX_BYTES,
|
|
2315
|
+
cancelled=self._cooperate,
|
|
2316
|
+
)
|
|
2317
|
+
finally:
|
|
2318
|
+
RETAINED_SIZE_WORK_LOCK.release()
|
|
2319
|
+
except RetainedSizeCancelled:
|
|
2320
|
+
continue
|
|
2321
|
+
except Exception as exc: # noqa: BLE001 - keep the sole worker alive
|
|
2322
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
2323
|
+
finally:
|
|
2324
|
+
roots = ()
|
|
2325
|
+
published = (
|
|
2326
|
+
error is None
|
|
2327
|
+
and estimated is not None
|
|
2328
|
+
and _publish_snapshot_measurement(
|
|
2329
|
+
generation, int(estimated), entry_count)
|
|
2330
|
+
)
|
|
2331
|
+
enforced = False
|
|
2332
|
+
completion_lock = _SNAPSHOT_MEMORY_COMPLETION_LOCK
|
|
2333
|
+
if not published and completion_lock is not None:
|
|
2334
|
+
with completion_lock:
|
|
2335
|
+
previous_owner = _OWNER_THREAD_IDENT
|
|
2336
|
+
mark_owner_thread()
|
|
2337
|
+
try:
|
|
2338
|
+
enforced = _enforce_snapshot_unsafe_completion(
|
|
2339
|
+
generation,
|
|
2340
|
+
None if estimated is None else int(estimated),
|
|
2341
|
+
error,
|
|
2342
|
+
)
|
|
2343
|
+
finally:
|
|
2344
|
+
_restore_owner_thread(previous_owner)
|
|
2345
|
+
with self._condition:
|
|
2346
|
+
self._active_generation = None
|
|
2347
|
+
if (
|
|
2348
|
+
not published and not enforced
|
|
2349
|
+
and not self._stop
|
|
2350
|
+
and generation == self._latest_generation
|
|
2351
|
+
):
|
|
2352
|
+
self._result = (
|
|
2353
|
+
generation,
|
|
2354
|
+
None if estimated is None else int(estimated),
|
|
2355
|
+
error,
|
|
2356
|
+
)
|
|
2357
|
+
|
|
2358
|
+
def shutdown(self) -> bool:
|
|
2359
|
+
with self._condition:
|
|
2360
|
+
self._stop = True
|
|
2361
|
+
self._pending = None
|
|
2362
|
+
thread = self._thread
|
|
2363
|
+
self._condition.notify_all()
|
|
2364
|
+
if thread is not None:
|
|
2365
|
+
thread.join(timeout=1.0)
|
|
2366
|
+
return thread is None or not thread.is_alive()
|
|
2367
|
+
|
|
2368
|
+
def is_alive(self) -> bool:
|
|
2369
|
+
with self._condition:
|
|
2370
|
+
return self._thread is not None and self._thread.is_alive()
|
|
2371
|
+
|
|
2372
|
+
|
|
2373
|
+
_SNAPSHOT_MEMORY_WORKER = _SnapshotMemoryWorker()
|
|
2374
|
+
|
|
2375
|
+
|
|
2376
|
+
def set_snapshot_memory_completion_lock(lock) -> None:
|
|
2377
|
+
"""Bind unsafe verifier completions to the dashboard publisher lock."""
|
|
2378
|
+
global _SNAPSHOT_MEMORY_COMPLETION_LOCK
|
|
2379
|
+
_SNAPSHOT_MEMORY_COMPLETION_LOCK = lock
|
|
2380
|
+
|
|
2381
|
+
|
|
2382
|
+
def _enforce_snapshot_unsafe_completion(
|
|
2383
|
+
generation: int, estimated: int | None, error: str | None,
|
|
2384
|
+
) -> bool:
|
|
2385
|
+
"""Evict a current over-cap/error result without awaiting another tick."""
|
|
2386
|
+
if error is None and (
|
|
2387
|
+
estimated is None or estimated <= _SNAPSHOT_ACCELERATOR_MAX_BYTES
|
|
2388
|
+
):
|
|
2389
|
+
return False
|
|
2390
|
+
with _SNAPSHOT_ACCELERATOR_DIRTY_LOCK:
|
|
2391
|
+
if (
|
|
2392
|
+
_SNAPSHOT_ACCELERATOR_DIRTY
|
|
2393
|
+
or generation != _SNAPSHOT_ACCELERATOR_GENERATION
|
|
2394
|
+
):
|
|
2395
|
+
return False
|
|
2396
|
+
entry_count = _snapshot_accelerator_entry_count()
|
|
2397
|
+
global _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES
|
|
2398
|
+
global _SNAPSHOT_ACCELERATOR_ENTRY_COUNT
|
|
2399
|
+
global _SNAPSHOT_ACCELERATOR_EVICTIONS
|
|
2400
|
+
global _SNAPSHOT_ACCELERATOR_FALLBACKS
|
|
2401
|
+
global _SNAPSHOT_ACCELERATOR_MEASUREMENT_ERRORS
|
|
2402
|
+
with _SNAPSHOT_ACCELERATOR_STATS_LOCK:
|
|
2403
|
+
_clear_snapshot_accelerators()
|
|
2404
|
+
_SNAPSHOT_ACCELERATOR_EVICTIONS += entry_count
|
|
2405
|
+
_SNAPSHOT_ACCELERATOR_FALLBACKS += 1
|
|
2406
|
+
if error is not None:
|
|
2407
|
+
_SNAPSHOT_ACCELERATOR_MEASUREMENT_ERRORS += 1
|
|
2408
|
+
_SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = 0
|
|
2409
|
+
_SNAPSHOT_ACCELERATOR_ENTRY_COUNT = _snapshot_accelerator_entry_count()
|
|
2410
|
+
return True
|
|
2411
|
+
|
|
2412
|
+
|
|
2413
|
+
def _publish_snapshot_measurement(
|
|
2414
|
+
generation: int, estimated: int, entry_count: int,
|
|
2415
|
+
) -> bool:
|
|
2416
|
+
"""Publish a safe under-cap result immediately, between publisher ticks."""
|
|
2417
|
+
if estimated > _SNAPSHOT_ACCELERATOR_MAX_BYTES:
|
|
2418
|
+
return False
|
|
2419
|
+
with _SNAPSHOT_ACCELERATOR_DIRTY_LOCK:
|
|
2420
|
+
if (
|
|
2421
|
+
_SNAPSHOT_ACCELERATOR_DIRTY
|
|
2422
|
+
or generation != _SNAPSHOT_ACCELERATOR_GENERATION
|
|
2423
|
+
):
|
|
2424
|
+
return False
|
|
2425
|
+
global _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES
|
|
2426
|
+
global _SNAPSHOT_ACCELERATOR_ENTRY_COUNT
|
|
2427
|
+
global _SNAPSHOT_ACCELERATOR_MEASURED_GENERATION
|
|
2428
|
+
global _SNAPSHOT_ACCELERATOR_LAST_MEASURED_MONOTONIC
|
|
2429
|
+
with _SNAPSHOT_ACCELERATOR_STATS_LOCK:
|
|
2430
|
+
_SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = int(estimated)
|
|
2431
|
+
_SNAPSHOT_ACCELERATOR_ENTRY_COUNT = int(entry_count)
|
|
2432
|
+
_SNAPSHOT_ACCELERATOR_MEASURED_GENERATION = int(generation)
|
|
2433
|
+
_SNAPSHOT_ACCELERATOR_LAST_MEASURED_MONOTONIC = time.monotonic()
|
|
2434
|
+
return True
|
|
2435
|
+
|
|
2436
|
+
|
|
2437
|
+
def _snapshot_accelerator_roots() -> tuple[object, ...]:
|
|
2438
|
+
"""Every cross-build retained accelerator, in one drift-resistant list."""
|
|
2439
|
+
return (
|
|
2440
|
+
_GROUP_A_CACHE._store,
|
|
2441
|
+
_GROUP_A_LAST_SEEN,
|
|
2442
|
+
_GROUP_A_CURRENT,
|
|
2443
|
+
_SESSION_CACHE._store,
|
|
2444
|
+
_SESSION_LAST_SEEN,
|
|
2445
|
+
_CODEX_ACCOUNTING_CACHE_STATE,
|
|
2446
|
+
_DOCTOR_MEMO,
|
|
2447
|
+
_WEEKREF_COST_CACHE,
|
|
2448
|
+
_WEEKREF_COST_LAST_SEEN,
|
|
2449
|
+
_PROJECTS_ENV_WEEK_CACHE,
|
|
2450
|
+
_PROJECTS_ENV_WEEK_TOTALS,
|
|
2451
|
+
_PROJECTS_ENV_LAST_SEEN,
|
|
2452
|
+
_PROJECTS_ENV_CURRENT,
|
|
2453
|
+
_BUGK_SEGMENT_CACHE,
|
|
2454
|
+
_BUGK_SEGMENT_LAST_SEEN,
|
|
2455
|
+
_CACHE_REPORT_DAY_CACHE,
|
|
2456
|
+
_CACHE_REPORT_LAST_SEEN,
|
|
2457
|
+
)
|
|
2458
|
+
|
|
2459
|
+
|
|
2460
|
+
def _snapshot_accelerator_entry_count() -> int:
|
|
2461
|
+
return sum(len(root) for root in _snapshot_accelerator_roots())
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
def _clear_snapshot_accelerators() -> None:
|
|
2465
|
+
"""Response-preserving overflow fallback: the next tick recomputes cold."""
|
|
2466
|
+
for root in _snapshot_accelerator_roots():
|
|
2467
|
+
root.clear()
|
|
2468
|
+
# Preserve the single-slot mapping shape expected by its callers.
|
|
2469
|
+
_PROJECTS_ENV_CURRENT["state"] = None
|
|
2470
|
+
|
|
2471
|
+
|
|
2472
|
+
def enforce_snapshot_accelerator_bounds(*, data_version: str) -> None:
|
|
2473
|
+
"""Admit the completed accelerator estate or discard it as one unit.
|
|
2474
|
+
|
|
2475
|
+
Called after a complete source build, when the just-built public snapshot
|
|
2476
|
+
no longer depends on these mutable stores. Overflow therefore changes only
|
|
2477
|
+
next-tick reuse, never the response that triggered admission.
|
|
2478
|
+
"""
|
|
2479
|
+
_assert_owner()
|
|
2480
|
+
del data_version # mutation generations, not render identity, own admission
|
|
2481
|
+
entry_count = _snapshot_accelerator_entry_count()
|
|
2482
|
+
global _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES
|
|
2483
|
+
global _SNAPSHOT_ACCELERATOR_ENTRY_COUNT
|
|
2484
|
+
global _SNAPSHOT_ACCELERATOR_EVICTIONS
|
|
2485
|
+
global _SNAPSHOT_ACCELERATOR_FALLBACKS
|
|
2486
|
+
global _SNAPSHOT_ACCELERATOR_MEASUREMENT_ERRORS
|
|
2487
|
+
global _SNAPSHOT_ACCELERATOR_GENERATION
|
|
2488
|
+
global _SNAPSHOT_ACCELERATOR_MEASURED_GENERATION
|
|
2489
|
+
global _SNAPSHOT_ACCELERATOR_LAST_MEASURED_MONOTONIC
|
|
2490
|
+
completed = _SNAPSHOT_MEMORY_WORKER.take_result()
|
|
2491
|
+
with _SNAPSHOT_ACCELERATOR_STATS_LOCK:
|
|
2492
|
+
last_measured = _SNAPSHOT_ACCELERATOR_LAST_MEASURED_MONOTONIC
|
|
2493
|
+
last_measured_generation = _SNAPSHOT_ACCELERATOR_MEASURED_GENERATION
|
|
2494
|
+
if (
|
|
2495
|
+
last_measured > 0
|
|
2496
|
+
and last_measured_generation == _SNAPSHOT_ACCELERATOR_GENERATION
|
|
2497
|
+
and time.monotonic() - last_measured
|
|
2498
|
+
>= _SNAPSHOT_ACCELERATOR_REMEASURE_SECONDS
|
|
2499
|
+
):
|
|
2500
|
+
_mark_snapshot_accelerators_dirty()
|
|
2501
|
+
with _SNAPSHOT_ACCELERATOR_DIRTY_LOCK:
|
|
2502
|
+
current_generation = int(_SNAPSHOT_ACCELERATOR_GENERATION)
|
|
2503
|
+
currently_dirty = bool(_SNAPSHOT_ACCELERATOR_DIRTY)
|
|
2504
|
+
with _SNAPSHOT_ACCELERATOR_STATS_LOCK:
|
|
2505
|
+
measured = (
|
|
2506
|
+
None if completed is None or completed[1] is None
|
|
2507
|
+
else int(completed[1])
|
|
2508
|
+
)
|
|
2509
|
+
measured_generation = -1 if completed is None else int(completed[0])
|
|
2510
|
+
measurement_error = None if completed is None else completed[2]
|
|
2511
|
+
result_is_current = (
|
|
2512
|
+
measured_generation == current_generation and not currently_dirty)
|
|
2513
|
+
|
|
2514
|
+
if entry_count > _SNAPSHOT_ACCELERATOR_MAX_ENTRIES:
|
|
2515
|
+
_clear_snapshot_accelerators()
|
|
2516
|
+
_SNAPSHOT_ACCELERATOR_EVICTIONS += entry_count
|
|
2517
|
+
_SNAPSHOT_ACCELERATOR_FALLBACKS += 1
|
|
2518
|
+
_SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = 0
|
|
2519
|
+
_SNAPSHOT_ACCELERATOR_ENTRY_COUNT = _snapshot_accelerator_entry_count()
|
|
2520
|
+
elif result_is_current and measurement_error is not None:
|
|
2521
|
+
_clear_snapshot_accelerators()
|
|
2522
|
+
_SNAPSHOT_ACCELERATOR_EVICTIONS += entry_count
|
|
2523
|
+
_SNAPSHOT_ACCELERATOR_FALLBACKS += 1
|
|
2524
|
+
_SNAPSHOT_ACCELERATOR_MEASUREMENT_ERRORS += 1
|
|
2525
|
+
_SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = 0
|
|
2526
|
+
_SNAPSHOT_ACCELERATOR_ENTRY_COUNT = _snapshot_accelerator_entry_count()
|
|
2527
|
+
elif (
|
|
2528
|
+
result_is_current
|
|
2529
|
+
and measured is not None
|
|
2530
|
+
and measured > _SNAPSHOT_ACCELERATOR_MAX_BYTES
|
|
2531
|
+
):
|
|
2532
|
+
_clear_snapshot_accelerators()
|
|
2533
|
+
_SNAPSHOT_ACCELERATOR_EVICTIONS += entry_count
|
|
2534
|
+
_SNAPSHOT_ACCELERATOR_FALLBACKS += 1
|
|
2535
|
+
_SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = 0
|
|
2536
|
+
_SNAPSHOT_ACCELERATOR_ENTRY_COUNT = _snapshot_accelerator_entry_count()
|
|
2537
|
+
elif result_is_current and measured is not None:
|
|
2538
|
+
_SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = measured
|
|
2539
|
+
_SNAPSHOT_ACCELERATOR_ENTRY_COUNT = int(entry_count)
|
|
2540
|
+
_SNAPSHOT_ACCELERATOR_MEASURED_GENERATION = measured_generation
|
|
2541
|
+
_SNAPSHOT_ACCELERATOR_LAST_MEASURED_MONOTONIC = time.monotonic()
|
|
2542
|
+
else:
|
|
2543
|
+
_SNAPSHOT_ACCELERATOR_ENTRY_COUNT = int(entry_count)
|
|
2544
|
+
|
|
2545
|
+
generation = _take_snapshot_accelerator_dirty()
|
|
2546
|
+
if generation is not None:
|
|
2547
|
+
measured_entry_count = _snapshot_accelerator_entry_count()
|
|
2548
|
+
_SNAPSHOT_MEMORY_WORKER.submit(
|
|
2549
|
+
generation,
|
|
2550
|
+
tuple(dict(root) for root in _snapshot_accelerator_roots()),
|
|
2551
|
+
measured_entry_count,
|
|
2552
|
+
)
|
|
2553
|
+
|
|
2554
|
+
|
|
2555
|
+
def snapshot_accelerator_memory_stats():
|
|
2556
|
+
"""Safe numeric diagnostics for the complete snapshot accelerator owner."""
|
|
2557
|
+
with _SNAPSHOT_ACCELERATOR_STATS_LOCK:
|
|
2558
|
+
return {
|
|
2559
|
+
"estimatedBytes": int(_SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES),
|
|
2560
|
+
"maxBytes": int(_SNAPSHOT_ACCELERATOR_MAX_BYTES),
|
|
2561
|
+
"entryCount": int(_SNAPSHOT_ACCELERATOR_ENTRY_COUNT),
|
|
2562
|
+
"maxEntries": int(_SNAPSHOT_ACCELERATOR_MAX_ENTRIES),
|
|
2563
|
+
"evictionCount": int(_SNAPSHOT_ACCELERATOR_EVICTIONS),
|
|
2564
|
+
"fallbackCount": int(_SNAPSHOT_ACCELERATOR_FALLBACKS),
|
|
2565
|
+
"measurementErrorCount": int(
|
|
2566
|
+
_SNAPSHOT_ACCELERATOR_MEASUREMENT_ERRORS),
|
|
2567
|
+
"measuredGeneration": int(
|
|
2568
|
+
_SNAPSHOT_ACCELERATOR_MEASURED_GENERATION),
|
|
2569
|
+
"currentGeneration": int(_SNAPSHOT_ACCELERATOR_GENERATION),
|
|
2570
|
+
"measurementPending": int(
|
|
2571
|
+
_SNAPSHOT_ACCELERATOR_MEASURED_GENERATION
|
|
2572
|
+
!= _SNAPSHOT_ACCELERATOR_GENERATION
|
|
2573
|
+
),
|
|
2574
|
+
"workerAlive": int(
|
|
2575
|
+
getattr(_SNAPSHOT_MEMORY_WORKER, "is_alive", lambda: False)()
|
|
2576
|
+
),
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
|
|
2580
|
+
def shutdown_snapshot_memory_worker() -> None:
|
|
2581
|
+
"""Cancel and join retained-size traversal before replacing its owner."""
|
|
2582
|
+
global _SNAPSHOT_MEMORY_WORKER
|
|
2583
|
+
worker = _SNAPSHOT_MEMORY_WORKER
|
|
2584
|
+
if not worker.shutdown():
|
|
2585
|
+
raise RuntimeError("snapshot memory verifier did not stop")
|
|
2586
|
+
_SNAPSHOT_MEMORY_WORKER = _SnapshotMemoryWorker()
|
|
2100
2587
|
|
|
2101
2588
|
|
|
2102
2589
|
def reset_cache_report_state():
|