cctally 1.115.0 → 1.116.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 +16 -0
- package/bin/_cctally_cache.py +85 -4
- package/bin/_cctally_completion.py +80 -12
- package/bin/_cctally_core.py +106 -9
- package/bin/_cctally_dashboard.py +6 -9
- package/bin/_cctally_dashboard_envelope.py +26 -32
- package/bin/_cctally_db.py +85 -4
- package/bin/_cctally_doctor.py +38 -5
- package/bin/_cctally_five_hour.py +83 -1017
- package/bin/_cctally_journal.py +1307 -180
- package/bin/_cctally_journal_repair.py +9 -7
- package/bin/_cctally_oauth_samples.py +280 -0
- package/bin/_cctally_parser.py +5 -0
- package/bin/_cctally_quarantine.py +14 -4
- package/bin/_cctally_quota.py +70 -8
- package/bin/_cctally_quota_model.py +35 -4
- package/bin/_cctally_record.py +414 -123
- package/bin/_cctally_rederive.py +632 -235
- package/bin/_cctally_refresh.py +29 -0
- package/bin/_cctally_statusline.py +984 -47
- package/bin/_cctally_store.py +972 -58
- package/bin/_cctally_tui.py +62 -7
- package/bin/_cctally_update.py +25 -0
- package/bin/_lib_account_protocol.py +201 -18
- package/bin/_lib_clone_copy.py +46 -11
- package/bin/_lib_dashboard_sources.py +57 -15
- package/bin/_lib_detector_access_registry.py +273 -52
- package/bin/_lib_doctor.py +302 -1
- package/bin/_lib_journal.py +145 -25
- package/bin/_lib_journal_router.py +166 -48
- package/bin/_lib_oauth_samples.py +238 -0
- package/bin/_lib_pricing_check.py +0 -12
- package/bin/_lib_provider_activity.py +770 -0
- package/bin/_lib_quarantine.py +3 -2
- package/bin/_lib_quota_model.py +96 -6
- package/bin/_lib_rebuild_progress.py +146 -0
- package/bin/_lib_record.py +22 -0
- package/bin/_lib_rederive.py +299 -3
- package/bin/_lib_snapshot_cache.py +43 -1
- package/bin/_lib_stale_proof.py +708 -0
- package/bin/_lib_statusline_candidates.py +65 -5
- package/bin/_lib_worker_launches.py +315 -0
- package/bin/cctally +6 -11
- package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
- package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
- package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
- package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
- package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +7 -2
- package/bin/_lib_merge_gate.py +0 -452
- package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
|
@@ -12,6 +12,8 @@ import math
|
|
|
12
12
|
import re
|
|
13
13
|
from typing import Callable, Mapping
|
|
14
14
|
|
|
15
|
+
import _lib_provider_activity
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
#: Candidate and tombstone documents. Every session on this machine writes
|
|
17
19
|
#: candidates, so this version may only move when peer sessions running the
|
|
@@ -59,6 +61,11 @@ class Candidate:
|
|
|
59
61
|
#: The immutable `AccountBinding` captured with this candidate (#825/#832
|
|
60
62
|
#: spec 4.2). The reducer partitions by its account; it is never inferred.
|
|
61
63
|
binding: object | None = None
|
|
64
|
+
#: The provider-activity witness (#797 spec 4.2,
|
|
65
|
+
#: `_lib_provider_activity.ActivityWitness`) captured once at this
|
|
66
|
+
#: arrival, before its intake commit. Required in every v2 candidate
|
|
67
|
+
#: document, so the intake identity hashes it; never recomputed.
|
|
68
|
+
activity: object | None = None
|
|
62
69
|
|
|
63
70
|
|
|
64
71
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -307,6 +314,16 @@ def _require_ascii_source(value: object) -> str:
|
|
|
307
314
|
return value
|
|
308
315
|
|
|
309
316
|
|
|
317
|
+
def validate_activity(value: object):
|
|
318
|
+
"""The candidate's activity witness, or ``StateValidationError``: a
|
|
319
|
+
missing or malformed witness makes the v2 document malformed and never
|
|
320
|
+
silently reads as ``unknown`` (#797 spec 4.2)."""
|
|
321
|
+
try:
|
|
322
|
+
return _lib_provider_activity.witness_from_json(value)
|
|
323
|
+
except _lib_provider_activity.MalformedWitness as exc:
|
|
324
|
+
raise StateValidationError(str(exc)) from exc
|
|
325
|
+
|
|
326
|
+
|
|
310
327
|
def _axis_value(
|
|
311
328
|
value: object,
|
|
312
329
|
*,
|
|
@@ -1118,7 +1135,14 @@ def _reduce_axis(
|
|
|
1118
1135
|
scope=None,
|
|
1119
1136
|
drop_allowed: bool = True,
|
|
1120
1137
|
evidence: AxisEvidence | None = None,
|
|
1138
|
+
member_facts: Mapping[str, object] | None = None,
|
|
1121
1139
|
) -> _AxisDecision:
|
|
1140
|
+
# #797 spec 4.4 (Q3): the per-member refusal facts
|
|
1141
|
+
# (`_lib_stale_proof.MemberFacts`, keyed by contributor token) are
|
|
1142
|
+
# resolved before the kernel. None, or no refused member, reduces
|
|
1143
|
+
# exactly as before.
|
|
1144
|
+
refused = frozenset(
|
|
1145
|
+
token for token, facts in (member_facts or {}).items() if facts.refused)
|
|
1122
1146
|
reduced_data = _reduced_candidate(
|
|
1123
1147
|
candidates, axis=axis, tombstone=tombstone, db_axis=db_axis
|
|
1124
1148
|
)
|
|
@@ -1137,8 +1161,11 @@ def _reduce_axis(
|
|
|
1137
1161
|
# ordinary first post-floor reading; one captured before it is
|
|
1138
1162
|
# stale and is never relabelled as post-floor. The reading opens
|
|
1139
1163
|
# no consensus and spends no attempt, under either gate.
|
|
1164
|
+
# #797 spec 4.4: a refused member is excluded from the post-floor
|
|
1165
|
+
# set; every other member keeps the receipt-floor test.
|
|
1140
1166
|
post = tuple((candidate, value) for candidate, value in in_window
|
|
1141
|
-
if candidate.
|
|
1167
|
+
if candidate.token not in refused
|
|
1168
|
+
and candidate.received_at >= int(evidence.floor_epoch))
|
|
1142
1169
|
if not post:
|
|
1143
1170
|
verdict = CLASS_STALE
|
|
1144
1171
|
else:
|
|
@@ -1184,7 +1211,13 @@ def _reduce_axis(
|
|
|
1184
1211
|
# drop. The maximum decides what is displayed; it may not erase the
|
|
1185
1212
|
# evidence that a drop is pending, which is why the low is read separately
|
|
1186
1213
|
# here and why a rise is a publication rather than a branch of its own.
|
|
1187
|
-
|
|
1214
|
+
# #797 spec 4.4 (Q3): `rising` and the published value come from the
|
|
1215
|
+
# members that are not refused; low evidence, supporter retraction,
|
|
1216
|
+
# unanimity, pending membership, `reduced_percent`, deadlines and attempt
|
|
1217
|
+
# accounting keep the unfiltered set. The eligible value applies at every
|
|
1218
|
+
# rising return (plain, armed, cancelled and reconciled).
|
|
1219
|
+
rise = _eligible_rise(reduced, in_window, refused, db_axis)
|
|
1220
|
+
rising = rise is not None and rise.percent > db_axis.percent
|
|
1188
1221
|
low = min(value.percent for _, value in in_window)
|
|
1189
1222
|
armed = (
|
|
1190
1223
|
pending is not None
|
|
@@ -1196,7 +1229,7 @@ def _reduce_axis(
|
|
|
1196
1229
|
if rising:
|
|
1197
1230
|
# The rise moves the baseline and no drop is pending against the old
|
|
1198
1231
|
# one, so there is nothing to preserve.
|
|
1199
|
-
return _AxisDecision("PUBLISH_DB",
|
|
1232
|
+
return _AxisDecision("PUBLISH_DB", rise, None)
|
|
1200
1233
|
if low >= db_axis.percent:
|
|
1201
1234
|
return _AxisDecision(
|
|
1202
1235
|
"WRITE_CONTROL" if pending is not None else "NOOP", None, None
|
|
@@ -1212,7 +1245,7 @@ def _reduce_axis(
|
|
|
1212
1245
|
# A cancelled or already-recorded drop stops being pending, and a rise still
|
|
1213
1246
|
# publishes on the way out.
|
|
1214
1247
|
cancelled = _AxisDecision(
|
|
1215
|
-
"PUBLISH_DB" if rising else "WRITE_CONTROL",
|
|
1248
|
+
"PUBLISH_DB" if rising else "WRITE_CONTROL", rise if rising else None, None
|
|
1216
1249
|
)
|
|
1217
1250
|
if _supporters_retracted(pending, in_window, db_axis):
|
|
1218
1251
|
return cancelled
|
|
@@ -1245,7 +1278,7 @@ def _reduce_axis(
|
|
|
1245
1278
|
# reporting a higher number erased another session's real reset. The
|
|
1246
1279
|
# retained low keeps its meaning, because the baseline only moved UP —
|
|
1247
1280
|
# a low armed below the old baseline is still a drop below the new one.
|
|
1248
|
-
return _AxisDecision("PUBLISH_DB",
|
|
1281
|
+
return _AxisDecision("PUBLISH_DB", rise, updated)
|
|
1249
1282
|
|
|
1250
1283
|
unanimous = (
|
|
1251
1284
|
reduced.percent < db_axis.percent
|
|
@@ -1289,6 +1322,25 @@ def _reduce_axis(
|
|
|
1289
1322
|
)
|
|
1290
1323
|
|
|
1291
1324
|
|
|
1325
|
+
def _eligible_rise(
|
|
1326
|
+
reduced: AxisValue,
|
|
1327
|
+
in_window: tuple[tuple[Candidate, AxisValue], ...],
|
|
1328
|
+
refused: frozenset,
|
|
1329
|
+
db_axis: AxisProjection,
|
|
1330
|
+
) -> AxisValue | None:
|
|
1331
|
+
"""The same-window maximum over members that are not refused (#797 spec
|
|
1332
|
+
4.4), or None when every member is refused. Without a refused member it
|
|
1333
|
+
is ``reduced`` itself."""
|
|
1334
|
+
if not refused or not any(c.token in refused for c, _ in in_window):
|
|
1335
|
+
return reduced
|
|
1336
|
+
eligible = [value.percent for candidate, value in in_window
|
|
1337
|
+
if candidate.token not in refused]
|
|
1338
|
+
if not eligible:
|
|
1339
|
+
return None
|
|
1340
|
+
return AxisValue(max(eligible), db_axis.raw_resets_at,
|
|
1341
|
+
canonical_key=reduced.canonical_key)
|
|
1342
|
+
|
|
1343
|
+
|
|
1292
1344
|
def reduce_candidates(
|
|
1293
1345
|
candidates: tuple[Candidate, ...] | list[Candidate],
|
|
1294
1346
|
*,
|
|
@@ -1298,6 +1350,7 @@ def reduce_candidates(
|
|
|
1298
1350
|
now_epoch: int,
|
|
1299
1351
|
account_key: str | None = None,
|
|
1300
1352
|
drop_consensus_allowed: bool = True,
|
|
1353
|
+
member_facts: Mapping[str, Mapping[str, object]] | None = None,
|
|
1301
1354
|
) -> ReductionDecision:
|
|
1302
1355
|
"""Reduce active candidates independently for 5h and 7d axes.
|
|
1303
1356
|
|
|
@@ -1319,7 +1372,12 @@ def reduce_candidates(
|
|
|
1319
1372
|
reading after a retiring floor); a stale reading takes the older-window
|
|
1320
1373
|
disposition, and an unclassifiable one is named in ``unknown_axes`` (fix
|
|
1321
1374
|
rounds K13 and K14c).
|
|
1375
|
+
|
|
1376
|
+
``member_facts`` maps an axis to its per-member refusal facts (#797 spec
|
|
1377
|
+
4.4, `_lib_stale_proof.MemberFacts` by contributor token). None, or an
|
|
1378
|
+
axis without facts, means every member is eligible.
|
|
1322
1379
|
"""
|
|
1380
|
+
facts = member_facts or {}
|
|
1323
1381
|
active = tuple(
|
|
1324
1382
|
candidate
|
|
1325
1383
|
for candidate in candidates
|
|
@@ -1335,6 +1393,7 @@ def reduce_candidates(
|
|
|
1335
1393
|
scope=None if account_key is None else (PROTOCOL_GENERATION, account_key, "fiveHour"),
|
|
1336
1394
|
drop_allowed=drop_consensus_allowed,
|
|
1337
1395
|
evidence=axis_evidence(db, "fiveHour"),
|
|
1396
|
+
member_facts=facts.get("fiveHour"),
|
|
1338
1397
|
)
|
|
1339
1398
|
seven = _reduce_axis(
|
|
1340
1399
|
"sevenDay",
|
|
@@ -1346,6 +1405,7 @@ def reduce_candidates(
|
|
|
1346
1405
|
scope=None if account_key is None else (PROTOCOL_GENERATION, account_key, "sevenDay"),
|
|
1347
1406
|
drop_allowed=drop_consensus_allowed,
|
|
1348
1407
|
evidence=axis_evidence(db, "sevenDay"),
|
|
1408
|
+
member_facts=facts.get("sevenDay"),
|
|
1349
1409
|
)
|
|
1350
1410
|
next_control = ControlState(
|
|
1351
1411
|
db_projection=db,
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""The launch ledger of the detached stats rebuild workers (#997, spec D8).
|
|
2
|
+
|
|
3
|
+
On 2026-10-22 an epoch-rebuild worker ran for hours and nothing recorded
|
|
4
|
+
whether one worker ran throughout or one died and another replaced it: the log
|
|
5
|
+
held only returned outcomes. Each worker (the epoch rebuild and the corruption
|
|
6
|
+
heal) now keeps two private records in ``APP_DIR``: a run state, written only
|
|
7
|
+
by the holder of the worker flock, and this launch ledger, written only under
|
|
8
|
+
the worker's admission flock. This module is the pure kernel that decides what
|
|
9
|
+
became of each launch; spawners, workers, ``doctor`` and ``db status`` all
|
|
10
|
+
classify through it, and only spawners and workers persist the result.
|
|
11
|
+
|
|
12
|
+
The central rule: **a death is settled by evidence, never by elapsed time.**
|
|
13
|
+
An unsettled entry is settled when
|
|
14
|
+
|
|
15
|
+
(a) the run state shows its ``launchId`` finished, which transfers that
|
|
16
|
+
outcome as ``finished:<outcome>``, or records its in-run death; or
|
|
17
|
+
(b) its process is confirmed gone by identity — absent, a zombie, or a live
|
|
18
|
+
pid whose ``birth`` differs — which settles ``died-pre-start`` without a
|
|
19
|
+
start acknowledgement and ``died-in-run`` with one. The caller probes the
|
|
20
|
+
identities BEFORE it reads the files it passes here, so a re-read after the
|
|
21
|
+
probe is what this function sees.
|
|
22
|
+
|
|
23
|
+
A process that is alive, or whose liveness is unknown (``ps`` failing, a live
|
|
24
|
+
pid with a null ``birth``), stays unresolved however old the entry is: a slow
|
|
25
|
+
child is never declared dead.
|
|
26
|
+
|
|
27
|
+
Ledger shape (``schemaVersion`` 1)::
|
|
28
|
+
|
|
29
|
+
{"schemaVersion": 1,
|
|
30
|
+
"entries": [{"launchId", "at", "pid", "birth",
|
|
31
|
+
"spawn": "reserved"|"ok"|"failed",
|
|
32
|
+
"ack": None | {"kind": "started", "runId", "at"}
|
|
33
|
+
| {"kind": "busy", "at"},
|
|
34
|
+
"outcome": None | <terminal>, "settledAt"}],
|
|
35
|
+
"counts": {<terminal>: int}, # cumulative, survives pruning
|
|
36
|
+
"lastDeathAt": str | None}
|
|
37
|
+
|
|
38
|
+
``pid`` and ``birth`` are null while ``spawn`` is ``reserved``; ``birth`` may
|
|
39
|
+
stay null after it (``ps`` could not see the child). Unresolved entries are
|
|
40
|
+
never dropped: the spawner admits no launch while ``LAUNCH_CAPACITY`` are
|
|
41
|
+
unresolved (``has_launch_capacity``), which is what bounds them.
|
|
42
|
+
|
|
43
|
+
``<terminal>`` is ``finished:<worker outcome>``, ``busy``, ``spawn-failed``,
|
|
44
|
+
``died-pre-start`` or ``died-in-run``. Every function returns a new ledger and
|
|
45
|
+
never mutates its input. Stdlib only, and importable on its own.
|
|
46
|
+
"""
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import copy
|
|
50
|
+
|
|
51
|
+
SCHEMA_VERSION = 1
|
|
52
|
+
#: Unresolved entries at which the spawner withholds further launches.
|
|
53
|
+
LAUNCH_CAPACITY = 100
|
|
54
|
+
#: Settled entries kept (the most recent); ``counts`` keeps the totals.
|
|
55
|
+
SETTLED_KEEP = 10
|
|
56
|
+
DEATHS = ("died-pre-start", "died-in-run")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def empty_ledger() -> dict:
|
|
60
|
+
return {
|
|
61
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
62
|
+
"entries": [],
|
|
63
|
+
"counts": {},
|
|
64
|
+
"lastDeathAt": None,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalize(ledger) -> dict:
|
|
69
|
+
"""A validated deep copy of ``ledger``; ``ValueError`` when malformed."""
|
|
70
|
+
if not isinstance(ledger, dict):
|
|
71
|
+
raise ValueError("launch ledger is not an object")
|
|
72
|
+
if ledger.get("schemaVersion") != SCHEMA_VERSION:
|
|
73
|
+
raise ValueError("unsupported launch ledger schemaVersion")
|
|
74
|
+
entries = ledger.get("entries", [])
|
|
75
|
+
if not isinstance(entries, list) or not all(
|
|
76
|
+
isinstance(e, dict) and isinstance(e.get("launchId"), str)
|
|
77
|
+
for e in entries):
|
|
78
|
+
raise ValueError("launch ledger entries are malformed")
|
|
79
|
+
counts = ledger.get("counts") or {}
|
|
80
|
+
if not isinstance(counts, dict) or not all(
|
|
81
|
+
isinstance(k, str) and isinstance(v, int)
|
|
82
|
+
for k, v in counts.items()):
|
|
83
|
+
raise ValueError("launch ledger counts are malformed")
|
|
84
|
+
last_death = ledger.get("lastDeathAt")
|
|
85
|
+
if last_death is not None and not isinstance(last_death, str):
|
|
86
|
+
raise ValueError("launch ledger lastDeathAt is malformed")
|
|
87
|
+
out = empty_ledger()
|
|
88
|
+
out["entries"] = [_entry(**{k: e.get(k) for k in _ENTRY_KEYS})
|
|
89
|
+
for e in copy.deepcopy(entries)]
|
|
90
|
+
out["counts"] = dict(counts)
|
|
91
|
+
out["lastDeathAt"] = last_death
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_ENTRY_KEYS = ("launchId", "at", "pid", "birth", "spawn", "ack", "outcome",
|
|
96
|
+
"settledAt")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _entry(launchId, at, pid, birth, spawn="ok", ack=None, outcome=None,
|
|
100
|
+
settledAt=None) -> dict:
|
|
101
|
+
return {
|
|
102
|
+
"launchId": launchId, "at": at, "pid": pid, "birth": birth,
|
|
103
|
+
"spawn": spawn or "ok", "ack": ack, "outcome": outcome,
|
|
104
|
+
"settledAt": settledAt,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find(ledger: dict, launch_id: str) -> "dict | None":
|
|
109
|
+
for entry in ledger.get("entries", ()):
|
|
110
|
+
if entry.get("launchId") == launch_id:
|
|
111
|
+
return entry
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def unresolved(ledger: dict) -> list:
|
|
116
|
+
return [e for e in ledger.get("entries", ()) if not e.get("outcome")]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def newest(ledger: dict) -> "dict | None":
|
|
120
|
+
"""The newest launch in ledger (launch) order."""
|
|
121
|
+
entries = ledger.get("entries") or []
|
|
122
|
+
return entries[-1] if entries else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _settle_in_place(ledger: dict, entry: dict, terminal: str, at) -> bool:
|
|
126
|
+
if entry.get("outcome"):
|
|
127
|
+
return False
|
|
128
|
+
entry["outcome"] = terminal
|
|
129
|
+
entry["settledAt"] = at
|
|
130
|
+
counts = ledger.setdefault("counts", {})
|
|
131
|
+
counts[terminal] = int(counts.get(terminal, 0)) + 1
|
|
132
|
+
if terminal in DEATHS:
|
|
133
|
+
ledger["lastDeathAt"] = at or entry.get("at")
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def append_entry(ledger: dict, *, launch_id: str, at, pid, birth,
|
|
138
|
+
spawn: str = "ok") -> dict:
|
|
139
|
+
"""Record a launch. A launch id already present is left as it is (the
|
|
140
|
+
worker's own acknowledgement may have recorded it first). A failed spawn
|
|
141
|
+
is settled at once as ``spawn-failed``."""
|
|
142
|
+
out = copy.deepcopy(ledger)
|
|
143
|
+
if find(out, launch_id) is not None:
|
|
144
|
+
return out
|
|
145
|
+
entry = _entry(launch_id, at, pid, birth, spawn=spawn)
|
|
146
|
+
out.setdefault("entries", []).append(entry)
|
|
147
|
+
if spawn == "failed":
|
|
148
|
+
_settle_in_place(out, entry, "spawn-failed", at)
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def reserve(ledger: dict, *, launch_id: str, at) -> dict:
|
|
153
|
+
"""The spawner's reservation, persisted BEFORE the spawn (revision 6): a
|
|
154
|
+
spawner killed after it leaves an entry with no pid, which stays
|
|
155
|
+
unresolved with unknown liveness until the child acknowledges."""
|
|
156
|
+
out = copy.deepcopy(ledger)
|
|
157
|
+
if find(out, launch_id) is None:
|
|
158
|
+
out.setdefault("entries", []).append(
|
|
159
|
+
_entry(launch_id, at, None, None, spawn="reserved"))
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def fill_spawn(ledger: dict, launch_id: str, *, pid, birth, at,
|
|
164
|
+
spawn: str = "ok") -> dict:
|
|
165
|
+
"""Complete a reservation with the spawn's result. A failed spawn is
|
|
166
|
+
settled at once as ``spawn-failed``. An identity the worker already
|
|
167
|
+
recorded at acknowledgement is never overwritten."""
|
|
168
|
+
out = copy.deepcopy(ledger)
|
|
169
|
+
entry = find(out, launch_id)
|
|
170
|
+
if entry is None:
|
|
171
|
+
out.setdefault("entries", []).append(
|
|
172
|
+
_entry(launch_id, at, None, None, spawn="reserved"))
|
|
173
|
+
entry = out["entries"][-1]
|
|
174
|
+
if spawn == "failed":
|
|
175
|
+
if entry.get("spawn") == "reserved":
|
|
176
|
+
entry["spawn"] = "failed"
|
|
177
|
+
_settle_in_place(out, entry, "spawn-failed", at)
|
|
178
|
+
return out
|
|
179
|
+
if entry.get("pid") is None:
|
|
180
|
+
entry["pid"] = pid
|
|
181
|
+
entry["birth"] = birth
|
|
182
|
+
if entry.get("spawn") == "reserved":
|
|
183
|
+
entry["spawn"] = "ok"
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def acknowledge(ledger: dict, launch_id: str, kind: str, run_id=None, *,
|
|
188
|
+
at, pid=None, birth=None) -> dict:
|
|
189
|
+
"""A worker's acknowledgement: ``started`` (with its ``runId``) or
|
|
190
|
+
``busy`` (which is also the entry's terminal). Fills the worker's own
|
|
191
|
+
``pid`` and ``birth`` into a reservation the spawner never completed, and
|
|
192
|
+
appends the entry when there is none (a ledger lost to the unreadable
|
|
193
|
+
archive path)."""
|
|
194
|
+
out = copy.deepcopy(ledger)
|
|
195
|
+
entry = find(out, launch_id)
|
|
196
|
+
if entry is None:
|
|
197
|
+
entry = _entry(launch_id, at, pid, birth)
|
|
198
|
+
out.setdefault("entries", []).append(entry)
|
|
199
|
+
elif entry.get("pid") is None and pid is not None:
|
|
200
|
+
entry["pid"] = pid
|
|
201
|
+
entry["birth"] = birth
|
|
202
|
+
if entry.get("spawn") == "reserved":
|
|
203
|
+
entry["spawn"] = "ok"
|
|
204
|
+
if kind == "started":
|
|
205
|
+
entry["ack"] = {"kind": "started", "runId": run_id, "at": at}
|
|
206
|
+
elif kind == "busy":
|
|
207
|
+
entry["ack"] = {"kind": "busy", "at": at}
|
|
208
|
+
_settle_in_place(out, entry, "busy", at)
|
|
209
|
+
else:
|
|
210
|
+
raise ValueError(f"unknown acknowledgement kind {kind!r}")
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def settle(ledger: dict, launch_id: str, terminal: str, *, at) -> dict:
|
|
215
|
+
"""Give an entry its terminal outcome; the first settlement wins."""
|
|
216
|
+
out = copy.deepcopy(ledger)
|
|
217
|
+
entry = find(out, launch_id)
|
|
218
|
+
if entry is not None:
|
|
219
|
+
_settle_in_place(out, entry, terminal, at)
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def liveness(entry: dict, identities: dict) -> str:
|
|
224
|
+
"""``alive`` | ``gone`` | ``unknown`` for a ledger entry or a run (both
|
|
225
|
+
carry ``pid`` and ``birth``), from ``process_identity`` results."""
|
|
226
|
+
pid = entry.get("pid")
|
|
227
|
+
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
|
|
228
|
+
return "unknown"
|
|
229
|
+
identity = identities.get(pid)
|
|
230
|
+
if not isinstance(identity, dict):
|
|
231
|
+
return "unknown"
|
|
232
|
+
status = identity.get("status")
|
|
233
|
+
if status == "gone":
|
|
234
|
+
return "gone"
|
|
235
|
+
if status != "alive":
|
|
236
|
+
return "unknown"
|
|
237
|
+
entry_birth, live_birth = entry.get("birth"), identity.get("birth")
|
|
238
|
+
if entry_birth and live_birth and entry_birth != live_birth:
|
|
239
|
+
return "gone" # the pid now names a different process
|
|
240
|
+
if not entry_birth or not live_birth:
|
|
241
|
+
return "unknown"
|
|
242
|
+
return "alive"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def classify(ledger: dict, state, identities: dict, *, now=None) -> dict:
|
|
246
|
+
"""Settle what the evidence settles. Returns ``{"ledger", "settled"}``,
|
|
247
|
+
``settled`` being the newly settled entries (copies). Never settles by
|
|
248
|
+
age; see the module docstring for rules (a) and (b)."""
|
|
249
|
+
out = copy.deepcopy(ledger)
|
|
250
|
+
run = state.get("run") if isinstance(state, dict) else None
|
|
251
|
+
run = run if isinstance(run, dict) else None
|
|
252
|
+
deaths = state.get("deaths") if isinstance(state, dict) else None
|
|
253
|
+
# A death whose ``launchId`` is not a string (a corrupt record in a
|
|
254
|
+
# state that still validates) settles nothing.
|
|
255
|
+
died_in_run = {d.get("launchId") for d in (deaths or ())
|
|
256
|
+
if isinstance(d, dict) and isinstance(d.get("launchId"), str)
|
|
257
|
+
and d.get("launchId")}
|
|
258
|
+
settled = []
|
|
259
|
+
for entry in out.get("entries", []):
|
|
260
|
+
if entry.get("outcome"):
|
|
261
|
+
continue
|
|
262
|
+
launch_id = entry.get("launchId")
|
|
263
|
+
terminal = None
|
|
264
|
+
if (run is not None and run.get("launchId") == launch_id
|
|
265
|
+
and run.get("status") == "finished"):
|
|
266
|
+
terminal = f"finished:{run.get('outcome')}"
|
|
267
|
+
elif launch_id in died_in_run:
|
|
268
|
+
terminal = "died-in-run"
|
|
269
|
+
elif liveness(entry, identities) == "gone":
|
|
270
|
+
ack = entry.get("ack")
|
|
271
|
+
started = isinstance(ack, dict) and ack.get("kind") == "started"
|
|
272
|
+
terminal = "died-in-run" if started else "died-pre-start"
|
|
273
|
+
if terminal is not None and _settle_in_place(out, entry, terminal,
|
|
274
|
+
now):
|
|
275
|
+
settled.append(copy.deepcopy(entry))
|
|
276
|
+
return {"ledger": out, "settled": settled}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _protected_ids(protected_launch_ids) -> frozenset:
|
|
280
|
+
if isinstance(protected_launch_ids, str):
|
|
281
|
+
return frozenset({protected_launch_ids})
|
|
282
|
+
if not isinstance(protected_launch_ids, (list, tuple, set, frozenset)):
|
|
283
|
+
return frozenset()
|
|
284
|
+
return frozenset(i for i in protected_launch_ids if isinstance(i, str))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def prune(ledger: dict, protected_launch_ids=None) -> dict:
|
|
288
|
+
"""Bound the settled entries to the newest ``SETTLED_KEEP``, in ledger
|
|
289
|
+
order. Unresolved entries are never dropped (``has_launch_capacity``
|
|
290
|
+
bounds them); ``counts`` and ``lastDeathAt`` are kept.
|
|
291
|
+
|
|
292
|
+
``protected_launch_ids`` (one id, or an iterable of them; a non-string
|
|
293
|
+
member protects nothing) names the entries a run state references or is
|
|
294
|
+
about to: the current ``run.launchId`` and, for a worker whose own state
|
|
295
|
+
write has not landed yet, its own launch as well. Each is exempt from the
|
|
296
|
+
bound, because the slot's entry is the record that makes the next
|
|
297
|
+
worker's death accounting exactly-once, and the settled bound then counts
|
|
298
|
+
them plus ``SETTLED_KEEP`` minus their number of others."""
|
|
299
|
+
out = copy.deepcopy(ledger)
|
|
300
|
+
entries = out.get("entries", [])
|
|
301
|
+
wanted = _protected_ids(protected_launch_ids)
|
|
302
|
+
protected = {id(e) for e in entries if e.get("launchId") in wanted}
|
|
303
|
+
done_ids = [id(e) for e in entries
|
|
304
|
+
if e.get("outcome") and id(e) not in protected]
|
|
305
|
+
settled_room = max(0, SETTLED_KEEP - sum(
|
|
306
|
+
1 for e in entries if id(e) in protected and e.get("outcome")))
|
|
307
|
+
dropped = set(done_ids[:max(0, len(done_ids) - settled_room)])
|
|
308
|
+
out["entries"] = [e for e in entries if id(e) not in dropped]
|
|
309
|
+
return out
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def has_launch_capacity(ledger: dict) -> bool:
|
|
313
|
+
"""Whether the spawner may admit another launch: fewer than
|
|
314
|
+
``LAUNCH_CAPACITY`` entries are unresolved."""
|
|
315
|
+
return len(unresolved(ledger)) < LAUNCH_CAPACITY
|
package/bin/cctally
CHANGED
|
@@ -404,7 +404,6 @@ diff_pricing = _lib_pricing_check.diff_pricing
|
|
|
404
404
|
stale_allowlist_entries = _lib_pricing_check.stale_allowlist_entries
|
|
405
405
|
expired_allowlist_entries = _lib_pricing_check.expired_allowlist_entries
|
|
406
406
|
check_table_shapes = _lib_pricing_check.check_table_shapes
|
|
407
|
-
pricing_issue_action = _lib_pricing_check.pricing_issue_action
|
|
408
407
|
CoverageGap = _lib_pricing_check.CoverageGap
|
|
409
408
|
DriftRow = _lib_pricing_check.DriftRow
|
|
410
409
|
DriftResult = _lib_pricing_check.DriftResult
|
|
@@ -1258,13 +1257,11 @@ _delete_doomed_snapshot_rows = _cctally_record._delete_doomed_snapshot_rows
|
|
|
1258
1257
|
# #834 S1 (#835) Gate A R4: the band is a connection-taking builder now,
|
|
1259
1258
|
# because the held predicate is omitted on a store predating the column.
|
|
1260
1259
|
_stale_replica_band_sql = _cctally_record._stale_replica_band_sql
|
|
1261
|
-
# #834 S1 (#835):
|
|
1262
|
-
#
|
|
1263
|
-
#
|
|
1260
|
+
# #834 S1 (#835): a week's credits and their floors.
|
|
1261
|
+
# `_latest_seven_day_and_window` reaches both through this namespace for its
|
|
1262
|
+
# credit-floor bound (the retirement band itself was removed by #840).
|
|
1264
1263
|
_credit_retirement_bands = _cctally_record._credit_retirement_bands
|
|
1265
1264
|
_latest_credit_floor_instant = _cctally_record._latest_credit_floor_instant
|
|
1266
|
-
_weekly_value_is_retired_replica = (
|
|
1267
|
-
_cctally_record._weekly_value_is_retired_replica)
|
|
1268
1265
|
_stale_replay_candidates = _cctally_record._stale_replay_candidates
|
|
1269
1266
|
_count_stale_replays = _cctally_record._count_stale_replays
|
|
1270
1267
|
_credit_preview_text = _cctally_record._credit_preview_text
|
|
@@ -1606,11 +1603,9 @@ _format_block_start = _cctally_five_hour._format_block_start
|
|
|
1606
1603
|
_format_hhmm_in_tz = _cctally_five_hour._format_hhmm_in_tz
|
|
1607
1604
|
_block_is_active = _cctally_five_hour._block_is_active
|
|
1608
1605
|
_latest_seven_day_and_window = _cctally_five_hour._latest_seven_day_and_window
|
|
1609
|
-
# #834 S1 (#835) Gate A S1: the ONE read
|
|
1610
|
-
#
|
|
1611
|
-
|
|
1612
|
-
_block_weekly_start_instant = _cctally_five_hour._block_weekly_start_instant
|
|
1613
|
-
_block_weekly_end_instant = _cctally_five_hour._block_weekly_end_instant
|
|
1606
|
+
# #834 S1 (#835) Gate A S1: the ONE read of a block's two weekly axes (the
|
|
1607
|
+
# stored values since #840). Both five-hour commands reach it through this
|
|
1608
|
+
# namespace.
|
|
1614
1609
|
_credit_aware_block_weekly_axes = (
|
|
1615
1610
|
_cctally_five_hour._credit_aware_block_weekly_axes)
|
|
1616
1611
|
# #834 S1 (#836): the ONE five-hour milestone read, shared by the CLI
|