Spaces:
Running
Running
File size: 20,787 Bytes
3240ef0 773d586 f423c9e 3240ef0 86bc0aa 3240ef0 86bc0aa 3240ef0 86bc0aa 3240ef0 5a5ce72 3240ef0 5a5ce72 3240ef0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | # 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. `kind` ∈ `existing_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_key`s |
| `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 pull** — `pull(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 commit** — `durable_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.
**Batching** — `deferred_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("")`/`None` → `None`. |
| `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_sources` → `orphan_check` → `build` → `parity_check` → upload. `main()` sets `INSPECTOR_BACKEND=bucket` + `INSPECTOR_BUCKET_REPO`, `init_db()`, then `sync.upload()`.
---
## healthz
`healthcheck()` (`__init__.py`) — cheap, never raises:
```json
{ "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.
```
|