|
Documentation: Operations

Operate

Operations

Health checks, logs, metrics, upgrades, runbooks, retention, backups and recovery objectives.

Health checks

Service Check Expected
api-pacs GET / 200
study-service GET /health, GET /health/detailed, GET /metrics {"status":"healthy"}; DB, Redis and Celery reachability; Prometheus exposition (operator token required)
Orthanc GET /system 200
Postgres pg_isready exit 0
Redis redis-cli PING PONG
Elasticsearch GET /_cluster/health green or yellow

Logs and SIEM forwarding

All services log to stdout: docker compose logs -f <service> or docker compose logs --since 1h --tail 200 api-pacs. Forward to a SIEM with one of three patterns in docker-compose.override.yml: the Docker syslog driver for low volume, a Filebeat sidecar when the customer already runs ELK, or Vector for everything else.

# A) Docker syslog driver (per service)
services:
  api-pacs:
    logging:
      driver: syslog
      options:
        syslog-address: "udp://siem.customer.local:514"
        tag: "pacs-ai/{{.Name}}"

# B) Filebeat sidecar (filebeat/filebeat.yml: filebeat.autodiscover docker provider with hints, output.logstash hosts)
services:
  filebeat:
    image: docker.elastic.co/beats/filebeat:8.13.4
    user: root
    volumes:
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./filebeat/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro
    command: ["-strict.perms=false"]
    networks: [pacs-net]

# C) Vector (vector/vector.toml: sources.docker type docker_logs, sinks.siem type socket tcp json to siem.customer.local:514)
services:
  vector:
    image: timberio/vector:0.39.0-debian
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./vector/vector.toml:/etc/vector/vector.toml:ro
    networks: [pacs-net]

Metrics and alerts

  • Prometheus instrumentation is wired but not scraped by default: study-service GET /metrics (bearer operator token; HTTP, pipeline transition and duration, polling-mode and model-execution series) and api-pacs GET /debug/vars (Go expvar JSON at the server root, behind the OpenAPI docs basic-auth gate; dispatch attempts and latency, callback status and event-delivery lag, run phase, outcome and attention counts, skip and attention codes, reconciliation counters, SSE connections and publish or write failures). No Prometheus or Grafana ships; point your platform at those endpoints. Metric labels are enums only and never carry run IDs, tenant IDs, study UIDs, model error text, DICOM metadata or results.
  • Ingestion metrics named in the plan: ingestion_candidates_discovered_total, ingestion_candidate_transitions_total{from,to}, ingestion_retrieval_attempts_total{outcome}, ingestion_retrieval_latency_seconds, ingestion_processing_callbacks_total{status,outcome}, ingestion_processing_rollups_total{status}, ingestion_cleanup_jobs_total{outcome}.
  • Recommended alerts: candidate stuck in RETRIEVAL_QUEUED beyond N minutes, Celery queue depth above N, Orthanc disk above 80%, any service unhealthy for 5 minutes, failed C-MOVE rate above N% over 15 minutes, sustained callback lag or callback errors, reconciliation failures, notification publish failures, subscriber drops, and unexpected polling fallback.

Upgrade and rollback

# pre-upgrade: read release notes, verify backups, drain the Celery queue, snapshot both Postgres volumes
docker compose stop api-pacs
until [ "$(docker compose exec -T redis redis-cli -n 2 LLEN celery)" = "0" ]; do sleep 5; done
docker compose exec study-service celery -A workers.celery_app inspect active

# upgrade
git fetch && git checkout <new-tag>
docker compose pull
docker compose up -d
docker compose exec api-pacs make -C /app migrate-up      # or: cd api-pacs && make migrate-up
docker compose exec study-service alembic upgrade head

# rollback: previous image tags + docker compose up -d
STEPS=1 make migrate-down                                  # api-pacs, when a down migration exists
docker compose exec study-service alembic downgrade -1     # cardio-agent
# if an irreversible migration was applied, restore from the pre-upgrade snapshot

Real-time worklist rollout runbook

The rollout of processing runs on an existing install follows api-pacs/docs/realtime-worklist-rollout-runbook.md. Take consistent backups of both PostgreSQL databases and prove them by restoring into isolated instances (a backup that has not been restored successfully is not a rollout gate). Deploy study-service first, then Go with INFERENCE_REQUIRE_PROCESSING_RUN_ID=false, confirm health, callback auth, operator job lookup, Redis, reconciliation, the REST snapshot and the SSE heartbeat, switch study-service atomically to ENABLE_ORTHANC_POLLING=false and ENABLE_GO_CALLBACKS=true, then run the backfill. Keep the nullable columns and additive migrations; never run down migrations while either version may still be active.

go run ./cmd/legacy-processing-run-backfill --dry-run      # plus docs/legacy-processing-run-backfill-audit.sql
go run ./cmd/legacy-processing-run-backfill --apply --confirm=LEGACY_IMPORT \
  --expected-studies=<fresh-eligible-studies> --expected-executions=<fresh-eligible-executions>
go run ./cmd/legacy-processing-run-backfill --verify \
  --expected-studies=<approved-studies> --expected-executions=<approved-executions>   # must report "passed": true
go run ./cmd/legacy-processing-run-backfill --rollback --confirm=ROLLBACK_LEGACY_IMPORT
  • Stop the preflight if any study is skipped, if the SQL and Go eligible counts differ, if tenant mismatches, duplicate plans or existing-run conflicts are non-zero, if ingestion or callbacks still create run-less work, or if the fresh counts differ from the approved change record. The 2026-08-06 baseline of 669 studies and 1,179 executions is historical evidence, not a constant.
  • Apply exactly once, with ingestion dispatch paused and fresh counts; if the command stops, run a new dry run and obtain approval for the new remaining counts. Each completed study is atomic.
  • Verification checks a two-model terminal aggregate, a completed-plus-failed mix producing PARTIAL_SUCCESS, a no-usable-DICOM structured skip, a manual run N+1, reconciliation repairing a withheld callback, and tenant B being blocked from tenant A run detail, history, snapshot and SSE.
  • Rollback trap: the execution foreign key is ON DELETE CASCADE, so deleting a LEGACY_IMPORT run removes its executions; and a pre-contract cardio-agent image whose Alembic graph ends at 0009 cannot start against revision 20260814_0012. Only then set INFERENCE_REQUIRE_PROCESSING_RUN_ID=true; the nullable compatibility paths are removed once no run-less work remains.

Runbooks

Symptom What to do
Candidate stuck in a state Inspect ingestion_candidates rows not updated in 15 to 30 minutes (query below). The reconciliation worker handles stale processing states; for a dead C-MOVE, requeue by setting the row back to STABLE after copying it to an audit table. The next worker tick picks it up within a minute.
Celery queue backed up docker compose exec redis redis-cli -n 2 LLEN celery; raise CELERY_WORKER_CONCURRENCY (default 2) or scale docker compose up -d --scale study-celery-worker=N.
Orthanc disk full Lower ORTHANC_LOCAL_CACHE_EXPIRATION_IN_HOURS and recreate api-pacs; check curl -s http://localhost:8042/statistics; delete single studies with DELETE http://localhost:8042/studies/<id>; bulk-delete older than N hours as a last resort. Add disk monitoring.
Model container crash-looping docker compose logs --tail=200 <model-service>; usual causes are GPU driver mismatch, missing weights, out of memory.
Remote PACS unreachable echoscu from the host, check ingestion runner logs, confirm the hospital firewall has not blocked the AE.
Firebase auth outage All API calls return 401; confirm on the Firebase status page; no local mitigation.
-- candidates queued for retrieval too long
SELECT id, study_instance_uid, status, processing_status, updated_at, last_retrieval_error
FROM ingestion_candidates
WHERE status = 'RETRIEVAL_QUEUED' AND updated_at < now() - interval '15 min'
ORDER BY updated_at;

-- breakdown of stuck states
SELECT status, count(*), min(updated_at), max(updated_at)
FROM ingestion_candidates
WHERE updated_at < now() - interval '30 min'
GROUP BY status;

-- requeue one dead retrieval (only after confirming the C-MOVE is not in flight)
UPDATE ingestion_candidates
SET status = 'STABLE', updated_at = now(), last_retrieval_error = NULL
WHERE id = '<candidate-id>' AND status = 'RETRIEVAL_QUEUED';

Data retention

Data Location Lifetime PHI
DICOM instances Orthanc volume 24-hour rolling cache Yes
Ingestion candidates and jobs PostgreSQL 5433 Long-term, no automatic deletion Yes (study UIDs, timestamps)
Inference results PostgreSQL 5434 pipeline_results Long-term; customer decides archival Yes (derived)
Audit and activity logs Elasticsearch Long-term; the customer must define an index-lifecycle policy (30, 90 or 365 days are common) or indices grow until the volume fills Yes
# sample Elasticsearch ILM policy: hot rollover at 30d / 50gb, delete after <retention-days>
curl -X PUT "http://localhost:9200/_ilm/policy/pacs-ai-logs" -H 'Content-Type: application/json' -d '{
  "policy": { "phases": {
    "hot":    { "actions": { "rollover": { "max_age": "30d", "max_primary_shard_size": "50gb" } } },
    "delete": { "min_age": "<retention-days>d", "actions": { "delete": {} } } } } }'
curl -X PUT "http://localhost:9200/_index_template/pacs-ai-logs-template" -H 'Content-Type: application/json' -d '{
  "index_patterns": ["pacs-ai-*", "logs-*"],
  "template": { "settings": { "index.lifecycle.name": "pacs-ai-logs", "index.lifecycle.rollover_alias": "pacs-ai-logs" } } }'
curl http://localhost:9200/_cat/indices          # adjust index_patterns to what api-pacs actually writes
curl http://localhost:9200/_ilm/policy/pacs-ai-logs

Backups and recovery objectives

No application-data backups are configured out of the box, so the effective recovery point objective is unbounded and the effective recovery time objective is the time to reinstall plus up to 24 hours for Orthanc to refill from the remote PACS (typically 4 to 24 hours). No RPO or RTO targets are committed to customers today; per-customer targets and the backups to meet them are agreed during contract scoping, and restore drills are recommended quarterly once backups exist.
  • What is lost on host failure today: all pipeline_results and all ingestion job and candidate history. They can be regenerated by re-running ingestion against the same source-PACS window, but only if the studies are still present at the source.
  • Recommended additions, in priority order: a pg_dump cron with an offsite copy for PostgreSQL 5434 (the only data that is costly to regenerate), then PostgreSQL 5433 (audit history), Firestore through Google Cloud project export; skip Orthanc (refills within the cache window) and Elasticsearch (rebuildable logs).
  • Secrets (.env, the Firebase service-account JSON, TLS certificates) live in the customer secrets vault and are backed up there.
PACS AI Logo status Status Terms and Conditions Privacy Policy Privacy Impact Assessment Document (EFVP)

© 2026 HeartWise AI Lab, Montreal Heart Institute. All rights reserved.