Uniform pipeline operation model#
Context#
“Stage” means three structurally different things across the pipeline today, so the system has no single handle on its own steps:
packaging status lives on the artifact —
RawDataPackage.statusbundling status lives on the artifact —
DataTransferPackage.statustransfer is a dedicated operation row —
DataTransfer.statusunpack is a phase on another op’s row —
DataTransfer.unpack_status(the transfer/unpack “unicorn record”: one row encoding two operations via paired columnsstatus/unpack_status,retry_count/unpack_retry_count,error_context/unpack_error_context, …)archive is a dedicated row using a differently named counter —
LongTermArchiveTransfer.attempt_count(notretry_count)staging is a single-phase
StagingJobdoing download+unpack as one all-or-nothing op
The shared Status enum (PENDING/SCHEDULED/IN_PROGRESS/COMPLETED/FAILED) is identical
everywhere, but the usage has drifted into bugs: only bundling sets IN_PROGRESS;
archive’s stuck-recovery resets healthy long-running transfers because it never sets it
(#151); recovery is keyed by free-text strings that drift apart (recovery handler archive
vs the long_term_archive the task writes → stalled archives never recover, #69; packaging
never sets its kind at all). Counters are inconsistent (retry_count /
unpack_retry_count / attempt_count; PhysicalCopy persists none — #90, #75).
Operator-chosen rollback (#149, “walk up the pipeline state chain”) is blocked on this: it needs every stage to be a uniform, independently addressable operation. This ADR establishes that model so #149 is purely additive.
Why now: the database is disposable (re-seed is cheap, no production data), so these are clean schema migrations with no backfill — the single largest risk is absent. That window closes when stable operations begin; this is the moment to pay the schema cost.
Decision#
Adopt one uniform operation model. Six coupled choices:
One polymorphic
Operationbase table (joined-table inheritance), the same pattern asSystemLog/PhysicalCopy. Subclasses carry stage-specific fields:PackagingOperation,BundlingOperation,TransferOperation,UnpackOperation,ArchiveOperation,StagingOperation. Packaging and bundling are lifted off their artifacts:RawDataPackage/DataTransferPackagekeep only their lifecyclestate(PackageState), and operationstatusmoves to theOperationrow. The transfer/ unpack unicorn record splits into two sibling rows (this realizes ops-db#84, which this ADR subsumes).One canonical operation state machine. Every operation walks
PENDING → SCHEDULED → IN_PROGRESS → COMPLETED/FAILED.IN_PROGRESSis mandatory and set in one place — theCCATEnhancedSQLAlchemyTaskbase (which already owns the start hook + heartbeat) — giving the DB an honest “running” signal and structurally fixing the #151 class for all stages. Packaging adopts the standard poll→SCHEDULED→dispatch find-work loop (dropping inline create-and-dispatch), making it re-runnable fromPENDINGand closing its double-dispatch race (#70-class). Oneretry_counton the base replacesattempt_count/unpack_retry_countand gives #75 a singleget_retry_count().One
OperationKind(str, Enum)whose values equal the frozen breadcrumb strings (raw_data_package,data_transfer_package,transfer,unpack,long_term_archive,staging). It is the single source of truth for the identity axis — recovery, circuit breaker,OperationFailureEventkey (ADR-0001), and the JTI discriminator all reference the enum member, never a literal. Because values equal already-stored strings, this is a code refactor, not a data migration, and it makes the #69 drift class unrepresentable. The existing stale literal (recovery’sarchive) is reconciled toOperationKind.ARCHIVE(=long_term_archive) on adoption. The routing axis (OperationType.value+QUEUE_OPERATIONS_BY_LOCATION_TYPE) is a separate, frozen contract: queue names are built from it and workers self-discover queues at startup, so nothing here changes how workers run. Identity (operation_kind) and routing (operation_type) must stay distinct in code.Artifact/copy-anchored lineage. An
Operationconsumes inputPhysicalCopy(s) and produces outputPhysicalCopy(s); this produce/consume contract is identical for every kind. Lineage is traced through the artifact/copy graph (RawDataFile → RawDataPackage → DataTransferPackage), not through operation-to-operation FKs — because the pipeline’s fan-in (bundling) and fan-out (unpack) live between artifacts. Transfer and unpack are siblings on the sameDataTransferPackage, not unpack-hung-off-transfer. This is the walk-up seam #149 builds on.OperationGroupfor batched operations. A generic polymorphic parent that bundles N co-created operations and carries a status derived from its children (never set independently).StagingJobbecomesOperationGroupplus a retention claim — itsactiveflag holds the staged packages until released (deletion_manageralready enforces this). The base is built minimal/claim-free now; its known future second customer is grouping the multiple results of a workflow reduction step.Deletion stays out, on shape grounds. Deletion is a
PhysicalCopyStatusstate machine on the artifact (PRESENT → DELETION_* → DELETED), running in the destroy direction — not a unit of work with aStatus. Forcing it into theOperationmodel would fabricate an op row for an artifact transition. Its missing counter (#90) is fixed separately onPhysicalCopy.
Migration is additive and incremental: build the tables, migrate one stage at a
time behind reconcile_package_state / classify_transferring_step, staging-first,
leaning on the ops-db#78 Alembic drift gate. New/moved JSON columns land as jsonb, not
json (ops-db#92).
Considered options#
Status stays on the artifact (no lift). Rejected: leaves packaging/bundling as snowflakes, so “every stage is an operation” stays false for two of five and #149 can’t address them uniformly. The act of packaging and the package artifact are genuinely separate concerns, and
reconcile_package_statealready derivesstatefrom operation statuses (ADR-0004, ops-db-api).Separate per-stage tables sharing an
OperationMixin(uniform shape, no base table). Lower blast radius and closest to today, but uniformity is by convention not schema,operation_idkeeps colliding across tables (soOperationFailureEventcan never become a real FK), and #149’s “same handle” stays a code-level abstraction over heterogeneous tables. Rejected for a disposable-DB window where the stronger cut is affordable.Operation-chained lineage (
upstream_operation_id, unpack 1:1 off transfer). Rejected: a 1:1 chain can’t represent bundling fan-in / unpack fan-out, which live between artifacts.Keep raw
operation_typestrings (no enum). Rejected: preserves the stringly-typed drift that produced #69 and the packaging mismatch.New clean
OperationKindenum + translation layer to the frozen strings. Rejected: the mapping boundary is a new drift surface that reintroduces the #69 class and forces a history consideration — strictly worse than values-equal-frozen-strings.Merge the identity and routing axes into one enum. Deferred, not chosen: routing values feed dynamically-discovered queue names, so changing them would silently spin up new queues and orphan in-flight messages (a live-drain hazard) and force coordinated worker redeploys. Not required for uniformity.
Drop
IN_PROGRESS, rely only on the Redis heartbeat. Rejected: loses the DB-visible running state the UI and stuck-detection need; the heartbeat stays the truth for liveness, the DB state is its legible projection.Fold deletion into the
Operationmodel. Rejected on shape (see Decision 6).
Consequences#
Cross-repo, lockstep. ops-db (models + migrations) → ops-db-api (
reconcile_package_state,classify_transferring_step, attribution, reset endpoints — all read the duality directly today and must move together; ADR-0004 preserved) → data-transfer managers + base task → ops-db-ui. The UI is more coupled than first assumed: 8–12 components branch on operation kind and read paired fields and per-kind failure counts, so it does not “barely move.”Realizes and supersedes ops-db#84; folds in #69, #75, the LTA counter; generalizes #151 (the per-stage
IN_PROGRESSfix). #151 remains the immediate standalone fix.Breadcrumbs (ADR-0001) stay correct.
OperationKindvalues equal the frozenOperationFailureEvent.operation_typestrings, so existing history stays correlatable; with a single globaloperation_idthe loose(operation_type, operation_id)key may later become a real FK (a follow-up, not required here).PackageState (
state) is unchanged — it remains the artifact lifecycle, derived from operation statuses; it is not absorbed intoOperation.status.Enables #149 by exposing every stage as a uniform operation with consumed/produced
PhysicalCopylineage; the rollback verb itself stays out of scope.No backfill, no production downtime because the DB is disposable — the deciding factor in choosing the deeper cut and in timing it before stable operations.