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/uploader.py ADDED
@@ -0,0 +1,1417 @@
1
+ """The uploader service — a canonical clip becomes a durable cloud object, exactly once.
2
+
3
+ ```text
4
+ claim (lease) ──→ pre-flight ──→ PUT ──→ verify ──→ record
5
+ │ │ │ │ │
6
+ └── commit ──────┘ └── no transaction is ever held across the network ──┘
7
+ ```
8
+
9
+ The service is a small pool of worker loops, each owning its own SQLite connection, each
10
+ independently running the five steps above. There is no scheduler thread and no queue in
11
+ memory: **the database is the queue**, and the atomic claim (contract §3.3) is the only
12
+ coordination. That is what makes any number of workers — in this process or another —
13
+ correct rather than merely concurrent.
14
+
15
+ Four rules from the design carry real weight here:
16
+
17
+ * **Nothing slow happens inside a transaction.** Claim, commit, upload, record. A PUT
18
+ holding the write lock would stall the clip-worker's writes for the duration of a 50 MB
19
+ upload.
20
+ * **Never claim a verification that was not performed.** `verified_by="none"` is reported as
21
+ `DEGRADED` (`UPLOAD_UNVERIFIED`) rather than being rounded up to success, because
22
+ `UPLOADED` is what tells cleanup a local file may be deleted.
23
+ * **Never delete, move or modify a file under `clips/`.** The compose mount makes that
24
+ structural ([INV-1](../../../docs/architecture/invariants.md)); this module does not even
25
+ have a code path that could.
26
+ * **An unconfigured deployment is `DISABLED`, not broken.** With no bucket set the service
27
+ starts, reports `DISABLED`, and does no work, so a local-only install is a supported
28
+ choice rather than a restart loop ([INV-6](../../../docs/architecture/invariants.md)).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import json
35
+ import logging
36
+ import math
37
+ import os
38
+ import random
39
+ import re
40
+ import signal
41
+ import socket
42
+ import sqlite3
43
+ import sys
44
+ import threading
45
+ import time
46
+ from collections.abc import Callable, Iterator, Mapping, Sequence
47
+ from contextlib import contextmanager
48
+ from dataclasses import dataclass
49
+ from pathlib import Path
50
+ from typing import Any
51
+
52
+ from . import __version__, db, health, storage, timeutil
53
+ from .config import CamerasConfig, ConfigError, load_cameras_config
54
+ from .logging import configure_logging
55
+ from .storage import StorageAdapter, StorageError
56
+
57
+ __all__ = [
58
+ "COMPONENT",
59
+ "EXIT_CONFIG",
60
+ "EXIT_UNAVAILABLE",
61
+ "PERMANENT",
62
+ "RETRY",
63
+ "UPLOADED",
64
+ "UploadOutcome",
65
+ "UploadService",
66
+ "UploadSettings",
67
+ "Uploader",
68
+ "UploaderHealth",
69
+ "backoff_delay",
70
+ "build_adapter",
71
+ "main",
72
+ "object_metadata",
73
+ "validate_key",
74
+ ]
75
+
76
+ logger = logging.getLogger(__name__)
77
+
78
+ COMPONENT = "uploader"
79
+
80
+ #: `EX_CONFIG` / `EX_TEMPFAIL` from `sysexits.h`, per LLD 00 §3. A bad config never becomes
81
+ #: good, so restarting cannot help; a briefly unavailable dependency might.
82
+ EXIT_CONFIG = 78
83
+ EXIT_UNAVAILABLE = 75
84
+ EXIT_OK = 0
85
+
86
+ DEFAULT_HEALTH_PORT = 8082
87
+ DEFAULT_SCHEDULER_TICK_S = 5.0
88
+ DEFAULT_LEASE_SECONDS = 900.0
89
+ DEFAULT_BACKOFF_BASE_S = 15.0
90
+ DEFAULT_BACKOFF_MAX_S = 3600.0
91
+ DEFAULT_QUOTA_BACKOFF_MAX_S = 21600.0
92
+ DEFAULT_UPLOAD_CONCURRENCY = 4
93
+ DEFAULT_STALE_UPLOAD_WARN_S = 1800.0
94
+ DEFAULT_BACKLOG_WARN_S = 1800.0
95
+ DEFAULT_BACKLOG_CRITICAL_S = 21600.0
96
+
97
+ #: How often the bulk policy pass runs, and how often the backlog is logged.
98
+ POLICY_INTERVAL_S = 60.0
99
+ SUMMARY_INTERVAL_S = 60.0
100
+
101
+ #: Seconds a storage `healthcheck()` result is reused. A `HEAD` per scrape would turn a
102
+ #: 2-second monitoring interval into a stream of requests against a provider that bills for
103
+ #: nothing but will rate-limit us.
104
+ DEFAULT_HEALTHCHECK_CACHE_S = 30.0
105
+
106
+ SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
107
+
108
+ #: Key rules from cloud-object-layout §7. A leading slash, a doubled slash, a backslash, a
109
+ #: parent reference or a control character are all refused rather than normalised: a key we
110
+ #: silently rewrote is a key that no longer matches the local path, and key equality is the
111
+ #: property restore and round-trip verification depend on.
112
+ _BAD_KEY_RE = re.compile(r"^/|//|\\|\.\.|[\x00-\x1f]")
113
+ MAX_KEY_BYTES = 1024
114
+
115
+ UPLOADED = "uploaded"
116
+ RETRY = "retry"
117
+ PERMANENT = "permanent"
118
+
119
+
120
+ def validate_key(key: str) -> str:
121
+ """Validate an object key, or raise. Returns the key unchanged when it is usable."""
122
+ if not key:
123
+ raise ValueError("A cloud key is required.")
124
+ if len(key.encode("utf-8")) > MAX_KEY_BYTES:
125
+ raise ValueError(f"Cloud key exceeds {MAX_KEY_BYTES} bytes.")
126
+ if _BAD_KEY_RE.search(key):
127
+ raise ValueError(
128
+ f"Cloud key {key!r} contains a leading or doubled slash, a backslash, a parent "
129
+ f"reference, or a control character (cloud-object-layout §7)."
130
+ )
131
+ return key
132
+
133
+
134
+ # ---------------------------------------------------------------------------------------
135
+ # Settings
136
+ # ---------------------------------------------------------------------------------------
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class UploadSettings:
141
+ """Every input is an environment variable, so there are no CLI arguments to get wrong."""
142
+
143
+ cameras_path: Path
144
+ data_dir: Path
145
+ db_path: Path
146
+ bucket: str | None
147
+ endpoint_url: str | None
148
+ region: str
149
+ key_id: str | None
150
+ application_key: str | None
151
+ health_host: str
152
+ health_port: int
153
+ internal_token: str | None
154
+ dry_run: bool
155
+ scheduler_tick_s: float
156
+ lease_seconds: float
157
+ max_attempts: int
158
+ backoff_base_s: float
159
+ backoff_max_s: float
160
+ quota_backoff_max_s: float
161
+ upload_concurrency: int
162
+ multipart_threshold_bytes: int
163
+ stale_upload_warn_s: float
164
+ backlog_warn_s: float
165
+ backlog_critical_s: float
166
+ log_level: str | None
167
+
168
+ @property
169
+ def storage_configured(self) -> bool:
170
+ return bool(self.bucket and self.key_id and self.application_key)
171
+
172
+ def configuration_problem(self) -> str | None:
173
+ """A single sentence naming what is wrong, or `None`.
174
+
175
+ A half-configured bucket is refused rather than guessed at: defaulting the key id or
176
+ the secret would produce an `UNHEALTHY` service whose error message says "denied"
177
+ when the real problem is a `.env` that was never finished.
178
+ """
179
+ if not self.bucket:
180
+ return None # deliberately unconfigured; the service reports DISABLED
181
+ missing = [
182
+ name
183
+ for name, value in (
184
+ ("CCTV_STORAGE_KEY_ID", self.key_id),
185
+ ("CCTV_STORAGE_APPLICATION_KEY", self.application_key),
186
+ )
187
+ if not value
188
+ ]
189
+ if missing:
190
+ return (
191
+ f"CCTV_STORAGE_BUCKET is set but {', '.join(missing)} is not. Set the "
192
+ f"credentials, or unset CCTV_STORAGE_BUCKET to run without cloud backup."
193
+ )
194
+ if self.upload_concurrency < 1:
195
+ return "CCTV_UPLOAD_CONCURRENCY must be at least 1."
196
+ if self.scheduler_tick_s <= 0 or self.lease_seconds <= 0:
197
+ return "CCTV_UPLOAD_TICK_S and CCTV_UPLOAD_LEASE_S must be positive."
198
+ return None
199
+
200
+ @classmethod
201
+ def from_environ(cls, environ: Mapping[str, str] | None = None) -> UploadSettings:
202
+ env = os.environ if environ is None else environ
203
+
204
+ def pick(*names: str) -> str | None:
205
+ for name in names:
206
+ value = (env.get(name) or "").strip()
207
+ if value:
208
+ return value
209
+ return None
210
+
211
+ def number(name: str, default: float) -> float:
212
+ raw = (env.get(name) or "").strip()
213
+ if not raw:
214
+ return default
215
+ try:
216
+ return float(raw)
217
+ except ValueError as exc:
218
+ raise ConfigError(f"{name}={raw!r} is not a number.") from exc
219
+
220
+ config_dir = Path(env.get("CCTV_CONFIG_DIR", "/config"))
221
+ data_dir = Path(env.get("CCTV_DATA_DIR", "/data"))
222
+ region = pick("CCTV_STORAGE_REGION", "B2_REGION") or "us-west-004"
223
+
224
+ return cls(
225
+ cameras_path=Path(env.get("CCTV_CAMERAS", str(config_dir / "cameras.yml"))),
226
+ data_dir=data_dir,
227
+ db_path=Path(env.get("CCTV_DB", str(data_dir / "cctv.db"))),
228
+ bucket=pick("CCTV_STORAGE_BUCKET", "B2_BUCKET"),
229
+ endpoint_url=pick("CCTV_STORAGE_ENDPOINT_URL", "B2_ENDPOINT_URL")
230
+ or f"https://s3.{region}.backblazeb2.com",
231
+ region=region,
232
+ key_id=pick("CCTV_STORAGE_KEY_ID", "B2_KEY_ID", "B2_APPLICATION_KEY_ID"),
233
+ application_key=pick("CCTV_STORAGE_APPLICATION_KEY", "B2_APPLICATION_KEY"),
234
+ health_host=(env.get("CCTV_HEALTH_HOST") or "0.0.0.0").strip(),
235
+ health_port=int(number("CCTV_UPLOADER_HEALTH_PORT", DEFAULT_HEALTH_PORT)),
236
+ internal_token=(env.get("INTERNAL_API_TOKEN") or "").strip() or None,
237
+ dry_run=(env.get("DRY_RUN") or "").strip().lower() in {"1", "true", "yes"},
238
+ scheduler_tick_s=number("CCTV_UPLOAD_TICK_S", DEFAULT_SCHEDULER_TICK_S),
239
+ lease_seconds=number("CCTV_UPLOAD_LEASE_S", DEFAULT_LEASE_SECONDS),
240
+ max_attempts=int(number("CCTV_UPLOAD_MAX_ATTEMPTS", db.MAX_ATTEMPTS)),
241
+ backoff_base_s=number("CCTV_UPLOAD_BACKOFF_BASE_S", DEFAULT_BACKOFF_BASE_S),
242
+ backoff_max_s=number("CCTV_UPLOAD_BACKOFF_MAX_S", DEFAULT_BACKOFF_MAX_S),
243
+ quota_backoff_max_s=number(
244
+ "CCTV_UPLOAD_QUOTA_BACKOFF_MAX_S", DEFAULT_QUOTA_BACKOFF_MAX_S
245
+ ),
246
+ upload_concurrency=int(number("CCTV_UPLOAD_CONCURRENCY", DEFAULT_UPLOAD_CONCURRENCY)),
247
+ multipart_threshold_bytes=int(
248
+ number(
249
+ "CCTV_UPLOAD_MULTIPART_THRESHOLD_BYTES",
250
+ storage.DEFAULT_MULTIPART_THRESHOLD_BYTES,
251
+ )
252
+ ),
253
+ stale_upload_warn_s=number("CCTV_UPLOAD_STALE_WARN_S", DEFAULT_STALE_UPLOAD_WARN_S),
254
+ backlog_warn_s=number("CCTV_UPLOAD_BACKLOG_WARN_S", DEFAULT_BACKLOG_WARN_S),
255
+ backlog_critical_s=number("CCTV_UPLOAD_BACKLOG_CRITICAL_S", DEFAULT_BACKLOG_CRITICAL_S),
256
+ log_level=env.get("CCTV_LOG_LEVEL") or None,
257
+ )
258
+
259
+
260
+ # ---------------------------------------------------------------------------------------
261
+ # Backoff (lld/04 §5)
262
+ # ---------------------------------------------------------------------------------------
263
+
264
+
265
+ def backoff_delay(
266
+ attempt: int,
267
+ *,
268
+ base_s: float,
269
+ max_s: float,
270
+ rng: random.Random | None = None,
271
+ ) -> float:
272
+ """`min(base * 2^(attempt-1), max)` with ±20 % jitter.
273
+
274
+ `attempt` is 1-based and is the value the claim already incremented, so the first failure
275
+ waits about `base_s`.
276
+
277
+ **The jitter is not decorative.** Without it, a provider outage that affects 200 clips
278
+ produces 200 identically-scheduled retries — a self-inflicted thundering herd against an
279
+ endpoint that has just told us it is unwell.
280
+ """
281
+ if attempt < 1:
282
+ raise ValueError("attempt is 1-based; it is the value the claim incremented.")
283
+ generator = rng if rng is not None else random
284
+ ceiling = max(0.0, max_s)
285
+ raw = min(base_s * math.pow(2, attempt - 1), ceiling)
286
+ return raw * generator.uniform(0.8, 1.2)
287
+
288
+
289
+ # ---------------------------------------------------------------------------------------
290
+ # Object metadata (cloud-object-layout §3.2)
291
+ # ---------------------------------------------------------------------------------------
292
+
293
+
294
+ def object_metadata(
295
+ row: sqlite3.Row, *, location: str, version: str = __version__
296
+ ) -> dict[str, str]:
297
+ """The self-describing metadata attached to every object.
298
+
299
+ This is the recovery path of last resort: with a bucket listing and these keys alone,
300
+ most of `clips` can be reconstructed after a total loss of `state.db`. It deliberately
301
+ carries no credential and nothing derived from one ([INV-2]).
302
+
303
+ Values are strings and ASCII-only, because S3 user metadata is ASCII and a provider that
304
+ accepts more is not one we should depend on.
305
+ """
306
+ metadata: dict[str, str] = {
307
+ "camera": str(row["camera"]),
308
+ "location": location,
309
+ "start-time": f"{float(row['start_time']):.6f}",
310
+ "cctv-version": version,
311
+ "review-id": str(row["review_id"]),
312
+ }
313
+
314
+ event_ids = _json_list(row["event_ids"])
315
+ metadata["event-id"] = event_ids[0] if event_ids else str(row["review_id"])
316
+
317
+ if row["end_time"] is not None:
318
+ metadata["end-time"] = f"{float(row['end_time']):.6f}"
319
+ if row["duration_s"] is not None:
320
+ metadata["duration-s"] = f"{float(row['duration_s']):.3f}"
321
+ if row["label"]:
322
+ metadata["label"] = str(row["label"])
323
+
324
+ zones = _json_list(row["zones"])
325
+ if zones:
326
+ metadata["zones"] = ",".join(zones)
327
+
328
+ return metadata
329
+
330
+
331
+ def _json_list(raw: object) -> list[str]:
332
+ if raw is None:
333
+ return []
334
+ if isinstance(raw, (list, tuple)):
335
+ return [str(item) for item in raw]
336
+ try:
337
+ parsed = json.loads(str(raw))
338
+ except json.JSONDecodeError:
339
+ return []
340
+ return [str(item) for item in parsed] if isinstance(parsed, list) else []
341
+
342
+
343
+ # ---------------------------------------------------------------------------------------
344
+ # One clip
345
+ # ---------------------------------------------------------------------------------------
346
+
347
+
348
+ @dataclass(frozen=True, slots=True)
349
+ class UploadOutcome:
350
+ """What happened to one clip. Returned rather than inferred from the database."""
351
+
352
+ clip_id: int
353
+ status: str
354
+ camera: str
355
+ path: str
356
+ attempts: int = 0
357
+ cloud_key: str | None = None
358
+ bytes_sent: int = 0
359
+ duration_ms: int = 0
360
+ verified_by: str | None = None
361
+ error_class: str | None = None
362
+ reason: str | None = None
363
+ skipped: bool = False
364
+
365
+ @property
366
+ def ok(self) -> bool:
367
+ return self.status == UPLOADED
368
+
369
+
370
+ class Uploader:
371
+ """Claims one clip and does the work. Stateless apart from counters.
372
+
373
+ Instantiated once and called from several worker threads; every method takes the
374
+ connection it should use, which is what keeps the concurrency story trivial.
375
+ """
376
+
377
+ def __init__(
378
+ self,
379
+ *,
380
+ data_dir: Path,
381
+ location: str,
382
+ adapter: StorageAdapter | None,
383
+ settings: UploadSettings,
384
+ clock: Callable[[], float] = timeutil.now,
385
+ rng: random.Random | None = None,
386
+ ) -> None:
387
+ self._data_dir = Path(data_dir)
388
+ self._location = location
389
+ self._adapter = adapter
390
+ self._settings = settings
391
+ self._clock = clock
392
+ self._rng = rng
393
+ self._lock = threading.Lock()
394
+ self._unverified = 0
395
+ self._uploaded = 0
396
+ self._failed_permanent = 0
397
+
398
+ # -- counters (read by the health checks) ------------------------------------------
399
+
400
+ @property
401
+ def unverified_uploads(self) -> int:
402
+ with self._lock:
403
+ return self._unverified
404
+
405
+ @property
406
+ def uploaded_uploads(self) -> int:
407
+ with self._lock:
408
+ return self._uploaded
409
+
410
+ # -- the loop ----------------------------------------------------------------------
411
+
412
+ def upload_one(self, conn: sqlite3.Connection, *, owner: str) -> UploadOutcome | None:
413
+ """Claim and process the oldest eligible clip, or return `None` when idle."""
414
+ if self._adapter is None:
415
+ return None
416
+
417
+ row = db.claim_next_upload(
418
+ conn,
419
+ owner=owner,
420
+ lease_seconds=self._settings.lease_seconds,
421
+ cloud_key_prefix=f"{self._location}/",
422
+ now=self._clock(),
423
+ )
424
+ if row is None:
425
+ return None
426
+ return self._perform(conn, row)
427
+
428
+ def _perform(self, conn: sqlite3.Connection, row: sqlite3.Row) -> UploadOutcome:
429
+ """Do the work, and turn *any* failure into a recorded state transition.
430
+
431
+ The broad `except` is deliberate and is the documented behaviour for an
432
+ unrecognised error (lld/04 §4): treat it as transient and retry, because the costs
433
+ are asymmetric — a bounded number of retries against a lost clip. The attempt
434
+ counter bounds it, so a genuine bug reaches `FAILED_PERMANENT` with an alert rather
435
+ than looping forever.
436
+ """
437
+ started = time.perf_counter()
438
+ try:
439
+ return self._upload(conn, row, started=started)
440
+ except StorageError as exc:
441
+ return self._record_failure(conn, row, exc, started=started)
442
+ except Exception as exc:
443
+ logger.exception(
444
+ "unexpected upload failure",
445
+ extra={"clip_id": int(row["id"]), "camera": row["camera"]},
446
+ )
447
+ classified = storage.TransientStorageError(
448
+ f"Unexpected {type(exc).__name__} during upload: {exc}"
449
+ )
450
+ return self._record_failure(conn, row, classified, started=started)
451
+
452
+ def _upload(
453
+ self, conn: sqlite3.Connection, row: sqlite3.Row, *, started: float
454
+ ) -> UploadOutcome:
455
+ clip_id = int(row["id"])
456
+ attempt_no = int(row["attempts"])
457
+ target = self._data_dir / str(row["path"])
458
+
459
+ size_on_disk = _size_or_none(target)
460
+ if size_on_disk is None:
461
+ # The database says a clip exists and it does not. That is a real inconsistency,
462
+ # and it is loud on purpose — but the clip is not deleted and not retried.
463
+ with db.write_txn(conn):
464
+ db.record_event(
465
+ conn,
466
+ component=COMPONENT,
467
+ code="UPLOAD_LOCAL_FILE_MISSING",
468
+ severity="critical",
469
+ message=f"Clip {clip_id} has no local file at {row['path']}.",
470
+ detail={"clip_id": clip_id, "path": str(row["path"])},
471
+ now=self._clock(),
472
+ )
473
+ raise storage.InvalidRequestError(
474
+ f"The local file for clip {clip_id} is missing ({target}). Nothing was "
475
+ f"uploaded and nothing was deleted; the database and the disk disagree."
476
+ )
477
+
478
+ digest = str(row["sha256"] or "")
479
+ if not SHA256_HEX_RE.match(digest) or row["size_bytes"] != size_on_disk:
480
+ # Recompute rather than trust a digest we cannot use, and record it so the next
481
+ # attempt does not have to. This is the only place the uploader writes anything
482
+ # about a clip other than its upload state.
483
+ digest = _sha256(target)
484
+ db.set_clip_checksum(
485
+ conn,
486
+ clip_id=clip_id,
487
+ sha256=digest,
488
+ size_bytes=size_on_disk,
489
+ now=self._clock(),
490
+ )
491
+
492
+ key = validate_key(str(row["cloud_key"] or ""))
493
+ metadata = object_metadata(row, location=self._location)
494
+
495
+ assert self._adapter is not None # guarded by upload_one
496
+ result = self._adapter.put_object(
497
+ key=key, path=target, metadata=metadata, checksum_sha256=digest
498
+ )
499
+ duration_ms = int((time.perf_counter() - started) * 1000)
500
+
501
+ db.mark_uploaded(
502
+ conn,
503
+ clip_id=clip_id,
504
+ cloud_etag=result.etag,
505
+ cloud_size_bytes=result.size,
506
+ now=self._clock(),
507
+ )
508
+ db.finish_upload_attempt(
509
+ conn,
510
+ clip_id=clip_id,
511
+ attempt_no=attempt_no,
512
+ outcome="success",
513
+ bytes_sent=result.size,
514
+ duration_ms=duration_ms,
515
+ now=self._clock(),
516
+ )
517
+
518
+ with self._lock:
519
+ self._uploaded += 1
520
+ if result.verified_by != "sha256":
521
+ self._unverified += 1
522
+
523
+ logger.info(
524
+ "uploaded clip",
525
+ extra={
526
+ "clip_id": clip_id,
527
+ "camera": row["camera"],
528
+ "path": key,
529
+ "attempts": attempt_no,
530
+ "duration_s": duration_ms / 1000.0,
531
+ "state": f"verified:{result.verified_by}",
532
+ "count": result.size,
533
+ },
534
+ )
535
+
536
+ return UploadOutcome(
537
+ clip_id=clip_id,
538
+ status=UPLOADED,
539
+ camera=str(row["camera"]),
540
+ path=str(row["path"]),
541
+ attempts=attempt_no,
542
+ cloud_key=key,
543
+ bytes_sent=result.size,
544
+ duration_ms=duration_ms,
545
+ verified_by=result.verified_by,
546
+ skipped=result.already_present,
547
+ )
548
+
549
+ def _record_failure(
550
+ self,
551
+ conn: sqlite3.Connection,
552
+ row: sqlite3.Row,
553
+ exc: StorageError,
554
+ *,
555
+ started: float,
556
+ ) -> UploadOutcome:
557
+ clip_id = int(row["id"])
558
+ attempt_no = int(row["attempts"])
559
+ duration_ms = int((time.perf_counter() - started) * 1000)
560
+ message = str(exc)
561
+
562
+ if exc.retryable:
563
+ ceiling = (
564
+ self._settings.quota_backoff_max_s
565
+ if exc.error_class == "quota"
566
+ else self._settings.backoff_max_s
567
+ )
568
+ delay = backoff_delay(
569
+ attempt_no,
570
+ base_s=self._settings.backoff_base_s,
571
+ max_s=ceiling,
572
+ rng=self._rng,
573
+ )
574
+ state = db.mark_transient_failure(
575
+ conn,
576
+ clip_id=clip_id,
577
+ error_class=exc.error_class,
578
+ message=message,
579
+ next_attempt_at=self._clock() + delay,
580
+ max_attempts=self._settings.max_attempts,
581
+ now=self._clock(),
582
+ )
583
+ final = state == "FAILED_PERMANENT"
584
+ outcome = "transient_error"
585
+ status = PERMANENT if final else RETRY
586
+ log = logger.error if final else logger.warning
587
+ log(
588
+ "upload failed; %s",
589
+ "attempts exhausted" if final else f"retrying in {delay:.0f}s",
590
+ extra={
591
+ "clip_id": clip_id,
592
+ "camera": row["camera"],
593
+ "attempts": attempt_no,
594
+ "reason": exc.error_class,
595
+ "state": state,
596
+ },
597
+ )
598
+ if final:
599
+ with self._lock:
600
+ self._failed_permanent += 1
601
+ else:
602
+ db.mark_permanent_failure(
603
+ conn,
604
+ clip_id=clip_id,
605
+ error_class=exc.error_class,
606
+ message=message,
607
+ now=self._clock(),
608
+ )
609
+ outcome = "checksum_mismatch" if exc.error_class == "checksum" else "permanent_error"
610
+ status = PERMANENT
611
+ with self._lock:
612
+ self._failed_permanent += 1
613
+ logger.error(
614
+ "upload failed permanently",
615
+ extra={
616
+ "clip_id": clip_id,
617
+ "camera": row["camera"],
618
+ "attempts": attempt_no,
619
+ "reason": exc.error_class,
620
+ "state": "FAILED_PERMANENT",
621
+ },
622
+ )
623
+
624
+ db.finish_upload_attempt(
625
+ conn,
626
+ clip_id=clip_id,
627
+ attempt_no=attempt_no,
628
+ outcome=outcome,
629
+ http_status=exc.http_status,
630
+ error_class=exc.error_class,
631
+ error_message=message,
632
+ duration_ms=duration_ms,
633
+ now=self._clock(),
634
+ )
635
+
636
+ return UploadOutcome(
637
+ clip_id=clip_id,
638
+ status=status,
639
+ camera=str(row["camera"]),
640
+ path=str(row["path"]),
641
+ attempts=attempt_no,
642
+ cloud_key=str(row["cloud_key"] or ""),
643
+ duration_ms=duration_ms,
644
+ error_class=exc.error_class,
645
+ reason=message,
646
+ )
647
+
648
+
649
+ # ---------------------------------------------------------------------------------------
650
+ # Health (service-contracts §1)
651
+ # ---------------------------------------------------------------------------------------
652
+
653
+
654
+ class UploaderHealth:
655
+ """Builds the uploader's health report. Read-only by construction."""
656
+
657
+ def __init__(
658
+ self,
659
+ *,
660
+ db_path: Path,
661
+ data_dir: Path,
662
+ adapter: StorageAdapter | None,
663
+ settings: UploadSettings,
664
+ uploader: Uploader,
665
+ started_at: float,
666
+ clock: Callable[[], float] = timeutil.now,
667
+ cache_s: float = DEFAULT_HEALTHCHECK_CACHE_S,
668
+ ) -> None:
669
+ self._db_path = Path(db_path)
670
+ self._data_dir = Path(data_dir)
671
+ self._adapter = adapter
672
+ self._settings = settings
673
+ self._uploader = uploader
674
+ self._started_at = started_at
675
+ self._clock = clock
676
+ self._cache_s = cache_s
677
+ self._cached: storage.AdapterHealth | None = None
678
+ self._cached_at: float = 0.0
679
+ self._lock = threading.Lock()
680
+
681
+ # -- helpers -----------------------------------------------------------------------
682
+
683
+ @contextmanager
684
+ def _readonly(self) -> Iterator[sqlite3.Connection]:
685
+ """A short-lived read-only connection.
686
+
687
+ Read-only so that monitoring physically cannot mutate what it monitors, and
688
+ short-lived so that the HTTP thread and the main loop never share a cursor. Opening
689
+ a connection is microseconds; reasoning about a shared one is not.
690
+ """
691
+ conn = db.open_db(self._db_path, readonly=True)
692
+ try:
693
+ yield conn
694
+ finally:
695
+ conn.close()
696
+
697
+ def _storage_health(self) -> storage.AdapterHealth | None:
698
+ if self._adapter is None:
699
+ return None
700
+ with self._lock:
701
+ now = self._clock()
702
+ if self._cached is not None and (now - self._cached_at) < self._cache_s:
703
+ return self._cached
704
+ self._cached = self._adapter.healthcheck()
705
+ self._cached_at = now
706
+ return self._cached
707
+
708
+ def summary(self) -> db.QueueSummary | None:
709
+ try:
710
+ with self._readonly() as conn:
711
+ return db.queue_summary(conn, now=self._clock())
712
+ except sqlite3.Error:
713
+ return None
714
+
715
+ # -- the report --------------------------------------------------------------------
716
+
717
+ def report(self) -> health.HealthReport:
718
+ now = self._clock()
719
+ if not self._settings.storage_configured:
720
+ return self._disabled_report(now)
721
+
722
+ try:
723
+ with self._readonly() as conn:
724
+ queue: db.QueueSummary | None = db.queue_summary(conn, now=now)
725
+ checks: list[health.Check] = [
726
+ self._state_db_check(conn),
727
+ self._local_files_check(conn),
728
+ ]
729
+ except sqlite3.Error as exc:
730
+ queue = None
731
+ checks = [
732
+ health.Check(
733
+ name="state_db",
734
+ state=health.UNHEALTHY,
735
+ code="DB_UNAVAILABLE",
736
+ detail=f"cannot open {self._db_path}: {exc}",
737
+ ),
738
+ health.Check(
739
+ name="local_files_readable",
740
+ state=health.UNKNOWN,
741
+ code="DB_UNAVAILABLE",
742
+ detail="the state database could not be read",
743
+ ),
744
+ ]
745
+
746
+ checks.extend(self._storage_checks())
747
+ if queue is None:
748
+ checks.append(
749
+ health.Check(
750
+ name="backlog_age",
751
+ state=health.UNKNOWN,
752
+ code="DB_UNAVAILABLE",
753
+ detail="the state database could not be read",
754
+ )
755
+ )
756
+ else:
757
+ checks.extend(self._backlog_checks(queue))
758
+
759
+ return health.HealthReport(
760
+ component=COMPONENT,
761
+ checks=tuple(checks),
762
+ summary=self._summary_text(queue),
763
+ since=self._started_at,
764
+ observed_at=now,
765
+ uptime_s=max(0.0, now - self._started_at),
766
+ )
767
+
768
+ def _disabled_report(self, now: float) -> health.HealthReport:
769
+ """All checks `DISABLED`, so the overall state is `DISABLED` — not healthy, not broken.
770
+
771
+ The distinction is the point: the operator chose not to configure cloud backup, and
772
+ a status page that reports `HEALTHY` for a component doing nothing would be a lie
773
+ ([INV-6](../../../docs/architecture/invariants.md)).
774
+ """
775
+ detail = "no storage bucket is configured, so nothing is backed up"
776
+ names = (
777
+ "state_db",
778
+ "storage_reachable",
779
+ "storage_credentials",
780
+ "local_files_readable",
781
+ "upload_staleness",
782
+ "backlog_age",
783
+ "failed_permanent",
784
+ )
785
+ return health.HealthReport(
786
+ component=COMPONENT,
787
+ checks=tuple(
788
+ health.Check(name=name, state=health.DISABLED, detail=detail) for name in names
789
+ ),
790
+ summary=detail,
791
+ since=self._started_at,
792
+ observed_at=now,
793
+ uptime_s=max(0.0, now - self._started_at),
794
+ )
795
+
796
+ def _state_db_check(self, conn: sqlite3.Connection) -> health.Check:
797
+ started = time.perf_counter()
798
+ try:
799
+ count = conn.execute("SELECT COUNT(*) AS n FROM clips").fetchone()["n"]
800
+ except sqlite3.Error as exc:
801
+ return health.Check(
802
+ name="state_db",
803
+ state=health.UNHEALTHY,
804
+ code="DB_UNAVAILABLE",
805
+ detail=f"cannot query {self._db_path}: {exc}",
806
+ )
807
+ return health.Check(
808
+ name="state_db",
809
+ state=health.HEALTHY,
810
+ detail=f"read/write ok, {int(count)} clips",
811
+ latency_ms=int((time.perf_counter() - started) * 1000),
812
+ )
813
+
814
+ def _local_files_check(self, conn: sqlite3.Connection) -> health.Check:
815
+ try:
816
+ rows = db.stored_clip_checks(conn)
817
+ except sqlite3.Error as exc:
818
+ return health.Check(
819
+ name="local_files_readable",
820
+ state=health.UNKNOWN,
821
+ code="DB_UNAVAILABLE",
822
+ detail=f"could not list stored clips: {exc}",
823
+ )
824
+ missing = [str(row["path"]) for row in rows if not (self._data_dir / row["path"]).exists()]
825
+ if missing:
826
+ return health.Check(
827
+ name="local_files_readable",
828
+ state=health.UNHEALTHY,
829
+ code="UPLOAD_LOCAL_FILE_MISSING",
830
+ detail=(
831
+ f"{len(missing)} clip(s) in a held state have no file, e.g. {missing[0]}. "
832
+ f"The database and the disk disagree."
833
+ ),
834
+ )
835
+ return health.Check(
836
+ name="local_files_readable",
837
+ state=health.HEALTHY,
838
+ detail=f"{len(rows)} held clip(s) readable",
839
+ )
840
+
841
+ def _storage_checks(self) -> list[health.Check]:
842
+ info = self._storage_health()
843
+ assert info is not None # guarded by the caller
844
+
845
+ if not info.reachable:
846
+ reachable = health.Check(
847
+ name="storage_reachable",
848
+ state=health.UNHEALTHY,
849
+ code="STORAGE_UNREACHABLE",
850
+ detail=info.detail,
851
+ latency_ms=info.latency_ms,
852
+ )
853
+ elif not info.bucket_present:
854
+ reachable = health.Check(
855
+ name="storage_reachable",
856
+ state=health.UNHEALTHY,
857
+ code="STORAGE_UNREACHABLE",
858
+ detail=f"{info.detail} (expected bucket {self._settings.bucket!r})",
859
+ latency_ms=info.latency_ms,
860
+ )
861
+ else:
862
+ reachable = health.Check(
863
+ name="storage_reachable",
864
+ state=health.HEALTHY,
865
+ detail=info.detail,
866
+ latency_ms=info.latency_ms,
867
+ )
868
+
869
+ credentials = health.Check(
870
+ name="storage_credentials",
871
+ state=health.HEALTHY if info.credentials_ok else health.UNHEALTHY,
872
+ code=None if info.credentials_ok else "STORAGE_AUTH_FAILED",
873
+ detail=(
874
+ "accepted"
875
+ if info.credentials_ok
876
+ else f"{info.detail} Rotate them: runbooks/rotate-credentials.md."
877
+ ),
878
+ )
879
+ return [reachable, credentials]
880
+
881
+ def _backlog_checks(self, queue: db.QueueSummary) -> list[health.Check]:
882
+ checks = [self._backlog_age_check(queue), self._staleness_check(queue)]
883
+
884
+ if queue.failed_permanent:
885
+ checks.append(
886
+ health.Check(
887
+ name="failed_permanent",
888
+ state=health.UNHEALTHY,
889
+ code="UPLOAD_FAILED_PERMANENT",
890
+ detail=(
891
+ f"{queue.failed_permanent} clip(s) will not retry without "
892
+ f"intervention. See runbooks/recover-failed-uploads.md."
893
+ ),
894
+ )
895
+ )
896
+ else:
897
+ checks.append(
898
+ health.Check(
899
+ name="failed_permanent",
900
+ state=health.HEALTHY,
901
+ detail="none",
902
+ )
903
+ )
904
+
905
+ if self._uploader.unverified_uploads:
906
+ checks.append(
907
+ health.Check(
908
+ name="upload_verification",
909
+ state=health.DEGRADED,
910
+ code="UPLOAD_UNVERIFIED",
911
+ detail=(
912
+ f"{self._uploader.unverified_uploads} upload(s) since start could not "
913
+ f"be verified against a provider checksum. An unverified backup is not "
914
+ f"a backup"
915
+ ),
916
+ )
917
+ )
918
+ return checks
919
+
920
+ def _backlog_age_check(self, queue: db.QueueSummary) -> health.Check:
921
+ age = queue.oldest_pending_age_s
922
+ if age is None:
923
+ return health.Check(
924
+ name="backlog_age",
925
+ state=health.HEALTHY,
926
+ detail=f"nothing pending; {queue.uploaded_24h} uploaded in the last 24h",
927
+ )
928
+ if age >= self._settings.backlog_critical_s:
929
+ state, code = health.UNHEALTHY, "BACKLOG_AGING"
930
+ elif age >= self._settings.backlog_warn_s:
931
+ state, code = health.DEGRADED, "BACKLOG_AGING"
932
+ else:
933
+ return health.Check(
934
+ name="backlog_age",
935
+ state=health.HEALTHY,
936
+ detail=f"{queue.pending} pending, oldest {_human(age)}",
937
+ )
938
+ return health.Check(
939
+ name="backlog_age",
940
+ state=state,
941
+ code=code,
942
+ detail=f"{queue.pending} pending, oldest {_human(age)}",
943
+ )
944
+
945
+ def _staleness_check(self, queue: db.QueueSummary) -> health.Check:
946
+ """No successful upload recently — but only meaningful when there is work to do.
947
+
948
+ An uploader with an empty queue and no recent upload is *idle*, which is healthy. This
949
+ is the distinction that stops a quiet site from alarming every night.
950
+ """
951
+ age = queue.stale_upload_s
952
+ if queue.pending == 0 and queue.uploading == 0 and queue.retry_wait == 0:
953
+ return health.Check(
954
+ name="upload_staleness",
955
+ state=health.HEALTHY,
956
+ detail="nothing to upload",
957
+ )
958
+ if age is None:
959
+ # A backlog with no upload ever recorded: judge it by the backlog itself.
960
+ age = queue.oldest_pending_age_s or 0.0
961
+ if age >= self._settings.backlog_critical_s:
962
+ state, code = health.UNHEALTHY, "STORAGE_STALE"
963
+ elif age >= self._settings.stale_upload_warn_s:
964
+ state, code = health.DEGRADED, "STORAGE_STALE"
965
+ else:
966
+ return health.Check(
967
+ name="upload_staleness",
968
+ state=health.HEALTHY,
969
+ detail=f"last success {_human(age)} ago",
970
+ )
971
+ return health.Check(
972
+ name="upload_staleness",
973
+ state=state,
974
+ code=code,
975
+ detail=f"last successful upload {_human(age)} ago",
976
+ )
977
+
978
+ def _summary_text(self, queue: db.QueueSummary | None) -> str:
979
+ if queue is None:
980
+ return "state database unreadable"
981
+ parts = [f"{queue.pending} pending"]
982
+ if queue.oldest_pending_age_s is not None:
983
+ parts.append(f"oldest {_human(queue.oldest_pending_age_s)}")
984
+ parts.append(f"{queue.uploaded_24h} uploaded in 24h")
985
+ if queue.failed_permanent:
986
+ parts.append(f"{queue.failed_permanent} failed permanently")
987
+ return "; ".join(parts)
988
+
989
+ def gauges(self) -> dict[str, float | int]:
990
+ queue = self.summary()
991
+ if queue is None:
992
+ return {}
993
+ return {
994
+ "cctv_clips_pending": queue.pending,
995
+ "cctv_clips_uploading": queue.uploading,
996
+ "cctv_clips_retry_wait": queue.retry_wait,
997
+ "cctv_clips_failed_permanent": queue.failed_permanent,
998
+ "cctv_clips_uploaded_24h": queue.uploaded_24h,
999
+ "cctv_oldest_pending_age_seconds": round(queue.oldest_pending_age_s or 0.0, 3),
1000
+ }
1001
+
1002
+
1003
+ def _human(seconds: float) -> str:
1004
+ if seconds < 90:
1005
+ return f"{seconds:.0f}s"
1006
+ if seconds < 5400:
1007
+ return f"{seconds / 60:.0f}m"
1008
+ return f"{seconds / 3600:.1f}h"
1009
+
1010
+
1011
+ # ---------------------------------------------------------------------------------------
1012
+ # Admin routes
1013
+ # ---------------------------------------------------------------------------------------
1014
+
1015
+
1016
+ def admin_routes(
1017
+ *, settings: UploadSettings, clock: Callable[[], float] = timeutil.now
1018
+ ) -> health.AdminRoutes:
1019
+ """The two admin operations the runbook actually uses.
1020
+
1021
+ A fresh connection per request: these are human-driven, so the cost is irrelevant and a
1022
+ connection shared with the workers would be one more thing to reason about.
1023
+ """
1024
+
1025
+ def _connect() -> sqlite3.Connection:
1026
+ return db.open_db(settings.db_path)
1027
+
1028
+ def queue() -> Mapping[str, Any]:
1029
+ conn = _connect()
1030
+ try:
1031
+ summary = db.queue_summary(conn, now=clock())
1032
+ oldest = conn.execute("""
1033
+ SELECT id, camera, path, start_time, attempts, last_error_class
1034
+ FROM clips
1035
+ WHERE upload_state = 'PENDING'
1036
+ ORDER BY start_time ASC, id ASC
1037
+ LIMIT 20
1038
+ """).fetchall()
1039
+ failed = conn.execute("""
1040
+ SELECT id, camera, path, attempts, last_error_class, last_error
1041
+ FROM clips
1042
+ WHERE upload_state = 'FAILED_PERMANENT'
1043
+ ORDER BY id
1044
+ """).fetchall()
1045
+ return {
1046
+ "counts": dict(summary.counts),
1047
+ "pending": summary.pending,
1048
+ "uploading": summary.uploading,
1049
+ "retry_wait": summary.retry_wait,
1050
+ "failed_permanent": summary.failed_permanent,
1051
+ "uploaded_24h": summary.uploaded_24h,
1052
+ "oldest_pending_age_s": summary.oldest_pending_age_s,
1053
+ "oldest_pending": [dict(row) for row in oldest],
1054
+ "failed": [dict(row) for row in failed],
1055
+ }
1056
+ finally:
1057
+ conn.close()
1058
+
1059
+ def retry(body: Mapping[str, Any]) -> Mapping[str, Any]:
1060
+ reason = str(body.get("reason") or "")
1061
+ clip_id = body.get("clip_id")
1062
+ camera = body.get("camera")
1063
+ since = body.get("since")
1064
+ conn = _connect()
1065
+ try:
1066
+ # `reset_failed_permanent` refuses an unfiltered or unexplained request, which is
1067
+ # an operator mistake rather than a database fault. Translating it to a ValueError
1068
+ # is what lets the HTTP layer answer `400` instead of `500`.
1069
+ try:
1070
+ ids = db.reset_failed_permanent(
1071
+ conn,
1072
+ reason=reason,
1073
+ clip_id=int(clip_id) if clip_id is not None else None,
1074
+ camera=str(camera) if camera is not None else None,
1075
+ since=float(since) if since is not None else None,
1076
+ now=clock(),
1077
+ )
1078
+ except db.DbError as exc:
1079
+ raise ValueError(str(exc)) from exc
1080
+ return {"requeued": len(ids), "clip_ids": ids}
1081
+ finally:
1082
+ conn.close()
1083
+
1084
+ return health.AdminRoutes(queue=queue, retry=retry)
1085
+
1086
+
1087
+ # ---------------------------------------------------------------------------------------
1088
+ # Process
1089
+ # ---------------------------------------------------------------------------------------
1090
+
1091
+
1092
+ def build_adapter(settings: UploadSettings) -> StorageAdapter | None:
1093
+ """Construct the adapter, or `None` when cloud backup is deliberately unconfigured."""
1094
+ if not settings.storage_configured:
1095
+ return None
1096
+ assert settings.bucket and settings.key_id and settings.application_key
1097
+ return storage.S3StorageAdapter(
1098
+ bucket=settings.bucket,
1099
+ access_key_id=settings.key_id,
1100
+ secret_access_key=settings.application_key,
1101
+ endpoint_url=settings.endpoint_url,
1102
+ region=settings.region,
1103
+ multipart_threshold_bytes=settings.multipart_threshold_bytes,
1104
+ )
1105
+
1106
+
1107
+ class UploadService:
1108
+ """Worker threads, the periodic policy pass, and the health surface."""
1109
+
1110
+ def __init__(
1111
+ self,
1112
+ *,
1113
+ settings: UploadSettings,
1114
+ config: CamerasConfig,
1115
+ uploader: Uploader,
1116
+ health_reporter: UploaderHealth,
1117
+ clock: Callable[[], float] = timeutil.now,
1118
+ rng: random.Random | None = None,
1119
+ ) -> None:
1120
+ self._settings = settings
1121
+ self._config = config
1122
+ self._uploader = uploader
1123
+ self._health = health_reporter
1124
+ self._clock = clock
1125
+ self._rng = rng or random.Random()
1126
+ self._last_outcome: UploadOutcome | None = None
1127
+
1128
+ # -- policy ------------------------------------------------------------------------
1129
+
1130
+ def policy_pass(self) -> int:
1131
+ """Mark clips from `retention.upload: false` cameras as `SKIPPED_BY_POLICY`."""
1132
+ skipped_cameras = [
1133
+ name for name, camera in self._config.cameras.items() if not camera.retention.upload
1134
+ ]
1135
+ if not skipped_cameras:
1136
+ return 0
1137
+ conn = db.open_db(self._settings.db_path)
1138
+ try:
1139
+ changed = db.skip_by_policy(conn, cameras=skipped_cameras, now=self._clock())
1140
+ if changed:
1141
+ logger.info(
1142
+ "clips skipped by policy",
1143
+ extra={
1144
+ "count": changed,
1145
+ "reason": ",".join(sorted(skipped_cameras)),
1146
+ },
1147
+ )
1148
+ return changed
1149
+ finally:
1150
+ conn.close()
1151
+
1152
+ # -- workers -----------------------------------------------------------------------
1153
+
1154
+ def _worker_loop(self, index: int, stop: threading.Event) -> None:
1155
+ """One claim→upload→record loop, owning its own connection.
1156
+
1157
+ Its own connection rather than a shared one: SQLite connections are not safe for
1158
+ concurrent use, and "each thread owns its connection" is a rule that needs no
1159
+ discipline to keep.
1160
+ """
1161
+ conn = db.open_db(self._settings.db_path)
1162
+ owner = f"{socket.gethostname()}:{os.getpid()}:{index}"
1163
+ try:
1164
+ while not stop.is_set():
1165
+ outcome: UploadOutcome | None = None
1166
+ try:
1167
+ outcome = self._uploader.upload_one(conn, owner=owner)
1168
+ except Exception:
1169
+ # The outermost loop of a process meant to run for years: log and keep
1170
+ # going. The lease on any claimed clip expires, so nothing is wedged.
1171
+ logger.exception("upload worker failed; continuing")
1172
+ if outcome is not None:
1173
+ self._last_outcome = outcome
1174
+ else:
1175
+ self._idle(stop)
1176
+ finally:
1177
+ conn.close()
1178
+
1179
+ def _idle(self, stop: threading.Event) -> None:
1180
+ """Sleep with jitter, so N idle workers do not wake in lock-step."""
1181
+ stop.wait(self._settings.scheduler_tick_s * self._rng.uniform(0.8, 1.2))
1182
+
1183
+ # -- the run loop ------------------------------------------------------------------
1184
+
1185
+ def run(self, stop: threading.Event) -> None:
1186
+ self.policy_pass()
1187
+
1188
+ threads = [
1189
+ threading.Thread(
1190
+ target=self._worker_loop,
1191
+ args=(index, stop),
1192
+ name=f"upload-{index}",
1193
+ daemon=True,
1194
+ )
1195
+ for index in range(max(1, self._settings.upload_concurrency))
1196
+ ]
1197
+ for thread in threads:
1198
+ thread.start()
1199
+ logger.info(
1200
+ "uploading",
1201
+ extra={"count": len(threads), "host": self._settings.bucket},
1202
+ )
1203
+
1204
+ last_policy = self._clock()
1205
+ last_summary = self._clock()
1206
+ while not stop.wait(1.0):
1207
+ now = self._clock()
1208
+ if now - last_policy >= POLICY_INTERVAL_S:
1209
+ self.policy_pass()
1210
+ last_policy = now
1211
+ if now - last_summary >= SUMMARY_INTERVAL_S:
1212
+ self._log_summary()
1213
+ last_summary = now
1214
+
1215
+ for thread in threads:
1216
+ thread.join(timeout=self._settings.scheduler_tick_s + 1.0)
1217
+
1218
+ def _log_summary(self) -> None:
1219
+ queue = self._health.summary()
1220
+ if queue is None:
1221
+ return
1222
+ logger.info(
1223
+ "upload backlog",
1224
+ extra={
1225
+ "count": queue.pending,
1226
+ "state": (
1227
+ f"pending={queue.pending} retry={queue.retry_wait} "
1228
+ f"failed={queue.failed_permanent} uploaded24h={queue.uploaded_24h}"
1229
+ ),
1230
+ "attempts": int(queue.oldest_pending_age_s or 0),
1231
+ },
1232
+ )
1233
+
1234
+
1235
+ def _install_signal_handlers(stop: threading.Event) -> None:
1236
+ """Turn SIGTERM/SIGINT into a cooperative shutdown.
1237
+
1238
+ Cooperative so that a `SIGTERM` does not interrupt a PUT half-way: the worker finishes the
1239
+ clip it is uploading, then exits. A killed upload leaves no lease held in practice — the
1240
+ lease simply expires — but finishing means the next retry is immediate rather than
1241
+ fifteen minutes away.
1242
+ """
1243
+
1244
+ def request_stop(signum: int, _frame: object) -> None:
1245
+ # Logged once. `timeout`, Docker and a shell can each deliver the signal to both the
1246
+ # process and its group, and two "shutdown requested" lines for one shutdown reads
1247
+ # like two shutdowns.
1248
+ if not stop.is_set():
1249
+ logger.info("shutdown requested", extra={"reason": signal.Signals(signum).name})
1250
+ stop.set()
1251
+
1252
+ for signum in (signal.SIGTERM, signal.SIGINT):
1253
+ signal.signal(signum, request_stop)
1254
+
1255
+
1256
+ def build_health_reporter(
1257
+ *, settings: UploadSettings, uploader: Uploader, adapter: StorageAdapter | None
1258
+ ) -> UploaderHealth:
1259
+ return UploaderHealth(
1260
+ db_path=settings.db_path,
1261
+ data_dir=settings.data_dir,
1262
+ adapter=adapter,
1263
+ settings=settings,
1264
+ uploader=uploader,
1265
+ started_at=timeutil.now(),
1266
+ )
1267
+
1268
+
1269
+ def main(argv: Sequence[str] | None = None) -> int:
1270
+ """LLD 00 §3's service skeleton: configure, validate, serve health, then work."""
1271
+ del argv # every input is an environment variable; see UploadSettings
1272
+
1273
+ try:
1274
+ settings = UploadSettings.from_environ()
1275
+ except ConfigError as exc:
1276
+ print(f"cctv-uploader: {exc}", file=sys.stderr)
1277
+ return EXIT_CONFIG
1278
+
1279
+ try:
1280
+ configure_logging(settings.log_level)
1281
+ except ValueError as exc:
1282
+ print(f"cctv-uploader: {exc}", file=sys.stderr)
1283
+ return EXIT_CONFIG
1284
+
1285
+ problem = settings.configuration_problem()
1286
+ if problem is not None:
1287
+ logger.error("configuration is unusable", extra={"reason": problem})
1288
+ return EXIT_CONFIG
1289
+
1290
+ logger.info(
1291
+ "starting",
1292
+ extra={
1293
+ "host": socket.gethostname(),
1294
+ "state": "disabled" if not settings.storage_configured else "configured",
1295
+ },
1296
+ )
1297
+
1298
+ try:
1299
+ config = load_cameras_config(settings.cameras_path)
1300
+ except (ConfigError, OSError) as exc:
1301
+ logger.error(
1302
+ "configuration is unusable",
1303
+ extra={"path": str(settings.cameras_path), "reason": str(exc)},
1304
+ )
1305
+ return EXIT_CONFIG
1306
+
1307
+ for warning in config.warnings:
1308
+ logger.warning(warning)
1309
+
1310
+ try:
1311
+ conn = db.open_db(settings.db_path)
1312
+ version = db.migrate(conn)
1313
+ db.integrity_check(conn)
1314
+ except (db.DbError, sqlite3.Error) as exc:
1315
+ logger.error(
1316
+ "state store is unusable",
1317
+ extra={"path": str(settings.db_path), "reason": str(exc)},
1318
+ )
1319
+ return EXIT_UNAVAILABLE
1320
+
1321
+ logger.info("state store ready", extra={"state": f"schema-{version}"})
1322
+
1323
+ adapter = build_adapter(settings)
1324
+ if adapter is None:
1325
+ logger.warning(
1326
+ "CCTV_STORAGE_BUCKET is unset: cloud backup is disabled and clips stay local. "
1327
+ "This is a supported choice, and the health endpoint reports DISABLED."
1328
+ )
1329
+ else:
1330
+ # A missing SDK is a configuration error, not a runtime one: every clip would fail
1331
+ # the same way, and the message is about an install, not about the network.
1332
+ probe = adapter.healthcheck()
1333
+ if isinstance(probe.error, storage.StorageBackendMissingError):
1334
+ logger.error("storage backend is unusable", extra={"reason": probe.detail})
1335
+ return EXIT_CONFIG
1336
+ try:
1337
+ adapter.ensure_bucket()
1338
+ except StorageError as exc:
1339
+ # Not fatal: the health check reports it, and a bucket can appear later.
1340
+ logger.warning("could not confirm the bucket", extra={"reason": str(exc)})
1341
+
1342
+ uploader = Uploader(
1343
+ data_dir=settings.data_dir,
1344
+ location=config.site.location,
1345
+ adapter=adapter,
1346
+ settings=settings,
1347
+ )
1348
+ reporter = build_health_reporter(settings=settings, uploader=uploader, adapter=adapter)
1349
+
1350
+ stop = threading.Event()
1351
+ _install_signal_handlers(stop)
1352
+
1353
+ server = None
1354
+ if settings.health_port > 0:
1355
+ server, _thread = health.start_health_server(
1356
+ port=settings.health_port,
1357
+ host=settings.health_host,
1358
+ snapshot=reporter.report,
1359
+ admin=admin_routes(settings=settings),
1360
+ token=settings.internal_token,
1361
+ gauges=reporter.gauges,
1362
+ )
1363
+ logger.info(
1364
+ "health endpoint listening",
1365
+ extra={"host": settings.health_host, "count": settings.health_port},
1366
+ )
1367
+ if not settings.internal_token:
1368
+ logger.warning("INTERNAL_API_TOKEN is unset, so the admin endpoints are not mounted")
1369
+ else:
1370
+ logger.warning("no health endpoint: CCTV_UPLOADER_HEALTH_PORT is 0")
1371
+
1372
+ service = UploadService(
1373
+ settings=settings, config=config, uploader=uploader, health_reporter=reporter
1374
+ )
1375
+
1376
+ try:
1377
+ if settings.dry_run:
1378
+ logger.warning("DRY_RUN is set: nothing will be uploaded")
1379
+ while not stop.wait(1.0):
1380
+ pass
1381
+ elif adapter is None:
1382
+ # Nothing to do, but the container stays up and reports DISABLED, so a
1383
+ # local-only install is a stable state rather than a crash loop.
1384
+ while not stop.wait(1.0):
1385
+ pass
1386
+ else:
1387
+ service.run(stop)
1388
+ finally:
1389
+ if server is not None:
1390
+ server.shutdown()
1391
+ server.server_close()
1392
+ conn.close()
1393
+ logger.info("stopped")
1394
+
1395
+ return EXIT_OK
1396
+
1397
+
1398
+ def _size_or_none(path: Path) -> int | None:
1399
+ try:
1400
+ return path.stat().st_size
1401
+ except OSError:
1402
+ return None
1403
+
1404
+
1405
+ def _sha256(path: Path, *, chunk: int = 1024 * 1024) -> str:
1406
+ digest = hashlib.sha256()
1407
+ with open(path, "rb") as handle:
1408
+ while True:
1409
+ block = handle.read(chunk)
1410
+ if not block:
1411
+ break
1412
+ digest.update(block)
1413
+ return digest.hexdigest()
1414
+
1415
+
1416
+ if __name__ == "__main__": # pragma: no cover
1417
+ raise SystemExit(main())