Admins can export the entire database (and the source-image filesystem) to a
downloadable archive and re-import it later. These are long-running background
jobs tracked by the AdminTask model. This page documents the task lifecycle,
cancellation semantics, the two-session import pattern, stale-task
reconciliation, and exactly what data round-trips.
In the frontend admin UI, these controls live under the Admin page's Backups tab. The separate Changelog tab is the default landing view.
- Endpoints:
backend/app/routers/admin.py - Runners:
backend/app/admin_ops.py - Model:
AdminTaskinbackend/app/models.py(see Domain model)
AdminTask.task_type is one of db_export, db_import, files_export,
files_import, rebuild_tiles. AdminTask.status transitions:
uploading ──▶ pending ──▶ running ──▶ completed
│ │ │ └─▶ failed
│ │ └─▶ cancelling ──▶ cancelled
└────────────┴──▶ cancelled (pre-runner cancel)
Other fields: progress (0–100), log (append-only text), result_filename /
result_path (export output), input_path (import source), error_message,
created_by (FK to User, SET NULL on user delete).
uploadingis only used by filesystem imports, which upload an archive before a runner exists (see below). Other task types start atpending.- Concurrency guard (
_create_task): creating a task is rejected with 409 if another task of the same type is already in an active status (uploading,pending,running,cancelling).
When TASK_EXECUTION_MODE=required, a Redis outage or enqueue submission
failure marks the newly created task failed before the API returns HTTP 503;
the task is never run in the API process. In local mode, the existing
BackgroundTasks fallback remains available.
The cancel endpoint behaves differently depending on the current status:
pending/running→cancelling(soft cancel). The runner notices via cancellation-aware progress updates and aborts cleanly.cancelling→cancelled(force cancel). Handles a runner that died while cancelling, which would otherwise leave the concurrency guard stuck.uploading→cancelled(pre-runner cancel). No runner exists yet, so the task is cancelled directly.
Cancellation-aware progress. _update_task(..., check_cancelled=True)
re-reads the task status from the database; if it has been set to cancelling,
it raises TaskCancelled so the runner aborts and cleans up any partial files.
run_db_import uses two separate database sessions:
status_session— writesAdminTaskprogress/log updates, committed freely so the admin UI sees live progress.data_session— performs the actual destructive clear + re-import, committed once atomically at the very end.
This means a mid-import failure rolls back all data changes (via
data_session) without losing task-status visibility (already committed via
status_session).
Self-deadlock prevention for created_by. Before data_session deletes the
users table, the importing task's own created_by FK is detached through
status_session first. Otherwise ON DELETE SET NULL on
admin_tasks.created_by would try to update this task's row from inside
data_session while status_session (same coroutine) holds it — a deadlock.
Export (run_db_export) writes a JSON document containing: programs,
groups, categories (with program_ids and group_ids), images,
source_images, users (with program memberships), collections,
measurement_findings (Measurement review findings,
excluding rebuild-fixture images), announcements (with program_ids and
group_ids), announcement_reactions, and content_media. Dumps written
before migration 0042_announcements carried a changelog_entries list and
an announcement singleton object instead; the importer still accepts them
(changelog entries become kind='release' / audience='staff' announcements,
a non-blank singleton becomes an audience='everyone' banner announcement).
All table reads run on a dedicated session pinned to one REPEATABLE READ
transaction, so the dump is a single consistent snapshot: a write that lands
mid-export (for example a measurement approval, which updates both the image's
metadata and its finding) is either fully in the dump or fully absent. Task
progress and cancellation checks commit on a separate session so they don't
end that snapshot.
Each exported collection carries name, description, type,
visibility, user_id (creator audit — may be null),
owner_program_id, owner_ids (the user-owner id list; both empty for an
orphaned collection), viewport_state, version, ordered image_ids,
program_ids and group_ids. Legacy dumps carrying only user_id import
that creator as the sole owner. Links to rebuild-fixture images are dropped
because those images are not part of the export. See
Collections.
Each exported group carries name, description, created_by_user_id,
member_ids, and instructor_ids.
Each exported source image carries uploaded_by (the original uploader's
users.id), restored on import. Because source_images references both
images (via image_id) and users (via uploaded_by), import inserts
source_images after users and images.
Import (run_db_import) clears existing data and re-inserts it. Order
matters because of foreign keys.
-
Delete order (junctions before parents):
collection_images → collection_programs → collection_groups → collection_owners → collections → source_images → measurement_findings → images → category_groups → category_programs → categories → group_members → group_instructors → groups → user_programs → users → announcement_reactions → announcement_groups → announcement_programs → content_media → announcements → programs -
Insert order (parents before junctions; groups after users because members/instructors/creator are users):
programs → users → groups → categories (restoring category↔program and category↔group links) → images → source_images → collections (with collection_images / collection_owners / collection_programs / collection_groups) → measurement_findings → announcements (with announcement_programs / announcement_groups) → announcement_reactions → content_media -
Sequence reset. After import, PostgreSQL sequences are reset to
max(id) + 1so subsequent inserts don't collide. Sequences reset:programs,groups,categories,images,users,announcements,announcement_reactions,content_media,source_images,collections,measurement_findings.
The Admin page's Filesystem Export is intentionally source-images only.
It writes a .tar.gz of the preserved filesystem data needed to restore the
application state, but it excludes the derived DZI tile pyramid under
/data/tiles/**. That keeps exports much smaller and avoids spending time
walking millions of generated tile files.
- Export contents: source images and other authoritative filesystem data.
- Excluded: the tile pyramid (
image_files/,image.dzi,thumbnail.jpeg, and other derived tile artifacts),admin_tasks/scratch files, andsource_images/.staging/— theTMPDIRupload spool (#1365), whose contents are transient and unowned. - Import behavior: filesystem imports restore the source files only. After a successful files import, a Rebuild Tiles task is queued automatically; it regenerates missing or stale DZI pyramids from the restored source images. The same Rebuild Tiles control can also be triggered manually if needed. Until then, viewers may show missing or stale tile placeholders.
For a full cross-environment clone, follow this order:
- Database import
- Filesystem import
- Rebuild Tiles (queued automatically; can be triggered manually if needed)
Compression is parallelized with
pigzwhen the backend container image provides it (the backend Dockerfile installs it); otherwise the export falls back to single-threaded gzip automatically. SetEXPORT_PIGZ_THREADS=2to cap pigz at a modest thread count; use0to opt out and let pigz use all available cores.
HRIV is not in production and has no legacy export archives. Imports do not need to support older export formats — backward-compat code can be removed rather than maintained. The one deliberate exception is manifest-less archives: retained filesystem-import archives already sitting on deployed data volumes predate the manifest, so they stay importable as legacy format v0 (see below) to keep their reruns working.
Every filesystem export embeds a small JSON manifest, hriv-manifest.json, at
the archive root (next to the data/ payload):
{
"format_version": 1,
"hriv_version": "1.2.0",
"export_type": "filesystem",
"created_at": "2026-07-28T18:42:00Z"
}format_versionis the archive layout version this release writes (FILES_EXPORT_FORMAT_VERSIONinadmin_ops.py).hriv_versionis taken from theAPP_VERSIONenv var (unknownwhen unset).
During import, the manifest is validated after extraction and before any
entry is swapped into /data, so a rejected archive never modifies the data
directory:
- An unsupported
format_versionis rejected with a clear operator-facing error naming the supported versions and the exporting HRIV version (SUPPORTED_FILES_IMPORT_FORMAT_VERSIONSleaves room for future migration logic when older formats gain upgrade paths). - A manifest with
export_typeother thanfilesystem, or one that is not valid JSON, is rejected. - Archives without a manifest (created before manifests existed — e.g.
previously retained archives being rerun) are accepted as legacy
format v0 with a logged warning; the task log records which path was
taken (
Archive manifest format v1 validated.vsLegacy archive (no manifest); imported as format v0.).
The manifest never appears in /data: it is consumed and removed from the
staging directory during validation.
Filesystem imports use a two-step flow to stream a potentially large archive:
create_files_importcreates the task inuploadingstatus.- For small archives,
upload_task_fileaccepts a rawapplication/octet-streamrequest body, streams the archive directly to disk, then atomically transitionsuploading → pending(a guardedUPDATE ... WHERE status = 'uploading', so a concurrent cancel is respected). Multipart uploads are rejected with 415 and wrong-state uploads return 409. - For archives larger than 10 MiB, the client uses the resumable chunked flow:
GET /api/admin/tasks/{task_id}/uploadreturns the currentbytes_received;PATCH /api/admin/tasks/{task_id}/uploadappends a raw chunk with theUpload-OffsetandUpload-Lengthheaders; andPOST /api/admin/tasks/{task_id}/upload/finalizetransitions the task topendingonce the total bytes match. Offset mismatches return 409 with the currentbytes_receivedso the client can resume without re-sending data. - Before streaming starts, the handler compares
Content-Length(or the chunk / total size for chunked uploads) against free space on the admin-tasks volume. If the declared size will not fit, the task is marked failed and the endpoint returns 507 with the required and available byte counts.
Once the task enters pending, run_files_import stages the archive under
IMPORT_STAGING_DIR on the same volume as data_dir (default:
<data_dir>/.import-staging), performs a coarse free-space preflight with a
small margin over the compressed archive size, and extracts the archive in a
single pass. A second runtime floor checks the staging volume during
extraction so highly compressible archives still fail before the swap if free
space drops too low. When extraction finishes, it swaps each exported top-level
entry into /data one by one with same-volume renames. That keeps tiles/
and admin_tasks/ in place, avoids a whole-directory rename of /data, and
removes the extra copytree back from /tmp.
Archive progress is reported from compressed bytes read, so the UI can keep a
meaningful extract bar without a separate count-only scan. The implementation
uses pigz -dc when available and falls back to Python gzip streaming when it
is not. Filesystem-import archives remain on the data volume after import so
operators can rerun them without re-uploading; delete them when you want to
reclaim space, and be aware that retained archives can accumulate over time.
The IMPORT_STAGING_FREE_SPACE_FACTOR preflight is only a coarse gate for the
compressed archive size; IMPORT_STAGING_MIN_FREE_BYTES is the authoritative
runtime floor during extraction.
Because entries are moved into place with os.rename, IMPORT_STAGING_DIR
must be on the same filesystem/volume as data_dir (the default satisfies
this). If it is overridden to a different volume, the import fails fast at the
preflight with a clear error rather than surfacing a cryptic cross-device
(EXDEV) failure part-way through the swap.
The swap replaces source_images/ wholesale, and exports omit its transient
.staging/ upload-spool subdirectory — so after the swap the import recreates
<source_images_dir>/.staging (#1365). A running API pod's TMPDIR keeps
pointing there; without this step the next multipart upload would fail on
tempfile rollover. Creation retries briefly for transient filesystem errors;
a persistent failure is logged but does not fail the import (the restore
itself succeeded) — UploadBodyLimitMiddleware re-ensures the directory on
every upload request, so the next upload self-heals in any execution mode.
The admin UI's "Previously uploaded import archives" list shows cumulative storage usage (for example, "3 retained archives using 87.4 GiB") so operators can see at a glance how much persistent space retained archives consume before deciding what to reclaim.
Operators can bound retained-archive storage with two env-driven settings
(both default to 0 = retain indefinitely, preserving the original behavior):
| Env var | Meaning |
|---|---|
FILES_IMPORT_ARCHIVE_RETENTION_COUNT |
Keep only the newest N distinct archives; older ones are deleted. |
FILES_IMPORT_ARCHIVE_RETENTION_DAYS |
Delete archives older than N days. |
Both dimensions can be combined; an archive is deleted when it violates
either one. An archive's age is measured from its most recent import task, so
re-running a retained archive resets its age — an archive that is actively
being reused is treated as fresh. Enforcement runs after each successful
filesystem import and once
at backend startup (so age-based limits apply even when no import runs).
Deletions reuse the same safe cleanup as the manual Delete action
(delete_files_import_archive), so an archive referenced by an active files
import is never removed, and rerun tasks sharing one on-disk file count as a
single archive. The active policy is exposed at
GET /api/admin/tasks/files-import/archive-retention and shown in the admin
UI next to the cumulative-usage summary whenever a non-zero policy is
configured.
The SHA-256 checksum of an import archive is recorded on its AdminTask
(admin_tasks.input_checksum) the first time the archive is imported.
Re-running a retained archive creates a new task that inherits the recorded
checksum; run_files_import recomputes the archive's SHA-256 before any
extraction or /data mutation and rejects the rerun if the on-disk file no
longer matches:
The retained archive no longer matches the originally uploaded file and cannot be reused. Please upload a new archive.
This detects accidental filesystem corruption or manual modification of
retained archives and makes reruns reproducible. It is an integrity check,
not a security feature — it complements (and does not replace) the archive
path validation and manifest validation above. Archives retained before this
feature have no recorded checksum; their next import records a baseline
checksum (backfilled onto every task sharing that archive's input_path, so
reruns launched from any of those tasks verify against it). The task log shows
either Archive SHA-256 recorded: <hex>. (baseline) or
Archive integrity verified: SHA-256 matches the original upload. (verified
rerun).
Completed db_export and files_export tasks leave their result archive on the
admin-tasks volume so it can be re-downloaded. Over time these accumulate, so
the admin UI's "Stored export archives" panel lists each retained export
artifact with its filename, size, owning task metadata, and cumulative storage
usage, and lets operators purge individual archives.
GET /admin/tasks/backup-archives(admin only) lists all on-disk export result archives. Onlydb_export/files_exporttasks whoseresult_pathresolves (via_safe_admin_task_file) to an existing file inside the admin-tasks directory are returned. Each entry carries apurgeableflag that isfalsewhile the owning task is still active.DELETE /admin/tasks/backup-archives/{task_id}/{artifact_role}(admin only) deletes a single archive from disk and clears the task'sresult_filename/result_pathcolumns. Onlyartifact_role="result"is supported (400 otherwise); the task must exist (404) and be an export task (404 otherwise), and it must not be active (409 whileuploading/pending/running/cancelling). Filesystem-import archives are managed separately viaDELETE /tasks/files-import/archives/{id}.
When an export task reaches a terminal state, the panel refreshes automatically so a newly produced archive appears without switching tabs.
rebuild_tiles regenerates DZI tile trees from the preserved source images.
Tiles are derived data, so this is the operator-safe recovery path when a
restore brings back the database (and source-image volume) but not the
large tile volume, or when a pipeline change makes existing tiles stale. See
Tile-cache provenance for how missing vs stale
is determined.
A rebuild_tiles task is queued automatically after a successful
files_import. The Admin UI also exposes a Rebuild Tiles control so an
operator can start one manually — for example, after a cancelled automatic
rebuild, a per-file restore, or a stale-tile cleanup.
Serial task creation shares a PostgreSQL advisory lock with the default-off
durable rebuild scheduler. An active serial AdminTask or durable
Job(job_type="rebuild_tiles") blocks another rebuild, so parallel scheduler
experimentation cannot overlap the established operator or post-import path.
Redis locks and queue depth are not used for this correctness boundary.
- Endpoint:
POST /admin/tasks/rebuild-tiles(admin only). Optional JSON body{ "scope": "missing_stale", "image_ids": [..] }. - Runner:
run_rebuild_tilesinadmin_ops.py; per-image work lives inprocessing.rebuild_source_image_tiles, with explicit preparation and promotion helpers for future durable workers, and target selection inprocessing.select_rebuild_targets. - Parameters are persisted to a small JSON file referenced by the task's
input_path(mirroring the db-import staging pattern) and deleted when the task reaches a terminal state.
Scopes (scope):
missing— only source images whose tile manifest is absent on disk.stale— tiles present on disk but generated under an older settings hash.missing_stale(default) — either of the above.all— force-rebuild every completed, linked source image.
image_ids optionally narrows the population to specific images.
Filesystem-aware selection. Selection checks the on-disk image.dzi
manifest directly rather than trusting database provenance, because a DB-only
restore can leave provenance reporting current while the tile files are gone.
Only the authoritative source image for each image (the one referenced by
Image.tile_sources) is rebuilt, so a source superseded by a replacement is
never resurrected.
Idempotent and resilient.
- Tiles are generated into a temp directory and atomically swapped into place, so a mid-generation failure never destroys a good tile tree.
- Slow libvips generation and source checksumming run in the preparation phase after the serial runner closes its read transaction. Promotion revalidates the source, locks the linked image row, confirms that image still points to the rebuilding source, swaps the prepared tree, and stages provenance updates in a short transaction without committing internally.
- While an image is in the long preparation phase, the serial runner refreshes
the task's
updated_atfrom a separate database session. The same poll observes cancellation without sharing the image-processing session, so stale reconciliation cannot admit an overlapping rebuild while libvips is active. - The prior tile tree is retained until the per-image database commit succeeds. A promotion, commit failure, or cancellation before commit restores it; a successful commit removes the retained tree. Cancellation during that cleanup is propagated after the retained tree has been removed.
- Each image commits independently; a per-image failure is logged and the batch
continues. The task only ends
failedfor a fatal setup error (e.g. an unreadable parameters file), never because one image failed. - A rerun skips tile sets that are already current (unless
scope = all), so the operation is safe to run repeatedly.
When REBUILD_PARALLEL_ENABLED=true and TASK_EXECUTION_MODE=required, the
Admin UI's Rebuild Tiles button instead creates a durable
Job(job_type="rebuild_tiles") through POST /api/jobs/rebuild-tiles, and the
Backups tab's Parallel tile rebuilds section shows supervisor status,
progress, per-status counts, and operator controls (cancel while active,
retry failed items, bounded failed-item inspection). Durable jobs are listed
separately from the serial AdminTask recent-tasks list; they are never
merged. See Durable jobs for the API surface and state model. When
the flag is off, the button keeps using the serial endpoint and the section
notes that parallel rebuilds are disabled; already-running durable jobs still
run to completion.
Durable rebuilds emit the hriv.tile_rebuild.* metrics contract and
rebuild.* structured events documented in
jobs.md; /api/metrics exposes the
PostgreSQL-derived hriv_tile_rebuild_jobs_active,
hriv_tile_rebuild_active_children, and hriv_tile_rebuild_queued_items
gauges. For a production-shaped scale rehearsal, python -m app.rebuild_fixture --count N seeds deterministic TRF- linked sources
backed by tiny valid TIFFs (--purge removes their rows, source files, generated
tiles, and rebuild temporary trees). Purge and database JSON export identify
fixtures only through metadata.rebuild_fixture=true, the linked source, and its
confined stored path; user-controlled TRF- names or high IDs alone never
qualify. Fixture seeding/purging refuses to run while a rebuild is active.
Filesystem exports and scheduled backups use a shared source-volume
lock and fail closed while the fixture directory exists; the full procedure and
measurement record live in
backup-restore-runbook.md.
When image processing dies mid-flight — pod crash, OOM, rollout, or node
drain — the reconciliation sweep marks the in-flight SourceImage
rows failed before they ever link an Image (image_id IS NULL).
These rows are terminal: there is no retry path, no viewer can request them,
and rebuild_tiles cannot select them (see the selection contract above —
status='completed' with a linked image only). Operators typically re-upload
the file, which creates a new SourceImage row, so failed orphans only
accumulate: each retains its preserved source file on the source-images
volume plus a partial tile tree on the tiles volume.
Purge them on demand with scripts/purge_failed_source_images.py, run inside
the backend container (it needs the database and both volumes):
# Dry run — lists candidates and reclaimable space (detector + safety check)
kubectl --context stable -n hriv exec -i deploy/hriv-backend -- \
python - < scripts/purge_failed_source_images.py
# Execute the purge
kubectl --context stable -n hriv exec -i deploy/hriv-backend -- \
python - --apply < scripts/purge_failed_source_images.py--older-than-days N(default 30) excludes recent failures an instructor may still want to retry or inspect.--source-ids <csv>narrows the run to specificSourceImageids.- On-disk artifacts are removed before the row, and a row whose artifact
removal fails is retained so the next dry run can find and retry it —
the row is the discoverability anchor for both its stored file and its
tile tree. (
list_orphaned_final_filesonly reports unowned files, and nothing else scanstiles_dir, so the retained row is the reliable handle.) - Eligibility is re-checked at delete time — a row that gained an
image_idor changed status between scan and delete is skipped.
Do not hand-delete failed rows that still carry an image_id: those are
linked to a live image and belong to the delete-image or rebuild-tiles paths.
If orphaned failures start recurring often enough that the manual purge
becomes routine, revisit promoting this to an admin API operation.
The admin area also exposes a manifest-browsing restore flow for restoring a single file out of a backup snapshot archive:
GET /admin/backups/snapshotsGET /admin/backups/snapshots/{snapshot_name}/manifestPOST /admin/tasks/file-restore
The backend talks directly to Azure Blob Storage with a read-only SAS URL
(AZURE_READ_SAS_URL) and the snapshot prefix (AZURE_BACKUP_PREFIX).
Snapshot manifests are read from the sidecar blob when present, with a
tar-stream fallback for older snapshots. The restore task only accepts members
under data/ and reminds operators that Rebuild Tiles can be run if a restored
source image needs fresh tiles.
reconcile_stale_tasks runs on backend startup. It marks any task stuck in
uploading/pending/running/cancelling whose updated_at is older than
ADMIN_TASK_STALE_SECONDS (default 900s = 15 min) as failed. This clears
tasks whose runner process died (pod crash, OOM, rollout) so they don't block the
_create_task concurrency guard forever.
The freshness check on updated_at makes this multi-replica safe: a
sibling pod actively running a task keeps writing progress to updated_at, so a
newly starting pod won't clobber it.
Runners also keep updated_at fresh through individual checkpoints that
cannot write progress themselves. A shared _poll_task_heartbeat poll in
admin_ops.py heartbeats from a separate session while observing
cancellation: the serial per-image tile rebuild runs it via
_run_with_task_heartbeat, and the filesystem import's archive SHA-256
pass runs it via _run_with_cancel_poll so a cancel request also signals
the threading.Event the hash loop checks between chunk reads (the
thread exits promptly instead of reading the rest of a large archive).
The remaining long spans (archive scanning/extraction, per-file restore)
use dedicated cancellation pollers that call _heartbeat_task on the
same cadence.
backend/tests/test_admin_ops.py— runner logic, export/import round-trip, reconciliation,run_rebuild_tilesbatch behaviour.backend/tests/test_router_admin.py— endpoint behaviour, cancellation, upload phase, concurrency guard, rebuild-tiles request handling.backend/tests/test_processing.py—select_rebuild_targets,rebuild_source_image_tiles, and the tile-presence helpers.
See also: Domain model, Groups, agent feature map.