aggregate_api 1.0.0__py3-none-any.whl
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.
- aggregate_api/__init__.py +41 -0
- aggregate_api/__main__.py +154 -0
- aggregate_api/app.py +206 -0
- aggregate_api/audit.py +395 -0
- aggregate_api/bounds.py +331 -0
- aggregate_api/cache.py +319 -0
- aggregate_api/capability.py +823 -0
- aggregate_api/completion.py +219 -0
- aggregate_api/config.py +363 -0
- aggregate_api/cors.py +61 -0
- aggregate_api/examples.py +620 -0
- aggregate_api/layer_pricing.py +840 -0
- aggregate_api/library.py +94 -0
- aggregate_api/library_notes.py +96 -0
- aggregate_api/models.py +1407 -0
- aggregate_api/net.py +281 -0
- aggregate_api/pnl.py +101 -0
- aggregate_api/pricing.py +778 -0
- aggregate_api/resources.py +257 -0
- aggregate_api/routes/__init__.py +8 -0
- aggregate_api/routes/decl.py +327 -0
- aggregate_api/routes/examples.py +82 -0
- aggregate_api/routes/meta.py +282 -0
- aggregate_api/routes/objects.py +4119 -0
- aggregate_api/routes/status.py +466 -0
- aggregate_api/serializers.py +565 -0
- aggregate_api/sessions.py +353 -0
- aggregate_api/static/aggregate-api-logo-512.png +0 -0
- aggregate_api/static/aggregate-api-logo.png +0 -0
- aggregate_api/static/aggregate-api-trim.png +0 -0
- aggregate_api/static/android-chrome-192x192.png +0 -0
- aggregate_api/static/android-chrome-512x512.png +0 -0
- aggregate_api/static/apple-touch-icon.png +0 -0
- aggregate_api/static/assets/bootstrap-icons-BeopsB42.woff +0 -0
- aggregate_api/static/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
- aggregate_api/static/assets/bootstrap-ohb1VZ53.js +5 -0
- aggregate_api/static/assets/codemirror-h62DHGGa.js +14 -0
- aggregate_api/static/assets/csv-grid.worker-DKzHGXac.js +4 -0
- aggregate_api/static/assets/echarts-B7o9sc00.js +40 -0
- aggregate_api/static/assets/echarts-gl-DG1Uf6wE.js +4282 -0
- aggregate_api/static/assets/lite-CUlcD8p4.css +1 -0
- aggregate_api/static/assets/lite-Dd2TnT4M.js +1 -0
- aggregate_api/static/assets/main-Bxhxa55v.css +9 -0
- aggregate_api/static/assets/main-CmoEiPit.js +9 -0
- aggregate_api/static/assets/tables-BHCF7qIF.js +8 -0
- aggregate_api/static/assets/tables-CxvajLr7.css +1 -0
- aggregate_api/static/favicon-16x16.png +0 -0
- aggregate_api/static/favicon-32x32.png +0 -0
- aggregate_api/static/favicon.ico +0 -0
- aggregate_api/static/index.html +912 -0
- aggregate_api/static/lite.html +83 -0
- aggregate_api/static/logo.png +0 -0
- aggregate_api/static/site.webmanifest +14 -0
- aggregate_api/static/sw.js +78 -0
- aggregate_api/status.py +536 -0
- aggregate_api/status_page.html +546 -0
- aggregate_api/tables.py +316 -0
- aggregate_api-1.0.0.dist-info/METADATA +187 -0
- aggregate_api-1.0.0.dist-info/RECORD +63 -0
- aggregate_api-1.0.0.dist-info/WHEEL +5 -0
- aggregate_api-1.0.0.dist-info/entry_points.txt +2 -0
- aggregate_api-1.0.0.dist-info/licenses/LICENSE +28 -0
- aggregate_api-1.0.0.dist-info/top_level.txt +1 -0
aggregate_api/status.py
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Process-lifetime counters and buffers behind ``GET /v1/status``.
|
|
2
|
+
|
|
3
|
+
The route lives in ``routes/status.py``; this module is the state it reports and
|
|
4
|
+
the arithmetic it needs. Split so that the state has no opinion about HTTP and
|
|
5
|
+
imports nothing from ``routes``, which is what lets ``routes/objects.py``
|
|
6
|
+
increment counters without an import cycle.
|
|
7
|
+
|
|
8
|
+
The invariant, stated once
|
|
9
|
+
--------------------------
|
|
10
|
+
|
|
11
|
+
**The status subsystem persists nothing and holds nothing unbounded.** Every
|
|
12
|
+
number it reports is either computed at request time, or a process-lifetime
|
|
13
|
+
counter, or an entry in a fixed-size ring buffer. Restarting the service clears
|
|
14
|
+
all of it, by construction rather than by a cleanup step. That is the author's
|
|
15
|
+
ruling of 2026-08-18 ("do not let it grow too large, and reset it on each
|
|
16
|
+
reboot") held as an invariant rather than as a set of limits, because an
|
|
17
|
+
invariant survives the next panel and a set of limits does not.
|
|
18
|
+
|
|
19
|
+
Concretely: counters are plain integers, so they start at zero when the process
|
|
20
|
+
starts and every reading of them is labeled "since ``<process start>``" with the
|
|
21
|
+
uptime beside it, so a small number after a restart is never read as a quiet
|
|
22
|
+
day. Text lives in :class:`collections.deque` with ``maxlen`` set, so the cap is
|
|
23
|
+
structural and cannot be forgotten at a call site. Everything else, the build
|
|
24
|
+
statistics and the cache and session rows, is a query against something already
|
|
25
|
+
bounded and is not retained here at all.
|
|
26
|
+
|
|
27
|
+
The budget is roughly 250 kB: fifty programs at two kilobytes, five hundred
|
|
28
|
+
float samples, twenty refusal records, and a few dozen integers. A future panel
|
|
29
|
+
that cannot fit that is a query and not a buffer.
|
|
30
|
+
|
|
31
|
+
**This does not touch the audit log.** That is SQLite on disk and survives
|
|
32
|
+
restarts, which is the point of an audit log. Section 8 question 4 of
|
|
33
|
+
``dev/done/plan-site-status-page.md`` stays open.
|
|
34
|
+
|
|
35
|
+
Thread safety
|
|
36
|
+
-------------
|
|
37
|
+
|
|
38
|
+
One lock over the whole module. Every counter is incremented from a request
|
|
39
|
+
thread and read from another, and the alternative to a lock is a set of numbers
|
|
40
|
+
that are individually atomic and collectively inconsistent, which for a page
|
|
41
|
+
whose job is to be believed is worse than the nanoseconds. The critical sections
|
|
42
|
+
are integer adds and deque appends.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import os
|
|
48
|
+
import re
|
|
49
|
+
import threading
|
|
50
|
+
import time
|
|
51
|
+
from collections import Counter, deque
|
|
52
|
+
from datetime import datetime, timezone
|
|
53
|
+
|
|
54
|
+
#: How many programs the key-scope buffer keeps, and how much of each. Fifty at
|
|
55
|
+
#: two kilobytes is the bulk of this module's memory budget.
|
|
56
|
+
PROGRAM_BUFFER = 50
|
|
57
|
+
PROGRAM_CHARS = 2000
|
|
58
|
+
|
|
59
|
+
#: How many gate refusals to keep. Address and timestamp only, no bodies.
|
|
60
|
+
REFUSAL_BUFFER = 20
|
|
61
|
+
|
|
62
|
+
#: The shape ``web/src/session.js`` mints: ``<ISO timestamp>-<uuid4>``. Used
|
|
63
|
+
#: only to split a display id, so anything else falls through to a plain cut.
|
|
64
|
+
_MINTED_ID = re.compile(r"\A(\d{4}-\d{2}-\d{2}T[\d:.]+Z)-(.+)\Z")
|
|
65
|
+
|
|
66
|
+
#: How many preview durations to retain for the percentile. A lifetime sum and
|
|
67
|
+
#: count give the mean; a percentile needs samples, and this many is a recent
|
|
68
|
+
#: window rather than a history, which is the honest thing to report anyway.
|
|
69
|
+
PREVIEW_SAMPLES = 512
|
|
70
|
+
|
|
71
|
+
#: Array counts behind :func:`estimated_bytes`. Deliberately round: see its
|
|
72
|
+
#: Notes for why this is arithmetic and not a measurement.
|
|
73
|
+
_ARRAYS_PER_AGGREGATE = 8
|
|
74
|
+
_ARRAYS_PER_UNIT = 4
|
|
75
|
+
|
|
76
|
+
_lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
# ----------------------------------------------------------------------
|
|
79
|
+
# Process facts
|
|
80
|
+
# ----------------------------------------------------------------------
|
|
81
|
+
_started_at: datetime | None = None
|
|
82
|
+
_started_monotonic: float | None = None
|
|
83
|
+
_library_load_ms: float | None = None
|
|
84
|
+
|
|
85
|
+
# ----------------------------------------------------------------------
|
|
86
|
+
# Counters. Plain integers, zero at process start, read under the lock.
|
|
87
|
+
# ----------------------------------------------------------------------
|
|
88
|
+
_counts: Counter = Counter()
|
|
89
|
+
|
|
90
|
+
# ----------------------------------------------------------------------
|
|
91
|
+
# Bounded buffers
|
|
92
|
+
# ----------------------------------------------------------------------
|
|
93
|
+
_preview_ms: deque = deque(maxlen=PREVIEW_SAMPLES)
|
|
94
|
+
_programs: deque = deque(maxlen=PROGRAM_BUFFER)
|
|
95
|
+
_refusals: deque = deque(maxlen=REFUSAL_BUFFER)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _now_iso() -> str:
|
|
99
|
+
"""ISO 8601 UTC timestamp, seconds precision. Matches the page's columns."""
|
|
100
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ----------------------------------------------------------------------
|
|
104
|
+
# Lifecycle
|
|
105
|
+
# ----------------------------------------------------------------------
|
|
106
|
+
def mark_started() -> None:
|
|
107
|
+
"""Record the process start time, once.
|
|
108
|
+
|
|
109
|
+
Notes
|
|
110
|
+
-----
|
|
111
|
+
Called from :func:`aggregate_api.app.create_app`, which runs once in
|
|
112
|
+
production and once per app in the tests. The guard makes it once per
|
|
113
|
+
process in both: a test that builds ten apps still reports one uptime, and
|
|
114
|
+
an uptime that resets under a test client would make every "since process
|
|
115
|
+
start" label a lie in exactly the place they are checked.
|
|
116
|
+
"""
|
|
117
|
+
global _started_at, _started_monotonic
|
|
118
|
+
with _lock:
|
|
119
|
+
if _started_at is None:
|
|
120
|
+
_started_at = datetime.now(timezone.utc)
|
|
121
|
+
_started_monotonic = time.monotonic()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def record_library_load(elapsed_ms: float) -> None:
|
|
125
|
+
"""Record what reading the recipe library cost at boot, in milliseconds."""
|
|
126
|
+
global _library_load_ms
|
|
127
|
+
with _lock:
|
|
128
|
+
_library_load_ms = float(elapsed_ms)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def process_facts() -> dict:
|
|
132
|
+
"""Start time, uptime, PID, and the boot library load cost.
|
|
133
|
+
|
|
134
|
+
Returns
|
|
135
|
+
-------
|
|
136
|
+
dict
|
|
137
|
+
``started_at`` is ``None`` before :func:`mark_started`, which cannot
|
|
138
|
+
happen through a route (the app has to exist first) but can in a unit
|
|
139
|
+
test importing this module alone.
|
|
140
|
+
"""
|
|
141
|
+
with _lock:
|
|
142
|
+
started_at = _started_at
|
|
143
|
+
started_monotonic = _started_monotonic
|
|
144
|
+
library_load_ms = _library_load_ms
|
|
145
|
+
uptime = None
|
|
146
|
+
if started_monotonic is not None:
|
|
147
|
+
uptime = round(time.monotonic() - started_monotonic, 1)
|
|
148
|
+
return {
|
|
149
|
+
"pid": os.getpid(),
|
|
150
|
+
"started_at": started_at.isoformat(timespec="seconds") if started_at else None,
|
|
151
|
+
"uptime_s": uptime,
|
|
152
|
+
"library_load_ms": (round(library_load_ms, 1)
|
|
153
|
+
if library_load_ms is not None else None),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ----------------------------------------------------------------------
|
|
158
|
+
# Key scope, section 4.5 of the plan
|
|
159
|
+
# ----------------------------------------------------------------------
|
|
160
|
+
def record_key_scope(scope: str, reason: str | None = None) -> None:
|
|
161
|
+
"""Count one keying decision from ``routes/objects.py::_cache_key``.
|
|
162
|
+
|
|
163
|
+
Parameters
|
|
164
|
+
----------
|
|
165
|
+
scope : {'shared', 'session'}
|
|
166
|
+
Which key builder the request used.
|
|
167
|
+
reason : {'session_reference', 'preview_unavailable'}, optional
|
|
168
|
+
Why the session key was taken. Ignored for ``'shared'``.
|
|
169
|
+
|
|
170
|
+
Notes
|
|
171
|
+
-----
|
|
172
|
+
The two reasons mean opposite things and are counted apart for that reason.
|
|
173
|
+
``session_reference`` is the rule working: the program touched a name its own
|
|
174
|
+
session declared, so it cannot share a slot. ``preview_unavailable`` is the
|
|
175
|
+
previewer declining to speak about the program at all, which is a fail-closed
|
|
176
|
+
path that is usually a program about to fail its build and is occasionally a
|
|
177
|
+
previewer refusing what the builder accepts. Only the second is a finding,
|
|
178
|
+
and telling them apart is the whole value of the panel.
|
|
179
|
+
"""
|
|
180
|
+
with _lock:
|
|
181
|
+
_counts[f"key_{scope}"] += 1
|
|
182
|
+
if scope == "session" and reason:
|
|
183
|
+
_counts[f"key_reason_{reason}"] += 1
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def record_preview_ms(elapsed_ms: float) -> None:
|
|
187
|
+
"""Sample one ``Underwriter.preview`` call.
|
|
188
|
+
|
|
189
|
+
Notes
|
|
190
|
+
-----
|
|
191
|
+
Worth watching because this parse sits on the cache **hit** path, where it
|
|
192
|
+
did not before a110: ``post_object``'s docstring names it as the
|
|
193
|
+
acknowledged cost of keying on what a program means rather than on how it
|
|
194
|
+
is spelled. A hit that grows to build-like latency would make that trade
|
|
195
|
+
look different.
|
|
196
|
+
"""
|
|
197
|
+
with _lock:
|
|
198
|
+
_counts["preview_calls"] += 1
|
|
199
|
+
_counts["preview_total_us"] += int(elapsed_ms * 1000)
|
|
200
|
+
_preview_ms.append(float(elapsed_ms))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def record_unpreviewable_build(program: str, session_id: str, kind: str | None,
|
|
204
|
+
elapsed_ms: int) -> None:
|
|
205
|
+
"""Keep a program the previewer refused and the builder then accepted.
|
|
206
|
+
|
|
207
|
+
Parameters
|
|
208
|
+
----------
|
|
209
|
+
program : str
|
|
210
|
+
The DecL as it arrived. Truncated to :data:`PROGRAM_CHARS`.
|
|
211
|
+
session_id : str
|
|
212
|
+
The caller's session. Shortened on the way in by
|
|
213
|
+
:func:`short_session_id`, so nothing here ever holds a full one.
|
|
214
|
+
kind : str or None
|
|
215
|
+
What the build produced.
|
|
216
|
+
elapsed_ms : int
|
|
217
|
+
What the request cost.
|
|
218
|
+
|
|
219
|
+
Notes
|
|
220
|
+
-----
|
|
221
|
+
**This narrow class is the only text this module keeps.** A program that
|
|
222
|
+
previewed ``None`` and then failed its build is an ordinary parse error and
|
|
223
|
+
is already in the audit log with a better message. A program that previewed
|
|
224
|
+
``None`` and then **built** is the previewer refusing what the builder
|
|
225
|
+
accepted: a shareable object took a private cache slot, so a room pays a
|
|
226
|
+
build each instead of one between them. That is an upstream ask against the
|
|
227
|
+
library's ``Underwriter.preview``, and an ask needs the program verbatim,
|
|
228
|
+
which is why author ruling 3 of 2026-08-18 lets this page hold program text.
|
|
229
|
+
"""
|
|
230
|
+
text, truncated = truncate(program, PROGRAM_CHARS)
|
|
231
|
+
with _lock:
|
|
232
|
+
_counts["unpreviewable_builds"] += 1
|
|
233
|
+
_programs.append({
|
|
234
|
+
"at": _now_iso(),
|
|
235
|
+
"session_id": short_session_id(session_id),
|
|
236
|
+
"kind": kind,
|
|
237
|
+
"elapsed_ms": elapsed_ms,
|
|
238
|
+
"program": text,
|
|
239
|
+
"truncated": truncated,
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def key_scope_state() -> dict:
|
|
244
|
+
"""The key-scope panel: counts, rate, reasons, preview timing, programs."""
|
|
245
|
+
with _lock:
|
|
246
|
+
shared = _counts["key_shared"]
|
|
247
|
+
session = _counts["key_session"]
|
|
248
|
+
reasons = {
|
|
249
|
+
"session_reference": _counts["key_reason_session_reference"],
|
|
250
|
+
"preview_unavailable": _counts["key_reason_preview_unavailable"],
|
|
251
|
+
}
|
|
252
|
+
calls = _counts["preview_calls"]
|
|
253
|
+
total_us = _counts["preview_total_us"]
|
|
254
|
+
samples = list(_preview_ms)
|
|
255
|
+
programs = list(_programs)
|
|
256
|
+
unpreviewable_builds = _counts["unpreviewable_builds"]
|
|
257
|
+
keyed = shared + session
|
|
258
|
+
return {
|
|
259
|
+
"shared": shared,
|
|
260
|
+
"session": session,
|
|
261
|
+
"session_rate": round(session / keyed, 4) if keyed else None,
|
|
262
|
+
"reasons": reasons,
|
|
263
|
+
"preview_calls": calls,
|
|
264
|
+
"preview_mean_ms": round(total_us / calls / 1000, 2) if calls else None,
|
|
265
|
+
"preview_p95_ms": percentile(samples, 0.95),
|
|
266
|
+
"preview_sample_size": len(samples),
|
|
267
|
+
"unpreviewable_builds": unpreviewable_builds,
|
|
268
|
+
"recent_unpreviewable": list(reversed(programs)),
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ----------------------------------------------------------------------
|
|
273
|
+
# Builds
|
|
274
|
+
# ----------------------------------------------------------------------
|
|
275
|
+
class build_slot: # noqa: N801 (a context manager used as a statement reads better lowercase)
|
|
276
|
+
"""Hold the build semaphore and count the wait, the run, and the queue.
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
----------
|
|
280
|
+
semaphore : threading.Semaphore
|
|
281
|
+
The single-slot build semaphore from ``routes/objects.py``. Passed in
|
|
282
|
+
rather than imported so this module keeps knowing nothing about routes.
|
|
283
|
+
|
|
284
|
+
Notes
|
|
285
|
+
-----
|
|
286
|
+
Written as a context manager so the three counters cannot drift from each
|
|
287
|
+
other: a ``waiting`` that is decremented on one path out and not another
|
|
288
|
+
would climb forever and the page would report a queue that is not there.
|
|
289
|
+
Entering counts a waiter, acquiring converts it to an in-flight build, and
|
|
290
|
+
leaving releases both regardless of how the body exited.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
__slots__ = ("_semaphore",)
|
|
294
|
+
|
|
295
|
+
def __init__(self, semaphore) -> None:
|
|
296
|
+
self._semaphore = semaphore
|
|
297
|
+
|
|
298
|
+
def __enter__(self):
|
|
299
|
+
with _lock:
|
|
300
|
+
_counts["build_waiting"] += 1
|
|
301
|
+
self._semaphore.acquire()
|
|
302
|
+
with _lock:
|
|
303
|
+
_counts["build_waiting"] -= 1
|
|
304
|
+
_counts["build_in_flight"] += 1
|
|
305
|
+
_counts["builds_started"] += 1
|
|
306
|
+
return self
|
|
307
|
+
|
|
308
|
+
def __exit__(self, exc_type, exc, tb):
|
|
309
|
+
with _lock:
|
|
310
|
+
_counts["build_in_flight"] -= 1
|
|
311
|
+
self._semaphore.release()
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def record_build_timeout() -> None:
|
|
316
|
+
"""Count one build abandoned at ``build_timeout_s``."""
|
|
317
|
+
with _lock:
|
|
318
|
+
_counts["build_timeouts"] += 1
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def build_state() -> dict:
|
|
322
|
+
"""Live build state: in flight, queued, started and timed out since start."""
|
|
323
|
+
with _lock:
|
|
324
|
+
return {
|
|
325
|
+
"in_flight": _counts["build_in_flight"],
|
|
326
|
+
"waiting": _counts["build_waiting"],
|
|
327
|
+
"started": _counts["builds_started"],
|
|
328
|
+
"timeouts": _counts["build_timeouts"],
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ----------------------------------------------------------------------
|
|
333
|
+
# The chart-document cache
|
|
334
|
+
# ----------------------------------------------------------------------
|
|
335
|
+
def record_cache(channel: str, event: str) -> None:
|
|
336
|
+
"""Count one ``hit``, ``miss``, ``store`` or ``eviction`` on one cache.
|
|
337
|
+
|
|
338
|
+
Parameters
|
|
339
|
+
----------
|
|
340
|
+
channel : str
|
|
341
|
+
Which cache is reporting, ``"chart"`` or ``"exhibit"``. It namespaces
|
|
342
|
+
the counter keys, so the two revalidation caches are counted apart
|
|
343
|
+
while sharing one implementation.
|
|
344
|
+
event : str
|
|
345
|
+
The event to count.
|
|
346
|
+
|
|
347
|
+
Notes
|
|
348
|
+
-----
|
|
349
|
+
``_counts`` is a ``Counter``, so a channel needs no registration here: a
|
|
350
|
+
key that has never been written reads as zero.
|
|
351
|
+
"""
|
|
352
|
+
with _lock:
|
|
353
|
+
_counts[f"{channel}_{event}"] += 1
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def cache_state(channel: str, entries: int, max_entries: int) -> dict:
|
|
357
|
+
"""One revalidation cache's panel. Size is passed in, counters come from here."""
|
|
358
|
+
with _lock:
|
|
359
|
+
hits = _counts[f"{channel}_hit"]
|
|
360
|
+
misses = _counts[f"{channel}_miss"]
|
|
361
|
+
evictions = _counts[f"{channel}_eviction"]
|
|
362
|
+
looks = hits + misses
|
|
363
|
+
return {
|
|
364
|
+
"entries": entries,
|
|
365
|
+
"max": max_entries,
|
|
366
|
+
"hits": hits,
|
|
367
|
+
"misses": misses,
|
|
368
|
+
"hit_rate": round(hits / looks, 4) if looks else None,
|
|
369
|
+
"evictions": evictions,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ----------------------------------------------------------------------
|
|
374
|
+
# Gate refusals
|
|
375
|
+
# ----------------------------------------------------------------------
|
|
376
|
+
def record_refusal(address: str, path: str) -> None:
|
|
377
|
+
"""Keep one refused status request: address, path and time, nothing else.
|
|
378
|
+
|
|
379
|
+
Notes
|
|
380
|
+
-----
|
|
381
|
+
A refusal on a correctly configured box means something changed, which is
|
|
382
|
+
why it is retained at all and why ``routes/status.py`` also logs it at
|
|
383
|
+
WARNING. No body and no headers are kept: the useful fact is that an address
|
|
384
|
+
outside the private list reached the app, and everything past that belongs
|
|
385
|
+
to the web server's own log.
|
|
386
|
+
"""
|
|
387
|
+
with _lock:
|
|
388
|
+
_counts["gate_refusals"] += 1
|
|
389
|
+
_refusals.append({"at": _now_iso(), "address": address, "path": path})
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def gate_state() -> dict:
|
|
393
|
+
"""Refusal count and the recent refusals, newest first."""
|
|
394
|
+
with _lock:
|
|
395
|
+
return {
|
|
396
|
+
"refusals": _counts["gate_refusals"],
|
|
397
|
+
"recent": list(reversed(_refusals)),
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ----------------------------------------------------------------------
|
|
402
|
+
# Helpers
|
|
403
|
+
# ----------------------------------------------------------------------
|
|
404
|
+
def truncate(text: str, limit: int) -> tuple[str, bool]:
|
|
405
|
+
"""Cut ``text`` to ``limit`` characters, reporting whether it was cut.
|
|
406
|
+
|
|
407
|
+
Returns
|
|
408
|
+
-------
|
|
409
|
+
(str, bool)
|
|
410
|
+
The text and whether anything was dropped. The flag travels rather than
|
|
411
|
+
an ellipsis in the string, so the page can mark the cut and a test can
|
|
412
|
+
assert on the length without parsing prose out of the payload.
|
|
413
|
+
"""
|
|
414
|
+
if text is None:
|
|
415
|
+
return "", False
|
|
416
|
+
if len(text) <= limit:
|
|
417
|
+
return text, False
|
|
418
|
+
return text[:limit], True
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def short_session_id(session_id: str | None) -> str | None:
|
|
422
|
+
"""Shorten a session id for display, keeping the useful half.
|
|
423
|
+
|
|
424
|
+
Parameters
|
|
425
|
+
----------
|
|
426
|
+
session_id : str or None
|
|
427
|
+
A minted id (``<ISO timestamp>-<uuid4>``), :data:`sessions.ANONYMOUS`,
|
|
428
|
+
or anything a client sent that passed the shape check.
|
|
429
|
+
|
|
430
|
+
Returns
|
|
431
|
+
-------
|
|
432
|
+
str or None
|
|
433
|
+
The timestamp in full, a middle dot, and eight characters of the uuid.
|
|
434
|
+
Anything not matching that shape is cut to 24 characters. ``None`` in,
|
|
435
|
+
``None`` out.
|
|
436
|
+
|
|
437
|
+
Notes
|
|
438
|
+
-----
|
|
439
|
+
**Applied server side, so the guarantee holds for the JSON and not only for
|
|
440
|
+
the page.** A session id is a namespace and not a security boundary: anyone
|
|
441
|
+
holding one gets that session's objects, which is exactly why no login is
|
|
442
|
+
needed for it. A status page rendering them in full would turn a private
|
|
443
|
+
page into a list of credentials the moment anyone shares a screenshot, and
|
|
444
|
+
a payload carrying them in full would do the same for anyone who saved the
|
|
445
|
+
JSON. Truncating in the page would leave the second hole open.
|
|
446
|
+
|
|
447
|
+
Eight characters of a uuid4 is enough to line a row up with an audit row or
|
|
448
|
+
a log line, and is not enough to impersonate. The timestamp travels whole
|
|
449
|
+
because it is the part an operator actually reads.
|
|
450
|
+
"""
|
|
451
|
+
if not session_id:
|
|
452
|
+
return session_id
|
|
453
|
+
match = _MINTED_ID.match(session_id)
|
|
454
|
+
if match:
|
|
455
|
+
return f"{match.group(1)}·{match.group(2)[:8]}…"
|
|
456
|
+
if len(session_id) > 24:
|
|
457
|
+
return f"{session_id[:24]}…"
|
|
458
|
+
return session_id
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def percentile(samples: list[float], q: float) -> float | None:
|
|
462
|
+
"""The ``q`` quantile of ``samples`` by nearest rank, or ``None`` if empty.
|
|
463
|
+
|
|
464
|
+
Notes
|
|
465
|
+
-----
|
|
466
|
+
Nearest rank rather than an interpolated quantile, and no numpy. The input
|
|
467
|
+
is at most :data:`PREVIEW_SAMPLES` floats on a page refreshed every ten
|
|
468
|
+
seconds; the difference between the two definitions is smaller than the
|
|
469
|
+
difference between two consecutive samples, and this one has no import.
|
|
470
|
+
"""
|
|
471
|
+
if not samples:
|
|
472
|
+
return None
|
|
473
|
+
ordered = sorted(samples)
|
|
474
|
+
index = min(len(ordered) - 1, max(0, int(round(q * (len(ordered) - 1)))))
|
|
475
|
+
return round(ordered[index], 2)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def estimated_bytes(obj, log2: int) -> int | None:
|
|
479
|
+
"""An order-of-magnitude size for a built object, computed not measured.
|
|
480
|
+
|
|
481
|
+
Parameters
|
|
482
|
+
----------
|
|
483
|
+
obj : Aggregate or Portfolio or other
|
|
484
|
+
The cached object.
|
|
485
|
+
log2 : int
|
|
486
|
+
The entry's log2, or 0 when the request asked for the library default.
|
|
487
|
+
|
|
488
|
+
Returns
|
|
489
|
+
-------
|
|
490
|
+
int or None
|
|
491
|
+
Estimated bytes, or ``None`` when the grid size cannot be established.
|
|
492
|
+
|
|
493
|
+
Notes
|
|
494
|
+
-----
|
|
495
|
+
**Deliberately not measured.** ``sys.getsizeof`` reports the shell of a
|
|
496
|
+
Python object and nothing it points at, so sizing an ``Aggregate`` honestly
|
|
497
|
+
would mean walking into numpy arrays and pandas frames: slow, and wrong the
|
|
498
|
+
moment a frame is shared between two entries. Worse, several of those frames
|
|
499
|
+
are built lazily on first access, so a status page that touched them to
|
|
500
|
+
measure them would *create* the memory it is reporting, on a timer.
|
|
501
|
+
|
|
502
|
+
So the estimate is arithmetic on what is already known. The density arrays
|
|
503
|
+
are ``2**log2`` float64, and the counts are round numbers standing for "the
|
|
504
|
+
handful an aggregate carries" and "the few more each portfolio unit adds".
|
|
505
|
+
The page labels the column estimated for exactly this reason.
|
|
506
|
+
"""
|
|
507
|
+
grid = getattr(obj, "log2", 0) or log2
|
|
508
|
+
if not grid or grid <= 0:
|
|
509
|
+
return None
|
|
510
|
+
cell = (2 ** int(grid)) * 8
|
|
511
|
+
arrays = _ARRAYS_PER_AGGREGATE
|
|
512
|
+
units = getattr(obj, "agg_list", None)
|
|
513
|
+
if units is not None:
|
|
514
|
+
try:
|
|
515
|
+
arrays += _ARRAYS_PER_UNIT * len(units)
|
|
516
|
+
except TypeError:
|
|
517
|
+
pass
|
|
518
|
+
return cell * arrays
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def reset() -> None:
|
|
522
|
+
"""Drop every counter and buffer. For tests, and for nothing else.
|
|
523
|
+
|
|
524
|
+
Notes
|
|
525
|
+
-----
|
|
526
|
+
Not called by :func:`aggregate_api.app.create_app`. In production this
|
|
527
|
+
module's state is exactly process-lifetime, which is what every "since
|
|
528
|
+
process start" label on the page promises, and a reset wired into app
|
|
529
|
+
creation would quietly make that promise false the first time anything built
|
|
530
|
+
a second app. The tests that need a clean slate call this themselves.
|
|
531
|
+
"""
|
|
532
|
+
with _lock:
|
|
533
|
+
_counts.clear()
|
|
534
|
+
_preview_ms.clear()
|
|
535
|
+
_programs.clear()
|
|
536
|
+
_refusals.clear()
|