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/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
+ )