Skip to content

feat: launcher orchestration — run launchers as AWS Batch jobs with lineage in WES - #183

Closed
accumb3ns wants to merge 12 commits into
refactor/wes-workspace-packagesfrom
feature/launcher-orchestration
Closed

accumb3ns wants to merge 12 commits into
refactor/wes-workspace-packagesfrom
feature/launcher-orchestration

Conversation

@accumb3ns

@accumb3ns accumb3ns commented Aug 18, 2026 •

Copy link
Copy Markdown

Why

Bioinformatics launchers (e.g. the RNA-Seq launcher) orchestrate work: read a samplesheet, submit one child workflow per sample, wait, then gather. Historically they ran wrapped in CWL so Seven Bridges or Arvados could execute them as a platform task. In AWS we decided not to add a CWL runner layer — the launcher image will run directly as an AWS Batch job, with CloudWatch as the log interface — and not to give the launcher a web server or status endpoint.

That removes the platform "task" record the launcher used to get for free. Nothing recorded that a launcher execution existed, and nothing linked it to the child runs it created. This PR adds that record, so launcher status and progress are derived from workflow rows this service already keeps. It is also the prerequisite for launcher restart/recovery.

Everything here is additive: no existing endpoint, schema, or callback changes shape.

What changed

A launcher execution is a WorkflowRun, not a new table — workflow_engine=awsbatch, workflow_run_id = the Batch jobId. It reuses the state machine, listing, filtering, callbacks, the client, the CLI, and the frontend's run views.

Lineage rides in as a reserved ParentRunId tag, promoted by create_run into an indexed parent_run_id column — the pattern already used for ProjectId and TaskName. POST /runs stays GA4GH-shaped, and ?filters={"parent_run_id":"…"} works through the existing generic filter with no server change. That listing is also how a restarted launcher rediscovers work it already submitted.

Parent state and child progress are deliberately separate. GET /runs/{run_id}/progress returns the launcher's own state plus a GROUP BY rollup of its direct children. They are not merged because the interesting failure is when they disagree: if the Batch job dies while 40 children run, the parent must be able to say SYSTEM_ERROR with those children still RUNNING — the orphan condition we want visible. Equally, a failed child does not fail the launcher.

Submission now dispatches by engine. Every POST /runs used to ping the HealthOmics Lambda, so a launcher run would have been pushed at HealthOmics. awsbatch runs go to ExternalDispatchSubmissionService: no AWS call, one system_logs line, left QUEUED for its submitter. Every other engine behaves exactly as before.

The Omics callback is now executor-agnostic. handle_omics_state_change became a thin adapter over handle_state_change with a per-executor status map, so the existing route, schema, and tests are untouched. POST /internal/callbacks/executor-state-change accepts either X-Internal-API-Key (the EventBridge relay Lambda) or X-Internal-Service-Key (APIServer, which is what knows the jobId from SubmitJob and reports pre-start failures) — two callers, one endpoint, two independently rotatable keys. A reported log stream becomes a CloudWatch console deep link, with the raw stream name kept in outputs for APIServer's log viewer.

Client and CLI: parent_run_id on list_runs/iter_runs, new get_run_progress and report_executor_state operations, plus wes runs list --parent, wes runs progress <id>, and wes runs tree <id>.

Who submits the Batch job

NGS360 APIServer submits; this service owns the record. APIServer already has submit_batch_job, a BatchJob table, an EventBridge-driven status/log-stream path, a paginated CloudWatch log viewer, and the IAM — and a launcher job submitted by WES would be missing from the jobs UI operators already use. If we later want WES to submit, the seam is get_submission_service: add a BatchWorkflowSubmissionService and register it. Nothing else in the design moves.

Review notes

  • The migration is hand-written (autogenerate needs a live DB, which wasn't available). Verified against MySQL 8: upgrade head from empty, alembic check reports "No new upgrade operations detected." — i.e. it matches the models exactly — then downgrade -1 and re-upgrade to prove reversibility.
  • ON DELETE SET NULL, not CASCADE: deleting a launcher must not delete the child workflows, whose outputs outlive the orchestration.
  • Index is composite (parent_run_id, created_at) — child listings are "one parent, newest first", and it satisfies InnoDB's FK indexing requirement, so no separate index.
  • Unknown parent is now 400. Related: create_run's other validation failures (missing ProjectId, unsupported workflow_type) were surfacing as 500s; the route already declared a 400.
  • make lint clean, make test 360 passed.
  • Based on refactor/wes-workspace-packages, not main.

Not in this PR

  • ../PAML (ngs360_platform.py), without which the launcher cannot participate. Written and unit-tested on a feature/launcher-orchestration branch there, to be released and pinned separately: get_current_task() returning a task built from WES_RUN_ID; ParentRunId in submit_task's tags; a WES_SERVICE_KEY fallback credential for headless runs (a Batch job has no user session to source a token from), with the bearer token still winning when both are set; and redacting credential-shaped values from the environment dump PlatformFactory.detect_platform writes to the log when detection fails.
  • Two reuse bugs found on the way, filed rather than fixed here. get_tasks_by_name does not paginate, so the task_name=None call — "all runs in this project" — is capped at the 10 newest runs, as is any sample retried more than 10 times; it fails by returning fewer rows than exist, with no way for a caller to tell truncation from a short list (NGS360Platform.get_tasks_by_name silently returns at most 10 runs (no pagination) PAML#130). And the launcher passes inputs_to_compare positionally into get_tasks_by_name's workflow parameter, so PAML receives None and skips the parameter comparison entirely, while the misplaced dict becomes a workflow_url filter that this service's filter handling silently drops — reuse therefore matches any same-named run regardless of parameters. Filed against the launcher repo; a one-word keyword-argument fix.
  • ../APIServer: the submit path — resolve the AWSBatch deployment, create the parent run, build argv from workflow_params, submit as jobName=wes-<run_id>, report the binding to the callback.
  • Infra: the EventBridge Batch rule branch, the launcher job definition, the WES_SERVICE_KEY secret.
  • Deferred by decision: CancelRun for a launcher does not terminate the Batch job or cascade — as today it only sets CANCELING for every engine, so this is not a regression, but cancelling a launcher without cascading leaves children burning compute.

Testing

Highest remaining risk is PAML: the integration test drives child submission over HTTP, not through the launcher's actual platform abstraction, and PAML's own tests mock requests, so nothing yet exercises the two sides against each other. Running the RNA-Seq launcher image against a local service with a two-sample samplesheet is the check that would catch a mismatch. scripts/launcher_example.sh walks the flow in the meantime.

🤖 Generated with Claude Code

vasques1 and others added 10 commits August 17, 2026 21:10
A launcher (orchestrator) execution is itself a workflow run, and every child
workflow it submits now points back at it. This is what lets launcher progress
be derived from persisted state rather than from a status endpoint on the
launcher container.

The index is composite (parent_run_id, created_at) for the same reason
ix_workflow_runs_project_created_at is: child listings are "one parent, newest
first", and a launcher fans out to hundreds of children. It also satisfies
InnoDB's requirement that a foreign key column be indexed.

ON DELETE SET NULL, not CASCADE: deleting a launcher run must not delete the
child workflows it submitted, which are independent executions whose outputs
outlive the orchestration that started them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…allback

- parent_run_id on RunSummary and RunLog, so a caller can see which launcher
  submitted a run without a second request.
- RunProgress: a run's own state plus a rollup of the runs it submitted. The
  two are separate fields on purpose -- a launcher whose Batch job died while
  its children run must be able to report SYSTEM_ERROR with 40 children still
  RUNNING, which is the orphan condition we want visible.
- ExecutorStateChangeCallback: the executor-agnostic successor to the
  HealthOmics-specific callback payload, carrying the executor name so the
  service can pick the right status vocabulary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dation as 400

Children arrive through the ordinary GA4GH POST /runs carrying a reserved
ParentRunId tag, which create_run promotes into the indexed parent_run_id
column -- the same tag-promotion pattern already used for ProjectId and
TaskName. Listing by parent then needs no server work at all, because the
generic ListRuns filter resolves any WorkflowRun attribute by name. That
listing is also how a restarted launcher rediscovers work it already
submitted.

An unknown parent is a client error, so map create_run's ValueError to 400
rather than letting it surface as a 500. Every validation failure in create_run
-- missing ProjectId, unsupported workflow_type, and now an unknown parent --
was being reported as a server fault; the route already declared a 400.

GET /runs/{run_id}/progress is one GROUP BY over the direct children, modelled
on get_system_state_counts. Direct children only: a grandchild is reached by
asking for its own parent's progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Omics

Every POST /runs pinged LambdaWorkflowSubmissionService, so a launcher run
created by NGS360 APIServer would have been pushed at HealthOmics. Runs whose
engine is awsbatch now go to ExternalDispatchSubmissionService, which makes no
AWS call, notes "awaiting external dispatch" in system_logs, and leaves the run
QUEUED for whoever submits the Batch job. Any other engine, including None,
still goes to the Lambda service exactly as before.

The factory is also the seam for the deferred decision about who submits: if
this service should submit Batch jobs itself, add a
BatchWorkflowSubmissionService and register it here, and nothing else in the
design moves.

Also parameterise the hardcoded "AWSHealthOmics (us-east)" deployment lookup by
the requested engine, so the same NGS360 registry lookup can serve a Batch
deployment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dentials

Generalised rather than replaced: handle_omics_state_change is now a thin
adapter over handle_state_change with the HealthOmics status map, so the
existing route, schema, and tests keep working untouched. AWS Batch gets its own
map (SUBMITTED/PENDING/RUNNABLE -> QUEUED, STARTING -> INITIALIZING,
SUCCEEDED -> COMPLETE, FAILED -> EXECUTOR_ERROR, SUBMIT_FAILED ->
SYSTEM_ERROR); the existing transition table already permits that progression,
so it is verified by test rather than edited.

POST /internal/callbacks/executor-state-change accepts either
X-Internal-API-Key (the relay Lambda, fed by EventBridge) or
X-Internal-Service-Key (APIServer, which is what knows the jobId it just got
back from SubmitJob, and which reports pre-start failures). Two callers, one
endpoint, two independently rotatable keys.

A reported log stream becomes a CloudWatch console deep link in stdout, with the
raw stream name kept in outputs so APIServer's log viewer can still use it.
batch_log_group and aws_console_region configure that link; no Batch
credentials are needed, because this service does not submit Batch jobs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Client: parent_run_id on list_runs and iter_runs, plus two new operations --
get_run_progress, and report_executor_state so the Python submitter (APIServer)
has a typed way to bind a job id and report state instead of hand-rolling the
POST.

CLI: `wes runs list --parent`, `wes runs progress <id>` for a launcher's own
state plus a rollup by child state, and `wes runs tree <id>` for one line per
child, colouring terminal-but-not-COMPLETE children red because those are the
samples that will not finish on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Service and API: ParentRunId promotion, an unknown parent rejected as 400, the
parent filter selecting only that launcher's children, and a progress rollup
that counts direct children while deliberately excluding a grandchild.

Callbacks: the full Batch progression through to SUCCEEDED, a duplicate
event_id as a no-op, FAILED recording the container exit code, a post-terminal
event ignored, an unknown executor rejected, and HealthOmics still working
through the generic route after the refactor. Route-level tests confirm either
internal key is accepted and a wrong one is 403 -- these patch get_settings
where callback_auth looks it up, because that module reads settings directly
rather than through FastAPI's DI, so the app fixture's override does not reach
it.

Integration: a launcher plus three children end to end, with the submission
factory left real so the launcher genuinely takes the external-dispatch path
and only the children's HealthOmics call stubbed. A failed child leaves the
launcher COMPLETE, which is the separation the design asks for.

Contract: the two new client operations are added to OPERATIONS, so every
server route again has a client operation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ARCHITECTURE.md gains a Launcher Orchestration section: why a launcher
execution is an ordinary WorkflowRun rather than a new table, how ParentRunId
lineage works, why parent state and child progress are kept separate, why
APIServer submits the Batch job while this service owns the record, the
per-executor status maps, the launcher container env contract, and what is
explicitly not done yet (cancel cascade, relaunch).

The README's numbered flow described the launcher as the submitter of workflows
to this API with no mention of the launcher's own record; it now covers the
launcher being started as a Batch job and its children being tagged back to it.

scripts/launcher_example.sh walks the whole flow against a local service:
submit a launcher, report its Batch job RUNNING, fan out two children, then
read progress and the tree back. It sends RUNNING before a terminal status
because QUEUED -> COMPLETE is not a legal transition, and skips the callback
steps when no key is set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The engine now selects the submission backend, so it has to be a name the
service actually supports. It was neither validated nor advertised: service-info
listed only cwltool, which this service cannot run, while get_submission_service
sent anything it did not recognise to the HealthOmics Lambda. A launcher
submitted as "aws-batch" was accepted, dispatched to HealthOmics, and failed
there on a deployment it never had, reporting a HealthOmics error for a typo.

service-info now advertises awsbatch and awshealthomics, and create_run
validates against that same map -- the spec makes it the client's only way to
discover legal values, so the check and the advertisement cannot disagree. The
submission factory dispatches through a SUBMISSION_STRATEGIES registry keyed by
those names and raises on an unregistered engine instead of defaulting.

Omitting the engine still means this instance's default backend, so callers that
predate launchers are unaffected. A submission naming an engine that is neither
advertised nor omitted is now a 400 listing the accepted names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rejection of unadvertised engines at both the service and the route, acceptance
of an advertised name in any case or padding, and the spec's rule that an engine
is required when an engine version is given.

Two tests exist to stop the advertisement and the dispatch registry from
drifting apart, which is what made the original bug invisible:
test_registry_matches_advertised_engines compares the two sets directly, and a
route test submits every engine service-info advertises and expects 200 -- the
spec's discovery contract as an assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vasques1 and others added 2 commits August 31, 2026 13:26
RunSummary and RunLog mirrored the promoted parent_run_id column as a
top-level response field. That duplicated information already present,
unmodified, in the tags field: create_run never strips ParentRunId out
of tags_dict before storing it, so tags["ParentRunId"] carries the same
value on every response. The spec's RunSummary requires only run_id,
state, start_time, end_time, and tags, so the extra field was this
service's own addition beyond the minimum GA4GH shape, unlike the
parent_run_id column itself -- an internal MySQL indexing detail the
spec has no opinion on either way, kept as-is for the same reason
ProjectId/TaskName are promoted into project/task_name.

Filtering (?filters={"parent_run_id":...}) and the progress rollup are
unaffected: both query the internal column directly and never touched
the response schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… key

The launcher env contract handed launchers the internal service key plus
an asserted X-On-Behalf-Of user. That key covers only this service: the
launcher also calls the NGS360 API directly (project lookup, results
upload), which does not accept it. And anything holding it may assert any
user, so a launcher container would carry this service's full trust
rather than its submitter's.

Launchers now get NGS360_AUTH_TOKEN, an ngs360_ API key minted for the
submitting user for one job, plus NGS360_AUTH_KEY_ID so the launcher can
retire it on exit. One user credential then covers every call, each
authorized against that user. The service key keeps its role for trusted
sibling services.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@accumb3ns

Copy link
Copy Markdown
Author

Pushed b24a0d0, a docs-only change: the launcher env contract in docs/ARCHITECTURE.md now gives launchers NGS360_AUTH_TOKEN, an ngs360_ API key minted for the submitting user for one job, plus NGS360_AUTH_KEY_ID so the launcher can retire it on exit. Before, it gave them WES_SERVICE_KEY + WES_ON_BEHALF_OF.

Why the service key is the wrong credential for launchers:

  • It covers only this service. The launcher also calls the NGS360 API directly (project lookup, results upload), and that API doesn't accept it.
  • Anything holding it may assert any user.

The service key itself is unchanged and keeps its role for trusted sibling services.

Counterparts: NGS360/APIServer#484 mints the key; NGS360/PAML#132 retires it.

Still outstanding on this branch: it predates main's change that forwards the caller's bearer token to the NGS360 API. That arrives when the base refactor/wes-workspace-packages is brought up to date with main, and is needed for child-run workflow lookups to authenticate.

@accumb3ns

Copy link
Copy Markdown
Author

Superseded by #233: the same launcher-orchestration work ported directly onto main, without the refactor/wes-workspace-packages package split this PR was stacked on. The package split never merged and main has moved on in the existing layout, and nothing the launcher feature needs depends on it. #233 drops the client/CLI lineage commands (they rely on the client package from the split) and uses the existing callback key on the executor callback instead of the service key. The feature/launcher-orchestration branch is left in place for reference.

@accumb3ns accumb3ns closed this Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant