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/audit.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""SQLite-backed audit log for build attempts.
|
|
2
|
+
|
|
3
|
+
One row per ``POST /v1/objects`` request, regardless of outcome.
|
|
4
|
+
The log captures the DecL source, build knobs, status, error
|
|
5
|
+
message (if any), elapsed time, client IP, and timestamp.
|
|
6
|
+
|
|
7
|
+
Why SQLite
|
|
8
|
+
----------
|
|
9
|
+
|
|
10
|
+
* Stdlib (zero extra deps in the ``[api]`` extra).
|
|
11
|
+
* WAL mode lets readers and the audit writer not block each other.
|
|
12
|
+
* Trivially inspectable from the shell with ``sqlite3 audit.db
|
|
13
|
+
"select * from builds order by ts desc limit 20"``.
|
|
14
|
+
|
|
15
|
+
The team-deploy assumption is that audit reads are infrequent and
|
|
16
|
+
manual; there's no admin route in v1.
|
|
17
|
+
|
|
18
|
+
Schema design notes
|
|
19
|
+
-------------------
|
|
20
|
+
|
|
21
|
+
* ``object_id`` is NULLABLE because failed builds (parse errors,
|
|
22
|
+
timeouts) never produce one.
|
|
23
|
+
* ``kind`` is NULLABLE for the same reason.
|
|
24
|
+
* ``elapsed_ms`` is captured even on failure -- a 9000 ms parse
|
|
25
|
+
error is qualitatively different from a 5 ms one.
|
|
26
|
+
* ``status`` is a free-form text label (not an enum) so we can
|
|
27
|
+
add new states ('rate_limited' etc.) without a migration.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import sqlite3
|
|
33
|
+
import threading
|
|
34
|
+
from contextlib import closing
|
|
35
|
+
from datetime import datetime, timedelta, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
# ----------------------------------------------------------------------
|
|
39
|
+
# DDL
|
|
40
|
+
# ----------------------------------------------------------------------
|
|
41
|
+
# Wrapped in IF NOT EXISTS so :meth:`AuditLog._ensure_schema` is
|
|
42
|
+
# idempotent -- every connection re-runs it cheaply and the schema
|
|
43
|
+
# survives server restarts without explicit migrations.
|
|
44
|
+
_SCHEMA = """
|
|
45
|
+
CREATE TABLE IF NOT EXISTS builds (
|
|
46
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
+
ts TEXT NOT NULL,
|
|
48
|
+
ip TEXT NOT NULL,
|
|
49
|
+
object_id TEXT,
|
|
50
|
+
kind TEXT,
|
|
51
|
+
decl TEXT NOT NULL,
|
|
52
|
+
log2 INTEGER,
|
|
53
|
+
bs REAL,
|
|
54
|
+
status TEXT NOT NULL,
|
|
55
|
+
error_msg TEXT,
|
|
56
|
+
elapsed_ms INTEGER NOT NULL,
|
|
57
|
+
session_id TEXT,
|
|
58
|
+
key_scope TEXT
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS builds_ts ON builds(ts);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS builds_ip ON builds(ip);
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
# Columns added after the table shipped. ``CREATE TABLE IF NOT EXISTS`` leaves an
|
|
65
|
+
# existing table alone, so a database written before a110 has neither of these
|
|
66
|
+
# and every insert would fail on the column count. SQLite has no
|
|
67
|
+
# ``ADD COLUMN IF NOT EXISTS``, so the check is a table read and the add is
|
|
68
|
+
# guarded by it.
|
|
69
|
+
_ADDED_COLUMNS = (
|
|
70
|
+
("session_id", "TEXT"),
|
|
71
|
+
("key_scope", "TEXT"),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AuditLog:
|
|
76
|
+
"""Append-only SQLite log.
|
|
77
|
+
|
|
78
|
+
Connections aren't shared across threads (sqlite3 forbids it
|
|
79
|
+
by default and the cost of a fresh connection per write is
|
|
80
|
+
irrelevant for our volume). A single lock serializes writes
|
|
81
|
+
so concurrent build endpoints don't race on the AUTOINCREMENT
|
|
82
|
+
sequence.
|
|
83
|
+
|
|
84
|
+
Set ``journal_mode=WAL`` on each connection so the audit file
|
|
85
|
+
can be inspected with the ``sqlite3`` CLI while the server is
|
|
86
|
+
running.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, db_path: str | Path) -> None:
|
|
90
|
+
self.db_path = Path(db_path)
|
|
91
|
+
self._lock = threading.Lock()
|
|
92
|
+
# Ensure the parent directory exists. The default audit-db
|
|
93
|
+
# path lives under ``~/.aggregate/api/`` which probably
|
|
94
|
+
# doesn't exist on a fresh install.
|
|
95
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
# Trigger initial schema creation, set WAL mode.
|
|
97
|
+
with closing(self._connect()) as conn:
|
|
98
|
+
conn.executescript(_SCHEMA)
|
|
99
|
+
self._add_missing_columns(conn)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _add_missing_columns(conn: sqlite3.Connection) -> None:
|
|
103
|
+
"""Bring a pre-existing table up to the current column list.
|
|
104
|
+
|
|
105
|
+
Notes
|
|
106
|
+
-----
|
|
107
|
+
Deliberately not a migration framework. The table is append-only and
|
|
108
|
+
every column added since it shipped is nullable, so "add what is
|
|
109
|
+
missing" is the whole of it, and a database from any earlier version
|
|
110
|
+
reaches the current shape in one pass. Old rows keep NULL, which reads
|
|
111
|
+
correctly as "written before the api knew about sessions".
|
|
112
|
+
"""
|
|
113
|
+
held = {row[1] for row in conn.execute("PRAGMA table_info(builds)")}
|
|
114
|
+
for column, sql_type in _ADDED_COLUMNS:
|
|
115
|
+
if column not in held:
|
|
116
|
+
conn.execute(f"ALTER TABLE builds ADD COLUMN {column} {sql_type}")
|
|
117
|
+
|
|
118
|
+
def _connect(self) -> sqlite3.Connection:
|
|
119
|
+
"""Open a new connection with sensible defaults.
|
|
120
|
+
|
|
121
|
+
``isolation_level=None`` puts the connection in autocommit
|
|
122
|
+
mode -- we use explicit transactions when we want them.
|
|
123
|
+
``check_same_thread=False`` is *not* set: each call
|
|
124
|
+
produces a fresh connection that lives only for the
|
|
125
|
+
duration of the caller's ``with`` block.
|
|
126
|
+
|
|
127
|
+
Notes
|
|
128
|
+
-----
|
|
129
|
+
**Every caller wraps this in** :func:`contextlib.closing`, and the
|
|
130
|
+
wrapper is not decoration. ``with sqlite3.connect(...) as conn`` is
|
|
131
|
+
sqlite3's *transaction* context manager: it commits on a clean exit and
|
|
132
|
+
rolls back on an exception, and it does **not** close the connection.
|
|
133
|
+
So the plain form, which is what this class used through a70, leaked one
|
|
134
|
+
connection per build and one per read, each of them held until the
|
|
135
|
+
garbage collector got to it. The finalizer is what then printed
|
|
136
|
+
``ResourceWarning: unclosed database in <sqlite3.Connection ...>``, and
|
|
137
|
+
because ``pricing.py`` was capturing warnings unscoped at the time, that
|
|
138
|
+
line reached the reader's status strip as though their program had
|
|
139
|
+
provoked it. Both halves are fixed; this is the half that stops the
|
|
140
|
+
connection leaking in the first place.
|
|
141
|
+
"""
|
|
142
|
+
conn = sqlite3.connect(self.db_path)
|
|
143
|
+
# WAL gives readers a stable snapshot while writes proceed,
|
|
144
|
+
# so ``sqlite3 audit.db`` from the shell never deadlocks.
|
|
145
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
146
|
+
# NORMAL trades durability of the last ~few writes for ~10x
|
|
147
|
+
# write throughput -- fine for an audit log.
|
|
148
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
149
|
+
conn.row_factory = sqlite3.Row
|
|
150
|
+
return conn
|
|
151
|
+
|
|
152
|
+
def record_build(
|
|
153
|
+
self,
|
|
154
|
+
*,
|
|
155
|
+
ip: str,
|
|
156
|
+
decl: str,
|
|
157
|
+
log2: int | None,
|
|
158
|
+
bs: float | None,
|
|
159
|
+
status: str,
|
|
160
|
+
object_id: str | None = None,
|
|
161
|
+
kind: str | None = None,
|
|
162
|
+
error_msg: str | None = None,
|
|
163
|
+
elapsed_ms: int = 0,
|
|
164
|
+
session_id: str | None = None,
|
|
165
|
+
key_scope: str | None = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Append one row.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
ip : str
|
|
172
|
+
Client address from ``routes.objects._client_ip``, which reads the
|
|
173
|
+
forwarded chain and falls back to the peer. ``"-"`` when neither
|
|
174
|
+
says anything. Rows written before a112 read the peer alone, so
|
|
175
|
+
every one of them from behind Caddy says ``127.0.0.1``.
|
|
176
|
+
decl : str
|
|
177
|
+
The raw DecL submitted (not the canonicalized form).
|
|
178
|
+
log2, bs : int|None, float|None
|
|
179
|
+
Build knobs as requested -- may be None when omitted.
|
|
180
|
+
status : str
|
|
181
|
+
One of ``'ok' | 'value' | 'parse_error' | 'build_error' | 'timeout'
|
|
182
|
+
| 'limit_exceeded'``. ``'value'`` is a program that meant a number
|
|
183
|
+
(``(2+2)``), which builds and answers but leaves no object; it has
|
|
184
|
+
its own status so the operator's page does not read arithmetic as
|
|
185
|
+
object builds. The summary groups by this column, so a new status
|
|
186
|
+
appears as its own row rather than disturbing an existing one.
|
|
187
|
+
object_id : str|None
|
|
188
|
+
Set on success.
|
|
189
|
+
kind : str|None
|
|
190
|
+
``'agg'`` or ``'port'`` on success; None on failure.
|
|
191
|
+
error_msg : str|None
|
|
192
|
+
One-line error summary (e.g. ``ErrorReport.message``).
|
|
193
|
+
elapsed_ms : int
|
|
194
|
+
Wall-clock time, including parse + cache check + build.
|
|
195
|
+
session_id : str|None
|
|
196
|
+
The caller's session, or ``'anonymous'`` for a headerless client.
|
|
197
|
+
key_scope : str|None
|
|
198
|
+
``'shared'`` or ``'session'``: which cache key this request used.
|
|
199
|
+
Recorded because it is the one number that says whether the
|
|
200
|
+
qualification rule is working. A demo where nearly every build is
|
|
201
|
+
``'session'`` means the rule is firing on programs that do not need
|
|
202
|
+
it, and the room is paying for builds it could have shared.
|
|
203
|
+
"""
|
|
204
|
+
# ISO 8601 with UTC; chosen for sortability and unambiguous TZ.
|
|
205
|
+
ts = datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
|
206
|
+
with self._lock, closing(self._connect()) as conn:
|
|
207
|
+
conn.execute(
|
|
208
|
+
"""INSERT INTO builds
|
|
209
|
+
(ts, ip, object_id, kind, decl, log2, bs, status, error_msg,
|
|
210
|
+
elapsed_ms, session_id, key_scope)
|
|
211
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
212
|
+
(ts, ip, object_id, kind, decl, log2, bs, status, error_msg,
|
|
213
|
+
elapsed_ms, session_id, key_scope),
|
|
214
|
+
)
|
|
215
|
+
conn.commit()
|
|
216
|
+
|
|
217
|
+
def recent(self, n: int = 100) -> list[dict]:
|
|
218
|
+
"""Most recent ``n`` rows, newest first."""
|
|
219
|
+
with closing(self._connect()) as conn:
|
|
220
|
+
rows = conn.execute(
|
|
221
|
+
"SELECT * FROM builds ORDER BY ts DESC LIMIT ?", (n,)
|
|
222
|
+
).fetchall()
|
|
223
|
+
return [dict(r) for r in rows]
|
|
224
|
+
|
|
225
|
+
def by_ip(self, ip: str, n: int = 100) -> list[dict]:
|
|
226
|
+
"""Recent rows from a specific client."""
|
|
227
|
+
with closing(self._connect()) as conn:
|
|
228
|
+
rows = conn.execute(
|
|
229
|
+
"SELECT * FROM builds WHERE ip = ? ORDER BY ts DESC LIMIT ?",
|
|
230
|
+
(ip, n),
|
|
231
|
+
).fetchall()
|
|
232
|
+
return [dict(r) for r in rows]
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _cutoff(window_s: float) -> str:
|
|
236
|
+
"""The ``ts`` value marking the start of a window ``window_s`` back.
|
|
237
|
+
|
|
238
|
+
Notes
|
|
239
|
+
-----
|
|
240
|
+
A string comparison, not a date function. Every row's ``ts`` is written
|
|
241
|
+
by :meth:`record_build` as an ISO 8601 UTC timestamp of fixed width, and
|
|
242
|
+
fixed-width ISO 8601 in one timezone sorts lexicographically in time
|
|
243
|
+
order, so ``ts >= ?`` is both correct and able to use the ``builds_ts``
|
|
244
|
+
index. Calling SQLite's ``datetime()`` on the column instead would be
|
|
245
|
+
correct and would scan the table.
|
|
246
|
+
"""
|
|
247
|
+
start = datetime.now(timezone.utc) - timedelta(seconds=window_s)
|
|
248
|
+
return start.isoformat(timespec="milliseconds")
|
|
249
|
+
|
|
250
|
+
def window_summaries(self, windows, *, top: int = 5,
|
|
251
|
+
slowest: int = 5) -> dict:
|
|
252
|
+
"""Several windows over one connection.
|
|
253
|
+
|
|
254
|
+
Parameters
|
|
255
|
+
----------
|
|
256
|
+
windows : iterable of (str, float)
|
|
257
|
+
Label and window length in seconds, for example
|
|
258
|
+
``(('hour', 3600.0), ('day', 86400.0))``.
|
|
259
|
+
top, slowest : int
|
|
260
|
+
Passed through to :meth:`window_summary`.
|
|
261
|
+
|
|
262
|
+
Returns
|
|
263
|
+
-------
|
|
264
|
+
dict
|
|
265
|
+
Label to summary.
|
|
266
|
+
|
|
267
|
+
Notes
|
|
268
|
+
-----
|
|
269
|
+
One connection for the lot. Each :meth:`_connect` runs two ``PRAGMA``
|
|
270
|
+
statements before the first query, which on the status route's two
|
|
271
|
+
windows was the larger half of the cost: the queries themselves are
|
|
272
|
+
indexed and answer in microseconds. The connection is still per call
|
|
273
|
+
rather than held, which is the pattern the rest of this class uses and
|
|
274
|
+
the reason it is safe across threads.
|
|
275
|
+
"""
|
|
276
|
+
with closing(self._connect()) as conn:
|
|
277
|
+
return {label: self.window_summary(window, top=top, slowest=slowest,
|
|
278
|
+
conn=conn)
|
|
279
|
+
for label, window in windows}
|
|
280
|
+
|
|
281
|
+
def window_summary(self, window_s: float, *, top: int = 5,
|
|
282
|
+
slowest: int = 5, conn: sqlite3.Connection | None = None) -> dict:
|
|
283
|
+
"""Everything ``GET /v1/status`` says about builds in one time window.
|
|
284
|
+
|
|
285
|
+
Parameters
|
|
286
|
+
----------
|
|
287
|
+
window_s : float
|
|
288
|
+
How far back to look, in seconds.
|
|
289
|
+
top : int
|
|
290
|
+
How many error messages and how many clients to name.
|
|
291
|
+
slowest : int
|
|
292
|
+
How many slow builds to list.
|
|
293
|
+
conn : sqlite3.Connection, optional
|
|
294
|
+
An open connection to reuse. Given by :meth:`window_summaries` so
|
|
295
|
+
several windows share one; ``None`` opens and closes its own.
|
|
296
|
+
|
|
297
|
+
Returns
|
|
298
|
+
-------
|
|
299
|
+
dict
|
|
300
|
+
``window_s``, ``total``, ``by_status``, ``by_key_scope``,
|
|
301
|
+
``p50_ms``, ``p95_ms``, ``slowest``, ``top_errors``,
|
|
302
|
+
``distinct_clients`` and ``top_clients``.
|
|
303
|
+
|
|
304
|
+
Notes
|
|
305
|
+
-----
|
|
306
|
+
**Seven small queries rather than one Python pass over the table.** The
|
|
307
|
+
audit database grows without bound by design, so reading rows into
|
|
308
|
+
Python to count them is a page whose cost rises with the age of the
|
|
309
|
+
deployment. Every query here is bounded by the window, is served by the
|
|
310
|
+
``builds_ts`` index, and carries a ``LIMIT``.
|
|
311
|
+
|
|
312
|
+
The percentiles are offsets into an ordered window rather than an
|
|
313
|
+
interpolated quantile, because SQLite has no percentile function and the
|
|
314
|
+
alternative is loading the column. Nearest rank on a few thousand rows
|
|
315
|
+
is the same number to the millisecond the page prints.
|
|
316
|
+
|
|
317
|
+
``by_key_scope`` covers only rows written since a110, which is when the
|
|
318
|
+
column arrived. Older rows read ``NULL`` and are grouped under
|
|
319
|
+
``'unknown'`` rather than silently folded into either scope.
|
|
320
|
+
"""
|
|
321
|
+
if conn is None:
|
|
322
|
+
with closing(self._connect()) as owned:
|
|
323
|
+
return self.window_summary(window_s, top=top, slowest=slowest,
|
|
324
|
+
conn=owned)
|
|
325
|
+
cutoff = self._cutoff(window_s)
|
|
326
|
+
total = conn.execute(
|
|
327
|
+
"SELECT COUNT(*) FROM builds WHERE ts >= ?", (cutoff,)
|
|
328
|
+
).fetchone()[0]
|
|
329
|
+
by_status = {
|
|
330
|
+
row[0]: row[1] for row in conn.execute(
|
|
331
|
+
"SELECT status, COUNT(*) FROM builds WHERE ts >= ? "
|
|
332
|
+
"GROUP BY status ORDER BY COUNT(*) DESC LIMIT 20", (cutoff,))
|
|
333
|
+
}
|
|
334
|
+
by_key_scope = {
|
|
335
|
+
(row[0] or "unknown"): row[1] for row in conn.execute(
|
|
336
|
+
"SELECT key_scope, COUNT(*) FROM builds WHERE ts >= ? "
|
|
337
|
+
"GROUP BY key_scope ORDER BY COUNT(*) DESC LIMIT 20", (cutoff,))
|
|
338
|
+
}
|
|
339
|
+
percentiles = {}
|
|
340
|
+
for label, q in (("p50_ms", 0.50), ("p95_ms", 0.95)):
|
|
341
|
+
if total == 0:
|
|
342
|
+
percentiles[label] = None
|
|
343
|
+
continue
|
|
344
|
+
offset = min(total - 1, max(0, int(round(q * (total - 1)))))
|
|
345
|
+
percentiles[label] = conn.execute(
|
|
346
|
+
"SELECT elapsed_ms FROM builds WHERE ts >= ? "
|
|
347
|
+
"ORDER BY elapsed_ms LIMIT 1 OFFSET ?", (cutoff, offset)
|
|
348
|
+
).fetchone()[0]
|
|
349
|
+
slow = [dict(row) for row in conn.execute(
|
|
350
|
+
"SELECT ts, elapsed_ms, status, kind, object_id, session_id "
|
|
351
|
+
"FROM builds WHERE ts >= ? ORDER BY elapsed_ms DESC LIMIT ?",
|
|
352
|
+
(cutoff, slowest))]
|
|
353
|
+
errors = [{"error_msg": row[0], "count": row[1]} for row in conn.execute(
|
|
354
|
+
"SELECT error_msg, COUNT(*) FROM builds WHERE ts >= ? "
|
|
355
|
+
"AND error_msg IS NOT NULL GROUP BY error_msg "
|
|
356
|
+
"ORDER BY COUNT(*) DESC LIMIT ?", (cutoff, top))]
|
|
357
|
+
distinct = conn.execute(
|
|
358
|
+
"SELECT COUNT(DISTINCT ip) FROM builds WHERE ts >= ?", (cutoff,)
|
|
359
|
+
).fetchone()[0]
|
|
360
|
+
clients = [{"ip": row[0], "count": row[1]} for row in conn.execute(
|
|
361
|
+
"SELECT ip, COUNT(*) FROM builds WHERE ts >= ? GROUP BY ip "
|
|
362
|
+
"ORDER BY COUNT(*) DESC LIMIT ?", (cutoff, top))]
|
|
363
|
+
return {
|
|
364
|
+
"window_s": window_s,
|
|
365
|
+
"total": total,
|
|
366
|
+
"by_status": by_status,
|
|
367
|
+
"by_key_scope": by_key_scope,
|
|
368
|
+
**percentiles,
|
|
369
|
+
"slowest": slow,
|
|
370
|
+
"top_errors": errors,
|
|
371
|
+
"distinct_clients": distinct,
|
|
372
|
+
"top_clients": clients,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
def size_bytes(self) -> int | None:
|
|
376
|
+
"""Bytes on disk for the database and its write-ahead log, or ``None``.
|
|
377
|
+
|
|
378
|
+
Notes
|
|
379
|
+
-----
|
|
380
|
+
The WAL is counted because it is real disk and can be the larger of the
|
|
381
|
+
two between checkpoints, and the page exists partly so that the audit
|
|
382
|
+
database's growth becomes obvious: section 8 question 4 of
|
|
383
|
+
``dev/done/plan-site-status-page.md`` is the retention decision this number
|
|
384
|
+
is meant to prompt.
|
|
385
|
+
"""
|
|
386
|
+
total = 0
|
|
387
|
+
seen = False
|
|
388
|
+
for suffix in ("", "-wal", "-shm"):
|
|
389
|
+
path = Path(str(self.db_path) + suffix)
|
|
390
|
+
try:
|
|
391
|
+
total += path.stat().st_size
|
|
392
|
+
seen = True
|
|
393
|
+
except OSError:
|
|
394
|
+
continue
|
|
395
|
+
return total if seen else None
|