Skip to content
dev

TORCH Extraction Implementation

Developer reference for how Aether drives a TORCH data-extraction job end to end: submitting a CRTDL query, polling for completion, and downloading the resulting FHIR NDJSON files.

This page documents the implementation — the types, functions, and control flow — behind Aether's use of TORCH's asynchronous Bulk Data extraction API. For operator-facing usage see the TORCH Integration guide and the TORCH Import step.

TORCH's two APIs

TORCH exposes two REST APIs, and Aether uses only the first:

  • FHIR controller — the extraction API, built on the FHIR Asynchronous Bulk Data Request Pattern: the $extract-data kick-off operation plus the GET /fhir/__status/{jobId} status/manifest endpoint.
  • Task API (task-controller) — controls extraction jobs in execution (inspect, pause, resume, cancel), exposing each job as a FHIR Task. Aether does not use it today; job control and Task reads are out of scope (see PR #474).

The asynchronous bulk extraction flow

Aether drives the FHIR controller through three phases:

  1. Kick-offPOST $extract-data creates the extraction job. TORCH returns the job's status URL (.../fhir/__status/{jobId}) in the Content-Location response header.
  2. Poll — Aether polls the status URL. 202 Accepted means the job is still running; 200 OK means it completed and the response body is the async-bulk manifest carrying the output file URLs.
  3. Download — Aether downloads each output file.

The extraction job is identified by a TORCH job ID (a UUID, distinct from the Aether pipeline job ID). Aether persists a job handle — the job ID plus its status URL — so it can re-attach to an in-flight job after a crash or restart instead of re-submitting (see Handle persistence and resume below). The CONTEXT.md glossary at the repository root defines the domain terms (extraction job, job handle, re-attach, orphan, re-roll).

Extraction job status (the "double status")

Each extraction job moves through a status lifecycle (coding system https://medizininformatik-initiative.de/torch/job-status):

StatusMeaning
PENDINGAccepted, not yet started
RUNNING_GET_COHORTSelecting the cohort
RUNNING_PROCESS_BATCHExtracting patient batches
RUNNING_PROCESS_COREAssembling shared (non-patient) core resources
PAUSEDPaused, resumable
TEMP_FAILEDTransient failure — not terminal; TORCH retries
COMPLETED / FAILED / CANCELLEDTerminal

This status appears in two places — an artifact of the async-bulk design that carries job state alongside the Task model: the Task API exposes it as a Task business status, and the completion manifest from __status embeds the same job state in a torch-job extension. Aether reads neither representation. It branches purely on the async-bulk HTTP status the __status endpoint returns (see the Polling stage below): TEMP_FAILED surfaces as 202/503, terminal failure as 500, and completion as 200.

Source map

FileResponsibility
internal/pipeline/import.goOrchestration: submit → persist handle → poll → download, resume, error classification
internal/pipeline/crtdl_prep.goPrepareCRTDL — copies/enriches the CRTDL into the job directory before submission
internal/services/torch_client.goTORCHClient: submit, download, result parsing, file-availability checks, URL resolution, request/response types
internal/services/torch_poller.goPollConfig and handlePollResponse: status interpretation, liveness window, exponential backoff
internal/models/config.goTORCHConfig — the tunable knobs
internal/models/job.goCRTDLPath, TORCHExtractionURL, TORCHJobID — the persisted resume handle
internal/models/step.goStepTorchImport step constant

Lifecycle

                          internal/pipeline/import.go
┌──────────────────────────────────────────────────────────────────────┐
│ importStep.Run  (step == torch)                                      │
│   InputTypeTORCHURL ──► executeTORCHDownload  (poll URL directly)    │
│   InputTypeCRTDL    ──► executeTORCHExtraction                       │
└───────────────────────────────┬──────────────────────────────────────┘

        job.TORCHJobID set? ──yes──► re-attach to in-flight job
                 │ no

   submitAndPersistTORCHHandle
     SubmitExtraction ─► POST {base_url}/fhir/$extract-data  (DoOnce, no retry)
        └─ 200 + Content-Location header ─► status URL
     persist job.TORCHExtractionURL + job.TORCHJobID to state.json


   PollExtractionStatus(statusURL)                 internal/services/torch_poller.go
     loop:
       GET statusURL (DoOnce)
       handlePollResponse:
         202 / 102 ─► in-progress; log OperationOutcome diagnostics; backoff
         200       ─► parseExtractionResult ─► [fileURLs]  ✔ done
         404 / 410 ─► ErrHandleDead ─► clear handle, re-submit, re-poll
         500       ─► terminal job failure (non-transient)
         408/429/other 5xx ─► transient; sleep + backoff; retry
       RecordContact() on every 200/202 (resets liveness window)
       CheckTimeout(): give up only after extraction_timeout of *silence*


   DownloadExtractionFiles([fileURLs], importDir)  internal/services/torch_client.go
     per file:
       waitForFileAvailability (HEAD, fallback Range GET)
       downloadFile ─► stall-guarded stream ─► {job}/import/<name>.ndjson[.zst]

Stage reference

1. CRTDL preparation

Before the import step runs, PrepareCRTDL (internal/pipeline/crtdl_prep.go) copies the input CRTDL into the job directory as crtdl.json, or — when CRTDL preprocessing is enabled with at least one enrichment — writes an enriched enriched-crtdl.json. It repoints job.CRTDLPath at that file so every downstream step shares one effective CRTDL. Enrichment adds attributes DIMP needs (for example, Patient.identifier) that the original query may omit.

2. Submission

TORCHClient.SubmitExtraction(crtdlPath):

  • Reads the CRTDL, validates it is JSON, and base64-encodes it (encodeCRTDLToBase64).
  • Wraps it in a FHIR Parameters resource with a single crtdl parameter carrying valueBase64Binary (TORCHExtractionRequest / TORCHParameter).
  • POST {base_url}/fhir/$extract-data with Content-Type: application/fhir+json and auth applied via HTTPClient.ApplyAuth.
  • Sends with DoOnce — deliberately no retry. $extract-data is non-idempotent job creation; a retried timeout could spawn a duplicate extraction on the server.
  • Reads the Content-Location header (the status URL) and normalizes it to an absolute URL (makeAbsoluteURL). A missing header is a hard error.

SubmitExtractionWithContent(crtdlContent []byte) is the in-memory variant for already-enriched documents; it is otherwise identical (it sends Content-Type: application/json).

3. Handle persistence and resume

submitAndPersistTORCHHandle stores the extraction handle before the long poll begins:

  • job.TORCHExtractionURL — the status URL to poll.
  • job.TORCHJobID — the URL's trailing path segment (JobIDFromStatusURL), for example .../fhir/__status/{jobId}.

Both are written to state.json via UpdateJob, so a crash mid-extraction leaves a recoverable handle. On resume, executeTORCHExtraction sees a non-empty job.TORCHJobID and re-attaches to the running job instead of submitting a new one.

If polling later reports the handle is dead (ErrHandleDead, from a 404/410), the orchestrator clears both fields, re-submits a fresh extraction, and resumes polling.

4. Polling

TORCHClient.PollExtractionStatus(statusURL, showProgress) runs the poll loop; internal/services/torch_poller.go owns the per-response logic and timing state (PollConfig).

Each iteration issues a single GET (DoOnce — the loop, not the HTTP client, owns retry cadence) with Accept: application/json, then handlePollResponse interprets the status:

StatusMeaningAction
202 Accepted, 102 ProcessingStill runningRead OperationOutcome information diagnostics for progress display; back off and poll again
200 OKCompleteparseExtractionResult → file URLs → return
404 Not Found, 410 GoneHandle goneReturn ErrHandleDead (caller re-submits)
500 Internal Server ErrorTerminal job failureNon-transient error, stop
408, 429, other 5xxTransientSleep + backoff, retry
other 4xxTerminalStop

Note: 500 is treated as terminal here even though 5xx is normally retryable, because TORCH surfaces a temporary failure (TEMP_FAILED) as 202/503 and reserves 500 for a genuinely failed job.

Liveness window. extraction_timeout is a no-progress window, not a total cap. CheckTimeout returns true only when time.Since(LastContact) > Timeout, and RecordContact resets LastContact on every 200/202. A healthy job that keeps answering — even one running for hours — never trips the timeout; Aether gives up only when TORCH goes silent. See ADR 0001 for the rationale.

Exponential backoff. The poll interval starts at polling_interval and doubles after each in-progress poll (CalculateNextPollInterval / UpdateInterval), capped at max_polling_interval.

Transient network failures on the GET itself (timeouts, connection resets) are swallowed and retried — the extraction may still be running server-side, and the liveness window is the safety net.

5. Result parsing

parseExtractionResult accepts two shapes and extracts absolute file URLs:

  • FHIR Parameters (TORCHExtractionResult): reads output parameters and their url parts (extractURLsFromFHIRFormat).
  • Async-bulk manifest (TORCHSimpleResponse): { "requiresAccessToken": …, "output": [ { "type": …, "url": … } ], "extension": [ … ] } (extractURLsFromSimpleFormat) — the format the server actually returns today. Aether reads only the output URLs; the manifest's torch-job / diagnostics extensions are ignored.

An empty output is not silently accepted: if TORCH reports errors the parser surfaces them; otherwise it returns a "no matching data" message explaining the likely CRTDL causes (criteria matched nothing, out-of-range time period, unknown cohort).

URL resolution. makeAbsoluteURL is a pure function that dispatches on the URL shape: absolute URLs pass through, path-relative URLs (/output/x.ndjson) resolve against base_url (resolvePathRelativeURL), and scheme-less host-prefixed URLs (host:8080/…, a known TORCH misconfiguration) inherit base_url's scheme (prependBaseScheme).

6. Download

TORCHClient.DownloadExtractionFiles writes each output file into the job's import/ directory. Per file:

  • Derive the filename from the URL's base, forcing an .ndjson suffix, then add the compression extension via GetCompressedFilename.
  • waitForFileAvailability polls the file before downloading (TORCH results are often served through a proxy with eventual consistency). It uses a HEAD request, falling back to a Range: bytes=0-0 GET when the server answers 403, 404, or 405. It retries up to file_ready_retries times spaced by file_ready_interval; setting file_ready_retries <= 0 disables the check.
  • downloadFile / downloadFileOnce stream the body with Accept: application/fhir+ndjson and optional zstd compression on write.

Stall watchdog. Downloads use a dedicated http.Client (downloadClient) with no whole-request deadline, so an arbitrarily large but steadily flowing NDJSON completes regardless of total size. Inactivity is bounded instead by a stallGuardReader: each read re-arms a time.AfterFunc timer, and if no bytes arrive within download_stall_timeout the timer cancels the request context. The cancellation is reported as errDownloadStalled rather than a generic "context canceled". The watchdog is armed before even an error body is read, so a proxy that flushes 4xx/5xx headers then goes silent is bounded too.

Retry. downloadFile retries only a retryable *TORCHError (a transient one), using the shared client's backoff config. A stall or a mid-body write error is not a *TORCHError and is not retried — restarting from scratch would only repeat the stall or waste a full re-transfer.

Each downloaded file yields a models.FHIRDataFile with size, resource (LineCount) count, and SourceStep: StepTorchImport.

7. Error classification

classifyImportError decides whether a failure is transient (pipeline may retry / pause and resume) or terminal:

  • It unwraps a *services.TORCHError (via errors.As, so wrapped submit/poll/download errors are caught) and returns its ErrorType.
  • For torch/http import steps, a bare network error is transient.
  • Everything else defaults to non-transient.

TORCHError carries Operation (submit/poll/download), StatusCode, Message, and ErrorType; IsRetryable() is true only for models.ErrorTypeTransient.

Direct TORCH result URL

When the input is an already-complete TORCH result URL (InputType == InputTypeTORCHURL), executeTORCHDownload skips submission entirely: it polls the URL directly (expecting an immediate 200) and downloads the listed files.

Data types

Defined in internal/services/torch_client.go:

TypePurpose
TORCHExtractionRequest / TORCHParameterKickoff body: FHIR Parameters with a base64 crtdl parameter
TORCHExtractionResult / TORCHResultParameter / TORCHResultPartFHIR Parameters result format
TORCHSimpleResponse / TORCHSimpleOutputSimplified { output: [ { type, url } ] } result format
OperationOutcome / OperationOutcomeIssueProgress diagnostics parsed from 202 bodies
TORCHErrorStructured submit/poll/download error with retryability

Sentinel errors: ErrExtractionTimeout, ErrHandleDead, ErrInvalidCRTDL.

Configuration

TORCHConfig (internal/models/config.go) tunes the flow. Defaults are set in DefaultConfig and range-checked in TORCHConfig.Validate:

FieldDefaultEffect
base_urlTORCH server base URL (required when the torch step is enabled)
username / passwordBasic auth (takes precedence over OAuth)
oauth_issuer_uri / oauth_client_id / oauth_client_secretOAuth 2.0 client-credentials auth
extraction_timeout30mLiveness window — max time to wait without a response; reset on every 200/202
polling_interval5sInitial status poll interval (must be ≥ 1s)
max_polling_interval30sBackoff cap (must be ≥ polling_interval)
file_ready_retries10Availability checks before download; 0 disables the check
file_ready_interval10sDelay between availability checks
download_stall_timeout60sInactivity window while streaming a file; 0 uses the built-in default

See the Configuration Reference for the operator-facing table.

Persisted job handle

For resume, the following PipelineJob fields (internal/models/job.go) are written to state.json:

FieldPurpose
CRTDLPathThe effective CRTDL used for submission (decoupled from the raw input source)
TORCHExtractionURLThe Content-Location status URL to re-poll
TORCHJobIDThe extraction job ID (handle) used to re-attach to an in-flight job

Testing seams

The import step depends on the Extractor interface — SubmitExtraction, PollExtractionStatus, DownloadExtractionFiles — which TORCHClient satisfies. extractorFactory (internal/pipeline/import.go) builds it, and SetExtractorFactoryForTesting / ResetExtractorFactory swap in a fake so tests exercise submit/poll/download without a live TORCH server. Pure helpers such as makeAbsoluteURL, CalculateNextPollInterval, and the PollConfig liveness methods are unit-tested directly.

Next Steps

Healthcare data integration made simple