cctally 1.112.0 → 1.114.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 +27 -0
- package/README.md +4 -5
- package/bin/_cctally_cache.py +1683 -485
- package/bin/_cctally_core.py +223 -1
- package/bin/_cctally_dashboard.py +96 -18
- package/bin/_cctally_dashboard_perf.py +128 -9
- package/bin/_cctally_dashboard_sources.py +267 -204
- package/bin/_cctally_db.py +861 -18
- package/bin/_cctally_diagnosis_sources.py +22 -4
- package/bin/_cctally_doctor.py +136 -18
- package/bin/_cctally_five_hour.py +2 -2
- package/bin/_cctally_forecast.py +27 -6
- package/bin/_cctally_journal.py +67 -5
- package/bin/_cctally_milestone_history.py +82 -9
- package/bin/_cctally_percent_breakdown.py +3 -2
- package/bin/_cctally_quota.py +201 -33
- package/bin/_cctally_record.py +14 -0
- package/bin/_cctally_rederive.py +23 -9
- package/bin/_cctally_source_analytics.py +60 -15
- package/bin/_cctally_statusline.py +17 -0
- package/bin/_cctally_store.py +80 -5
- package/bin/_cctally_tui.py +4 -3
- package/bin/_cctally_weekrefs.py +128 -21
- package/bin/_lib_cache_report.py +35 -6
- package/bin/_lib_codex_conversation.py +32 -0
- package/bin/_lib_codex_conversation_query.py +164 -30
- package/bin/_lib_conversation_query.py +95 -56
- package/bin/_lib_conversation_retention.py +1605 -640
- package/bin/_lib_cost_provenance.py +53 -2
- package/bin/_lib_doctor.py +263 -69
- package/bin/_lib_fmt.py +11 -0
- package/bin/_lib_merge_gate.py +469 -51
- package/bin/_lib_perf.py +27 -1
- package/bin/_lib_pricing.py +209 -8
- package/bin/_lib_pricing_check.py +20 -4
- package/bin/_lib_reclaim_planner.py +792 -0
- package/bin/_lib_rederive.py +225 -77
- package/bin/_lib_snapshot_cache.py +21 -0
- package/bin/_lib_sqlite_close.py +81 -0
- package/bin/_lib_subscription_weeks.py +100 -17
- package/bin/_lib_tick_stats.py +217 -17
- package/bin/_lib_wal_checkpoint.py +1077 -0
- package/bin/_lib_write_budget.py +483 -0
- package/bin/_lib_write_io.py +703 -0
- package/bin/cctally +8 -0
- package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
- package/dashboard/static/assets/index-BKITk1CT.css +1 -0
- package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
- package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""Write-budget kernel for the dashboard's disk writes (#901 spec §5.6).
|
|
2
|
+
|
|
3
|
+
Stdlib-only leaf shared by the dashboard (`/api/debug/backend` `writeIo`), the
|
|
4
|
+
doctor check `performance.dashboard_disk_writes`, `dashboard-perf` and the
|
|
5
|
+
soak write-budget leg. Every function is pure: nothing here reads a clock, a
|
|
6
|
+
file, a counter or a database.
|
|
7
|
+
|
|
8
|
+
Two statistics with two windows:
|
|
9
|
+
|
|
10
|
+
* STEADY STATE (I2): a five-minute trailing window over one cumulative process
|
|
11
|
+
write counter. Bytes inside a marked deletion operation (its BEGIN to the
|
|
12
|
+
return of its COMMIT) are subtracted and reported beside it; everything else
|
|
13
|
+
counts, a reclaim chunk and a delayed checkpoint copy included (Q7).
|
|
14
|
+
* MAINTENANCE (I4): the durable charge ledger — charged bytes, never measured
|
|
15
|
+
bytes — over the current partial UTC hour plus the 24 complete hours before
|
|
16
|
+
it. A correct pacer can never exceed its allowance, so exceeding it means
|
|
17
|
+
pacing is broken.
|
|
18
|
+
|
|
19
|
+
The I2 caps are fixed and never move, and `LIMITS` equals them (Q20). The
|
|
20
|
+
§6.3 calibration (twice the largest healthy five-minute measurement of three
|
|
21
|
+
repetitions of each frozen workload, rounded up to whole MiB) is reported as a
|
|
22
|
+
diagnostic and never sets a limit: frozen inputs carry only the controlled
|
|
23
|
+
append, and the operator's real activity measured above it, within the caps.
|
|
24
|
+
A limit above a cap is refused by `validate_limits`; a measurement above a cap
|
|
25
|
+
means the outcome is not achieved, and that is escalated rather than absorbed.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import dataclasses
|
|
30
|
+
import datetime as dt
|
|
31
|
+
|
|
32
|
+
KiB = 1024
|
|
33
|
+
MiB = 1024 * KiB
|
|
34
|
+
GiB = 1024 * MiB
|
|
35
|
+
UTC = dt.timezone.utc
|
|
36
|
+
_NS = 1_000_000_000
|
|
37
|
+
|
|
38
|
+
#: I2 caps — FIXED by the specification.
|
|
39
|
+
CAP_BYTES_PER_MINUTE = 16 * MiB
|
|
40
|
+
CAP_BYTES_PER_PUBLICATION = 8 * MiB
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclasses.dataclass(frozen=True)
|
|
44
|
+
class BudgetLimits:
|
|
45
|
+
policy_version: int
|
|
46
|
+
bytes_per_minute: int
|
|
47
|
+
bytes_per_publication: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_limits(limits: BudgetLimits) -> BudgetLimits:
|
|
51
|
+
"""Refuse a non-positive limit and any limit above its I2 cap."""
|
|
52
|
+
if limits.bytes_per_minute <= 0 or limits.bytes_per_publication <= 0:
|
|
53
|
+
raise ValueError("write-budget limits must be positive")
|
|
54
|
+
if limits.bytes_per_minute > CAP_BYTES_PER_MINUTE:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"bytes_per_minute {limits.bytes_per_minute} exceeds the I2 cap "
|
|
57
|
+
f"{CAP_BYTES_PER_MINUTE}")
|
|
58
|
+
if limits.bytes_per_publication > CAP_BYTES_PER_PUBLICATION:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"bytes_per_publication {limits.bytes_per_publication} exceeds "
|
|
61
|
+
f"the I2 cap {CAP_BYTES_PER_PUBLICATION}")
|
|
62
|
+
return limits
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Versioned limits: the I2 caps (Q20); calibration is diagnostic only ────
|
|
66
|
+
POLICY_VERSION = 1
|
|
67
|
+
LIMITS = validate_limits(BudgetLimits(
|
|
68
|
+
policy_version=POLICY_VERSION,
|
|
69
|
+
bytes_per_minute=16 * MiB,
|
|
70
|
+
bytes_per_publication=8 * MiB,
|
|
71
|
+
))
|
|
72
|
+
# ───────────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
WINDOW_SECONDS = 300
|
|
75
|
+
MIN_COUNTER_SAMPLES = 6
|
|
76
|
+
MIN_PUBLICATIONS = 10
|
|
77
|
+
MAX_SAMPLE_GAP_SECONDS = 60
|
|
78
|
+
EXCLUDED_TIME_MAX_FRACTION = 0.20
|
|
79
|
+
HYSTERESIS_SPACING_SECONDS = 60
|
|
80
|
+
CLEAR_FRACTION = 0.80
|
|
81
|
+
|
|
82
|
+
#: I4 allowance — the one home for the pacer and the statistic.
|
|
83
|
+
MAINTENANCE_RATE_BYTES_PER_MINUTE = 4 * MiB
|
|
84
|
+
MAINTENANCE_CREDIT_BYTES = 4 * MiB
|
|
85
|
+
LEDGER_BUCKETS = 25
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclasses.dataclass(frozen=True)
|
|
89
|
+
class CounterSample:
|
|
90
|
+
t_ns: int
|
|
91
|
+
bytes: "int | None"
|
|
92
|
+
publications: int = 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclasses.dataclass(frozen=True)
|
|
96
|
+
class DeletionInterval:
|
|
97
|
+
start_ns: int
|
|
98
|
+
end_ns: int
|
|
99
|
+
start_bytes: "int | None"
|
|
100
|
+
end_bytes: "int | None"
|
|
101
|
+
rows: int = 0
|
|
102
|
+
ended_wall_s: "int | None" = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclasses.dataclass(frozen=True)
|
|
106
|
+
class SteadyStatistic:
|
|
107
|
+
status: str # "qualified" | "insufficient" | "unavailable"
|
|
108
|
+
reasons: "tuple[str, ...]" = ()
|
|
109
|
+
window_seconds: "float | None" = None
|
|
110
|
+
bytes_written: "int | None" = None
|
|
111
|
+
bytes_per_minute: "int | None" = None
|
|
112
|
+
rate_qualified: bool = False
|
|
113
|
+
publications: "int | None" = None
|
|
114
|
+
mean_bytes_per_publication: "int | None" = None
|
|
115
|
+
publication_qualified: bool = False
|
|
116
|
+
excluded_operations: int = 0
|
|
117
|
+
excluded_rows: int = 0
|
|
118
|
+
excluded_bytes: int = 0
|
|
119
|
+
excluded_seconds: float = 0.0
|
|
120
|
+
last_excluded_wall_s: "int | None" = None
|
|
121
|
+
sample_count: int = 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def steady_statistic(samples, deletions=(), *, now_ns, warm_admitted_ns,
|
|
125
|
+
counter_status="ok") -> SteadyStatistic:
|
|
126
|
+
"""The trailing five-minute statistic, or the typed reason it has none."""
|
|
127
|
+
if counter_status != "ok":
|
|
128
|
+
return SteadyStatistic("unavailable", (str(counter_status),))
|
|
129
|
+
start_ns = now_ns - WINDOW_SECONDS * _NS
|
|
130
|
+
gap_ns = MAX_SAMPLE_GAP_SECONDS * _NS
|
|
131
|
+
if warm_admitted_ns is None or warm_admitted_ns > start_ns:
|
|
132
|
+
return SteadyStatistic("insufficient", ("warming_up",))
|
|
133
|
+
ordered = sorted(samples, key=lambda s: s.t_ns)
|
|
134
|
+
if any(s.bytes is None and start_ns - gap_ns <= s.t_ns <= now_ns
|
|
135
|
+
for s in ordered):
|
|
136
|
+
return SteadyStatistic("insufficient", ("invalid_samples",))
|
|
137
|
+
valid = [s for s in ordered if s.bytes is not None and s.t_ns <= now_ns]
|
|
138
|
+
base = None
|
|
139
|
+
for sample in valid:
|
|
140
|
+
if sample.t_ns <= start_ns:
|
|
141
|
+
base = sample
|
|
142
|
+
if base is None or start_ns - base.t_ns > gap_ns:
|
|
143
|
+
return SteadyStatistic("insufficient", ("insufficient_coverage",))
|
|
144
|
+
span = [s for s in valid if s.t_ns >= base.t_ns]
|
|
145
|
+
end = span[-1]
|
|
146
|
+
if now_ns - end.t_ns > gap_ns or any(
|
|
147
|
+
b.t_ns - a.t_ns > gap_ns for a, b in zip(span, span[1:])):
|
|
148
|
+
return SteadyStatistic("insufficient", ("insufficient_coverage",))
|
|
149
|
+
if len(span) < MIN_COUNTER_SAMPLES:
|
|
150
|
+
return SteadyStatistic("insufficient", ("insufficient_samples",))
|
|
151
|
+
if any(b.bytes < a.bytes for a, b in zip(span, span[1:])):
|
|
152
|
+
return SteadyStatistic("unavailable", ("counter_reset",))
|
|
153
|
+
excluded_bytes = 0
|
|
154
|
+
excluded_ns = 0
|
|
155
|
+
operations = 0
|
|
156
|
+
rows = 0
|
|
157
|
+
last_wall = None
|
|
158
|
+
for d in deletions:
|
|
159
|
+
overlap_ns = max(0, min(d.end_ns, end.t_ns) - max(d.start_ns, base.t_ns))
|
|
160
|
+
if d.start_bytes is not None and d.end_bytes is not None:
|
|
161
|
+
overlap_bytes = max(0, min(d.end_bytes, end.bytes)
|
|
162
|
+
- max(d.start_bytes, base.bytes))
|
|
163
|
+
else:
|
|
164
|
+
overlap_bytes = 0
|
|
165
|
+
if overlap_ns <= 0 and overlap_bytes <= 0:
|
|
166
|
+
continue
|
|
167
|
+
operations += 1
|
|
168
|
+
rows += max(0, int(d.rows))
|
|
169
|
+
excluded_ns += overlap_ns
|
|
170
|
+
excluded_bytes += overlap_bytes
|
|
171
|
+
if d.ended_wall_s is not None:
|
|
172
|
+
last_wall = max(last_wall or 0, int(d.ended_wall_s))
|
|
173
|
+
elapsed_ns = end.t_ns - base.t_ns
|
|
174
|
+
written = end.bytes - base.bytes - excluded_bytes
|
|
175
|
+
publications = end.publications - base.publications
|
|
176
|
+
common = dict(
|
|
177
|
+
window_seconds=elapsed_ns / _NS,
|
|
178
|
+
bytes_written=written,
|
|
179
|
+
bytes_per_minute=int(round(written * 60 * _NS / elapsed_ns)),
|
|
180
|
+
publications=publications,
|
|
181
|
+
mean_bytes_per_publication=(
|
|
182
|
+
int(round(written / publications)) if publications > 0 else None),
|
|
183
|
+
excluded_operations=operations,
|
|
184
|
+
excluded_rows=rows,
|
|
185
|
+
excluded_bytes=excluded_bytes,
|
|
186
|
+
excluded_seconds=excluded_ns / _NS,
|
|
187
|
+
last_excluded_wall_s=last_wall,
|
|
188
|
+
sample_count=len(span),
|
|
189
|
+
)
|
|
190
|
+
if excluded_ns > EXCLUDED_TIME_MAX_FRACTION * elapsed_ns:
|
|
191
|
+
return SteadyStatistic("insufficient", ("excluded_time_over_limit",),
|
|
192
|
+
**common)
|
|
193
|
+
return SteadyStatistic(
|
|
194
|
+
"qualified", (), rate_qualified=True,
|
|
195
|
+
publication_qualified=publications >= MIN_PUBLICATIONS, **common)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
#: PR-8: the plain phrase for each reason `steady_statistic` gives no rate
|
|
199
|
+
#: or verdict for. Only `warming_up` is the first five minutes; the others
|
|
200
|
+
#: can hold on a dashboard that has run for hours. The doctor summary and the
|
|
201
|
+
#: `dashboard-perf` row share them.
|
|
202
|
+
STATISTIC_REASON_PHRASES = {
|
|
203
|
+
"warming_up": "no samples yet (needs 5 minutes)",
|
|
204
|
+
"insufficient_samples": "not enough samples in the last 5 minutes",
|
|
205
|
+
"insufficient_coverage": "the last 5 minutes were not fully sampled",
|
|
206
|
+
"invalid_samples": "a counter reading in the last 5 minutes failed",
|
|
207
|
+
"counter_reset": "the write counter went backwards; measuring again",
|
|
208
|
+
"excluded_time_over_limit": (
|
|
209
|
+
"transcript maintenance filled over 20% of the last 5 minutes"),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def statistic_reason_phrase(reason) -> str:
|
|
214
|
+
"""The phrase for a statistic reason; an unknown one is named."""
|
|
215
|
+
phrase = STATISTIC_REASON_PHRASES.get(str(reason))
|
|
216
|
+
if phrase is not None:
|
|
217
|
+
return phrase
|
|
218
|
+
return f"not measured ({str(reason or 'unavailable').replace('_', ' ')})"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _exceeds(stat, limits) -> "tuple[bool, bool]":
|
|
222
|
+
rate = bool(stat.rate_qualified and stat.bytes_per_minute is not None
|
|
223
|
+
and stat.bytes_per_minute > limits.bytes_per_minute)
|
|
224
|
+
per_pub = bool(stat.publication_qualified
|
|
225
|
+
and stat.mean_bytes_per_publication is not None
|
|
226
|
+
and stat.mean_bytes_per_publication
|
|
227
|
+
> limits.bytes_per_publication)
|
|
228
|
+
return rate, per_pub
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _below_clear(stat, limits) -> bool:
|
|
232
|
+
if not stat.rate_qualified or stat.bytes_per_minute is None:
|
|
233
|
+
return False
|
|
234
|
+
if stat.bytes_per_minute >= CLEAR_FRACTION * limits.bytes_per_minute:
|
|
235
|
+
return False
|
|
236
|
+
if stat.publication_qualified and stat.mean_bytes_per_publication is not None:
|
|
237
|
+
return (stat.mean_bytes_per_publication
|
|
238
|
+
< CLEAR_FRACTION * limits.bytes_per_publication)
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclasses.dataclass(frozen=True)
|
|
243
|
+
class HysteresisState:
|
|
244
|
+
verdict: str = "ok" # "ok" | "over"
|
|
245
|
+
pending: "str | None" = None # "over" | "ok" | None
|
|
246
|
+
since_ns: "int | None" = None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def advance(state: HysteresisState, stat: SteadyStatistic, limits: BudgetLimits,
|
|
250
|
+
now_ns: int) -> HysteresisState:
|
|
251
|
+
"""One evaluation. Only a qualified statistic moves the state; a
|
|
252
|
+
transition needs two qualifying evaluations at least a minute apart, and
|
|
253
|
+
any evaluation that disagrees cancels the pending candidate."""
|
|
254
|
+
if stat.status != "qualified":
|
|
255
|
+
return state
|
|
256
|
+
spacing = HYSTERESIS_SPACING_SECONDS * _NS
|
|
257
|
+
if state.verdict == "ok":
|
|
258
|
+
if not any(_exceeds(stat, limits)):
|
|
259
|
+
return HysteresisState("ok")
|
|
260
|
+
if state.pending == "over":
|
|
261
|
+
if now_ns - state.since_ns >= spacing:
|
|
262
|
+
return HysteresisState("over")
|
|
263
|
+
return state
|
|
264
|
+
return HysteresisState("ok", "over", now_ns)
|
|
265
|
+
if not _below_clear(stat, limits):
|
|
266
|
+
return HysteresisState("over")
|
|
267
|
+
if state.pending == "ok":
|
|
268
|
+
if now_ns - state.since_ns >= spacing:
|
|
269
|
+
return HysteresisState("ok")
|
|
270
|
+
return state
|
|
271
|
+
return HysteresisState("over", "ok", now_ns)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def steady_verdict(stat: SteadyStatistic, state: HysteresisState,
|
|
275
|
+
limits: BudgetLimits = LIMITS) -> "tuple[str, tuple[str, ...]]":
|
|
276
|
+
"""`(verdict, reasons)` for the surfaces; verdict in VERDICTS."""
|
|
277
|
+
if stat.status == "unavailable":
|
|
278
|
+
return "unavailable", tuple(stat.reasons)
|
|
279
|
+
if stat.status == "insufficient":
|
|
280
|
+
return "insufficient", tuple(stat.reasons)
|
|
281
|
+
rate, per_pub = _exceeds(stat, limits)
|
|
282
|
+
reasons = []
|
|
283
|
+
if rate:
|
|
284
|
+
reasons.append("rate_over_limit")
|
|
285
|
+
if per_pub:
|
|
286
|
+
reasons.append("publication_over_limit")
|
|
287
|
+
if not stat.publication_qualified:
|
|
288
|
+
reasons.append("insufficient_publications")
|
|
289
|
+
if state.pending == "over":
|
|
290
|
+
reasons.append("pending_over")
|
|
291
|
+
elif state.pending == "ok":
|
|
292
|
+
reasons.append("pending_clear")
|
|
293
|
+
if not reasons:
|
|
294
|
+
reasons.append("within_limits")
|
|
295
|
+
return state.verdict, tuple(reasons)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
VERDICTS = ("ok", "over", "insufficient", "unavailable")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ── maintenance ledger and statistic ──────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
@dataclasses.dataclass(frozen=True)
|
|
304
|
+
class LedgerBucket:
|
|
305
|
+
hour: dt.datetime
|
|
306
|
+
charged: int
|
|
307
|
+
largest: int
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def hour_floor(t: dt.datetime) -> dt.datetime:
|
|
311
|
+
return t.astimezone(UTC).replace(minute=0, second=0, microsecond=0)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def iso_z(t: dt.datetime) -> str:
|
|
315
|
+
return t.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _parse_hour(value) -> "dt.datetime | None":
|
|
319
|
+
if not isinstance(value, str):
|
|
320
|
+
return None
|
|
321
|
+
try:
|
|
322
|
+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
323
|
+
except ValueError:
|
|
324
|
+
return None
|
|
325
|
+
if parsed.tzinfo is None:
|
|
326
|
+
return None
|
|
327
|
+
return hour_floor(parsed)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def parse_ledger(raw) -> "tuple[LedgerBucket, ...]":
|
|
331
|
+
"""A legacy, absent or malformed ledger reads as no charges."""
|
|
332
|
+
if not isinstance(raw, (list, tuple)):
|
|
333
|
+
return ()
|
|
334
|
+
buckets = {}
|
|
335
|
+
for entry in raw:
|
|
336
|
+
if not isinstance(entry, dict):
|
|
337
|
+
continue
|
|
338
|
+
hour = _parse_hour(entry.get("hour"))
|
|
339
|
+
charged = entry.get("charged")
|
|
340
|
+
largest = entry.get("largest", charged)
|
|
341
|
+
if (hour is None or isinstance(charged, bool)
|
|
342
|
+
or not isinstance(charged, int) or charged < 0
|
|
343
|
+
or isinstance(largest, bool) or not isinstance(largest, int)
|
|
344
|
+
or largest < 0):
|
|
345
|
+
continue
|
|
346
|
+
prior = buckets.get(hour)
|
|
347
|
+
if prior is not None:
|
|
348
|
+
charged += prior.charged
|
|
349
|
+
largest = max(largest, prior.largest)
|
|
350
|
+
buckets[hour] = LedgerBucket(hour, charged, largest)
|
|
351
|
+
return tuple(sorted(buckets.values(), key=lambda b: b.hour))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def serialize_ledger(buckets) -> "list[dict]":
|
|
355
|
+
return [{"hour": iso_z(b.hour), "charged": int(b.charged),
|
|
356
|
+
"largest": int(b.largest)} for b in buckets]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def ledger_add(raw, *, at_utc: dt.datetime, charge_bytes: int) -> "list[dict]":
|
|
360
|
+
"""Add one operation's charge to the bucket of its start, then drop every
|
|
361
|
+
bucket more than 24 complete hours old (at most 25 remain)."""
|
|
362
|
+
hour = hour_floor(at_utc)
|
|
363
|
+
buckets = {b.hour: b for b in parse_ledger(raw)}
|
|
364
|
+
prior = buckets.get(hour)
|
|
365
|
+
charge = max(0, int(charge_bytes))
|
|
366
|
+
buckets[hour] = LedgerBucket(
|
|
367
|
+
hour,
|
|
368
|
+
(prior.charged if prior else 0) + charge,
|
|
369
|
+
max(prior.largest if prior else 0, charge),
|
|
370
|
+
)
|
|
371
|
+
floor = hour - dt.timedelta(hours=LEDGER_BUCKETS - 1)
|
|
372
|
+
kept = sorted((b for b in buckets.values() if b.hour >= floor),
|
|
373
|
+
key=lambda b: b.hour)[-LEDGER_BUCKETS:]
|
|
374
|
+
return serialize_ledger(kept)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@dataclasses.dataclass(frozen=True)
|
|
378
|
+
class MaintenanceStatistic:
|
|
379
|
+
charged_bytes: int
|
|
380
|
+
largest_charge_bytes: int
|
|
381
|
+
window_start: dt.datetime
|
|
382
|
+
window_end: dt.datetime
|
|
383
|
+
window_minutes: int
|
|
384
|
+
allowance_bytes: int
|
|
385
|
+
verdict: str
|
|
386
|
+
|
|
387
|
+
def as_wire(self) -> dict:
|
|
388
|
+
return {
|
|
389
|
+
"chargedBytes": self.charged_bytes,
|
|
390
|
+
"largestChargeBytes": self.largest_charge_bytes,
|
|
391
|
+
"windowStart": iso_z(self.window_start),
|
|
392
|
+
"windowEnd": iso_z(self.window_end),
|
|
393
|
+
"windowMinutes": self.window_minutes,
|
|
394
|
+
"allowanceBytes": self.allowance_bytes,
|
|
395
|
+
"verdict": self.verdict,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
def as_details(self) -> dict:
|
|
399
|
+
return {
|
|
400
|
+
"charged_bytes": self.charged_bytes,
|
|
401
|
+
"largest_charge_bytes": self.largest_charge_bytes,
|
|
402
|
+
"window_start": iso_z(self.window_start),
|
|
403
|
+
"window_end": iso_z(self.window_end),
|
|
404
|
+
"window_minutes": self.window_minutes,
|
|
405
|
+
"allowance_bytes": self.allowance_bytes,
|
|
406
|
+
"verdict": self.verdict,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def maintenance_statistic(raw_ledger, now_utc: dt.datetime) -> MaintenanceStatistic:
|
|
411
|
+
"""Charged bytes over the current partial UTC hour plus the 24 complete
|
|
412
|
+
hours before it, against 4 MiB x (L + 1) + the largest charge in it."""
|
|
413
|
+
now = now_utc.astimezone(UTC)
|
|
414
|
+
current = hour_floor(now)
|
|
415
|
+
start = current - dt.timedelta(hours=24)
|
|
416
|
+
minutes = 1440 + int((now - current).total_seconds() // 60)
|
|
417
|
+
inside = [b for b in parse_ledger(raw_ledger) if start <= b.hour <= current]
|
|
418
|
+
charged = sum(b.charged for b in inside)
|
|
419
|
+
largest = max((b.largest for b in inside), default=0)
|
|
420
|
+
allowance = MAINTENANCE_RATE_BYTES_PER_MINUTE * (minutes + 1) + largest
|
|
421
|
+
return MaintenanceStatistic(
|
|
422
|
+
charged_bytes=charged, largest_charge_bytes=largest,
|
|
423
|
+
window_start=start, window_end=now, window_minutes=minutes,
|
|
424
|
+
allowance_bytes=allowance,
|
|
425
|
+
verdict="over" if charged > allowance else "ok")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ── operator wording (spec §5.7) ──────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
def _one_decimal(value: float, unit: str) -> str:
|
|
431
|
+
if abs(value) >= 100 or float(value).is_integer():
|
|
432
|
+
return f"{value:.0f} {unit}"
|
|
433
|
+
return f"{value:.1f} {unit}"
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def format_mib(n) -> str:
|
|
437
|
+
"""MiB with one decimal below 100 and whole numbers at or above it."""
|
|
438
|
+
return _one_decimal(n / MiB, "MiB")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def format_size(n) -> str:
|
|
442
|
+
"""GiB from one GiB up, MiB below it (``format_mib``).
|
|
443
|
+
|
|
444
|
+
GiB carries three significant figures with trailing zeros dropped, the
|
|
445
|
+
precision spec §5.7 states (`allowance 5.98 GiB`, `charged 7.9 GiB`); one
|
|
446
|
+
decimal rounded a 6,122 MiB allowance up to `6.0 GiB` (#901 Amendment 19
|
|
447
|
+
PR-12)."""
|
|
448
|
+
if abs(n) >= GiB:
|
|
449
|
+
value = n / GiB
|
|
450
|
+
if abs(value) >= 100:
|
|
451
|
+
text = f"{value:.0f}"
|
|
452
|
+
elif abs(value) >= 10:
|
|
453
|
+
text = f"{value:.1f}"
|
|
454
|
+
else:
|
|
455
|
+
text = f"{value:.2f}"
|
|
456
|
+
if "." in text:
|
|
457
|
+
text = text.rstrip("0").rstrip(".")
|
|
458
|
+
return f"{text} GiB"
|
|
459
|
+
return format_mib(n)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def format_kib(n) -> str:
|
|
463
|
+
return f"{n / KiB:.1f} KiB"
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def format_window_hm(minutes: int) -> str:
|
|
467
|
+
return f"{int(minutes) // 60} h {int(minutes) % 60} m"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def format_window_ms(seconds: float) -> str:
|
|
471
|
+
total = int(round(seconds))
|
|
472
|
+
return f"{total // 60}m {total % 60:02d}s"
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def format_since(window_start: dt.datetime, window_end: dt.datetime) -> str:
|
|
476
|
+
start = window_start.astimezone(UTC)
|
|
477
|
+
end = window_end.astimezone(UTC)
|
|
478
|
+
clock = start.strftime("%H:%M")
|
|
479
|
+
if start.date() == end.date():
|
|
480
|
+
return f"{clock} UTC today"
|
|
481
|
+
if start.date() == end.date() - dt.timedelta(days=1):
|
|
482
|
+
return f"{clock} UTC yesterday"
|
|
483
|
+
return f"{clock} UTC {start.date().isoformat()}"
|