cctv-stack 0.3.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.
- cctv/__init__.py +19 -0
- cctv/atomic.py +184 -0
- cctv/cleanup.py +1014 -0
- cctv/clip.py +278 -0
- cctv/config.py +742 -0
- cctv/config_cli.py +217 -0
- cctv/db.py +1478 -0
- cctv/dispatch.py +270 -0
- cctv/ffprobe.py +246 -0
- cctv/frigate.py +411 -0
- cctv/health.py +413 -0
- cctv/logging.py +157 -0
- cctv/materialise.py +488 -0
- cctv/migrations/0001_init.sql +199 -0
- cctv/migrations/0002_finding_kinds.sql +35 -0
- cctv/mqtt.py +171 -0
- cctv/paths.py +290 -0
- cctv/reviews.py +279 -0
- cctv/storage.py +751 -0
- cctv/sweep.py +254 -0
- cctv/timeutil.py +154 -0
- cctv/uploader.py +1417 -0
- cctv/worker.py +385 -0
- cctv_stack-0.3.0.dist-info/METADATA +410 -0
- cctv_stack-0.3.0.dist-info/RECORD +28 -0
- cctv_stack-0.3.0.dist-info/WHEEL +4 -0
- cctv_stack-0.3.0.dist-info/entry_points.txt +5 -0
- cctv_stack-0.3.0.dist-info/licenses/LICENSE +201 -0
cctv/db.py
ADDED
|
@@ -0,0 +1,1478 @@
|
|
|
1
|
+
"""The state store: one SQLite database, and the only coordination between services.
|
|
2
|
+
|
|
3
|
+
Services never call each other. They coordinate exclusively through these tables, which
|
|
4
|
+
is what makes any one of them being down a survivable condition rather than a cascading
|
|
5
|
+
failure. See [`docs/architecture/lld/03-state-store.md`] and the schema contract.
|
|
6
|
+
|
|
7
|
+
Three rules from that LLD are enforced here rather than left to discipline:
|
|
8
|
+
|
|
9
|
+
1. **No network I/O inside a transaction.** A slow Frigate response or a 30-second B2
|
|
10
|
+
upload holding the write lock stalls every other writer. The accessors below are
|
|
11
|
+
deliberately shaped to make the safe pattern the easy one: `claim_next_upload` commits
|
|
12
|
+
before it returns, and `mark_uploaded` opens its own short transaction afterwards.
|
|
13
|
+
2. **`BEGIN IMMEDIATE`, never deferred.** A deferred transaction that reads and then
|
|
14
|
+
writes can fail on lock upgrade and cannot be safely retried mid-transaction.
|
|
15
|
+
3. **Every state transition is validated.** The contract lists the legal edges; every
|
|
16
|
+
other one raises. An illegal transition means a logic bug or database tampering, and
|
|
17
|
+
silently accepting it would corrupt the record that INV-1 depends on.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import sqlite3
|
|
25
|
+
from collections.abc import Collection, Iterator, Mapping, Sequence
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from . import timeutil
|
|
31
|
+
from .timeutil import Instant
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"CLIP_STATES",
|
|
35
|
+
"FINDING_KINDS",
|
|
36
|
+
"LOCAL_EXPIRY_STATES",
|
|
37
|
+
"MAX_ATTEMPTS",
|
|
38
|
+
"STATE_ORDER",
|
|
39
|
+
"ClipRecord",
|
|
40
|
+
"DbError",
|
|
41
|
+
"IllegalTransitionError",
|
|
42
|
+
"MigrationChecksumError",
|
|
43
|
+
"OverdueSummary",
|
|
44
|
+
"QueueSummary",
|
|
45
|
+
"SchemaTooNewError",
|
|
46
|
+
"claim_next_upload",
|
|
47
|
+
"counts_by_state",
|
|
48
|
+
"ensure_camera",
|
|
49
|
+
"finish_upload_attempt",
|
|
50
|
+
"get_watermark",
|
|
51
|
+
"insert_clip",
|
|
52
|
+
"integrity_check",
|
|
53
|
+
"known_cameras",
|
|
54
|
+
"known_review_ids",
|
|
55
|
+
"last_successful_upload_at",
|
|
56
|
+
"local_expiry_batch",
|
|
57
|
+
"mark_local_deleted",
|
|
58
|
+
"migrate",
|
|
59
|
+
"open_db",
|
|
60
|
+
"overdue_local",
|
|
61
|
+
"queue_summary",
|
|
62
|
+
"record_event",
|
|
63
|
+
"record_finding",
|
|
64
|
+
"release_lease",
|
|
65
|
+
"reset_failed_permanent",
|
|
66
|
+
"schema_version",
|
|
67
|
+
"set_clip_checksum",
|
|
68
|
+
"set_watermark",
|
|
69
|
+
"skip_by_policy",
|
|
70
|
+
"stored_clip_checks",
|
|
71
|
+
"write_txn",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
|
75
|
+
|
|
76
|
+
#: Every legal `upload_state`. The CHECK in migration 0001 carries the same set; this is
|
|
77
|
+
#: the copy the accessors validate against so a bad name fails before SQLite sees it.
|
|
78
|
+
CLIP_STATES = frozenset(
|
|
79
|
+
{
|
|
80
|
+
"PENDING",
|
|
81
|
+
"UPLOADING",
|
|
82
|
+
"RETRY_WAIT",
|
|
83
|
+
"UPLOADED",
|
|
84
|
+
"FAILED_PERMANENT",
|
|
85
|
+
"SKIPPED_BY_POLICY",
|
|
86
|
+
"DELETED_LOCAL",
|
|
87
|
+
"EXPIRED_CLOUD",
|
|
88
|
+
"FORCE_DELETED",
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
#: The transition table from the contract §3.2, verbatim. Any edge not listed raises.
|
|
93
|
+
VALID_TRANSITIONS = frozenset(
|
|
94
|
+
{
|
|
95
|
+
("PENDING", "UPLOADING"),
|
|
96
|
+
("UPLOADING", "UPLOADED"),
|
|
97
|
+
("UPLOADING", "PENDING"), # lease expired
|
|
98
|
+
("UPLOADING", "RETRY_WAIT"),
|
|
99
|
+
("UPLOADING", "FAILED_PERMANENT"),
|
|
100
|
+
("RETRY_WAIT", "PENDING"),
|
|
101
|
+
("RETRY_WAIT", "FAILED_PERMANENT"),
|
|
102
|
+
("PENDING", "FAILED_PERMANENT"),
|
|
103
|
+
("PENDING", "SKIPPED_BY_POLICY"),
|
|
104
|
+
("UPLOADED", "DELETED_LOCAL"),
|
|
105
|
+
("UPLOADED", "EXPIRED_CLOUD"),
|
|
106
|
+
("FAILED_PERMANENT", "FORCE_DELETED"),
|
|
107
|
+
("SKIPPED_BY_POLICY", "DELETED_LOCAL"),
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
#: States a clip can never leave. Re-marking one is a no-op rather than an error, because
|
|
112
|
+
#: a crash between an upload and its record must be recoverable by simply redoing it.
|
|
113
|
+
TERMINAL_STATES = frozenset({"DELETED_LOCAL", "EXPIRED_CLOUD", "FORCE_DELETED"})
|
|
114
|
+
|
|
115
|
+
#: Retries before a transient failure becomes permanent. See lld/04 §5.1.
|
|
116
|
+
#:
|
|
117
|
+
#: Twelve attempts with the documented backoff is roughly five hours of retrying: long
|
|
118
|
+
#: enough to ride out a provider incident or an evening's internet outage, short enough that
|
|
119
|
+
#: a genuinely broken configuration surfaces the next morning rather than the next week.
|
|
120
|
+
MAX_ATTEMPTS = 12
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class DbError(RuntimeError):
|
|
124
|
+
"""Anything wrong with the database itself: corruption, a bad migration, misuse."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SchemaTooNewError(DbError):
|
|
128
|
+
"""The database was written by a newer build. Downgrades are refused, not attempted.
|
|
129
|
+
|
|
130
|
+
Refusing is the point ([INV-8]): a newer schema may hold columns and rows this build
|
|
131
|
+
would silently ignore, and "ignore" here means losing upload state.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class MigrationChecksumError(DbError):
|
|
136
|
+
"""A migration file changed after it was applied.
|
|
137
|
+
|
|
138
|
+
That means someone edited history, and the database no longer describes how it was
|
|
139
|
+
built. Guessing which version is right is worse than refusing.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class IllegalTransitionError(DbError):
|
|
144
|
+
"""A state change the contract does not allow."""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------------------
|
|
148
|
+
# Connection and transaction policy — contract §1.1, LLD §2 and §3
|
|
149
|
+
# ---------------------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
_PRAGMAS = (
|
|
152
|
+
"PRAGMA journal_mode = WAL", # readers never block the writer
|
|
153
|
+
"PRAGMA synchronous = FULL", # durability over throughput; we are low-rate
|
|
154
|
+
"PRAGMA foreign_keys = ON", # off by default in SQLite, which surprises people
|
|
155
|
+
"PRAGMA busy_timeout = 5000", # wait rather than fail on a brief lock
|
|
156
|
+
"PRAGMA wal_autocheckpoint = 1000",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def open_db(path: str | Path, *, readonly: bool = False) -> sqlite3.Connection:
|
|
161
|
+
"""Open the database with the project's connection policy applied.
|
|
162
|
+
|
|
163
|
+
`readonly=True` uses SQLite's `mode=ro`, so an observer physically cannot write. That
|
|
164
|
+
matters for `status-collector`: it is a structural guarantee that monitoring never
|
|
165
|
+
mutates the thing it monitors, rather than a promise not to.
|
|
166
|
+
|
|
167
|
+
Note that `journal_mode = WAL` is a no-op for `:memory:`. Tests that care about WAL
|
|
168
|
+
use a temporary file, because otherwise they would be asserting nothing.
|
|
169
|
+
"""
|
|
170
|
+
path = Path(path)
|
|
171
|
+
if not readonly:
|
|
172
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
|
|
174
|
+
conn = sqlite3.connect(
|
|
175
|
+
f"file:{path}?mode={'ro' if readonly else 'rwc'}",
|
|
176
|
+
uri=True,
|
|
177
|
+
isolation_level=None, # explicit transaction control; see write_txn
|
|
178
|
+
timeout=5.0,
|
|
179
|
+
check_same_thread=False,
|
|
180
|
+
)
|
|
181
|
+
conn.row_factory = sqlite3.Row
|
|
182
|
+
|
|
183
|
+
for pragma in _PRAGMAS:
|
|
184
|
+
if readonly and pragma.startswith("PRAGMA journal_mode"):
|
|
185
|
+
continue # cannot set journal mode on a read-only connection
|
|
186
|
+
conn.execute(pragma)
|
|
187
|
+
|
|
188
|
+
return conn
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@contextmanager
|
|
192
|
+
def write_txn(conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
|
|
193
|
+
"""A write transaction that takes the lock immediately.
|
|
194
|
+
|
|
195
|
+
**Nothing slow may happen inside this block.** No HTTP, no upload, no `ffprobe`.
|
|
196
|
+
Claim, commit, work, complete.
|
|
197
|
+
"""
|
|
198
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
199
|
+
try:
|
|
200
|
+
yield conn
|
|
201
|
+
except BaseException:
|
|
202
|
+
conn.execute("ROLLBACK")
|
|
203
|
+
raise
|
|
204
|
+
conn.execute("COMMIT")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------------------
|
|
208
|
+
# Migrations — LLD §4
|
|
209
|
+
# ---------------------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _split_statements(sql: str) -> list[str]:
|
|
213
|
+
"""Split a migration into statements without `executescript`.
|
|
214
|
+
|
|
215
|
+
`sqlite3.Connection.executescript` issues an implicit COMMIT before it runs, which
|
|
216
|
+
would silently break the per-migration atomicity the LLD requires. So statements are
|
|
217
|
+
executed one at a time inside our own transaction instead.
|
|
218
|
+
|
|
219
|
+
Comment lines are dropped before accumulating, which is also what makes a `;` inside a
|
|
220
|
+
comment harmless.
|
|
221
|
+
"""
|
|
222
|
+
statements: list[str] = []
|
|
223
|
+
buffer = ""
|
|
224
|
+
for line in sql.splitlines(keepends=True):
|
|
225
|
+
if line.lstrip().startswith("--"):
|
|
226
|
+
continue
|
|
227
|
+
buffer += line
|
|
228
|
+
if sqlite3.complete_statement(buffer):
|
|
229
|
+
statement = buffer.strip()
|
|
230
|
+
if statement:
|
|
231
|
+
statements.append(statement)
|
|
232
|
+
buffer = ""
|
|
233
|
+
if buffer.strip():
|
|
234
|
+
raise DbError(
|
|
235
|
+
"A migration ends with an incomplete statement. That usually means a missing "
|
|
236
|
+
"semicolon, and it is better to fail here than to half-apply a schema change."
|
|
237
|
+
)
|
|
238
|
+
return statements
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _checksum(sql: str) -> str:
|
|
242
|
+
return hashlib.sha256(sql.encode("utf-8")).hexdigest()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _discover(migrations_dir: Path) -> dict[int, Path]:
|
|
246
|
+
found: dict[int, Path] = {}
|
|
247
|
+
for path in sorted(migrations_dir.glob("*.sql")):
|
|
248
|
+
prefix = path.stem.split("_", 1)[0]
|
|
249
|
+
if not prefix.isdigit():
|
|
250
|
+
raise DbError(f"{path.name}: migration files must be named NNNN_description.sql")
|
|
251
|
+
version = int(prefix)
|
|
252
|
+
if version in found:
|
|
253
|
+
raise DbError(f"Two migrations claim version {version}: {path.name} and more.")
|
|
254
|
+
found[version] = path
|
|
255
|
+
return found
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def schema_version(conn: sqlite3.Connection) -> int:
|
|
259
|
+
"""The highest applied migration, or 0 for an empty database."""
|
|
260
|
+
row = conn.execute(
|
|
261
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'"
|
|
262
|
+
).fetchone()
|
|
263
|
+
if row is None:
|
|
264
|
+
return 0
|
|
265
|
+
row = conn.execute("SELECT COALESCE(MAX(version), 0) AS v FROM schema_migrations").fetchone()
|
|
266
|
+
return int(row["v"])
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def migrate(
|
|
270
|
+
conn: sqlite3.Connection,
|
|
271
|
+
*,
|
|
272
|
+
migrations_dir: Path | None = None,
|
|
273
|
+
backup_dir: Path | None = None,
|
|
274
|
+
) -> int:
|
|
275
|
+
"""Apply pending migrations. Returns the resulting schema version.
|
|
276
|
+
|
|
277
|
+
Runs before any service serves traffic. Each migration is checksummed, optionally
|
|
278
|
+
backed up, and applied inside its own transaction, so a partial migration cannot
|
|
279
|
+
exist.
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
SchemaTooNewError: the database is from a newer build.
|
|
283
|
+
MigrationChecksumError: an applied migration file has changed.
|
|
284
|
+
DbError: a statement failed, or the files are malformed.
|
|
285
|
+
"""
|
|
286
|
+
migrations_dir = MIGRATIONS_DIR if migrations_dir is None else migrations_dir
|
|
287
|
+
available = _discover(migrations_dir)
|
|
288
|
+
|
|
289
|
+
conn.execute("""
|
|
290
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
291
|
+
version INTEGER PRIMARY KEY,
|
|
292
|
+
applied_at REAL NOT NULL,
|
|
293
|
+
checksum TEXT NOT NULL
|
|
294
|
+
)
|
|
295
|
+
""")
|
|
296
|
+
|
|
297
|
+
applied = {
|
|
298
|
+
int(row["version"]): row["checksum"]
|
|
299
|
+
for row in conn.execute("SELECT version, checksum FROM schema_migrations")
|
|
300
|
+
}
|
|
301
|
+
newest_available = max(available, default=0)
|
|
302
|
+
newest_applied = max(applied, default=0)
|
|
303
|
+
|
|
304
|
+
if newest_applied > newest_available:
|
|
305
|
+
raise SchemaTooNewError(
|
|
306
|
+
f"The database is at schema version {newest_applied} but this build only knows "
|
|
307
|
+
f"up to {newest_available}. Refusing to start: a newer schema may contain state "
|
|
308
|
+
f"this build would silently ignore, and that state is upload progress (INV-8). "
|
|
309
|
+
f"Upgrade the software, or restore a backup taken before the newer version ran."
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
for version in sorted(available):
|
|
313
|
+
if version in applied:
|
|
314
|
+
if applied[version] != _checksum(available[version].read_text(encoding="utf-8")):
|
|
315
|
+
raise MigrationChecksumError(
|
|
316
|
+
f"{available[version].name} has changed since it was applied. An applied "
|
|
317
|
+
f"migration is history and must never be edited — add a new one instead."
|
|
318
|
+
)
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
sql = available[version].read_text(encoding="utf-8")
|
|
322
|
+
|
|
323
|
+
if backup_dir is not None:
|
|
324
|
+
backup_to(conn, backup_dir / f"pre-{version:04d}.db")
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
with write_txn(conn):
|
|
328
|
+
# Re-check *inside* the write transaction. Two services start together on a
|
|
329
|
+
# fresh install (the clip-worker and the uploader both open this database),
|
|
330
|
+
# and the pre-loop read above is outside any transaction — so without this,
|
|
331
|
+
# both would try to insert the same `schema_migrations` row and one would
|
|
332
|
+
# fail at startup. `BEGIN IMMEDIATE` serialises them, which makes this read
|
|
333
|
+
# authoritative.
|
|
334
|
+
peer = conn.execute(
|
|
335
|
+
"SELECT checksum FROM schema_migrations WHERE version = ?",
|
|
336
|
+
(version,),
|
|
337
|
+
).fetchone()
|
|
338
|
+
if peer is not None:
|
|
339
|
+
if peer["checksum"] != _checksum(sql):
|
|
340
|
+
raise MigrationChecksumError(
|
|
341
|
+
f"{available[version].name} has changed since it was applied."
|
|
342
|
+
)
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
for statement in _split_statements(sql):
|
|
346
|
+
conn.execute(statement)
|
|
347
|
+
conn.execute(
|
|
348
|
+
"INSERT INTO schema_migrations (version, applied_at, checksum) "
|
|
349
|
+
"VALUES (?, ?, ?)",
|
|
350
|
+
(version, timeutil.now(), _checksum(sql)),
|
|
351
|
+
)
|
|
352
|
+
except sqlite3.Error as exc:
|
|
353
|
+
raise DbError(f"Migration {available[version].name} failed: {exc}") from exc
|
|
354
|
+
|
|
355
|
+
return newest_available
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def backup_to(conn: sqlite3.Connection, destination: str | Path) -> Path:
|
|
359
|
+
"""Online backup. Safe while other connections are reading and writing."""
|
|
360
|
+
destination = Path(destination)
|
|
361
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
target = sqlite3.connect(destination)
|
|
363
|
+
try:
|
|
364
|
+
conn.backup(target)
|
|
365
|
+
finally:
|
|
366
|
+
target.close()
|
|
367
|
+
return destination
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def integrity_check(conn: sqlite3.Connection) -> None:
|
|
371
|
+
"""Verify structure and referential integrity. Called at startup (LLD R5)."""
|
|
372
|
+
row = conn.execute("PRAGMA integrity_check").fetchone()
|
|
373
|
+
if row is None or row[0] != "ok":
|
|
374
|
+
raise DbError(f"PRAGMA integrity_check failed: {row[0] if row else 'no result'}")
|
|
375
|
+
|
|
376
|
+
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
|
|
377
|
+
if violations:
|
|
378
|
+
detail = ", ".join(f"{v[0]}→{v[2]}" for v in violations[:5])
|
|
379
|
+
raise DbError(
|
|
380
|
+
f"PRAGMA foreign_key_check found {len(violations)} violation(s): {detail}. "
|
|
381
|
+
f"A dangling reference means a row was written while foreign keys were off."
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ---------------------------------------------------------------------------------------
|
|
386
|
+
# Clips
|
|
387
|
+
# ---------------------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@dataclass(frozen=True, slots=True)
|
|
391
|
+
class ClipRecord:
|
|
392
|
+
"""A clip about to be recorded. Everything the `clips` row needs.
|
|
393
|
+
|
|
394
|
+
The quality fields (`requested_*`, `coverage_ratio`, `max_gap_s`, `fetch_attempts`)
|
|
395
|
+
exist so that INV-14 is auditable. A truncated clip and a perfect clip are both valid
|
|
396
|
+
MP4s that play and upload fine; without these columns, "are the clips right?" is an
|
|
397
|
+
opinion rather than a query.
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
review_id: str
|
|
401
|
+
camera: str
|
|
402
|
+
severity: str
|
|
403
|
+
start_time: Instant
|
|
404
|
+
end_time: Instant
|
|
405
|
+
path: str
|
|
406
|
+
event_ids: tuple[str, ...] = ()
|
|
407
|
+
zones: tuple[str, ...] = ()
|
|
408
|
+
label: str | None = None
|
|
409
|
+
duration_s: float | None = None
|
|
410
|
+
requested_start_ts: float | None = None
|
|
411
|
+
requested_end_ts: float | None = None
|
|
412
|
+
pre_roll_s: float | None = None
|
|
413
|
+
post_roll_s: float | None = None
|
|
414
|
+
max_gap_s: float | None = None
|
|
415
|
+
has_gaps: bool = False
|
|
416
|
+
has_audio: bool = False
|
|
417
|
+
video_codec: str | None = None
|
|
418
|
+
audio_codec: str | None = None
|
|
419
|
+
fetch_attempts: int = 0
|
|
420
|
+
degraded: bool = False
|
|
421
|
+
long_clip: bool = False
|
|
422
|
+
size_bytes: int | None = None
|
|
423
|
+
sha256: str | None = None
|
|
424
|
+
sidecar_path: str | None = None
|
|
425
|
+
materialised_at: Instant | None = None
|
|
426
|
+
upload_state: str = "PENDING"
|
|
427
|
+
|
|
428
|
+
@property
|
|
429
|
+
def requested_s(self) -> float | None:
|
|
430
|
+
if self.requested_start_ts is None or self.requested_end_ts is None:
|
|
431
|
+
return None
|
|
432
|
+
return self.requested_end_ts - self.requested_start_ts
|
|
433
|
+
|
|
434
|
+
@property
|
|
435
|
+
def coverage_ratio(self) -> float | None:
|
|
436
|
+
requested = self.requested_s
|
|
437
|
+
if requested is None or requested <= 0 or self.duration_s is None:
|
|
438
|
+
return None
|
|
439
|
+
return self.duration_s / requested
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _validate_clip(clip: ClipRecord) -> None:
|
|
443
|
+
if not clip.review_id:
|
|
444
|
+
raise DbError("A clip needs a review_id: it is the dedup key (ADR-0016).")
|
|
445
|
+
if clip.severity not in {"alert", "detection"}:
|
|
446
|
+
raise DbError(f"severity must be 'alert' or 'detection', got {clip.severity!r}.")
|
|
447
|
+
if not clip.path:
|
|
448
|
+
raise DbError("A clip needs a path.")
|
|
449
|
+
if clip.path.startswith("/"):
|
|
450
|
+
raise DbError(
|
|
451
|
+
f"path must be relative to DATA_DIR, got {clip.path!r}. An absolute path breaks "
|
|
452
|
+
f"restore to a different mount point."
|
|
453
|
+
)
|
|
454
|
+
if clip.upload_state not in CLIP_STATES:
|
|
455
|
+
raise DbError(f"Unknown upload_state {clip.upload_state!r}.")
|
|
456
|
+
if clip.end_time < clip.start_time:
|
|
457
|
+
raise DbError("end_time is before start_time.")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def insert_clip(
|
|
461
|
+
conn: sqlite3.Connection, clip: ClipRecord, *, now: Instant | None = None
|
|
462
|
+
) -> tuple[int, bool]:
|
|
463
|
+
"""Record a clip. Returns `(clip_id, created)`.
|
|
464
|
+
|
|
465
|
+
**Idempotent by construction**, which is [INV-7] and the reason the schema has
|
|
466
|
+
`UNIQUE(review_id)`. Delivering the same review five times produces one row, and
|
|
467
|
+
`created=False` on the repeat deliveries — so the caller does not have to check first,
|
|
468
|
+
and there is no window between a check and an insert.
|
|
469
|
+
"""
|
|
470
|
+
_validate_clip(clip)
|
|
471
|
+
now = timeutil.now() if now is None else now
|
|
472
|
+
|
|
473
|
+
with write_txn(conn):
|
|
474
|
+
row = conn.execute(
|
|
475
|
+
"""
|
|
476
|
+
INSERT INTO clips (
|
|
477
|
+
review_id, event_ids, severity, camera, label, zones,
|
|
478
|
+
start_time, end_time, duration_s,
|
|
479
|
+
requested_start_ts, requested_end_ts, requested_s, coverage_ratio,
|
|
480
|
+
pre_roll_s, post_roll_s, max_gap_s,
|
|
481
|
+
has_gaps, has_audio, video_codec, audio_codec, fetch_attempts,
|
|
482
|
+
degraded, long_clip,
|
|
483
|
+
path, sidecar_path, size_bytes, sha256,
|
|
484
|
+
upload_state, attempts, materialised_at, created_at, updated_at
|
|
485
|
+
) VALUES (
|
|
486
|
+
:review_id, :event_ids, :severity, :camera, :label, :zones,
|
|
487
|
+
:start_time, :end_time, :duration_s,
|
|
488
|
+
:requested_start_ts, :requested_end_ts, :requested_s, :coverage_ratio,
|
|
489
|
+
:pre_roll_s, :post_roll_s, :max_gap_s,
|
|
490
|
+
:has_gaps, :has_audio, :video_codec, :audio_codec, :fetch_attempts,
|
|
491
|
+
:degraded, :long_clip,
|
|
492
|
+
:path, :sidecar_path, :size_bytes, :sha256,
|
|
493
|
+
:upload_state, 0, :materialised_at, :created_at, :updated_at
|
|
494
|
+
)
|
|
495
|
+
ON CONFLICT (review_id) DO NOTHING
|
|
496
|
+
RETURNING id
|
|
497
|
+
""",
|
|
498
|
+
{
|
|
499
|
+
"review_id": clip.review_id,
|
|
500
|
+
"event_ids": json.dumps(list(clip.event_ids)),
|
|
501
|
+
"severity": clip.severity,
|
|
502
|
+
"camera": clip.camera,
|
|
503
|
+
"label": clip.label,
|
|
504
|
+
"zones": json.dumps(list(clip.zones)),
|
|
505
|
+
"start_time": clip.start_time,
|
|
506
|
+
"end_time": clip.end_time,
|
|
507
|
+
"duration_s": clip.duration_s,
|
|
508
|
+
"requested_start_ts": clip.requested_start_ts,
|
|
509
|
+
"requested_end_ts": clip.requested_end_ts,
|
|
510
|
+
"requested_s": clip.requested_s,
|
|
511
|
+
"coverage_ratio": clip.coverage_ratio,
|
|
512
|
+
"pre_roll_s": clip.pre_roll_s,
|
|
513
|
+
"post_roll_s": clip.post_roll_s,
|
|
514
|
+
"max_gap_s": clip.max_gap_s,
|
|
515
|
+
"has_gaps": int(clip.has_gaps),
|
|
516
|
+
"has_audio": int(clip.has_audio),
|
|
517
|
+
"video_codec": clip.video_codec,
|
|
518
|
+
"audio_codec": clip.audio_codec,
|
|
519
|
+
"fetch_attempts": clip.fetch_attempts,
|
|
520
|
+
"degraded": int(clip.degraded),
|
|
521
|
+
"long_clip": int(clip.long_clip),
|
|
522
|
+
"path": clip.path,
|
|
523
|
+
"sidecar_path": clip.sidecar_path,
|
|
524
|
+
"size_bytes": clip.size_bytes,
|
|
525
|
+
"sha256": clip.sha256,
|
|
526
|
+
"upload_state": clip.upload_state,
|
|
527
|
+
"materialised_at": clip.materialised_at,
|
|
528
|
+
"created_at": now,
|
|
529
|
+
"updated_at": now,
|
|
530
|
+
},
|
|
531
|
+
).fetchone()
|
|
532
|
+
|
|
533
|
+
if row is not None:
|
|
534
|
+
return int(row["id"]), True
|
|
535
|
+
|
|
536
|
+
existing = conn.execute(
|
|
537
|
+
"SELECT id FROM clips WHERE review_id = ?", (clip.review_id,)
|
|
538
|
+
).fetchone()
|
|
539
|
+
# Not reachable in practice — ON CONFLICT DO NOTHING with no other constraint can
|
|
540
|
+
# only return no row when review_id collided — but raising beats returning None.
|
|
541
|
+
if existing is None:
|
|
542
|
+
raise DbError(
|
|
543
|
+
f"Insert of review {clip.review_id!r} affected no row and no existing row "
|
|
544
|
+
f"was found. The database is in an unexpected state."
|
|
545
|
+
)
|
|
546
|
+
return int(existing["id"]), False
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def ensure_camera(
|
|
550
|
+
conn: sqlite3.Connection,
|
|
551
|
+
*,
|
|
552
|
+
name: str,
|
|
553
|
+
display_name: str | None = None,
|
|
554
|
+
enabled: bool = True,
|
|
555
|
+
config_hash: str | None = None,
|
|
556
|
+
now: Instant | None = None,
|
|
557
|
+
) -> None:
|
|
558
|
+
"""Register or update a camera.
|
|
559
|
+
|
|
560
|
+
`first_seen_at` is never rewritten, and re-enabling clears `retired_at`. A camera that
|
|
561
|
+
disappears from config is retired rather than deleted ([INV-13]) so its clips keep
|
|
562
|
+
resolving.
|
|
563
|
+
"""
|
|
564
|
+
now = timeutil.now() if now is None else now
|
|
565
|
+
with write_txn(conn):
|
|
566
|
+
conn.execute(
|
|
567
|
+
"""
|
|
568
|
+
INSERT INTO cameras (
|
|
569
|
+
name, display_name, enabled, first_seen_at, config_hash, updated_at
|
|
570
|
+
)
|
|
571
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
572
|
+
ON CONFLICT (name) DO UPDATE SET
|
|
573
|
+
display_name = excluded.display_name,
|
|
574
|
+
enabled = excluded.enabled,
|
|
575
|
+
retired_at = CASE WHEN excluded.enabled = 1 THEN NULL ELSE cameras.retired_at END,
|
|
576
|
+
config_hash = excluded.config_hash,
|
|
577
|
+
updated_at = excluded.updated_at
|
|
578
|
+
""",
|
|
579
|
+
(name, display_name or name, int(enabled), now, config_hash, now),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# ---------------------------------------------------------------------------------------
|
|
584
|
+
# Upload state machine — contract §3.2
|
|
585
|
+
# ---------------------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _state_of(conn: sqlite3.Connection, clip_id: int) -> str:
|
|
589
|
+
row = conn.execute("SELECT upload_state FROM clips WHERE id = ?", (clip_id,)).fetchone()
|
|
590
|
+
if row is None:
|
|
591
|
+
raise DbError(f"No clip with id {clip_id}.")
|
|
592
|
+
return str(row["upload_state"])
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _require_transition(current: str, target: str, clip_id: int) -> None:
|
|
596
|
+
if current == target and target in TERMINAL_STATES | {"UPLOADED"}:
|
|
597
|
+
return # idempotent re-marking; a crash must be recoverable by redoing the work
|
|
598
|
+
if (current, target) not in VALID_TRANSITIONS:
|
|
599
|
+
raise IllegalTransitionError(
|
|
600
|
+
f"Clip {clip_id}: {current} -> {target} is not a legal transition "
|
|
601
|
+
f"(contract §3.2). An illegal transition means a logic bug or tampering, and "
|
|
602
|
+
f"accepting it would corrupt the state that INV-1 depends on."
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def claim_next_upload(
|
|
607
|
+
conn: sqlite3.Connection,
|
|
608
|
+
*,
|
|
609
|
+
owner: str,
|
|
610
|
+
lease_seconds: float,
|
|
611
|
+
cloud_key_prefix: str,
|
|
612
|
+
now: Instant | None = None,
|
|
613
|
+
) -> sqlite3.Row | None:
|
|
614
|
+
"""Claim the oldest eligible clip, or return `None`.
|
|
615
|
+
|
|
616
|
+
Returns **after committing**. The caller then does the network work with no transaction
|
|
617
|
+
held, and calls `mark_uploaded` or `mark_transient_failure` to open a new one. This is
|
|
618
|
+
the single most important shape in the module.
|
|
619
|
+
|
|
620
|
+
In one transaction it also promotes due retries and reclaims expired leases, so a
|
|
621
|
+
previous worker being killed cannot wedge a clip.
|
|
622
|
+
|
|
623
|
+
`attempts` is incremented here, not on failure, so a clip that crashes the worker
|
|
624
|
+
repeatedly cannot be retried forever.
|
|
625
|
+
"""
|
|
626
|
+
now = timeutil.now() if now is None else now
|
|
627
|
+
lease_until = now + lease_seconds
|
|
628
|
+
|
|
629
|
+
with write_txn(conn):
|
|
630
|
+
conn.execute(
|
|
631
|
+
"""
|
|
632
|
+
UPDATE clips
|
|
633
|
+
SET upload_state = 'PENDING', last_error_class = NULL, updated_at = ?
|
|
634
|
+
WHERE upload_state = 'RETRY_WAIT'
|
|
635
|
+
AND next_attempt_at IS NOT NULL
|
|
636
|
+
AND next_attempt_at <= ?
|
|
637
|
+
""",
|
|
638
|
+
(now, now),
|
|
639
|
+
)
|
|
640
|
+
conn.execute(
|
|
641
|
+
"""
|
|
642
|
+
UPDATE clips
|
|
643
|
+
SET upload_state = 'PENDING', lease_owner = NULL, lease_expires_at = NULL,
|
|
644
|
+
updated_at = ?
|
|
645
|
+
WHERE upload_state = 'UPLOADING'
|
|
646
|
+
AND lease_expires_at IS NOT NULL
|
|
647
|
+
AND lease_expires_at <= ?
|
|
648
|
+
""",
|
|
649
|
+
(now, now),
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
row = conn.execute(
|
|
653
|
+
"""
|
|
654
|
+
UPDATE clips
|
|
655
|
+
SET upload_state = 'UPLOADING',
|
|
656
|
+
lease_owner = ?,
|
|
657
|
+
lease_expires_at = ?,
|
|
658
|
+
attempts = attempts + 1,
|
|
659
|
+
cloud_key = COALESCE(cloud_key, ? || path),
|
|
660
|
+
updated_at = ?
|
|
661
|
+
WHERE id = (
|
|
662
|
+
SELECT id
|
|
663
|
+
FROM clips
|
|
664
|
+
WHERE upload_state = 'PENDING'
|
|
665
|
+
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
666
|
+
-- Oldest first, by *event* time (contract §3.3). Ordering by id would
|
|
667
|
+
-- be FIFO by materialisation and would let a clip from a camera that
|
|
668
|
+
-- had one long outage age indefinitely behind newer ones.
|
|
669
|
+
ORDER BY start_time ASC, id ASC
|
|
670
|
+
LIMIT 1
|
|
671
|
+
)
|
|
672
|
+
RETURNING id, review_id, camera, severity, label, zones, event_ids,
|
|
673
|
+
path, sha256, size_bytes, cloud_key, attempts,
|
|
674
|
+
start_time, end_time, duration_s
|
|
675
|
+
""",
|
|
676
|
+
(owner, lease_until, cloud_key_prefix, now, now),
|
|
677
|
+
).fetchone()
|
|
678
|
+
|
|
679
|
+
if row is None:
|
|
680
|
+
return None
|
|
681
|
+
|
|
682
|
+
conn.execute(
|
|
683
|
+
"""
|
|
684
|
+
INSERT INTO upload_attempts (clip_id, attempt_no, started_at)
|
|
685
|
+
VALUES (?, ?, ?)
|
|
686
|
+
""",
|
|
687
|
+
(row["id"], row["attempts"], now),
|
|
688
|
+
)
|
|
689
|
+
return row
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def mark_uploaded(
|
|
693
|
+
conn: sqlite3.Connection,
|
|
694
|
+
*,
|
|
695
|
+
clip_id: int,
|
|
696
|
+
cloud_etag: str | None,
|
|
697
|
+
cloud_size_bytes: int | None,
|
|
698
|
+
now: Instant | None = None,
|
|
699
|
+
) -> None:
|
|
700
|
+
"""UPLOADING → UPLOADED, after the provider checksum has been verified."""
|
|
701
|
+
now = timeutil.now() if now is None else now
|
|
702
|
+
with write_txn(conn):
|
|
703
|
+
_require_transition(_state_of(conn, clip_id), "UPLOADED", clip_id)
|
|
704
|
+
conn.execute(
|
|
705
|
+
"""
|
|
706
|
+
UPDATE clips
|
|
707
|
+
SET upload_state = 'UPLOADED', cloud_etag = ?, cloud_size_bytes = ?,
|
|
708
|
+
uploaded_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
709
|
+
last_error_class = NULL, last_error = NULL, updated_at = ?
|
|
710
|
+
WHERE id = ?
|
|
711
|
+
""",
|
|
712
|
+
(cloud_etag, cloud_size_bytes, now, now, clip_id),
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def mark_transient_failure(
|
|
717
|
+
conn: sqlite3.Connection,
|
|
718
|
+
*,
|
|
719
|
+
clip_id: int,
|
|
720
|
+
error_class: str,
|
|
721
|
+
message: str,
|
|
722
|
+
next_attempt_at: Instant,
|
|
723
|
+
max_attempts: int = MAX_ATTEMPTS,
|
|
724
|
+
now: Instant | None = None,
|
|
725
|
+
) -> str:
|
|
726
|
+
"""UPLOADING → RETRY_WAIT, or → FAILED_PERMANENT once attempts are exhausted.
|
|
727
|
+
|
|
728
|
+
Returns the state actually entered, so the caller can alert on the difference rather
|
|
729
|
+
than inferring it.
|
|
730
|
+
|
|
731
|
+
`max_attempts` is a parameter rather than the module constant alone so the value is
|
|
732
|
+
configurable per deployment without a code change, while the default stays the one the
|
|
733
|
+
LLD documents.
|
|
734
|
+
"""
|
|
735
|
+
now = timeutil.now() if now is None else now
|
|
736
|
+
with write_txn(conn):
|
|
737
|
+
current = _state_of(conn, clip_id)
|
|
738
|
+
_require_transition(current, "RETRY_WAIT", clip_id)
|
|
739
|
+
|
|
740
|
+
attempts = int(
|
|
741
|
+
conn.execute("SELECT attempts FROM clips WHERE id = ?", (clip_id,)).fetchone()[
|
|
742
|
+
"attempts"
|
|
743
|
+
]
|
|
744
|
+
)
|
|
745
|
+
target = "FAILED_PERMANENT" if attempts >= max_attempts else "RETRY_WAIT"
|
|
746
|
+
|
|
747
|
+
conn.execute(
|
|
748
|
+
"""
|
|
749
|
+
UPDATE clips
|
|
750
|
+
SET upload_state = ?, last_error_class = ?, last_error = ?,
|
|
751
|
+
next_attempt_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
752
|
+
updated_at = ?
|
|
753
|
+
WHERE id = ?
|
|
754
|
+
""",
|
|
755
|
+
(target, error_class, message[:2000], next_attempt_at, now, clip_id),
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
if target == "FAILED_PERMANENT":
|
|
759
|
+
record_event(
|
|
760
|
+
conn,
|
|
761
|
+
component="uploader",
|
|
762
|
+
code="UPLOAD_ATTEMPTS_EXHAUSTED",
|
|
763
|
+
severity="critical",
|
|
764
|
+
message=f"Clip {clip_id} failed {attempts} upload attempts.",
|
|
765
|
+
detail={"error_class": error_class},
|
|
766
|
+
now=now,
|
|
767
|
+
)
|
|
768
|
+
return target
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def mark_permanent_failure(
|
|
772
|
+
conn: sqlite3.Connection,
|
|
773
|
+
*,
|
|
774
|
+
clip_id: int,
|
|
775
|
+
error_class: str,
|
|
776
|
+
message: str,
|
|
777
|
+
now: Instant | None = None,
|
|
778
|
+
) -> None:
|
|
779
|
+
"""UPLOADING → FAILED_PERMANENT, for an error that retrying cannot fix.
|
|
780
|
+
|
|
781
|
+
The local file stays. That is [INV-1]: a clip is never deleted before its upload
|
|
782
|
+
succeeded, so a permanent failure holds storage and raises an alert rather than making
|
|
783
|
+
room by destroying evidence.
|
|
784
|
+
"""
|
|
785
|
+
now = timeutil.now() if now is None else now
|
|
786
|
+
with write_txn(conn):
|
|
787
|
+
_require_transition(_state_of(conn, clip_id), "FAILED_PERMANENT", clip_id)
|
|
788
|
+
conn.execute(
|
|
789
|
+
"""
|
|
790
|
+
UPDATE clips
|
|
791
|
+
SET upload_state = 'FAILED_PERMANENT', last_error_class = ?, last_error = ?,
|
|
792
|
+
lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
|
|
793
|
+
WHERE id = ?
|
|
794
|
+
""",
|
|
795
|
+
(error_class, message[:2000], now, clip_id),
|
|
796
|
+
)
|
|
797
|
+
record_event(
|
|
798
|
+
conn,
|
|
799
|
+
component="uploader",
|
|
800
|
+
code="UPLOAD_PERMANENT_FAILURE",
|
|
801
|
+
severity="critical",
|
|
802
|
+
message=f"Clip {clip_id} cannot be uploaded: {message[:200]}",
|
|
803
|
+
detail={"error_class": error_class},
|
|
804
|
+
now=now,
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
# ---------------------------------------------------------------------------------------
|
|
809
|
+
# Sweep watermarks and the audit log
|
|
810
|
+
# ---------------------------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def get_watermark(conn: sqlite3.Connection, name: str) -> sqlite3.Row | None:
|
|
814
|
+
return conn.execute("SELECT * FROM sweep_state WHERE name = ?", (name,)).fetchone()
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def set_watermark(
|
|
818
|
+
conn: sqlite3.Connection,
|
|
819
|
+
*,
|
|
820
|
+
name: str,
|
|
821
|
+
watermark: Instant,
|
|
822
|
+
succeeded: bool = True,
|
|
823
|
+
note: str | None = None,
|
|
824
|
+
now: Instant | None = None,
|
|
825
|
+
) -> float:
|
|
826
|
+
"""Advance a sweep watermark, or record a failure. Returns the stored watermark.
|
|
827
|
+
|
|
828
|
+
**Monotonic.** The watermark can never move backwards, because that would mean
|
|
829
|
+
re-notifying work already durably recorded — harmless — versus the failure mode it
|
|
830
|
+
prevents, which is skipping work by advancing past something that failed.
|
|
831
|
+
|
|
832
|
+
On failure the watermark does not move at all and `consecutive_failures` increments.
|
|
833
|
+
The next run redoes the batch. Redoing is safe; skipping is not.
|
|
834
|
+
"""
|
|
835
|
+
now = timeutil.now() if now is None else now
|
|
836
|
+
with write_txn(conn):
|
|
837
|
+
conn.execute(
|
|
838
|
+
"""
|
|
839
|
+
INSERT INTO sweep_state (name, watermark, last_run_at, last_success_at,
|
|
840
|
+
consecutive_failures, notes)
|
|
841
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
842
|
+
ON CONFLICT (name) DO UPDATE SET
|
|
843
|
+
watermark = CASE
|
|
844
|
+
WHEN ? = 1 THEN MAX(COALESCE(sweep_state.watermark, excluded.watermark),
|
|
845
|
+
excluded.watermark)
|
|
846
|
+
ELSE sweep_state.watermark
|
|
847
|
+
END,
|
|
848
|
+
last_run_at = excluded.last_run_at,
|
|
849
|
+
last_success_at = CASE WHEN ? = 1 THEN excluded.last_success_at
|
|
850
|
+
ELSE sweep_state.last_success_at END,
|
|
851
|
+
consecutive_failures = CASE WHEN ? = 1 THEN 0
|
|
852
|
+
ELSE sweep_state.consecutive_failures + 1 END,
|
|
853
|
+
notes = COALESCE(excluded.notes, sweep_state.notes)
|
|
854
|
+
""",
|
|
855
|
+
(
|
|
856
|
+
name,
|
|
857
|
+
watermark if succeeded else None,
|
|
858
|
+
now,
|
|
859
|
+
now if succeeded else None,
|
|
860
|
+
0 if succeeded else 1,
|
|
861
|
+
note,
|
|
862
|
+
1 if succeeded else 0,
|
|
863
|
+
1 if succeeded else 0,
|
|
864
|
+
1 if succeeded else 0,
|
|
865
|
+
),
|
|
866
|
+
)
|
|
867
|
+
row = conn.execute("SELECT watermark FROM sweep_state WHERE name = ?", (name,)).fetchone()
|
|
868
|
+
return float(row["watermark"]) if row and row["watermark"] is not None else 0.0
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def record_event(
|
|
872
|
+
conn: sqlite3.Connection,
|
|
873
|
+
*,
|
|
874
|
+
component: str,
|
|
875
|
+
code: str,
|
|
876
|
+
severity: str,
|
|
877
|
+
message: str | None = None,
|
|
878
|
+
detail: object | None = None,
|
|
879
|
+
dedupe_key: str | None = None,
|
|
880
|
+
now: Instant | None = None,
|
|
881
|
+
) -> None:
|
|
882
|
+
"""Append to the audit log. Doubles as the alert-deduplication store.
|
|
883
|
+
|
|
884
|
+
`code` is a stable machine API — alerting rules reference it — so changing one is a
|
|
885
|
+
breaking change.
|
|
886
|
+
|
|
887
|
+
Assumes a transaction is already open, since every caller is mid-transition and
|
|
888
|
+
nesting transactions is not possible. That is deliberate: an audit row and the state
|
|
889
|
+
change it describes must commit together or not at all.
|
|
890
|
+
"""
|
|
891
|
+
conn.execute(
|
|
892
|
+
"""
|
|
893
|
+
INSERT INTO system_events (at, severity, component, code, message, detail, dedupe_key)
|
|
894
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
895
|
+
""",
|
|
896
|
+
(
|
|
897
|
+
timeutil.now() if now is None else now,
|
|
898
|
+
severity,
|
|
899
|
+
component,
|
|
900
|
+
code,
|
|
901
|
+
message,
|
|
902
|
+
None if detail is None else json.dumps(detail),
|
|
903
|
+
dedupe_key,
|
|
904
|
+
),
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def known_review_ids(conn: sqlite3.Connection, review_ids: Collection[str]) -> set[str]:
|
|
909
|
+
"""Which of these reviews already have a row.
|
|
910
|
+
|
|
911
|
+
One indexed query rather than one per candidate: `UNIQUE(review_id)` is on the table
|
|
912
|
+
precisely so this is a lookup and not a scan, and the sweep asks this question for
|
|
913
|
+
every review it considers.
|
|
914
|
+
"""
|
|
915
|
+
ids = list(review_ids)
|
|
916
|
+
if not ids:
|
|
917
|
+
return set()
|
|
918
|
+
# SQLite's default limit on bound parameters is 999; a sweep window is far smaller, but
|
|
919
|
+
# chunking here means a large `limit` cannot fail in a way that looks like a bug in the
|
|
920
|
+
# sweep rather than in the caller's configuration.
|
|
921
|
+
found: set[str] = set()
|
|
922
|
+
for start in range(0, len(ids), 900):
|
|
923
|
+
chunk = ids[start : start + 900]
|
|
924
|
+
placeholders = ",".join("?" * len(chunk))
|
|
925
|
+
rows = conn.execute(
|
|
926
|
+
f"SELECT review_id FROM clips WHERE review_id IN ({placeholders})", chunk
|
|
927
|
+
)
|
|
928
|
+
found.update(str(row["review_id"]) for row in rows)
|
|
929
|
+
return found
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def counts_by_state(conn: sqlite3.Connection) -> dict[str, int]:
|
|
933
|
+
"""Clips per upload state. The dashboard's headline query, and the invariant check."""
|
|
934
|
+
return {
|
|
935
|
+
str(row["upload_state"]): int(row["n"])
|
|
936
|
+
for row in conn.execute(
|
|
937
|
+
"SELECT upload_state, COUNT(*) AS n FROM clips "
|
|
938
|
+
"GROUP BY upload_state ORDER BY upload_state"
|
|
939
|
+
)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
#: Kept for callers that need the documented state list in a stable order.
|
|
944
|
+
STATE_ORDER: Sequence[str] = tuple(sorted(CLIP_STATES))
|
|
945
|
+
|
|
946
|
+
#: The states in which the local file **must** exist. INV-1 expressed as a query: these are
|
|
947
|
+
#: the clips retention may not touch, and therefore the ones the uploader must still be able
|
|
948
|
+
#: to read. A missing file in one of these states is a real inconsistency, not a cache miss.
|
|
949
|
+
HELD_STATES: tuple[str, ...] = (
|
|
950
|
+
"PENDING",
|
|
951
|
+
"UPLOADING",
|
|
952
|
+
"RETRY_WAIT",
|
|
953
|
+
"FAILED_PERMANENT",
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
@dataclass(frozen=True, slots=True)
|
|
958
|
+
class QueueSummary:
|
|
959
|
+
"""The upload backlog, as the health checks and the status page need it.
|
|
960
|
+
|
|
961
|
+
`oldest_pending_age_s` is the single most useful number the uploader produces
|
|
962
|
+
(lld/04 §6). A *running* uploader with a six-hour-old backlog is not working, and a
|
|
963
|
+
*stopped* uploader with an empty queue is a monitoring problem rather than a data
|
|
964
|
+
problem. Process liveness cannot tell those apart; this number can.
|
|
965
|
+
"""
|
|
966
|
+
|
|
967
|
+
counts: Mapping[str, int]
|
|
968
|
+
oldest_pending_age_s: float | None
|
|
969
|
+
uploaded_24h: int
|
|
970
|
+
last_successful_upload_at: Instant | None
|
|
971
|
+
|
|
972
|
+
def _n(self, state: str) -> int:
|
|
973
|
+
return self.counts.get(state, 0)
|
|
974
|
+
|
|
975
|
+
@property
|
|
976
|
+
def pending(self) -> int:
|
|
977
|
+
return self._n("PENDING")
|
|
978
|
+
|
|
979
|
+
@property
|
|
980
|
+
def uploading(self) -> int:
|
|
981
|
+
return self._n("UPLOADING")
|
|
982
|
+
|
|
983
|
+
@property
|
|
984
|
+
def retry_wait(self) -> int:
|
|
985
|
+
return self._n("RETRY_WAIT")
|
|
986
|
+
|
|
987
|
+
@property
|
|
988
|
+
def failed_permanent(self) -> int:
|
|
989
|
+
return self._n("FAILED_PERMANENT")
|
|
990
|
+
|
|
991
|
+
@property
|
|
992
|
+
def skipped_by_policy(self) -> int:
|
|
993
|
+
return self._n("SKIPPED_BY_POLICY")
|
|
994
|
+
|
|
995
|
+
@property
|
|
996
|
+
def uploaded(self) -> int:
|
|
997
|
+
return self._n("UPLOADED")
|
|
998
|
+
|
|
999
|
+
@property
|
|
1000
|
+
def held(self) -> int:
|
|
1001
|
+
"""Clips still holding local storage because their upload has not succeeded."""
|
|
1002
|
+
return sum(self.counts.get(state, 0) for state in HELD_STATES)
|
|
1003
|
+
|
|
1004
|
+
@property
|
|
1005
|
+
def stale_upload_s(self) -> float | None:
|
|
1006
|
+
"""Seconds since the last successful upload, or `None` if there has never been one."""
|
|
1007
|
+
if self.last_successful_upload_at is None:
|
|
1008
|
+
return None
|
|
1009
|
+
return max(0.0, timeutil.now() - self.last_successful_upload_at)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def queue_summary(
|
|
1013
|
+
conn: sqlite3.Connection, *, now: Instant | None = None, window_s: float = 86400.0
|
|
1014
|
+
) -> QueueSummary:
|
|
1015
|
+
"""One snapshot of the backlog. Read-only: no transaction, no writes.
|
|
1016
|
+
|
|
1017
|
+
Backlog age is measured from when the clip became *uploadable* rather than from the
|
|
1018
|
+
event's own start time — a clip materialised an hour after its event is not an hour
|
|
1019
|
+
behind on upload, and conflating the two would make the metric unactionable.
|
|
1020
|
+
"""
|
|
1021
|
+
now = timeutil.now() if now is None else now
|
|
1022
|
+
|
|
1023
|
+
oldest_row = conn.execute(
|
|
1024
|
+
"SELECT MIN(COALESCE(materialised_at, created_at)) AS oldest FROM clips "
|
|
1025
|
+
"WHERE upload_state = 'PENDING'"
|
|
1026
|
+
).fetchone()
|
|
1027
|
+
oldest = None if oldest_row is None else oldest_row["oldest"]
|
|
1028
|
+
|
|
1029
|
+
return QueueSummary(
|
|
1030
|
+
counts=counts_by_state(conn),
|
|
1031
|
+
oldest_pending_age_s=None if oldest is None else max(0.0, now - float(oldest)),
|
|
1032
|
+
uploaded_24h=int(
|
|
1033
|
+
conn.execute(
|
|
1034
|
+
"SELECT COUNT(*) AS n FROM clips WHERE uploaded_at IS NOT NULL AND uploaded_at > ?",
|
|
1035
|
+
(now - window_s,),
|
|
1036
|
+
).fetchone()["n"]
|
|
1037
|
+
),
|
|
1038
|
+
last_successful_upload_at=last_successful_upload_at(conn),
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def set_clip_checksum(
|
|
1043
|
+
conn: sqlite3.Connection,
|
|
1044
|
+
*,
|
|
1045
|
+
clip_id: int,
|
|
1046
|
+
sha256: str,
|
|
1047
|
+
size_bytes: int,
|
|
1048
|
+
now: Instant | None = None,
|
|
1049
|
+
) -> None:
|
|
1050
|
+
"""Backfill a clip's digest and size.
|
|
1051
|
+
|
|
1052
|
+
The clip-worker computes both at materialisation, so this is a repair path: an older row
|
|
1053
|
+
written before the column existed, or one whose file was re-hashed because the size on
|
|
1054
|
+
disk no longer matched what was recorded. The digest is what INV-7's idempotence check
|
|
1055
|
+
compares against the provider's, so a clip without one cannot be verified at all.
|
|
1056
|
+
"""
|
|
1057
|
+
now = timeutil.now() if now is None else now
|
|
1058
|
+
with write_txn(conn):
|
|
1059
|
+
conn.execute(
|
|
1060
|
+
"UPDATE clips SET sha256 = ?, size_bytes = ?, updated_at = ? WHERE id = ?",
|
|
1061
|
+
(sha256, size_bytes, now, clip_id),
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def finish_upload_attempt(
|
|
1066
|
+
conn: sqlite3.Connection,
|
|
1067
|
+
*,
|
|
1068
|
+
clip_id: int,
|
|
1069
|
+
attempt_no: int,
|
|
1070
|
+
outcome: str,
|
|
1071
|
+
http_status: int | None = None,
|
|
1072
|
+
bytes_sent: int | None = None,
|
|
1073
|
+
error_class: str | None = None,
|
|
1074
|
+
error_message: str | None = None,
|
|
1075
|
+
duration_ms: int | None = None,
|
|
1076
|
+
now: Instant | None = None,
|
|
1077
|
+
) -> None:
|
|
1078
|
+
"""Close the `upload_attempts` row that `claim_next_upload` opened.
|
|
1079
|
+
|
|
1080
|
+
Append-only history is what turns "why is this clip stuck?" into a query rather than a
|
|
1081
|
+
guess. A failed update here is never allowed to fail the upload: the attempt row is
|
|
1082
|
+
diagnostics, and losing diagnostics must not lose an upload.
|
|
1083
|
+
"""
|
|
1084
|
+
now = timeutil.now() if now is None else now
|
|
1085
|
+
with write_txn(conn):
|
|
1086
|
+
conn.execute(
|
|
1087
|
+
"""
|
|
1088
|
+
UPDATE upload_attempts
|
|
1089
|
+
SET finished_at = ?, outcome = ?, http_status = ?, bytes_sent = ?,
|
|
1090
|
+
error_class = ?, error_message = ?, duration_ms = ?
|
|
1091
|
+
WHERE clip_id = ? AND attempt_no = ? AND finished_at IS NULL
|
|
1092
|
+
""",
|
|
1093
|
+
(
|
|
1094
|
+
now,
|
|
1095
|
+
outcome,
|
|
1096
|
+
http_status,
|
|
1097
|
+
bytes_sent,
|
|
1098
|
+
error_class,
|
|
1099
|
+
None if error_message is None else error_message[:2000],
|
|
1100
|
+
duration_ms,
|
|
1101
|
+
clip_id,
|
|
1102
|
+
attempt_no,
|
|
1103
|
+
),
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def last_successful_upload_at(conn: sqlite3.Connection) -> Instant | None:
|
|
1108
|
+
"""When an upload last succeeded, or `None` if one never has.
|
|
1109
|
+
|
|
1110
|
+
`None` and "a long time ago" must stay distinguishable: on a fresh install the correct
|
|
1111
|
+
answer is "no upload has happened yet, and that is expected", not `STORAGE_STALE`.
|
|
1112
|
+
"""
|
|
1113
|
+
row = conn.execute("SELECT MAX(uploaded_at) AS at FROM clips").fetchone()
|
|
1114
|
+
return None if row is None or row["at"] is None else float(row["at"])
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def stored_clip_checks(conn: sqlite3.Connection, *, limit: int = 1000) -> list[sqlite3.Row]:
|
|
1118
|
+
"""Rows whose local file must exist, for the `local_files_readable` health check.
|
|
1119
|
+
|
|
1120
|
+
Bounded on purpose: the check exists to catch a mount that went away or a volume that
|
|
1121
|
+
was restored from the wrong snapshot, and the first N rows catch both. Scanning every
|
|
1122
|
+
held clip on every health request would turn a 2 ms check into a table scan.
|
|
1123
|
+
"""
|
|
1124
|
+
placeholders = ",".join("?" * len(HELD_STATES))
|
|
1125
|
+
return conn.execute(
|
|
1126
|
+
f"SELECT id, path, camera, upload_state FROM clips "
|
|
1127
|
+
f"WHERE upload_state IN ({placeholders}) ORDER BY id LIMIT ?",
|
|
1128
|
+
(*HELD_STATES, limit),
|
|
1129
|
+
).fetchall()
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def skip_by_policy(
|
|
1133
|
+
conn: sqlite3.Connection, *, cameras: Collection[str], now: Instant | None = None
|
|
1134
|
+
) -> int:
|
|
1135
|
+
"""PENDING → SKIPPED_BY_POLICY for cameras with `retention.upload: false`.
|
|
1136
|
+
|
|
1137
|
+
Done in bulk *before* claiming rather than per-clip: the state machine's only legal edge
|
|
1138
|
+
into `SKIPPED_BY_POLICY` is from `PENDING` (contract §3.2), so a clip that has already
|
|
1139
|
+
been claimed cannot be skipped — it can only be uploaded or failed.
|
|
1140
|
+
|
|
1141
|
+
This never alerts. Not backing up a camera is a decision the operator made, and a
|
|
1142
|
+
notification about a deliberate choice is noise that trains people to ignore alerts.
|
|
1143
|
+
"""
|
|
1144
|
+
names = list(cameras)
|
|
1145
|
+
if not names:
|
|
1146
|
+
return 0
|
|
1147
|
+
now = timeutil.now() if now is None else now
|
|
1148
|
+
placeholders = ",".join("?" * len(names))
|
|
1149
|
+
|
|
1150
|
+
with write_txn(conn):
|
|
1151
|
+
changed = conn.execute(
|
|
1152
|
+
f"""
|
|
1153
|
+
UPDATE clips
|
|
1154
|
+
SET upload_state = 'SKIPPED_BY_POLICY', lease_owner = NULL,
|
|
1155
|
+
lease_expires_at = NULL, updated_at = ?
|
|
1156
|
+
WHERE upload_state = 'PENDING'
|
|
1157
|
+
AND camera IN ({placeholders})
|
|
1158
|
+
""",
|
|
1159
|
+
(now, *names),
|
|
1160
|
+
).rowcount
|
|
1161
|
+
if changed:
|
|
1162
|
+
record_event(
|
|
1163
|
+
conn,
|
|
1164
|
+
component="uploader",
|
|
1165
|
+
code="UPLOAD_SKIPPED_BY_POLICY",
|
|
1166
|
+
severity="info",
|
|
1167
|
+
message=(
|
|
1168
|
+
f"{changed} clip(s) will not be backed up: their camera sets "
|
|
1169
|
+
f"retention.upload: false."
|
|
1170
|
+
),
|
|
1171
|
+
detail={"cameras": names, "count": changed},
|
|
1172
|
+
dedupe_key=f"skip_by_policy:{','.join(sorted(names))}",
|
|
1173
|
+
now=now,
|
|
1174
|
+
)
|
|
1175
|
+
return int(changed)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def release_lease(conn: sqlite3.Connection, *, clip_id: int, now: Instant | None = None) -> bool:
|
|
1179
|
+
"""UPLOADING → PENDING on a clean shutdown, so the retry is immediate.
|
|
1180
|
+
|
|
1181
|
+
Leases expire on their own, so this is an optimisation rather than a correctness
|
|
1182
|
+
requirement — but the difference for the operator is a clip that resumes in five
|
|
1183
|
+
seconds versus one that resumes in fifteen minutes.
|
|
1184
|
+
|
|
1185
|
+
Returns whether anything was released. A clip that finished while the signal was being
|
|
1186
|
+
handled is left alone: undoing a verified upload would be far worse than a slow retry.
|
|
1187
|
+
"""
|
|
1188
|
+
now = timeutil.now() if now is None else now
|
|
1189
|
+
with write_txn(conn):
|
|
1190
|
+
if _state_of(conn, clip_id) != "UPLOADING":
|
|
1191
|
+
return False
|
|
1192
|
+
_require_transition("UPLOADING", "PENDING", clip_id)
|
|
1193
|
+
conn.execute(
|
|
1194
|
+
"UPDATE clips SET upload_state = 'PENDING', lease_owner = NULL, "
|
|
1195
|
+
"lease_expires_at = NULL, updated_at = ? WHERE id = ?",
|
|
1196
|
+
(now, clip_id),
|
|
1197
|
+
)
|
|
1198
|
+
return True
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def reset_failed_permanent(
|
|
1202
|
+
conn: sqlite3.Connection,
|
|
1203
|
+
*,
|
|
1204
|
+
reason: str,
|
|
1205
|
+
clip_id: int | None = None,
|
|
1206
|
+
camera: str | None = None,
|
|
1207
|
+
since: Instant | None = None,
|
|
1208
|
+
now: Instant | None = None,
|
|
1209
|
+
) -> list[int]:
|
|
1210
|
+
"""FAILED_PERMANENT → PENDING, for the documented recovery path.
|
|
1211
|
+
|
|
1212
|
+
Returns the clip ids requeued. Two rules are enforced here rather than documented and
|
|
1213
|
+
hoped for:
|
|
1214
|
+
|
|
1215
|
+
* **A reason is mandatory.** "Why did someone retry 400 uploads, and was that before or
|
|
1216
|
+
after the credentials were rotated?" is a question worth being able to answer.
|
|
1217
|
+
* **A filter is mandatory.** Retrying *every* permanently-failed clip with one call is
|
|
1218
|
+
how a mistyped command becomes a thundering herd against an endpoint that is still
|
|
1219
|
+
down. The operator names a clip, a camera, or a starting point.
|
|
1220
|
+
"""
|
|
1221
|
+
if not reason or not reason.strip():
|
|
1222
|
+
raise DbError(
|
|
1223
|
+
"A reason is required to requeue failed uploads; it is written to the audit log."
|
|
1224
|
+
)
|
|
1225
|
+
if clip_id is None and camera is None and since is None:
|
|
1226
|
+
raise DbError(
|
|
1227
|
+
"Refusing to requeue every FAILED_PERMANENT clip at once. Give a clip_id, a "
|
|
1228
|
+
"camera, or a since — or fix the cause and let the next attempt happen by "
|
|
1229
|
+
"itself. See runbooks/recover-failed-uploads.md."
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
now = timeutil.now() if now is None else now
|
|
1233
|
+
clauses = ["upload_state = 'FAILED_PERMANENT'"]
|
|
1234
|
+
params: list[object] = []
|
|
1235
|
+
if clip_id is not None:
|
|
1236
|
+
clauses.append("id = ?")
|
|
1237
|
+
params.append(clip_id)
|
|
1238
|
+
if camera is not None:
|
|
1239
|
+
clauses.append("camera = ?")
|
|
1240
|
+
params.append(camera)
|
|
1241
|
+
if since is not None:
|
|
1242
|
+
clauses.append("start_time >= ?")
|
|
1243
|
+
params.append(since)
|
|
1244
|
+
|
|
1245
|
+
with write_txn(conn):
|
|
1246
|
+
ids = [
|
|
1247
|
+
int(row["id"])
|
|
1248
|
+
for row in conn.execute(
|
|
1249
|
+
f"SELECT id FROM clips WHERE {' AND '.join(clauses)} ORDER BY id",
|
|
1250
|
+
params,
|
|
1251
|
+
)
|
|
1252
|
+
]
|
|
1253
|
+
if ids:
|
|
1254
|
+
placeholders = ",".join("?" * len(ids))
|
|
1255
|
+
conn.execute(
|
|
1256
|
+
f"""
|
|
1257
|
+
UPDATE clips
|
|
1258
|
+
SET upload_state = 'PENDING', attempts = 0, next_attempt_at = NULL,
|
|
1259
|
+
lease_owner = NULL, lease_expires_at = NULL,
|
|
1260
|
+
last_error = NULL, last_error_class = NULL, updated_at = ?
|
|
1261
|
+
WHERE id IN ({placeholders})
|
|
1262
|
+
""",
|
|
1263
|
+
(now, *ids),
|
|
1264
|
+
)
|
|
1265
|
+
record_event(
|
|
1266
|
+
conn,
|
|
1267
|
+
component="uploader",
|
|
1268
|
+
code="UPLOAD_RETRY_REQUESTED",
|
|
1269
|
+
severity="info",
|
|
1270
|
+
message=f"Operator requeued {len(ids)} permanently failed clip(s).",
|
|
1271
|
+
detail={
|
|
1272
|
+
"reason": reason.strip(),
|
|
1273
|
+
"count": len(ids),
|
|
1274
|
+
"clip_ids": ids[:50],
|
|
1275
|
+
},
|
|
1276
|
+
now=now,
|
|
1277
|
+
)
|
|
1278
|
+
return ids
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
# ---------------------------------------------------------------------------------------
|
|
1282
|
+
# Local retention — cleanup J1 (LLD 05 §2, §3). The only queries that authorise a deletion.
|
|
1283
|
+
# ---------------------------------------------------------------------------------------
|
|
1284
|
+
|
|
1285
|
+
#: The upload states whose clips **may** be deleted locally, enumerated rather than negated.
|
|
1286
|
+
#:
|
|
1287
|
+
#: This is the primary guard behind [INV-1]. Written as `upload_state NOT IN ('PENDING', …)`
|
|
1288
|
+
#: every state added later would be deletable by default; naming the permitted states means a
|
|
1289
|
+
#: new one is safe until somebody deliberately adds it here and considers the consequences.
|
|
1290
|
+
LOCAL_EXPIRY_STATES: frozenset[str] = frozenset({"UPLOADED", "SKIPPED_BY_POLICY"})
|
|
1291
|
+
|
|
1292
|
+
#: The finding kinds the schema accepts (migration 0002, `lld/05` §5.1). Kept here so a typo
|
|
1293
|
+
#: fails with a message naming the accepted values rather than as a bare CHECK violation.
|
|
1294
|
+
FINDING_KINDS: frozenset[str] = frozenset(
|
|
1295
|
+
{
|
|
1296
|
+
"orphan_object",
|
|
1297
|
+
"dangling_row",
|
|
1298
|
+
"checksum_mismatch",
|
|
1299
|
+
"unexpected_local_file",
|
|
1300
|
+
"missing_local_file",
|
|
1301
|
+
"key_mismatch",
|
|
1302
|
+
}
|
|
1303
|
+
)
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def known_cameras(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
|
1307
|
+
"""Every camera ever seen, retired ones included ([INV-13]).
|
|
1308
|
+
|
|
1309
|
+
Cleanup iterates this rather than the config: a camera removed from `cameras.yml` still
|
|
1310
|
+
has clips on disk, and they still expire. Skipping retired cameras would mean their clips
|
|
1311
|
+
live forever, which is the opposite of what INV-13 intends — the invariant preserves the
|
|
1312
|
+
*rows*, not the files.
|
|
1313
|
+
"""
|
|
1314
|
+
return list(conn.execute("SELECT name, retired_at FROM cameras ORDER BY name"))
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def local_expiry_batch(
|
|
1318
|
+
conn: sqlite3.Connection,
|
|
1319
|
+
*,
|
|
1320
|
+
camera: str,
|
|
1321
|
+
cutoff: Instant,
|
|
1322
|
+
limit: int,
|
|
1323
|
+
states: Collection[str] = LOCAL_EXPIRY_STATES,
|
|
1324
|
+
after: tuple[float, int] | None = None,
|
|
1325
|
+
) -> list[sqlite3.Row]:
|
|
1326
|
+
"""One page of clips eligible for local deletion for one camera, oldest first.
|
|
1327
|
+
|
|
1328
|
+
Read-only, deliberately: the caller opens the write transaction only when it is about to
|
|
1329
|
+
act, so a page query never holds the write lock (contract §1.1).
|
|
1330
|
+
|
|
1331
|
+
`local_deleted_at IS NULL` keeps a re-run idempotent, and ordering by `start_time` makes
|
|
1332
|
+
the oldest clip the first to go — the operator's expectation, and what leaves a coherent
|
|
1333
|
+
tree behind after a partial run.
|
|
1334
|
+
|
|
1335
|
+
`after` is a keyset cursor: the `(start_time, id)` of the last row the caller handled.
|
|
1336
|
+
Paging **must** be keyset rather than "re-query and the set shrinks", because the set does
|
|
1337
|
+
not always shrink — a refused clip keeps its `local_deleted_at`, and under `DRY_RUN`
|
|
1338
|
+
nothing changes at all. A shrink-based loop would return the same page forever, which is
|
|
1339
|
+
an infinite loop rather than a slow run.
|
|
1340
|
+
"""
|
|
1341
|
+
if not states:
|
|
1342
|
+
return []
|
|
1343
|
+
placeholders = ",".join("?" * len(states))
|
|
1344
|
+
params: list[object] = [camera, *sorted(states), cutoff]
|
|
1345
|
+
cursor = ""
|
|
1346
|
+
if after is not None:
|
|
1347
|
+
# Row-value comparison written longhand: `(a, b) > (?, ?)` needs SQLite 3.15+, and
|
|
1348
|
+
# this form is portable and unambiguous. `<=` ordering cannot drift because the
|
|
1349
|
+
# ORDER BY is on the same two columns.
|
|
1350
|
+
cursor = "AND (start_time > ? OR (start_time = ? AND id > ?))"
|
|
1351
|
+
params.extend([after[0], after[0], after[1]])
|
|
1352
|
+
params.append(limit)
|
|
1353
|
+
return list(
|
|
1354
|
+
conn.execute(
|
|
1355
|
+
f"""
|
|
1356
|
+
SELECT id, camera, path, sidecar_path, size_bytes, sha256,
|
|
1357
|
+
upload_state, start_time, cloud_key, uploaded_at
|
|
1358
|
+
FROM clips
|
|
1359
|
+
WHERE camera = ?
|
|
1360
|
+
AND upload_state IN ({placeholders})
|
|
1361
|
+
AND start_time < ?
|
|
1362
|
+
AND local_deleted_at IS NULL
|
|
1363
|
+
{cursor}
|
|
1364
|
+
ORDER BY start_time ASC, id ASC
|
|
1365
|
+
LIMIT ?
|
|
1366
|
+
""",
|
|
1367
|
+
params,
|
|
1368
|
+
)
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def mark_local_deleted(
|
|
1373
|
+
conn: sqlite3.Connection, *, clip_id: int, now: Instant | None = None
|
|
1374
|
+
) -> bool:
|
|
1375
|
+
"""`UPLOADED`/`SKIPPED_BY_POLICY` → `DELETED_LOCAL`, and stamp `local_deleted_at`.
|
|
1376
|
+
|
|
1377
|
+
**Assumes a transaction is already open**, like `record_event`: the row update and the
|
|
1378
|
+
audit record describing it must commit together, and the caller opens one transaction per
|
|
1379
|
+
batch (`lld/05` §3.3).
|
|
1380
|
+
|
|
1381
|
+
Returns whether anything changed. A row that already carries `local_deleted_at` is left
|
|
1382
|
+
alone — a crash between the unlink and the commit simply means the work is redone, and
|
|
1383
|
+
redoing it must not be an error.
|
|
1384
|
+
"""
|
|
1385
|
+
now = timeutil.now() if now is None else now
|
|
1386
|
+
row = conn.execute(
|
|
1387
|
+
"SELECT upload_state, local_deleted_at FROM clips WHERE id = ?", (clip_id,)
|
|
1388
|
+
).fetchone()
|
|
1389
|
+
if row is None:
|
|
1390
|
+
raise DbError(f"No clip with id {clip_id}.")
|
|
1391
|
+
if row["local_deleted_at"] is not None:
|
|
1392
|
+
return False
|
|
1393
|
+
_require_transition(str(row["upload_state"]), "DELETED_LOCAL", clip_id)
|
|
1394
|
+
conn.execute(
|
|
1395
|
+
"UPDATE clips SET upload_state = 'DELETED_LOCAL', local_deleted_at = ?, updated_at = ? "
|
|
1396
|
+
"WHERE id = ?",
|
|
1397
|
+
(now, now, clip_id),
|
|
1398
|
+
)
|
|
1399
|
+
return True
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def record_finding(
|
|
1403
|
+
conn: sqlite3.Connection,
|
|
1404
|
+
*,
|
|
1405
|
+
kind: str,
|
|
1406
|
+
subject: str,
|
|
1407
|
+
detail: object | None = None,
|
|
1408
|
+
now: Instant | None = None,
|
|
1409
|
+
) -> None:
|
|
1410
|
+
"""Append a reconciliation finding. Assumes a transaction is already open.
|
|
1411
|
+
|
|
1412
|
+
A finding is a **report**, never a repair ([INV-10]): resolving one takes an explicit,
|
|
1413
|
+
audited operator action. J1 records exactly one kind — `missing_local_file`, when a row
|
|
1414
|
+
points at footage that is already gone — because that state is not a guess, it is a fact
|
|
1415
|
+
observed while trying to act.
|
|
1416
|
+
"""
|
|
1417
|
+
if kind not in FINDING_KINDS:
|
|
1418
|
+
raise DbError(
|
|
1419
|
+
f"{kind!r} is not a finding kind. The documented kinds are "
|
|
1420
|
+
f"{', '.join(sorted(FINDING_KINDS))} (lld/05 §5.1)."
|
|
1421
|
+
)
|
|
1422
|
+
conn.execute(
|
|
1423
|
+
"INSERT INTO reconciliation_findings (run_at, kind, subject, detail) VALUES (?, ?, ?, ?)",
|
|
1424
|
+
(
|
|
1425
|
+
timeutil.now() if now is None else now,
|
|
1426
|
+
kind,
|
|
1427
|
+
subject,
|
|
1428
|
+
None if detail is None else json.dumps(detail),
|
|
1429
|
+
),
|
|
1430
|
+
)
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
@dataclass(frozen=True, slots=True)
|
|
1434
|
+
class OverdueSummary:
|
|
1435
|
+
"""Clips held past local retention because their upload is not terminal-success.
|
|
1436
|
+
|
|
1437
|
+
The honest side of [INV-1]: a clip is held rather than deleted, and this is the count and
|
|
1438
|
+
the cost. See `lld/05` §7.
|
|
1439
|
+
"""
|
|
1440
|
+
|
|
1441
|
+
count: int = 0
|
|
1442
|
+
bytes_held: int = 0
|
|
1443
|
+
oldest_start_time: Instant | None = None
|
|
1444
|
+
#: How long past its retention deadline the oldest held clip is. This — not the clip's
|
|
1445
|
+
#: age — is what `lld/05` §9's "older than 30 days" threshold is about: a 100-day-old clip
|
|
1446
|
+
#: on a 90-day window has been held for 10 days, not 100.
|
|
1447
|
+
oldest_overdue_s: float | None = None
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def overdue_local(conn: sqlite3.Connection, *, camera: str, cutoff: Instant) -> OverdueSummary:
|
|
1451
|
+
"""Count the clips one camera is holding past its retention window.
|
|
1452
|
+
|
|
1453
|
+
This filter **is** a negation, unlike the deletion query, and that is safe for the same
|
|
1454
|
+
reason it is unsafe there: nothing is deleted from this result. The question is "what is
|
|
1455
|
+
not finished?", and a state added later should count as unfinished until someone decides
|
|
1456
|
+
otherwise.
|
|
1457
|
+
"""
|
|
1458
|
+
row = conn.execute(
|
|
1459
|
+
"""
|
|
1460
|
+
SELECT COUNT(*) AS n,
|
|
1461
|
+
COALESCE(SUM(size_bytes), 0) AS bytes,
|
|
1462
|
+
MIN(start_time) AS oldest
|
|
1463
|
+
FROM clips
|
|
1464
|
+
WHERE camera = ?
|
|
1465
|
+
AND upload_state NOT IN ('UPLOADED', 'SKIPPED_BY_POLICY')
|
|
1466
|
+
AND start_time < ?
|
|
1467
|
+
AND local_deleted_at IS NULL
|
|
1468
|
+
""",
|
|
1469
|
+
(camera, cutoff),
|
|
1470
|
+
).fetchone()
|
|
1471
|
+
return OverdueSummary(
|
|
1472
|
+
count=int(row["n"]),
|
|
1473
|
+
bytes_held=int(row["bytes"]),
|
|
1474
|
+
oldest_start_time=None if row["oldest"] is None else float(row["oldest"]),
|
|
1475
|
+
oldest_overdue_s=(
|
|
1476
|
+
None if row["oldest"] is None else max(0.0, cutoff - float(row["oldest"]))
|
|
1477
|
+
),
|
|
1478
|
+
)
|