Troubleshooting Runbook — scripts/smoke_e2e.py and the local dev stack¶
This runbook is for triaging a failing run of scripts/smoke_e2e.py
— the end-to-end walk that exercises the full vertical slice (bootstrap → load an admin
boundary → ingest hazard → upload exposure → assign centroids → compute impact → read back
results) against a live docker compose up stack (docker-compose.yml).
It replaced the old demo/smoke script (scripts/demo.py), deleted whole alongside the rest of
the demo scaffolding — issue #892 — because it drove a bespoke seeded path rather than the real
user journey; see ADR-077.
Every command and claim below is grounded in a committed file — cited inline — so this stays trustworthy even though it can't be exercised against a running stack in this environment (no Docker/PostGIS here). Where something is a genuinely external convention (e.g. how Celery keys its Redis results) rather than something this repo defines, it's called out as such.
For production operations (release flow, backups, the Hetzner host), see
DEPLOYMENT.md instead — this doc is about the local dev stack
(docker-compose.yml), which is what scripts/smoke_e2e.py targets by default
(--base-url http://localhost:8000).
Before you start — a checklist that catches most first-run failures¶
.envexists, withCL_BOOTSTRAP_ADMIN_EMAIL/CL_BOOTSTRAP_ADMIN_PASSWORDset.cp .env.example .env(README.md "First-time setup"), then fill in both — there is no default credential, so leaving them blank leaves the environment with no admin (see DEPLOYMENT.md). Themigrations,bootstrap,api,worker, andbeatservices all declareenv_file: .env(docker-compose.yml) —docker compose uprefuses to start them without the file existing.- The stack is up:
docker compose up(ordocker compose --profile fullstack upfor the full UI+API slice, perCLAUDE.md's "Demoable MVP Slice"). Thebootstrapone-shot runs aftermigrationson everyupand provisions the organization + admin from step 1 —docker compose logs bootstrapconfirms it ran (Bootstrap complete: …orAlready bootstrapped: …, both success). scripts/smoke_e2e.pyneedsrequestsimportable — it's a module-level import (scripts/smoke_e2e.py) with no other project dependency, so any Python 3 environment withrequestsinstalled can run it (e.g.uv pip install -e ".[dev,worker]", the install path.github/workflows/sdk-smoke.ymluses in CI).- The hazard-ingest payload needs a raster staged on disk that a clean clone doesn't have.
By default the walk ingests the tiny tracked reference raster,
tests/fixtures/reference_datasets/river_flood.tif, from the worker-visible path/data/river_flood.tif(DEFAULT_HAZARD_TIF,scripts/smoke_e2e.py). That path is served by the./data:/data:robind mount on theworkerservice (docker-compose.yml), anddata/is gitignored (.gitignore), so a fresh clone has nothing there until you stage it — the same step.github/workflows/sdk-smoke.yml's "Stage the reference hazard raster for the worker" step runs in CI: Point at a different raster with--hazard-tif(or$SDK_SMOKE_HAZARD_TIF) if you need one. - Every stage past bootstrap needs
Role.ANALYST, and the walk fails immediately without it — unlike the retired demo script, there is no best-effort skip-on-401 (seescripts/smoke_e2e.py's module docstring, "Authentication"). If the composebootstrapone-shot already provisioned an account (step 2 above), point the walk at those same credentials and skip re-provisioning:DropSDK_SMOKE_EMAIL="$CL_BOOTSTRAP_ADMIN_EMAIL" \ SDK_SMOKE_PASSWORD="$CL_BOOTSTRAP_ADMIN_PASSWORD" \ SDK_SMOKE_ORG_SLUG="${CL_BOOTSTRAP_ORG_SLUG:-default}" \ python scripts/smoke_e2e.py --skip-bootstrap--skip-bootstrap(and theSDK_SMOKE_*vars, using--roleinstead) to have the walk provision its own account viascripts/bootstrap_env.py— needs a privileged DB role, see DEPLOYMENT.md.
Quick triage — where to look first¶
GET /health(liveness — process is up) thenGET /health/ready(readiness — probes Postgres, Redis, and MinIO individually and reports per-check detail) —src/climate_lama/main.pyhealth()/health_ready().scripts/smoke_e2e.py'swait_for_health()(step 3, unless--skip-bootstrap/--skip-boundariesmove it earlier) pollsGET /healthuntil it answers or times out, so a dependency outage that keeps the API from ever coming up surfaces there; a readiness-level failure (Postgres/Redis/MinIO individually) instead shows up as the first stage that touches the broken dependency failing itsresp.raise_for_status().docker compose ps— which containers aren'trunning/healthy.docker compose logs <service> --tail=100for whichever checkhealth/readyflagged.
Postgres¶
Symptoms: GET /health/ready reports "postgres": {"status": "error", "detail": ...}
(main.py); scripts/smoke_e2e.py fails at step 1 (bootstrap_env.py needs a reachable DB to
even start) unless --skip-bootstrap is passed, in which case it instead fails at step 3's
health wait.
First three checks:
docker compose ps postgres— healthcheck ispg_isready -U climate_lama(docker-compose.yml).docker compose logs postgres --tail=100(container nameclimate_lama_postgres,docker-compose.yml).docker compose logs migrations— the one-shotmigrationsservice runsalembic upgrade headand only starts once Postgres is healthy (depends_on: postgres: condition: service_healthy,docker-compose.yml);api,worker, andbeatall wait onmigrations: condition: service_completed_successfully, so a wedged or failed migration blocks every other service, not just Postgres.
Gotcha — port mismatch between alembic.ini and docker-compose.yml: the compose Postgres
is published on host port 5433, not 5432 ("5433:5432", docker-compose.yml), but
migrations/alembic.ini's own default sqlalchemy.url still
points at localhost:5432. Running alembic upgrade head / alembic current directly from
the host (outside the migrations container — e.g. the Quickstart's
uv run alembic upgrade head, docs/quickstart/index.md) needs
DATABASE_SYNC_URL exported first, or it fails closed with a connection error:
export DATABASE_SYNC_URL="postgresql+psycopg2://climate_lama:password@localhost:5433/climate_lama"
uv run alembic upgrade head
migrations/env.py reads DATABASE_SYNC_URL from the environment and overrides
alembic.ini's default when it's set.)
Redis¶
Symptoms: GET /health/ready reports "redis": {"status": "error", ...} (main.py);
scripts/smoke_e2e.py jobs never leave pending (poll_job() keeps printing
... {label}: pending — the worker can't receive tasks without the broker).
First three checks:
docker compose ps redis— healthcheck isredis-cli ping(docker-compose.yml).docker compose exec redis redis-cli ping.- Broker and result backend are two different logical Redis databases on the same
instance — broker on db 0, results on db 1 (
CELERY_BROKER_URL=redis://redis:6379/0,CELERY_RESULT_BACKEND=redis://redis:6379/1,docker-compose.yml; wired the same way insrc/climate_lama/worker/celery_app.pyviaCelery(..., broker=settings.celery_broker_url, backend=settings.celery_result_backend)). If you're inspecting keys by hand, pick the right db:docker compose exec redis redis-cli -n 0 keys '*'for queued tasks,-n 1for stored results.
MinIO¶
Symptoms: GET /health/ready reports
"minio": {"status": "error", "detail": "bucket 'climate-lama' does not exist"} — MinIO itself
can be reachable while the bucket is still missing, because health_ready() explicitly checks
bucket_exists, not just connectivity (main.py). Hazard-ingest or exposure-upload jobs fail
while staging to object storage.
First three checks:
docker compose ps minio minio-init—minio's healthcheck ismc ready local;minio-initis a one-shot job that creates the bucket and only printsBucket ready.on success (docker-compose.yml,minio-initentrypoint:mc alias set local http://minio:9000 minioadmin minioadmin && mc mb --ignore-existing local/climate-lama).docker compose logs minio-init— confirms whether the bucket-creation step actually ran and succeeded.- Inspect the bucket directly, mirroring
minio-init's own alias pattern:(credentials aredocker compose exec minio mc alias set local http://localhost:9000 minioadmin minioadmin docker compose exec minio mc ls local/climate-lama.env.example's dev defaults:MINIO_ACCESS_KEY=minioadmin/MINIO_SECRET_KEY=minioadmin.) Or use the browser console athttp://localhost:9001(docker-compose.ymlport mapping9001:9001; also listed in README.md's service table).
API¶
Symptoms: any scripts/smoke_e2e.py stage raising via resp.raise_for_status(); an
unauthenticated run fails immediately at step 3 rather than 401ing partway through — every
stage past that point needs Role.ANALYST and there is no best-effort skip (see the module
docstring's "Authentication" section).
First three checks:
GET /healththenGET /health/ready(main.py) — establishes whether the API process itself is up before treating a failure as request-specific.GET /v1/info— reportsversion,environment,git_shaandfeatures(main.pyinfo()), so you know which build actually answered and which feature lines it has switched on (features.live_mode, issue #1280;features.geocoding, issue #1336 — the latter isgeocoder_enabled AND geocoder_advertised, so afalsethere does not by itself mean the geocoder is disabled). Useful when comparing a locallydocker compose --build-ed API against a pulledghcr.io/cortomaltese3/climate-lama:${CLIMATE_LAMA_TAG}image (docker-compose.yml).docker compose logs api --tail=100(containerclimate_lama_api). The devapiservice mounts./srcand runs with--reload(command: uvicorn climate_lama.main:app --host 0.0.0.0 --port 8000 --reload,docker-compose.yml), so a stack trace from a local edit shows up here immediately.
Reading the error envelope: every API error follows one shape —
{"error": {code, severity, message_en, details}, "meta": {request_id, timestamp}} (ADR-028;
exercised directly by scripts/smoke_e2e.py's ingest and compute stages). Match error.code against the Code enum in
src/climate_lama/core/errors.py, e.g.:
E_ENGINE_UNKNOWN(422) /E_ENGINE_UNAVAILABLE(400) — the optionalX-Engineheader onPOST /v1/compute/impactpicked an unregistered engine name, or a registered-but-not-deployed one (src/climate_lama/api/v1/compute.py).E_INGEST_CHORD_TIMEOUT/E_INGEST_WEDGED— hazard ingest stalled past its time budget; see the Worker section below.E_GEOCODE_UNAVAILABLE(503) —GET /v1/geocodewhen the opt-ingeocodingprofile (self-hosted Nominatim) isn't running (docker-compose.yml,nominatimservice). Not exercised byscripts/smoke_e2e.py, which never calls that endpoint.E_SERVICE_BUSY(503,Retry-After: 5) — a request timed out waiting for a database connection (a SQLAlchemy pool checkout or the asyncpg connect), or on another bounded wait (main.pytimeout_error_handler). The API is overloaded, not broken: retry. A burst of these alongside unanswered/health/liveprobes means something is blocking the single uvicorn event loop — checkdocker compose logs api --timestampsfor a gap between lines (issue #1457). A vector tile route (/v1/tiles/results|exposures|portfolios/…) also answers it on purpose, with the cause indetails.reason(issue #1804):tile_query_timeout— PostgreSQL cancelled the tile's statement atVECTOR_TILE_STATEMENT_TIMEOUT_MS(default 5000); the tile is slow, so look for the statement inpg_stat_activity/ the PostgreSQL log (canceling statement due to statement timeout) rather than raising the timeout first.tile_concurrency_cap—VECTOR_TILE_MAX_CONCURRENCY(default 8) tile requests were in flight in that API process and none finished withinVECTOR_TILE_SLOT_WAIT_MS(default 2000) of this one arriving. The map does not ask again for a tile answered 503: that tile stays blank until the next pan or zoom, when the map asks for it afresh. A steady stream of these means tiles are slow enough to pile up. Neither reason on any other route's 503 means the pool itself ran dry (DB_POOL_SIZE+DB_MAX_OVERFLOW, default 5 + 10). The API log names which:… -> 503 E_SERVICE_BUSY: TileQueryTimeoutError,TileConcurrencyCapError, or the driver's timeout type.
Worker¶
Symptoms: ingest/compute jobs stuck at pending/running past scripts/smoke_e2e.py's
--timeout (default 600s) — poll_job() raises
TimeoutError: Job ... did not complete within 600s. GET /v1/hazards/ingest-jobs/{id}
(scripts/smoke_e2e.py step 4) shows status stuck at writing/aggregating with progress_pct
not moving.
First three checks:
docker compose ps worker— note: the devdocker-compose.yml'sworkerservice defines nohealthcheck(unlikedocker-compose.prod.yml's worker, which probescelery -A climate_lama.worker.celery_app inspect ping -d celery@$$HOSTNAME), sodocker compose pscan showworkeras merely "running" even if Celery itself failed to initialize on boot — don't trust the state column alone, check logs.docker compose logs worker --tail=100— on boot, Celery prints its resolved task list; compare against theinclude=[...]list incelery_app.py(e.g.climate_lama.worker.tasks,climate_lama.worker.ingest.stage_source, ...). A task missing from that boot-time list means its module failed to import.docker compose exec worker celery -A climate_lama.worker.celery_app inspect active(also tryinspect registered,inspect ping) — the same-A climate_lama.worker.celery_appinvocation theworker/beatservices themselves use (command: celery -A climate_lama.worker.celery_app worker --loglevel=info --queues=compute,impact,docker-compose.yml); confirms the worker process is actually consuming thecomputeandimpactqueues. On prod, impact runs are onimpact-worker(--queues=impact), so an impact job stuckpendingthere is triaged withdocker compose exec impact-worker ….
If a job looks permanently wedged rather than merely slow: the chord-timeout and
chunk-wedge watchdogs (sweep-wedged-ingest-chords every 10 minutes,
sweep-wedged-writing-ingest-jobs every ingest_wedged_writing_sweep_minutes minutes, default
10 — both in celery_app.py's beat_schedule) only run if the separate beat service is
up: "Without this service those tasks never run anywhere" (docker-compose.yml, comment on
the beat service). docker compose ps beat is worth checking any time an ingest job looks
stuck for good — with beat down, a wedged job never gets flipped to failed and just sits
there past ingest_chord_timeout_seconds (default 6h) or ingest_wedged_writing_ttl_seconds
(default 45m) (config.py).
Live worker — the live queue¶
What it is: a second Celery consumer (live-worker, container
climate_lama_live_worker) on the same image as worker, bound to the live queue only
(celery … worker --loglevel=info --queues=live --concurrency=1). It exists so a
live-feed tick — which fires every few minutes — never queues behind the multi-hour
ingest and impact work on compute. Un-profiled in all three compose files, so a plain
docker compose up (dev) and deploy-prod.yml's up -d --remove-orphans (prod) both
start it; there is no profile to enable and no separate image to pull.
Symptoms: live feeds never advance — last_polled_at frozen — while ordinary
ingest/compute jobs run fine (that split is the tell: compute has a consumer, live
does not). Nothing appears in the live-worker logs at all.
First three checks:
| Check | What it tells you |
|---|---|
docker compose ps live-worker |
Is the container up? Like dev's worker it has no healthcheck in docker-compose.yml (prod's does — the same celery … inspect ping -d celery@$$HOSTNAME probe), so "running" alone does not prove Celery initialised — read the logs too. |
docker compose logs live-worker --tail=100 |
The boot banner lists the queues this process consumes. It must show live — if it shows compute, the command: override was lost and the container is running the image's default CMD (--queues=compute,impact), leaving live with no consumer. |
docker compose exec live-worker celery -A climate_lama.worker.celery_app inspect active |
Confirms the process is reachable on the broker. inspect registered should list the live-feed tasks; a task missing there means its module failed to import. |
Tick fires but nothing happens (no error): check LIVE_MODE_ENABLED in the
container, not in the file — docker compose exec live-worker printenv LIVE_MODE_ENABLED.
All four services that care (api, worker, live-worker, beat) declare it the same
way, as ${LIVE_MODE_ENABLED:-false} in their own environment: block, so they resolve
identically from one source. Unset or false makes the tick a logged no-op by design.
Check beat too — it is the scheduler that fires the tick, and a beat/live-worker
disagreement looks exactly like "nothing happens": docker compose exec beat printenv
LIVE_MODE_ENABLED. (Before #1281 beat read the flag only through its env_file, so a
value exported in the shell rather than written to .env/.env.prod reached the workers
and not the scheduler.)
live-worker up but the logs show a missing live_feeds relation: this is the
pre-migration window, not a broken deploy. deploy-prod.yml rolls the containers
(up -d --remove-orphans) before running alembic upgrade head — the migration runs
inside the new api container after a health wait — so on a release that adds or changes
the live-feed tables, live-worker and beat come up against the old schema and a
scheduled tick can fire in that gap. The tick tolerates it (no-op, logged) rather than
crash-looping. Resolve it by restarting the two once migrations are at head:
# on the prod host, from /opt/climate-lama
P="-f docker-compose.prod.yml -f docker-compose.hetzner.yml --env-file .env.prod"
docker compose $P exec -T api alembic current # must print '(head)'
docker compose $P restart live-worker beat
docker compose $P logs live-worker --tail=20 # fresh boot banner, queue `live`
If the relation is still missing after that, the migration itself did not land — triage that, not the worker.
Queue depth: the dev monitoring profile's redis-exporter exports every queue as
key-size metrics (REDIS_EXPORTER_CHECK_SINGLE_KEYS: "0=compute,0=impact,0=live",
docker-compose.yml). Every entry is db 0 because a Celery queue is a Redis list
named after the queue in the broker database (CELERY_BROKER_URL = redis://redis:6379/0);
db 1 is the result backend and holds no queue lists. Query it as
redis_key_size{key="live"}, or read it straight from Redis:
docker compose exec redis redis-cli -n 0 llen live.
Reading a job's state without the API¶
- Coarse:
GET /v1/jobs/{job_id}(src/climate_lama/api/v1/jobs.py) —status(pending/running/completed/failed),celery_task_id,result_id,error_message. - Fine-grained (hazard ingest only):
GET /v1/hazards/ingest-jobs/{ingest_job_id}(src/climate_lama/api/v1/hazards.py) —statuswalkspending → running → chunking → writing → aggregating → committed → succeeded(terminal statesfailed/cancelled), perIngestJobStatus(src/climate_lama/models/enums.py), plusprogress_pct,chunks_total/chunks_done,error_code, and paginatedlogs(?logs_offset=N). - Directly in Redis (last resort — only when even the API is unreachable): the result
backend lives on db 1 (
CELERY_RESULT_BACKEND=redis://redis:6379/1,docker-compose.yml). Celery's Redis backend keys results ascelery-task-meta-<task_id>— this is Celery's own convention, not something this repo defines, so treat the exact key format as unverified against a specific Celery version rather than a repo guarantee: Get<celery_task_id>fromGET /v1/jobs/{job_id}'scelery_task_idfield above. The DB-backedJob/IngestJobrows via the API are the supported way to check status.
The bell said a job failed — triaging a JOB_FAILED notification¶
JOB_FAILED (job_failed) is the sixth NotificationType
(src/climate_lama/models/enums.py), added in Phase 14
(issue #1182). It means one thing precisely: a jobs (or ingest_jobs) row was moved to
failed and the submitter was told. Before it existed, failure paths wrote status /
error_message onto the row and nobody was ever notified, so "no notification" used to be
indistinguishable from "no failure" — it is not any more.
It is emitted from exactly one place per lane, and those are the only writers of
JobStatus.FAILED:
JobRepository.mark_failed (async), mark_job_failed_sync / reap_jobs_sync (worker and beat
sweeps) and IngestJobRepository.mark_failed
(job_repository.py,
ingest_job_repository.py).
A static (AST) guard, tests/test_failed_writers_guard.py,
fails the build if anything outside db/repositories/ assigns JobStatus.FAILED — so a failed
job with no notification is a bug in one of those four functions, not a missing call site
somewhere in the tree.
The payload (built once by core/notifications.py::job_failed_payload, so the async and
worker lanes cannot drift):
| Key | Meaning |
|---|---|
job_id |
The jobs row — the deep-link target. null when the failing work has no jobs row (several ingest dispatch paths never create one) |
job_type |
e.g. impact_calc, data_ingest |
error_code |
The machine-readable half: a Code from the ADR-028 registry (core/errors.py) |
error_message |
Free text, capped at the 1024 characters jobs.error_message stores |
title |
Ready to render, from core/job_titles.py::derive_failure_title — the same resolver that names the GET /v1/jobs row |
ingest_job_id |
Ingest lane only; absent on the compute lane |
Where to read the error_code. Three surfaces, same value, no parsing of English:
curl -s "$BASE/v1/jobs/$JOB_ID" | jq '.data.error_code, .data.error_message'
curl -s "$BASE/v1/jobs?status=failed" | jq '.data.items[] | {id, title, error_code}'
# ingest lane, when the payload's job_id is null — use ingest_job_id instead
curl -s "$BASE/v1/hazards/ingest-jobs/$INGEST_JOB_ID" | jq '.data.error_code, .data.logs'
or straight from the database when the API is unreachable:
select id, job_type, status, error_code, left(error_message, 200), completed_at
from jobs where status = 'failed' order by completed_at desc limit 20;
The first two things to check:
- Read
error_code, noterror_message. A specific code is a verdict about the input, not the stack —E_HAZARD_INVALID_SOURCEmeans the file was refused, and no amount of restarting the worker changes that. Look the code up incore/errors.py's registry (it carries a severity and a one-line meaning) and stop there if it names the fault. - If the code is the generic
E_JOB_FAILED, ask whether the job was reaped rather than failed. That code is the fallback used when nothing more specific fits — including by the beat sweeps inworker/job_watchdog.py, which close rows whose worker died. Acompleted_atfar later thanstarted_at(hours, at one of the sweep TTLs) is a reaped row, not an execution failure: the work never reported back, so read the next section rather than the error text.
A job stuck in running that never resolves¶
select id, status, job_type, created_at, started_at, error_message
from jobs where status in ('pending', 'running') order by created_at;
A row here that is hours (or days) old with a NULL error_message is stranded, not slow:
its worker was killed mid-task, its broker message was lost, or a failure path closed the
ingest_jobs row without closing this one. Three sweeps exist, in increasing order of how
long they wait (src/climate_lama/worker/celery_app.py
registers all three in beat_schedule):
| Sweep | Watches | Fires after | Closes the jobs row? |
|---|---|---|---|
sweep_wedged_writing_ingest_jobs |
chunk liveness of a writing ingest |
INGEST_WEDGED_WRITING_TTL_SECONDS (45m) |
yes |
sweep_wedged_ingest_chords |
any non-terminal ingest_jobs row |
INGEST_CHORD_TIMEOUT_SECONDS (6h) |
yes |
sweep_stale_jobs |
the jobs row itself, any job type |
JOB_STALE_TTL_SECONDS (24h, floored at the chord timeout) |
yes |
The last one (src/climate_lama/worker/job_watchdog.py)
is the backstop: it is the only one that covers a job with no ingest_jobs row at all
(POST /v1/hazards/{id}/assign-centroids, impact_calc, report_render). Check Celery
beat is actually running before concluding a row is un-reapable — with no beat process,
none of the three ever fire.
To inspect or close stranded rows now, without waiting for a tick:
python scripts/reap_stale_jobs.py # dry-run (default) — lists candidates, writes nothing
python scripts/reap_stale_jobs.py --apply # flips them to 'failed' with the watchdog's message
Both are idempotent and never touch a job inside the TTL. Reaping only closes the book-keeping row — it does not roll back partial work; re-submit the request to retry.
Retrying a step without rerunning the whole script¶
scripts/smoke_e2e.py has no per-step retry/resume flag. It does have two
skip-a-stage flags — --skip-bootstrap and --skip-boundaries (the latter applies to every
leg) — but those exist to point the walk at an already-provisioned stack (e.g. one whose
compose bootstrap one-shot already ran), not to resume from an arbitrary failed step. There is
no documented resume-from-step-N mechanism beyond that — inventing one here would be worse than
not documenting it.
Since issue #1045 the walk runs once per country leg (Greece, then Cyprus, on the same org).
A pass prints one E2E_READY_GRC hazard_id=... exposure_id=... result_id=... line and one
E2E_READY_CYP hazard_id=... exposure_id=... result_id=... line (format_ready_line(), one call
per leg) naming every id that leg produced; any other outcome exits 1 with the failing leg and
stage named, e.g. [CYP] ....
A gated leg prints its own line only when armed, and sdk-smoke.yml requires it anyway, so an
unarmed leg fails the job rather than vanishing:
| Leg | Line and what it carries | Armed by | Run it locally |
|---|---|---|---|
| Series (#1296) | E2E_READY_SERIES_GRC live_event_id=… feed_id=… snapshots=N results=N elapsed=…s — the first watch and its replay feed; N is the staged series' own length (len(manifest.observations), 1 for the vendored Evia 2021 series), and results counts results the feed auto-computed (origin=feed), not the leg's one manual compute |
SDK_SMOKE_SERIES_DIR or --series-dir: the worker-visible path of the staged series (sdk-smoke.yml exports /data/live_series/evia_2021) |
Stage it where live-worker's ./data:/data:ro mount sees it: mkdir -p data/live_series/evia_2021 && cp tests/fixtures/reference_datasets/live_series/evia_2021/manifest.json tests/fixtures/reference_datasets/live_series/evia_2021/*.tif data/live_series/evia_2021/. Bring the stack up with LIVE_MODE_ENABLED=true (the leg needs live-worker and beat), then SDK_SMOKE_SERIES_DIR=/data/live_series/evia_2021 python scripts/smoke_e2e.py --skip-bootstrap |
A red series leg names its stage. was paused quotes the feed's paused_reason — an unstaged or
empty directory reads replay series manifest is unreadable. A TimeoutError after 300 s carries
the snapshot/scored counts and the feed's last_error; read the live-worker logs next (see
"Live worker" above).
Two grounded workarounds instead:
- Capture the ids from a passing run's
E2E_READY_GRC/E2E_READY_CYPline, then call the specific downstream endpoint yourself — e.g. re-POST /v1/compute/impactwith the capturedhazard_dataset_id/impact_function_id(src/climate_lama/api/v1/compute.py) — instead of rerunning the whole script. - Be aware reruns are not idempotent for hazard ingest:
POST /v1/hazards/ingest(src/climate_lama/api/v1/hazards.py) creates a newIngestJoband hazard-dataset row on every call, with no dedup-by-name check. Rerunningscripts/smoke_e2e.pyagainst an already-provisioned stack accumulates additional "Smoke river flood (reference tile)" (Greek leg) and "Smoke river flood (reference tile, CYP)" (Cyprus leg) datasets rather than reusing the earlier ones — and once a leg's name is no longer unique, that leg's own walk fails loudly at the "resolve the ingested dataset" stage (_unique_match()) instead of silently picking one. Running the walk throughscripts/smoke_walk_wsl.sh --clean-slate(below) sidesteps this entirely — it resets the database and/uploadsto empty before each run, so there is nothing left to accumulate.
Reference — dev stack ports and container names (docker-compose.yml)¶
| Service | Container name | Host port(s) | Notes |
|---|---|---|---|
| postgres | climate_lama_postgres |
5433 → 5432 | PostGIS 16; not 5432 on the host |
| redis | climate_lama_redis |
6379 | broker on db 0, result backend on db 1 |
| minio | climate_lama_minio |
9000 (S3 API), 9001 (console) | |
| migrations | climate_lama_migrations |
— | one-shot alembic upgrade head |
| api | climate_lama_api |
8000 | --reload, mounts ./src read-write |
| worker | climate_lama_worker |
— | --queues=compute,impact; no healthcheck in dev |
| live-worker | climate_lama_live_worker |
— | --queues=live --concurrency=1; same image as worker, no healthcheck in dev |
| beat | climate_lama_beat |
— | drives the watchdog / nightly-maintenance schedule |
| titiler | climate_lama_titiler |
7800 → 80 | COG raster tiles |
Real-stack walk on WSL — scripts/smoke_walk_wsl.sh¶
scripts/smoke_walk_wsl.sh is the whole walk as one
command: it rebuilds the COPY-based images from the checkout, brings the fullstack
profile up, asserts alembic_version against the repo's highest migration, runs
scripts/smoke_e2e.py, probes POST /v1/hazards/upload for a staged-file leak, and
prints a provenance block to paste into an issue as evidence. It restores everything it
touched from an EXIT trap, so an interrupted run leaves no dirty file and no stray stack.
scripts/smoke_walk_wsl.sh # walk the current trunk
scripts/smoke_walk_wsl.sh --require-merged '1234:^fix\(' \
--require-symbol src/climate_lama/api/v1/hazards.py:some_symbol \
--waiting-label "waiting on the fix" # exit 3 if unmet, nothing run
scripts/smoke_walk_wsl.sh --clean-slate # reset DB + /uploads before walking
On this box it publishes compose postgres on 5434, not the stock 5433: the native PG
cluster 16 owns 5433 and stopping it needs a sudo password. Only
ADMIN_LOADER_DATABASE_URL consumes that host port — containers reach the database at
postgres:5432 — so the remap is behaviourally inert. It edits docker-compose.yml in
place after the pull (a dirty compose file makes git pull --ff-only refuse) and reverts
it in the trap; the provenance block records the file as dirty rather than claiming a clean
tree. Free 6379/9000 first if the OasisEvaluation stack is up: docker compose -p oasisevaluation stop.
The walk is not idempotent on its own — see point 2 above — so a second consecutive run
against a stack the first run left up fails at _unique_match() rather than passing cleanly.
--clean-slate is the fix: it runs docker compose down -v (the ONE explicitly-guarded
exception to the script's "never down -v" rule — see the header comment in the script
itself) before up. -v drops every named volume in docker-compose.yml, not just
postgres and /uploads: postgres_data, redis_data, minio_data (all previously ingested
hazard/exposure objects, not only the two datasets this walk itself ingests), hazard_uploads
(/uploads), nominatim_data (the geocoding profile's OSM import, if it was ever run —
reimporting has a 45-minute healthcheck start_period), prometheus_data, grafana_data and
loki_data. Nothing the walk itself needs must be backed up first — bootstrap recreates the
org/admin, the walk loads its own GADM boundaries per leg, and migrations replay 0001 → head
from scratch (slower, but a strictly stronger check than the incremental path) — but anything
else this box was used to ingest or run (MinIO objects, a completed Nominatim import, Grafana
dashboards) is gone with it. The flag refuses with exit 3, before touching Docker, unless
every one of its project guards holds: .env has no APP_ENV/ENVIRONMENT equal to prod or
the legacy production (case-insensitive — matches src/climate_lama/config.py's
normalize_app_env), DATABASE_URL/POSTGRES_HOST in .env (when set) resolve to
localhost/127.0.0.1, and the checkout lives under $HOME or /mnt (a WSL DrvFs-mounted
Windows path) — never both true of the actual prod host, a separate Linux box at
/opt/climate-lama as root. The flag is meant to be impossible to fire against production by
accident, and the guard fails closed (a missing or unreadable .env refuses rather than
proceeding). The two other down calls in the script — the EXIT-trap teardown and the
pre-bind-test teardown — are unaffected and stay
-v-free, so a green non---clean-slate walk still leaves its database up for inspection.
Two traps this script exists to make unmissable:
- A cached
migrationsimage migrates to its own head and exits 0 — the service COPYs the repo in, and compose will not rebuild an image that already exists, so the schema silently pins at whatever revision the image was built with. The script rebuildsworker migrations bootstrapand then assertsselect version_num from alembic_version. - Two checkouts of one repo on one machine (the Windows one and the WSL one) — a grep in
the wrong tree produces phantom findings about what did or did not land. The script always
works from
git rev-parse --show-topleveland resolves precondition symbols againstorigin/main, never the working tree.
Note that --require-merged <issue> on its own is a weak gate: a docs PR that merely
mentions the issue matches it. Append a title regex ('1234:^fix\(') or pair it with
--require-symbol, which is the authoritative check.
Operator scripts — the one-shot repair/migration tools¶
These do not run on a schedule. Each is idempotent, defaults to a dry run, and
prints the number of rows it matched. As of the release that added
COPY scripts/ ./scripts/ to docker/core.Dockerfile, all of them exist inside
the api image — before that they had to be docker cp'd in by hand, and the
copy did not survive a container recreate.
| Script | What it repairs |
|---|---|
scripts/backfill_dataset_identity.py |
Fills provider / upstream_version / native_resolution on hazard_datasets. Nothing emitted these before, so every row read blank — which also left core/dataset_precedence.py's heaviest term (resolution, 60 pts) scoring 0 for everything, making the finest-wins rule inert. |
scripts/backfill_dataset_footprint.py |
Stamps hazard_datasets.footprint (ADR-045 topic 2 / ADR-057, issue #596) for datasets ingested before that column existed. Derives the envelope from the dataset's hazard_centroids extent, falling back to the stored intensity NPZ's grid geometry only when a dataset has no centroid rows at all. Until this runs for a given dataset, the "which datasets cover this point/bbox" coverage rule silently skips it. |
scripts/backfill_source_manifests.py |
Writes hazards/{dataset_id}/source_manifest.json for pre-ADR-066 datasets. Relocates the real captured record from staging/{ingest_job_id}/manifest.json where it survives (keeping the genuine checksums) and only reconstructs — explicitly marked capture_mode: "reconstructed" — where it does not. |
scripts/migrate_org_data.py |
Moves one org's data to another. Data moves; identity, credentials, history and org policy stay. |
# always dry-run first and read the matched count
./dc exec -T api python scripts/backfill_dataset_identity.py
./dc exec -T api python scripts/backfill_dataset_identity.py --apply
The failure mode to watch for is a false clean. Every org-scoped table is
under RLS, and a connection without app.current_org_id bound sees zero
rows — so a script can cheerfully report "nothing to do" against a full
database. Each of these exits non-zero and names RLS when it matches zero rows,
rather than reporting success; if you see MATCHED 0, treat it as a
connection/permissions problem, not as "already done".
backfill_dataset_footprint.py specifically¶
Run it once against a database whose hazard_datasets table predates
migration 0075_hazard_dataset_footprint.py (i.e. any environment that had
data before that migration shipped) — check first with:
A non-zero count means there is work to do; 0 means either it already ran
or the environment only ever had post-#596 ingests. Then:
./dc exec -T api python scripts/backfill_dataset_footprint.py # dry-run
./dc exec -T api python scripts/backfill_dataset_footprint.py --apply
Expected dry-run output shape — the two RLS-guard counts first, then one line per candidate, then a summary:
INFO scripts.backfill_dataset_footprint: SCANNED 42 hazard_datasets row(s) across 3 org(s); 5 carry a NULL footprint
INFO scripts.backfill_dataset_footprint: WOULD SET [centroids] JRC river flood — Greece (id=3fa9..., haz_type=river_flood, org_id=8b1c...) -> footprint=(19.30000, 34.70000, 28.30000, 41.80000)
INFO scripts.backfill_dataset_footprint: WOULD SET [npz_grid] legacy-windstorm-footprint (id=7c21..., haz_type=storm_europe, org_id=8b1c...) -> footprint=(-10.00000, 35.00000, 30.00000, 60.00000)
INFO scripts.backfill_dataset_footprint: 0 row(s) would be stamped, 0 unresolved, 5 seen with a NULL footprint.
INFO scripts.backfill_dataset_footprint: dry-run: nothing was written. Re-run with --apply to write.
If the first line ever reads SCANNED 0 hazard_datasets row(s), the run
exits non-zero and names the RLS false-clean explicitly — that is a
connection/permissions problem (app.current_org_id never bound), never a
legitimately empty catalog, unless you pass --allow-empty.
Home's coverage grid depends on the footprint (issue #1318)¶
GET /v1/catalog/coverage — the grid behind Home's Start-here card and the
per-country peril tiles — derives each hazard dataset's country from
COALESCE(footprint, bbox). hazard_datasets.bbox is the upstream extent
and no ingest path writes it, so in practice the footprint is the only
extent an ingested hazard dataset has. Such a row with footprint IS NULL
therefore derives no country and is reported under undetermined: correct
behaviour (NULL means "not derivable", never "in no country"), but it reads on
Home as "No hazard datasets held here yet." under a country that in fact holds
dozens.
So on any environment whose Home grid shows gaps, run the backfill and then re-read the grid:
./dc exec -T api python scripts/backfill_dataset_footprint.py # dry-run — read the matched count
./dc exec -T api python scripts/backfill_dataset_footprint.py --apply
Verify — as a superuser role (climate_lama); the app role is under
FORCE ROW LEVEL SECURITY and an unbound app.current_org_id returns zero
rows, which reads as a clean database rather than as no access:
-- The backfill's candidate set: non-superseded datasets with no stamped
-- footprint. Do NOT read this as "the undetermined count" — under
-- COALESCE(footprint, bbox) a row with a usable bbox and no footprint is
-- still placeable. The undetermined number is the `(undetermined)` row of
-- the second query, and that is the one that must drop after --apply.
SELECT count(*) FROM hazard_datasets
WHERE footprint IS NULL
AND (status IS NULL OR lower(btrim(status)) <> 'superseded');
-- The country split the grid will report, mirroring the shipped derivation:
-- COALESCE(footprint, bbox_envelope(bbox)), largest admin-0 overlap, ties on
-- iso3, superseded excluded. Per org, because the grid is org-scoped — one
-- organization's numbers never include another's.
WITH placed AS (
SELECT hd.org_id,
(SELECT ab.iso3
FROM admin_boundaries ab
WHERE ab.admin_level = 0
AND ST_Intersects(ab.geom, hd.extent)
ORDER BY ST_Area(ST_Intersection(ST_Envelope(ab.geom), hd.extent)) DESC,
ab.iso3 ASC
LIMIT 1) AS iso3
FROM (
SELECT org_id,
COALESCE(
footprint,
CASE WHEN jsonb_typeof(bbox -> 0) = 'number'
AND jsonb_typeof(bbox -> 1) = 'number'
AND jsonb_typeof(bbox -> 2) = 'number'
AND jsonb_typeof(bbox -> 3) = 'number'
THEN ST_MakeEnvelope(
LEAST((bbox ->> 0)::float8, (bbox ->> 2)::float8),
LEAST((bbox ->> 1)::float8, (bbox ->> 3)::float8),
GREATEST((bbox ->> 0)::float8, (bbox ->> 2)::float8),
GREATEST((bbox ->> 1)::float8, (bbox ->> 3)::float8),
4326)
END
) AS extent
FROM hazard_datasets
WHERE status IS NULL OR lower(btrim(status)) <> 'superseded'
) hd
)
SELECT org_id, COALESCE(iso3, '(undetermined)') AS country, count(*) AS datasets
FROM placed
GROUP BY 1, 2
ORDER BY 1, 3 DESC;
Then read the endpoint itself, remembering the grid is cached per organization
for 60 s (COVERAGE_CACHE_TTL_SECONDS) — a request inside that window still
serves the pre-backfill answer, so wait it out rather than concluding the
backfill did nothing:
curl -s -H "Authorization: Bearer $TOKEN" https://<host>/v1/catalog/coverage | jq '{
countries: [.data.countries[] | {iso3, held: ([.hazards[].dataset_count] | add)}],
undetermined: ([.data.undetermined.hazards[].dataset_count] | add)
}'
undetermined should drop by exactly the number of rows the backfill stamped,
and the countries those datasets cover should gain them — the same movement the
second SQL query's (undetermined) row shows. Those two answers are held in
lockstep by tests/test_db/test_hazard_repository_coverage.py, which executes
the SQL above straight out of this file and asserts it equals what
HazardRepository.coverage_groups returns; if you edit the queries, that test
is what tells you whether the edit still describes the shipped rule. The prod
run itself is owner-driven, not part of this fix —
#1323 tracks it.
migrate_org_data.py specifically¶
Read --print-storage-remediation before you consider the move finished.
Rendered reports live at reports/{org_id}/{subject_id}/{template}/ and that
key is recomputed from the row's live org_id — no column stores it. After
the move every existing PDF is unreachable, and because the readiness check is
the manifest's presence, it fails as a silent 404 that reads as "not rendered
yet", not as an error. COGs are safe by contrast: cog_path is read from the
stored column, so it keeps resolving even though the acme org UUID stays
embedded in the string.
Stop api and worker before --apply: it takes row locks, and briefly an
ACCESS EXCLUSIVE lock while it drops and recreates the three composite FKs
that include org_id (no update ordering satisfies them, so those five tables
move in one transaction). It needs a superuser DSN — the RLS policies are
FORCE ROW LEVEL SECURITY with no WITH CHECK, so binding the GUC is not
enough and the script refuses to run without rolsuper/rolbypassrls.
Management tasks — the hand-invoked builders¶
None of them runs on a schedule, and all are deliberately absent from
celery_app.beat_schedule. They are Celery tasks rather than scripts/ tools
because they need the worker's session, MinIO client and org GUC.
| Task | What it does |
|---|---|
build_reference_surfaces |
Builds the org-less reference_risk_* plane from a designated scenario matrix — dispatch half queues the cells, promote half writes them. The only path into the shared plane. Verified as part of the RLS cut-over in DEPLOYMENT.md ("one matrix run"). |
resave_run_surfaces |
Re-derives an existing org_risk_surfaces row's cells from the run that already produced them. Writes no new answer — it exists so a stored surface gains a cell metric it was written too early to carry. |
backfill_result_cells |
Builds the H3 and GADM aggregates (result_h3_cells, result_admin_cells) of an impact result written before #1470, from its stored eai_exp array. Recomputes nothing — it exists so an older result's map is served as vector tiles instead of the GeoJSON fallback. |
resave_run_surfaces — when a stored surface has no rollup metric¶
Symptom: an admin-unit or portfolio rollup returns metric_absent_from_cells
for a hazard whose surfaces are plainly there, and the address-level card for a
point inside the same ground bands fine.
Why: a cell's metrics blob carries the active score scheme's metric key
only if the run that wrote it knew to put it there. ADR-075 (issue #932) added
that at run-save time; every surface written before it carries only
{eai, eai_density_km2, points}. Both rungs read cell.metrics[scheme.metric],
so they see nothing.
This is recurring, not a one-off backfill. The rungs resolve the active
scheme at read time (core/rollup_service.py), so re-versioning a scheme or
renaming its metric darkens every stored surface until it is re-saved under
the new key. Run this after any migration that seeds a new scheme version, and
after any change to a scheme's metric.
Two other ways to reach the same state, for completeness: re-running the
scenario also fixes it (a run-save replaces a surface's cells wholesale), and
the reference plane is refreshed by re-promoting through
build_reference_surfaces, which already takes a result_id.
Always dry-run first — it is the default, and it reads everything the apply would (including each dataset's intensity matrix), so the plan it prints is a real preview rather than a guess:
# dry run: one org, or every live org when org_id is omitted
docker compose -f docker-compose.prod.yml exec -T worker \
python -c "import json; from climate_lama.worker.tasks import resave_run_surfaces; \
print(json.dumps(resave_run_surfaces.apply(kwargs={'org_id': '<org-uuid>'}).get(), indent=2))"
# apply
docker compose -f docker-compose.prod.yml exec -T worker \
python -c "import json; from climate_lama.worker.tasks import resave_run_surfaces; \
print(json.dumps(resave_run_surfaces.apply(kwargs={'org_id': '<org-uuid>', 'dry_run': False}).get(), indent=2))"
.apply() runs the task inline in that container rather than queueing it, which
is what you want for an operator action you are watching.
The report carries dry_run, surfaces, counts and one entries row per
surface. Every count key is always present, so two runs are directly
comparable. What each outcome means:
| Outcome | Meaning | Action |
|---|---|---|
resaved |
Re-derived and written (or, under dry_run, would be — the row's applied flag tells them apart). |
none |
skipped_legacy |
dataset_id IS NULL: written before #589, so it names no hazard dataset and there is no identity to join a stored result to. |
Re-run the scenario to mint a dataset-keyed surface. |
skipped_no_result |
Candidates were searched and none of them would have written this surface's key. | Re-run the scenario. |
skipped_orphaned_dataset |
The hazard dataset the surface names is gone, so no run could be searched for at all. Kept separate from skipped_no_result precisely because re-running is not available as a remedy. |
Nothing to re-run. Either the surface is stale and can be deleted, or the dataset was removed in error. |
skipped_drift |
The exposure set changed since the run, so the stored array can no longer be attributed to named exposures (#370). Refused rather than filed under wrong coordinates. | Re-run the scenario against the current exposures. |
no_design_rp |
The dataset publishes no event at the peril's design return period (or has no stored intensity matrix). Reported as unenrichable; the surface is left untouched. | Nothing to do here — the peril's design RP or its dataset's event table is the thing to fix. |
no_metric_key |
No seeded score scheme claims this peril at this intensity unit, so there is no key to file a metric under. | Seed the scheme. |
failed |
That surface raised; the rest of the pass continued. | Read the reason and the worker log. |
Two failure modes worth knowing:
- A false clean.
org_risk_surfacesis under RLS, so a connection with noapp.current_org_idbound sees zero rows. The task enumerates organizations fromorganizations(which is not org-scoped) and binds the GUC per org for exactly this reason, and logs a warning when a whole pass matches no surfaces at all. If you see that warning against a database you know is not empty, treat it as a permissions problem, not as "already done". - Applying with run-saves switched off.
resave_run_surfacesrefuses to run withdry_run: FalsewhileSURFACE_RUN_SAVE_ENABLEDis false, because the writer would decline every write and the pass would report a clean it did not achieve.
backfill_result_cells — when an older result's map has no tiles¶
Symptom: a result's GET /v1/results/{id} carries no metadata.cells block
(or one whose status is "failed"), so its map is drawn from the GeoJSON
fallback instead of vector tiles.
Why: a result's H3 and GADM aggregates are written by compute_impact when
the result is stored (issue #1470, ADR-083). Every result written before that
has none, and nothing else creates them. This task builds them from the stored
per-asset eai_exp array, paired with the exposure rows
load_aligned_exposures re-derives in compute order (the #370 contract), and
writes through the same write_result_cells the compute hook uses — the
impact is never recomputed, and impact_results is touched only by the
metadata.cells stamp. A result already stamped "built" is skipped, so a
second pass is a no-op; one stamped "failed" is retried.
Always dry-run first — it is the default, and it runs every read the apply would (the array download, the compute-order join, the H3 build and the GADM sums), so the counts it prints are the ones the apply will store:
# dry run: one org, or every live org when org_id is omitted
docker compose -f docker-compose.prod.yml exec -T worker \
python -c "import json; from climate_lama.worker.tasks import backfill_result_cells; \
print(json.dumps(backfill_result_cells.apply(kwargs={'org_id': '<org-uuid>'}).get(), indent=2))"
# apply
docker compose -f docker-compose.prod.yml exec -T worker \
python -c "import json; from climate_lama.worker.tasks import backfill_result_cells; \
print(json.dumps(backfill_result_cells.apply(kwargs={'org_id': '<org-uuid>', 'dry_run': False}).get(), indent=2))"
The report carries dry_run, results, counts and one entries row per
result (with points, h3_cells, admin_cells and applied). Every count key
is always present. Each result runs in its own transaction, so stopping a long
pass keeps the results already built. What each outcome means:
| Outcome | Meaning | Action |
|---|---|---|
built |
Aggregates built and stamped "built" (or, under dry_run, would be — the row's applied flag tells them apart). |
none |
skipped_built |
metadata.cells.status is already "built". Nothing was read or written. |
none |
skipped_no_array |
The result has no eai_exp_path, so there is no per-asset array to aggregate. |
Re-run the scenario if the map matters. |
skipped_drift |
The exposure set changed since the run, so the stored array can no longer be attributed to named exposures (#370). Refused rather than filed under wrong coordinates. | Re-run the scenario against the current exposures. |
failed |
That result raised; its transaction was rolled back and the rest of the pass continued. | Read the reason and the worker log. |
The task walks results under RLS with the org GUC bound per org, so read any
SELECT count(*) FROM result_h3_cells check as the owner role
(psql -U "$POSTGRES_USER") — through the app role the table reads as empty.
Recovering a mis-rejected dataset review (re-arm)¶
Symptom: a dataset that should be in the review queue is not, and
GET /v1/admin/pending-ingests?status=rejected shows it as rejected (or
ingest_failed). A single mis-click does this, and so does a bulk sweep — 22
rows were rejected on prod in one scripted run on 2026-08-11, 21 of them inside
0.22 s.
Why it does not fix itself: the weekly poll records every item it sees in
dataset_source_catalog, whether or not anyone approves it. A decided row is
therefore skipped by every later poll until the upstream content changes. That
is deliberate — it is what stops a declined item nagging admins every week —
but it means the decision is absorbing. Re-arm is the supported way back: it
deletes that item's last-seen marker so a poll sees the item as new again, and
it dispatches a scoped poll (that one source, those items, no admin
notification fan-out) so the item is back in the queue in seconds rather than
at 05:00 next Monday.
Re-arm never touches the decided row — it stays as the audit record — and it
never triggers an ingest. The item comes back through review, not around it.
Only rejected and ingest_failed rows are re-armable; anything else is a 422.
Every call below needs a platform admin token (is_platform_admin = true),
not an org admin. Set API=https://<host> and TOKEN=<jwt> first.
1. Find the rows¶
curl -s -H "Authorization: Bearer $TOKEN" \
"$API/v1/admin/pending-ingests?status=rejected&limit=500" \
| jq -r '.data.items[] | [.id, .source_id, .item_id, .reviewed_at] | @tsv'
Narrow to the sweep you are undoing before you re-arm anything — reviewed_at
is the field that separates one bad batch from years of deliberate decisions:
curl -s -H "Authorization: Bearer $TOKEN" \
"$API/v1/admin/pending-ingests?status=rejected&limit=500" \
| jq '[.data.items[] | select(.reviewed_at >= "2026-08-11T00:00:00")
| select(.source_id == "worldpop") | .id]' > /tmp/rearm-ids.json
jq 'length' /tmp/rearm-ids.json # sanity-check the count before sending it
2. Re-arm¶
One row:
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
"$API/v1/admin/pending-ingests/<id>/re-arm" | jq .data
The whole batch in one call (max 500 ids):
jq -c '{ingest_ids: .}' /tmp/rearm-ids.json > /tmp/rearm-body.json
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" --data @/tmp/rearm-body.json \
"$API/v1/admin/pending-ingests/re-arm" | jq '{counts, polls_dispatched, failed}'
The bulk call is 200 even when every id fails — read counts, not the
status code. Ids that do not exist, or rows that are not in a dead-end
status, come back in failed with a code of not_found / not_rearmable
while every other id is still re-armed.
Read three fields on each re-armed row:
| Field | Meaning if it is not what you expect |
|---|---|
last_seen_cleared |
false = there was no marker, so the item was already pollable. Harmless; not a failure. |
source_registered |
false = the item's source is no longer registered in this build. The marker was cleared but no poll can act on it — the item stays invisible until the source is registered again and a poll runs. |
polls_dispatched |
[] = nothing was polled (this is always the case when source_registered is false). Otherwise one entry per source, with the item count. |
3. Confirm it came back¶
The scoped poll runs on the compute queue and takes as long as the source's
manifest fetch — seconds for JRC/WorldPop, longer for a queued Copernicus
retrieval. Then:
curl -s -H "Authorization: Bearer $TOKEN" \
"$API/v1/admin/pending-ingests?status=awaiting_review&limit=500" \
| jq -r '.data.items[] | [.source_id, .item_id] | @tsv' | sort
If the item is still missing after the poll has had time to run:
# Did the scoped poll actually see the item?
docker logs climate_lama_worker 2>&1 | grep poll_dataset_source | tail -20
seen=0— the source no longer publishes thatitem_id. Nothing is broken; the upstream dataset is gone or was renamed, and re-arm cannot conjure it back.queued=0withseen=1— anawaiting_reviewrow already exists for that item at the same content key (the poll's re-queue guard). Check the queue again; it is already there.errornon-null — the source's manifest fetch failed. Triage it like any other source failure, then re-run the poll:./dc exec -T worker python -c "from climate_lama.worker.dataset_polling import poll_dataset_source; print(poll_dataset_source.delay('<source_id>', ['<item_id>']).id)"
Every re-arm writes a pending_ingest.rearmed audit entry recording who did
it, last_seen_cleared, source_registered, and whether it was part of a
batch — so a recovery is as auditable as the rejection it undoes.
Multi-engine agreement verification¶
scripts/verify_engine_agreement.py runs one impact scenario through every
available compute engine and prints a divergence table with a gating exit
code — see the script's module docstring for the full contract. Worked
invocation:
uv run python scripts/verify_engine_agreement.py \
--base-url http://localhost:8000 --hazard <hazard_dataset_id> \
[--exposure <exposure_dataset_id>] [--impf <impact_function_id>] \
[--max-ead-divergence-pct 1.0] [--allow-single-engine] \
[--require-engine climada --require-engine oasis]
The three-engine proof (issue #1481) brings up both sidecars
(docker compose --profile climada --profile oasis up -d), ingests a
return-period river-flood hazard and an exposure through the real async APIs,
and runs the command with --require-engine climada --require-engine oasis.
It passes only with three engine rows, each with its curve_basis column
(OEP on all three). A required engine that is excluded, failed or absent
exits 1, so a stack without the Oasis sidecar cannot pass as a two-engine run.
Exit codes: 0 — every --require-engine engine and every compared engine completed and stayed under
--max-ead-divergence-pct (default 1.0, the house parity tolerance); 2 —
only one engine was ever available (the rest registered but not deployed
here, per excluded_engines) and it completed cleanly — pass
--allow-single-engine to accept this as 0 on a stack that has not brought
the CLIMADA sidecar up; 1 — anything else that is not a clean pass: a
failed engine, a divergence at/over threshold, an unreachable API, or a
timed-out poll. --allow-single-engine only ever downgrades the
single-engine-availability case (2 → 0); it never masks a real 1.
CLIMADA engine unavailable / comparison shows failed_engines: ["climada"]¶
Two different situations both read as "CLIMADA isn't in the answer," and they need
different triage — src/climate_lama/worker/models/climada_adapter.py's module
docstring is the canonical source for the split:
- Config-off (not a failure) —
CLIMADA_SIDECAR_URLis unset (or present but empty) on this deployment. The registry's availability probe (_climada_sidecar_configuredinworker/models/registry.py, backed bySettings.climada_sidecar_url) reports the engine unavailable before any job is queued: a comparison that omitsengineslists it in the response'sexcluded_engineswithreason: "unavailable"and proceeds with whatever else is available, while namingclimadaexplicitly fails fast with400 E_ENGINE_UNAVAILABLE. No job ever runs and none ever fails — this is the expected shape on any stack that hasn't opted into theclimadacompose profile. See DEPLOYMENT.md → "Enabling the CLIMADA engine" to turn it on. - Sidecar down (a real failure) — the URL is configured but the container is
unreachable, times out, or errors. The engine was queued like any other, so its job
fails and
climadalands indivergence.failed_engines— notexcluded_engines— while the rest of the comparison still reports normally.
Triage the second case:
- In-network health probe. The sidecar is never exposed over HTTP — only the
worker reaches it, in-network — so probe from inside the worker container:
(
$Pis the Release → Production shorthand with--profile climadafolded in — see DEPLOYMENT.md.) A healthy sidecar answers{"status": "ok", ...};scripts/verify-deploy.shruns the same probe (plus a--max-timebound and astatusfield check) as its own step 6 (the CLIMADA sidecar check moved from step 5 to step 6 when the UI bundle identity check, issue #1315, was inserted ahead of it). - Sidecar logs:
- Restart without rolling api/worker. The profile makes it an ordinary compose
service, so a scoped restart is just that:
docker compose $P psbefore and after confirms onlyclimada-sidecarrecreated — api/worker/beat are untouched. - Re-run the worked invocation above against the same hazard/exposure pair to confirm the comparison completes clean.
Country data run on prod — the pack invocation that works¶
The Cyprus data run (#1048, executed 2026-08-31/09-02) found every checklist command for a country pack run wrong as written. None of this was in this runbook or in DEPLOYMENT.md before. Eight things a second-country run needs to know before it starts (item 8 was added by the Phase 13 replay run, #1146, 2026-09-03):
- Every pack needs the
ingestsubcommand.add_country_argumentis attached to theingestsubparser under arequired=Truegroup, so e.g.coastal_flood.py --country CYP(no subcommand) exits 2. --org-slug <slug>is mandatory (scripts/org_target.py— "no target organization"; there is deliberately no default).--data-dirand--worker-data-dirmust both be passed explicitly, as/data/<pack>. The default is/app/data/<pack>inside the container, which is not the./data:/datamount — leaving either at its default reproduces the #748 class, where clipped files never reach the worker.heatwave_packs.py ingesttakes a required positionalitem_id(e.g.heatwave-ecde-europe) — a checklist that omits it silently no-ops at argparse. The CDS heatwave collections declare no server-sidearea, so a heatwave pack has no per-country clip: a second country's--country CYPrun against the sameitem_idcorrectly reports "already in catalog, skipping" — that message is the pass condition for a second country, not a failure.- LitPop's
convertruns off-box, on the operator workstation, because the api image has noh5py:uv run --with h5py python scripts/packs/litpop_exposure.py convert \ --source climada-api --country-iso3 CYP \ --data-dir /c/tmp/litpop-cyp --out /c/tmp/litpop-cyp/LitPop_150arcsec_CYP.csv--country-iso3is independent of--countryand defaults toGRC— omitting it on a non-Greek run silently fetches the Greek release. Verify the right release downloaded by checkingregion_idon every row (ISO 3166-1 numeric: Cyprus 196, Greece 300), not just the row count. Theingesthalf runs in-container as usual (noh5pyneeded on that path — it only imports lazily insideconvert). - Working form of the standalone-pack invocation, combining points 1-3: Append `