Error breadcrumb storage: per-row error_context + generic OperationFailureEvent#
Context#
When a pipeline operation fails, the only durable record is str(exc) written to a
free-text failure_error_message column (and the scattered unpack_failure_error_message
/ error_message siblings; RawDataPackage had no error field at all). For a bare
FileNotFoundError that string is just [Errno 2] No such file or directory: '<path>' —
no operation id, no step, no host, no side, no traceback, no “why”. The rich failure
context that on_failure already assembles is sent to an email notification and then
discarded, never persisted. And reset_state_on_failure (auto-retry) plus the
frontend reset both NULL the message, so the trail of a flapping operation is erased
exactly when it is most useful. This made staging failures cryptic and hard to debug.
Decision#
Store breadcrumbs in two places (hybrid):
Per-row
error_context(JSON) on each operation — the latest cause, for the UI’s current-error view. It mirrors the existingfailure_error_messagelifecycle: set on permanent failure, NULLed on reset.DataTransfercarries two (error_context+unpack_error_context) to match its two phases;RawDataPackageis brought up to parity.A generic
OperationFailureEvent(aSystemLogpolymorphic subclass) keyed by(operation_type, operation_id), appended on every failure (retryable and permanent) and never erased by a reset — the append-only durable trail.
error_context has a two-tier schema: Tier 1 (always available from the failure hook)
and Tier 2 (the where/why — step, side, path, host, diagnosis — filled by
boundary-wrapping, null until then). A capped traceback is stored always, not gated
on DEVELOPMENT_MODE.
Write path: the base on_failure builds the error_context dict once (the notification
formats from it) and appends the OperationFailureEvent uniformly; subclass hooks only
write the dict to their model-specific column alongside their status transition.
Considered options#
Per-row JSON column only. Simplest, but loses the flap trail on reset/retry — the exact pain. Rejected as insufficient.
Per-operation log subclasses (
DataTransferFailureEvent, …) with real FKs. Type-safe and matchesDataTransferLog, but ~5 tables + boilerplate and awkward for the two-phaseDataTransfer. Rejected for cost/complexity.Hybrid (chosen). Cheap visible win now (per-row) + durable trail (generic event).
Consequences#
The per-row
error_contextis an intentional denormalized cache of the most recentOperationFailureEvent. The duplication is deliberate (cheap UI reads, minimal UI change).OperationFailureEventhas no FK to the operation (it can’t — it spans operation types). It keys on(operation_type, operation_id), the pair the whole system already correlates on (Celery task state, recovery handlers, circuit breaker, routing). The missing FK follows existing precedent (PhysicalCopy.deletion_task_idis a bareInteger).Split-neutral w.r.t. the
DataTransfertransfer/unpack split (ops-db#84): the generic history table needs zero changes whichever way that goes; only the two per-row columns would later collapse to one per record.
Note: NUL-safety of the breadcrumb (#173)#
Breadcrumb persistence relies on ops-db’s NUL-safe JSONB_VARIANT column type: a
U+0000 (NUL) in any string value is replaced by the sentinel ␀ (U+2400) at the
type boundary on every write, all dialects. This matters because the highest-risk
producer — a transfer exception whose message is raw subprocess stderr
(BBCPError(message=result.stderr)) — can carry embedded NULs, and PostgreSQL
jsonb rejects a NUL. data-transfer deliberately does not scrub locally: the
fix is enforced once at the ops-db type boundary (ops-db ADR-0003), not in each of
the ~28 writers. A regression test
(tests/test_error_context.py::test_nul_in_error_context_is_sanitized_end_to_end,
ops-db #110) pins both sinks (per-row error_context + OperationFailureEvent).