Live cross-host filesystem-check contract (diagnostics.stat_paths)#

ops-db-api #111, epic data-transfer #139. Worker side: data-transfer #141.

Context#

The DB-only diagnosis (#110) reports what the database believes about an operation’s files. The next tier asks the per-site hosts whether the bytes are actually on disk right now. ops-db-api owns the admin trigger/poll API; the data-transfer workers own the actual stat on each host (they alone are deployed at the sites, with the mounts and credentials). The two repos are separate codebases — ops-db-api must not import ccat_data_transfer — so the only thing binding producer and worker is a wire contract, which this ADR pins.

Two further constraints shaped the design:

  • Broker vs. buffering Redis. In observatory deployments ccat_ops_db_api_settings.redis_host is redis-ccat, the read-only transaction-buffering Redis — not the Celery broker. The existing dependencies.get_redis() points at that buffering Redis. Dispatching the stat task there would put it on a Redis no worker consumes from.

  • No new DB table. The correlation between a poll request and its dispatched tasks is short-lived operational state, not a durable record.

Decision#

The contract between the ops-db-api producer and the data-transfer worker is:

  • Task namediagnostics.stat_paths.

  • Args — a single positional argument: the list of path strings to stat. The producer calls send_task("diagnostics.stat_paths", args=[[p1, p2, …]]).

  • Per-site queue routing — one task per relevant location, routed to the owning Site.short_name queue (cologne / us / fyst). These are the site-level static queues that data-transfer’s disk_monitor declares and the stat worker (#141) consumes from — NOT the compound {short_name}_{name}_{operation} queues that route_task_by_location builds for the transfer/monitor tasks.

  • Result shape — the worker returns a list of per-path dicts, each {path, exists, size, readable, error} (size/readable null when absent, error a string when the stat itself failed).

The producer (ccat_ops_db_api/live_check/producer.py) is a producer-only Celery app built against a new, separate broker connection:

  • New settings celery_broker_host / celery_broker_port / celery_broker_db / celery_broker_ssl_*, kept explicitly distinct from redis_host. They point at the MAIN broker (the broker data-transfer’s workers consume from) and reuse the existing /etc/redis/certs/ SSL material (redis_ca_cert / redis_certfile / redis_keyfile). They default to localhost so local dev and CI need no broker.

  • The producer’s result backend uses the same celery_broker_db index as the broker. This index MUST match the data-transfer worker’s REDIS_DB: the worker writes its stat result to its REDIS_DB, and the poll endpoint reads AsyncResult from celery_broker_db. A mismatch silently lands results in a different db and the poll stays pending forever (no error surfaces).

  • It dispatches purely by task name via send_task; it registers no worker code. get_redis() is not reused for dispatch.

Only DISK locations are filesystem-checked. A site can own both a disk buffer and an S3 long_term_archive under the same short_name (the production Cologne topology). Since the worker stats with os.stat, an S3/tape object key would always read as absent and falsely report a present archive as “missing”. The producer skips non-disk copies and surfaces them in the trigger response’s skipped list so the caller knows they were not live-checked (the #110 DB diagnosis still covers them).

The correlation mapping {correlation_id [(task_id, host_label, queue), …]} is a TTL’d Redis key on the MAIN broker Redis (the producer’s own sync client, producer.get_broker_redis()), never on the buffering redis-ccat. The poll endpoint reads the mapping and each task’s AsyncResult, then reduces each host’s per-path dicts to one status: error if any path errored, else missing if any path is absent, else present; an unready task is pending; a vanished correlation key is state="expired". A finished task with an empty/falsy result is error, not present — every dispatched host has ≥1 path, so an empty result means the payload expired/was evicted from the result backend, and reporting present there would be a false all-OK on a diagnostic tool.

Contract test and its limitation#

A TRUE cross-repo round-trip (API → real data-transfer worker) is not feasible in ops-db-api CI: the worker isn’t installed here and can’t be imported. tests/test_stat_paths_contract.py is the strongest self-contained equivalent and exercises the real producer send_task path:

  • The producer dispatches over an in-memory broker (memory://). The test drains the per-site queue and inspects the published message, pinning the task name, the args ([[paths]]), and the queue routing on the wire. (task_always_eager is deliberately not used: Celery’s send_task ignores it — AlwaysEagerIgnored — so the memory transport is what removes the live broker.)

  • A local task registered under the exact name diagnostics.stat_paths produces the {path, exists, …} result, which is fed through the real poll-assembly logic to prove the result shape drives the per-host status.

Limitation: the worker body in the test is a stand-in, not data-transfer’s real implementation. The contract is pinned by shape and routing, not by the worker’s actual stat behaviour. When data-transfer #141 lands, the real worker must register a task under this exact name, accept args=[[paths]], consume the cologne/us/fyst queues, and return the per-path dict shape. Any change to name, args, routing, or result keys is a breaking change to this contract and must update both repos together.

Consequences#

  • Two Redis connections from ops-db-api: the buffering redis-ccat (get_redis(), unchanged) and the new MAIN broker (producer). Operators must configure celery_broker_* to the real broker per environment; the localhost default is a dev/CI convenience, not a production value.

  • The live tier is best-effort and ephemeral: if the broker is unreachable the trigger returns 503; if the correlation key expires the poll returns expired. No durable record is kept (no new table), matching the operational, throwaway nature of a “is it on disk right now?” check.

  • celery becomes a direct dependency of ops-db-api (it was previously only transitive).