hetchyy's picture
deploy: inspector prod @ 2ce61c0
86bc0aa verified
|
Raw
History Blame Contribute Delete
20.8 kB

Inspector SQLite substrate (inspector.db)

A single container-local inspector.db (SQLite, WAL, single-writer) is the source of truth for what used to be 7 bucket-resident JSON stores (state, catalog, access, audit, activity/dismissals, claims, pending requests) + the audit log. Per-reciter content (reciters/<slug>/) stays JSON-on-bucket and is not in the DB. The DB is full-file synced to the HF bucket (db/inspector.db) — pulled at boot, pushed after every committed write under a db_seq compare-and-swap guard. The inspector runs single-worker gunicorn-gthread (16 threads); all writes serialize through one writer connection.

Code lives in inspector/services/db/. Public API is inspector/services/db/__init__.py: init_db, healthcheck, current_db_seq, current_version, transaction, get_conn, get_writer, reset, db_path, set_db_path_for_test, run_migrations.

DB path resolves from INSPECTOR_DB_PATH (else <tempdir>/inspector.db). init_db() opens the writer, runs pending migrations, chmods 0600 (POSIX; no-op on Windows). :memory: is supported (tests).


Concurrency model

inspector/services/db/connection.py. Single-worker gunicorn, 16 threads.

Aspect Mechanism
Writer ONE shared sqlite3.Connection (_writer), guarded by re-entrant _WRITE_LOCK = threading.RLock(). Autocommit (isolation_level=None); transaction() manages BEGIN/COMMIT explicitly.
Write txn transaction() CM: top-level acquires _WRITE_LOCK, BEGIN IMMEDIATE on writer, yields conn, bumps db_seq, COMMIT (or ROLLBACK on any exception, then re-raise).
Readers Thread-local connections (_readers), autocommit + PRAGMA query_only = ON. WAL lets them run concurrently with the writer.
Read-after-write Readers hold no long-lived txn → every SELECT sees the latest committed WAL snapshot. No stale reads after a commit.
Re-entrancy transaction() is savepoint-aware. Nested calls (active conn tracked via _active ContextVar) enroll in the active connection and use SAVEPOINT sp_<depth> (+ ROLLBACK TO/RELEASE), never a fresh BEGIN. Multi-repo handlers are one atomic unit.
Connection routing get_conn() returns the active write-txn conn if one is open on this context, else the thread-local reader. Repos call get_conn() and work on whichever they get.
db_seq bump _bump_db_seq() runs inside the top-level commit only (nested savepoints don't bump). Monotonic; CAS guard for bucket sync.
Pragmas foreign_keys=ON, busy_timeout=5000, journal_mode=WAL, synchronous=NORMAL, temp_store=MEMORY; readers add query_only=ON.
Reset reset() closes the writer + bumps _generation so each thread's reader lazily reopens. Tests + path switches only.

Invariant when adding a write: every mutation MUST run through transaction() (or sync.durable_transaction() at a service boundary — see below). Never write on a reader connection (query_only will reject it; repos that need the writer call get_conn() inside an open txn). A handler that touches multiple repos opens ONE outer transaction() (or durable_transaction()); the inner repo calls nest as savepoints and commit atomically with it. Repo write functions do not open their own txn (except repo_transitions.append, which uses re-entrant transaction() so it nests under the caller).


Table inventory

All from inspector/services/db/migrations/0001_init.sql. Datetimes are TEXT (ISO-8601 UTC, Z suffix); booleans INTEGER 0/1; JSON columns TEXT (orjson). No CHECK on free-vocabulary columns (transitions.event, requests.kind, claims.close_reason) — new kinds need zero migration; validity enforced in pydantic/app layer.

Table Purpose Owning repo
db_meta key/value runtime metadata; holds db_seq (seeded '0') connection.py (_bump_db_seq, current_db_seq)
users HF identity + cached login + first/last seen repo_access
role_assignments maintainer/owner grants (active + soft-revoked); ux_role_active partial-unique = one active role per user repo_access
riwayahs catalog vocab: riwayah slug/short/name repo_catalog
styles catalog vocab: style slug/short/name repo_catalog
sources catalog vocab: source + audio_categories JSON repo_catalog
channels catalog vocab: channel + host_patterns JSON repo_catalog
recording_contexts catalog vocab: studio/broadcast/prayer/taraweeh/mixed repo_catalog
catalog_meta single row id=1: schema_version, generated_at, persisted derived JSON repo_catalog
catalog_aliases slug/reciter_id rename history repo_catalog
reciters reciter identity (name_en/ar, country, notes) repo_catalog
deliveries per-(reciter × riwayah × style × source × channel …) audio delivery; FKs into all vocab tables repo_catalog
transitions canonical append-only event log (replaces audit/*.jsonl); seq PK, unique id, content_hash repo_transitions
delivery_states current lifecycle projection per slug (state, visibility, last_save_at, timestamps_job_ids, revision_in_progress) repo_state
claims first-class claims (current + history); ux_claim_open_slug = one open claim per slug repo_claims
requests unified pending + 3 archives (status pending/accepted/returned/discarded); ux_request_pending_slug = one pending per non-null slug; evolving fields in payload JSON. kindexisting_combo_edit (slug-based edit) | existing_reciter_new_combo | new_reciter (the latter two are slugless intake — slug=NULL until accept; payload carries reciter_id, source, attestations, cached probe; exempt from ux_request_pending_slug). repo_requests
request_views unused — was per-admin "viewed" marks for the retired Requests-tab unviewed badge, (request_id, hf_user_id) PK. "New request" awareness moved to the My Notifications rail (request.received alert); table left in place (no drop migration). Migration 0004
review_views retained but unused — was the per-admin marked-ready "viewed" mark for the Reviews-tab dot, (slug, hf_user_id) PK. The marked-ready notification was retired with the Releases restructure; repo_review_views is deleted and nothing reads the table. The frozen migration 0005 keeps the table; no reader.
activity_tombstones owner-only global deletes for public-rail cards, keyed on audit_content_hash. Per-user dismissals (activity_dismissals) were dropped in migration 0006 alongside the retired admin notifications rail. repo_activity
guide_views per-user "read" marks for the validation-accordion help guides, (view_key, hf_user_id) PK, write-once (INSERT OR IGNORE — no unread transition). view_key is the collapsed guide id (low_confidence_v2 → low_confidence); no FK (app-level enum). Drives the FE unread ? border + first-edit onboarding gate. Surfaced on /api/me as guides_read. Migration 0010 repo_guides
ts_reports public Timestamps-tab categorized reports (audio/timing/mapping/tajweed/other) pointing at a flexible target (verse/word/cell/phoneme/column/cell_group). One row per (slug, verse_key, category, target_key, identity) where identity is hf_user_id (signed in) XOR anon_token; two partial unique indexes split the per-identity uniqueness (ON CONFLICT targets echo the WHERE predicate). target_key is the canonical descriptor string. snap_* is the targeted-content fingerprint for staleness; status open/resolved + resolved_*; stale set on re-stamp. Migration 0025 repo_ts_reports
ts_verse_flags superseded by ts_reports — table retained until its few rows are manually migrated, then dropped in a follow-up migration. Migration 0024 (no repo)

Key indexes: ix_transitions_slug_ts, ix_transitions_ts (DESC), ix_transitions_event, ix_transitions_hash; ix_delivery_states_state; ix_claim_open_assignee (partial, open only); ix_requests_status, ix_requests_slug; ix_request_views_user, ix_review_views_user, ix_guide_views_user; ix_deliveries_reciter; ix_role_user.

Note: assignee_* / marked_ready are not columns on delivery_states — they live on the open claims row and are LEFT-JOINed in by repo_state.


Repos

inspector/services/db/repo_*.py. Each owns its table(s) and assembles the legacy pydantic read model so the service/route/FE wire contract is unchanged. Write functions assume the caller already opened a transaction().

Repo Table(s) Key ops Read model
repo_state delivery_states (+ LEFT JOIN claims) get_row, all_rows, exists, upsert_state, update_state(**writable) ReciterRow (assignee/marked_ready filled from open claim; only on under_review)
repo_catalog riwayahs/styles/sources/channels/recording_contexts/catalog_meta/catalog_aliases/reciters/deliveries snapshot, find_reciter/find_delivery/find_source, edit_reciter/edit_delivery, add_reciter/add_delivery/add_source (raise Duplicate), insert_*, load_vocab, insert_alias, set_meta, refresh_derived ReciterCatalog (full model_dump(by_alias=True) byte round-trips parity gate)
repo_access users, role_assignments ensure_user, get_login, resolve_role, find_member, active_members, snapshot, grant_role, revoke_role (soft), update_role, has_any_active Member / RolesFile (snapshot = active + revoked)
repo_transitions transitions append (re-entrant txn), get, get_by_content_hash, for_slug, since, feed AuditRecord (append) / audit-record-shaped dict (reads) — ts is exact stored string so activity_classification.audit_id recomputes content_hash
repo_claims claims get_open_claim, open_claim_for_user (O(1) one-claim check), open_claims_for_user, open_claim, close_claim, set_marked_ready, reassign raw sqlite3.Row (consumed by repo_state)
repo_requests requests, request_views submit, resolve (→ accepted/returned/discarded), delete_pending, get_pending/has_pending/all_pending, count_pending, all_archived(kind), get_for_slug(kind, slug), get_by_id, admin_list_rows(status), counts_by_status; views: mark_viewed/is_viewed/viewed_ids_for_user/count_unviewed_open_for_user PendingRequest / ArchivedRequest (archive kind→status: completed→accepted, returned→returned, discarded→discarded)
repo_activity activity_tombstones delete/undelete/is_deleted/deleted_set (global tombstones for the public feed; owner-only writes) set[str] keyed on content_hash
repo_guides guide_views record_view (INSERT OR IGNORE — write-once), read_views list[str] of read view_keys
repo_ts_reports ts_reports create(...) -> (row, created) (per-identity ON CONFLICT on target_key), verse_counts(slug) (open/resolved), list_for_verse, resolve(...), list_open_for_recheck/mark_stale (staleness), delete(...) plain dicts (the route assembles the TsReport wire model)

repo_errors (errors.py): RepoError, Duplicate (PK / partial-unique violation), NotFound. Services map these to their own contracts (e.g. catalog's InvalidCatalogChange) instead of leaking sqlite3.IntegrityError.

one-claim-per-non-owner is enforced in the transition layer (owners exempt by policy), NOT a DB index — ix_claim_open_assignee is non-unique. ux_claim_open_slug (unique) does enforce one open claim per slug.


Migrations

inspector/services/db/migrate.py, scripts in inspector/services/db/migrations/NNNN_*.sql.

  • Version tracked via PRAGMA user_version (NOT a table).
  • run_migrations(conn) (called by init_db): _discover() globs *.sql, parses the 4-digit prefix (^(\d{4})_.*\.sql$), sorts ascending, applies every file with number > user_version.
  • Each script is wrapped BEGIN; <sql> PRAGMA user_version = NNNN; COMMIT; and run via executescript — DDL + version bump are atomic. Failure rolls back and aborts boot (fail-fast → /healthz 503), never half-applies.
  • Non-conforming filenames are logged and skipped.

Adding a migration: create NNNN_<desc>.sql (next 4-digit number) in migrations/. Write raw DDL/DML only — do NOT add BEGIN/COMMIT/PRAGMA user_version (the runner wraps it). It applies on next boot. Current head: 0025_ts_reports.sql.

Never edit an already-applied migration to change schema. Existing DBs (every deployed/dev/local one is pulled from the bucket already past user_version 1) re-run nothing ≤ their version, so edits to 0001 etc. reach fresh DBs only and silently miss live data. New columns/tables ALWAYS go in a new NNNN_*.sql with an additive ALTER/CREATE. (0007 exists precisely because the claims.mark_ready_* columns were first inlined into 0001 and never reached the live claims table — the Reviews drawer detail query 500'd with no such column.)


Bucket sync

inspector/services/db/sync.py. Bucket paths: db/inspector.db (the DB), db/inspector.seq (CAS sidecar), db/inspector-<YYYY-MM-DD>.db (daily snapshots, 30-day retention). Uses bucket primitives directly (read_bytes_direct/write_bytes_direct), bypassing the mount's debounced flush so an acked write is durable before the response.

Boot pullpull(dest): downloads db/inspector.db to the local path (default configured DB path), clears stale -wal/-shm, chmods 0600. Returns False (fresh init) if the bucket has no DB. Always trusts the bucket.

Push after commitdurable_transaction() is the mutating service boundary (wraps state.transition, catalog/requests mutations, access grant/revoke/update, activity mutations). It opens connection.transaction(), and only the OUTERMOST boundary calls mark_durable() after the txn commits and the active-conn ContextVar clears (snapshot() requires no open txn). Nested durable_transaction() is a savepoint and does NOT upload.

  • snapshot() → standalone DB bytes via SQLite online backup() (no WAL sidecar) + the db_seq read from the snapshot itself, so labelled seq always matches uploaded bytes. Raises if called inside an active write txn.
  • upload(): snapshot → read remote inspector.seq → CAS check → write inspector.db then inspector.seq ({seq, nonce, ts}). Returns the uploaded seq.

CAS guard (db_seq): the per-process _NONCE (12 hex) identifies "our own prior upload" vs "another container raced us during a rolling deploy". Upload refuses (UploadConflict, → 5xx; local commit stays ahead, later upload reconciles) iff remote seq >= local snap_seq AND remote nonce != _NONCE. Caveat: bucket I/O has no atomic CAS, so this catches a stale racer, not two simultaneous writers — safe under the single-active-writer invariant; it covers the rolling-deploy overlap window.

Batchingdeferred_sync() (ContextVar depth counter): boot-scan (hydrate_initial_seen) applies N transitions in a loop; this coalesces them into ONE upload on outermost exit (only if no exception and the batch actually advanced db_seq — a no-op batch, e.g. a boot scan that finds nothing stuck, skips the upload so it doesn't trip the equal-seq CAS guard against the previous container's nonce and log a spurious ERR on every restart). mark_durable() is a no-op inside it. set_sync_enabled(False) disarms uploads (tests; default armed in prod).

current_db_seq() (in connection.py) reads db_meta.db_seq — the monotonic counter the CAS guard compares. Bumped once per committed top-level write txn.

Daily snapshot: daily_snapshot() writes db/inspector-<day>.db and prunes any older than _SNAPSHOT_RETENTION_DAYS (30). status() (counters: nonce, last_bucket_upload_ts, bucket_lag_seconds, last_error, queue) backs /healthz.


serde

inspector/services/db/_serde.py — row ↔ pydantic helpers.

Helper Behavior
to_iso(dt) / from_iso(s) datetime ↔ ISO-8601 UTC string. to_iso emits a Z suffix (not +00:00) to match pydantic v2 model_dump(mode="json") byte-for-byte — stored ts and wire ts must be identical (activity card parity).
json_dumps/json_loads orjson; json_loads("")/NoneNone.
now() datetime.now(timezone.utc).
new_transition_id() req_<12hex> (mirrors old audit request_id).
content_hash(ts, event, slug, actor_hf, result) sha1(ts|event|slug|actor_hf|result)[:16] from the exact stored strings. MUST stay byte-identical to services.activity.activity_classification.audit_id so dismissals/tombstones keep matching transitions.
content_hash_for_record(record) same hash from a raw audit/transition dict (used by the JSON→SQLite migration on historical records).

Repos round-trip these pydantic models from qua_shared/schemas/ (enum values serialized via .value; the FE-facing subset re-exported at qua_shared/schemas/fe_types.py):

Schema module Models Repo
state.py ReciterRow, ReciterState, Visibility, RevisionContext repo_state
catalog.py ReciterCatalog, Vocab, Riwayah, Style, Source, Channel, RecordingContext, ReciterEntry, Delivery, Alias, Derived repo_catalog
access.py Member, Role, RolesFile repo_access
audit.py AuditRecord, Actor repo_transitions
pending_requests.py PendingRequest, ArchivedRequest, ProposedEdits repo_requests
activity_state.py ActivityState (migration read of legacy store) repo_activity

JSON→SQLite migration

scripts/migrations/migrate_json_to_sqlite.py — one-shot, run once per bucket (--bucket dev|prod). Reads the 7 legacy stores + audit JSONL via the storage backend, decomposes into the SQLite tables, runs a parity readback, and (unless --dry-run) uploads db/inspector.db. Refuses to overwrite an existing bucket DB without --force. Refuses orphan slugs (state/request slug missing from catalog deliveries) without --allow-orphans.

Decomposition (build(), all in one transaction(); guards that target tables are empty first):

Source → Tables
access (RolesFile) users + role_assignments (active + revoked)
catalog (ReciterCatalog) vocab + reciters + deliveries + catalog_aliases + persisted derived/generated_at
state (ReciterStateFile) delivery_states + a synthesized OPEN claims row for each under_review row with an assignee (+ marked_ready stamp)
requests/pending.json + 3 archives requests (status pending / accepted / returned / discarded)
audit/<YYYY>-<MM>.jsonl transitions (ts/id/content_hash preserved verbatim; slug NULLed if its delivery no longer exists, to satisfy the FK while preserving the event)
activity/state.json (legacy deleted list only) activity_tombstones (keyed on content_hash). The legacy dismissals field is ignored — per-user dismissals were dropped with the admin notifications rail.

Parity gate (parity_check(), → SystemExit on any issue): SEMANTIC parity (list order normalized — repos read slug-sorted), not raw byte-for-byte file diff. Checks:

  • catalog snapshot().model_dump(mode="json", by_alias=True) == source dump (after _norm_catalog sorts vocab/reciters/deliveries) — the full-fidelity round-trip.
  • active roles set match; full role_assignments count == source members.
  • every in-catalog state row model_dump matches.
  • pending slug set match; per-status archive row counts match.
  • open claims set (slug, assignee) == under_review rows with assignee.
  • activity tombstones set matches the migrated deleted list.
  • transitions count == audit record count.

run() ordering: read_sourcesorphan_checkbuildparity_check → upload. main() sets INSPECTOR_BACKEND=bucket + INSPECTOR_BUCKET_REPO, init_db(), then sync.upload().


healthz

healthcheck() (__init__.py) — cheap, never raises:

{ "open": true, "schema_version": <PRAGMA user_version> }

On any error: { "open": false, "error": "<first 200 chars>" }. Sync counters from sync.status() (bucket_lag_seconds, last_error, nonce, last_bucket_upload_ts, queue) supplement the DB health on /healthz; a failed boot migration aborts startup → /healthz 503.