docs/adr/0037-pipeline-run-integrity-at-scale.md
Rama master · réplica de lectura del repositorio original.
# ADR-0037: Pipeline Run Integrity at Scale
## Metadata
| Field | Value |
|-------|-------|
| **Status** | Accepted |
| **Date** | 2026-08-04 |
| **Authors** | AlessioBrillo |
| **Deciders** | AlessioBrillo |
| **Supersedes** | N/A |
| **Relates to** | ADR-0003, ADR-0011, ADR-0023, ADR-0035 |
| **Project** | DOMINUS |
## Context
Pipeline runs are executed asynchronously by the `JobWorker` (ADR-0023) and
large runs (hundreds to thousands of candidates) regularly exceed the fixed
per-stage timeout. The failure mode is silent and dangerous: when a stage's
`Promise.race` timeout fires with no results, the whole run completed with an
*empty* `passed` list and no marker — so a user could believe there were no
eligible domains, when in fact the run simply did not finish.
Three compounding defects made this worse:
1. **Fixed, candidate-independent timeout**: a single `stageTimeoutMs`
(default 5 min per stage) did not scale with input size. A slow-but-finite
stage on a large batch was indistinguishable from a hung stage.
2. **No partial-result harvesting**: on timeout the stage result was
discarded wholesale. Candidates already processed were lost even though
their per-candidate results were valid.
3. **No degradation signal**: nothing on the `PipelineRunResult`, the job
queue, the API, the CLI, or the dashboard indicated that output was
incomplete. Callers silently consumed truncated recommendations.
Separately, the RDAP confirmation stage cross-validated availability against
WHOIS port-43. A slow WHOIS server (or a block-prone ccTLD registry) could
hang the cross-validation for every candidate — one slow external dependency
capped the throughput and reliability of the whole stage, even after ADR-0035
made RDAP authoritative.
## Decision
Fully time-box every pipeline stage; harvest partial results on expiry; and
surface degraded output end-to-end so it is never mistaken for a clean "no
recommendations" run.
### Stage budget (orchestrator)
Replace the fixed `stageTimeoutMs` with a candidate-scaled budget
(`src/pipeline/orchestrator.ts`):
- `budgetMs = min(baseMs + perCandidateMs × inputCount, capMs)`.
Defaults: base 30 s, 200 ms per candidate, cap 1 h, all configurable
(`STAGE_TIMEOUT_BASE_MS`, `STAGE_TIMEOUT_PER_CANDIDATE_MS`,
`STAGE_TIMEOUT_CAP_MS`, `STAGE_TIMEOUT_GRACE_MS`).
- When the budget expires, the stage's AbortController is aborted and a
**grace window** (default 5 s) begins. If the stage resolves within grace
(it propagates the abort signal to its providers), the *partial* result is
harvested and the stage is marked `timedOut` for the purposes of
accounting. If grace also elapses, the stage returns an empty result
immediately.
- Every budget expiry (or stage error with no partial output) pushes a
`StageDegradation` entry: `{ stageName, reason: 'timeout' | 'error',
processedCount, expectedCount, message? }`.
### Degradation surfaced end-to-end
- `PipelineRunResult` gains `degraded: boolean` and `degradedReasons:
StageDegradation[]`. `degraded = true` when any stage degraded *or* any
stage returned an error (`stageErrors`), i.e. output may be incomplete.
- The job queue result (`PipelineRunResult`), the runs repository
(`PipelineRunResults`), and `buildResultsSummary` in the run service carry
`degraded` / `degradedReasons` through to persistence and the API
(`POST /api/v1/runs` and run status responses).
- The CLI (`dominus run`) prints one warning per degradation with
`processed X/Y candidates` and exits **non-zero** on a degraded run — in
both synchronous mode and async polling — so scripts and automation cannot
mistake the output for a clean verdict.
- The dashboard `Runs` page shows a banner for degraded runs listing each
stage, reason, and processed/expected counts.
### WHOIS budget (RDAP confirmation)
`RdapConfirmationStage` gains a per-domain WHOIS budget
(`RDAP_WHOIS_BUDGET_MS`, default 1000 ms, min 50, max 5000;
`whoisBudgetMs` constructor parameter). The WHOIS cross-check races against
`AbortSignal.timeout(budget)` and is ignored when it loses: RDAP remains
authoritative (ADR-0035). A disagreement still blocks the candidate — but
only when WHOIS answers within budget, keeping the cross-validation
conservative without making run throughput hostage to slow WHOIS servers.
## Consequences
### Positive
- Large runs no longer complete silently empty: either the stage finishes
(budget scales with candidate count) or the run completes marked
`degraded` with an explicit reason and non-zero CLI exit.
- Partial results from budget-expired stages are preserved and counted
(`processedCount`), so a timeout loses only the unprocessed tail, not the
work already done.
- Degraded output is visible in the API response, persisted run record,
CLI exit code, and dashboard banner — "no recommendations" is never
conflated with "run timed out".
- The WHOIS cross-validation can no longer stall or cap the RDAP stage;
throughput is bounded by the authoritative path.
### Negative
- Budget is a heuristic: a finite-but-brute-slow stage may still be aborted
if it does not respect the abort signal and exceeds grace. Operators can
raise `STAGE_TIMEOUT_*` or `RDAP_WHOIS_BUDGET_MS` for pathological inputs.
- Stage-level accounting (`processedCount`) is derived from returned
`passed`/`filtered` counts; stages that discard results internally appear
with a lower `processedCount` than actually processed.
- New config surface adds operational tuning surface (mitigated by sane
defaults and documented in `.env.example`).
### Risks and mitigation
- **Abort coherence**: harvest relies on providers honouring the abort
signal. `TrademarkGateStage` and the DNS/RDAP stages already propagate the
signal; the grace window absorbs non-cooperative providers up to a bound.
- **False degradation on healthy runs**: a stage that emits a recoverable
per-candidate error and no stage-level error is not degraded; only stage
failures that truncate output mark the run. Errors already surfaced via
`stageErrors` are now also reflected in `degraded`.
- **Exit-code breakage**: scripts that relied on `dominus run` returning 0
for a timed-out run will now see 1. This is the intended loud-failure
behaviour; quiet success was the original bug.