Skip to content

ci(dagster): deploy code location as PEX instead of an image - #863

Merged
jirhiker merged 4 commits into
stagingfrom
ci/dagster-pex-fast-deploys
Aug 19, 2026
Merged

ci(dagster): deploy code location as PEX instead of an image#863
jirhiker merged 4 commits into
stagingfrom
ci/dagster-pex-fast-deploys

Conversation

@jirhiker

@jirhiker jirhiker commented Aug 19, 2026

Copy link
Copy Markdown
Member

Why

Both Dagster+ CD workflows built and pushed a container image to ECR on every run, which dominated deploy time even when only ingestion source changed. Serverless deploys can instead publish two PEX files — deps.pex and source.pex — and reuse a cached deps.pex when the resolved requirements are unchanged, so a source-only change uploads ~9MB rather than rebuilding a full image. See Fast deploys with PEX and Docker.

What

Each workflow now runs actions/utils/prerun, which reports pex-deploy or docker-deploy from ENABLE_FAST_DEPLOYS.

  • PEX path (default): checkout to project-repo/, export requirements, build_deploy_python_executable. No image build.
  • Docker path: the old workflow verbatim, in a second job reached by setting ENABLE_FAST_DEPLOYS: "false". Escape hatch for anything PEX cannot express — a dependency with no Linux wheel that also fails to build from source, or a system package needing apt. Neither applies today.

On the branch workflow, prerun also handles the closed-PR teardown: it runs ci branch-deployment to mark the deployment closed and reports skip, so the docker action's own closed-PR branch is no longer reached.

dagster_cloud_post_install.sh is unchanged apart from its header. Only the Docker path still runs it; the source-pex builder performs the same uv pip install --no-deps . of this repository itself, which is what keeps db/ and domain/ importable from the process that executes a step.

Two details are load-bearing

--no-hashes on the PEX export, and only there. The PEX builder unions requirements.txt with [project].dependencies from pyproject.toml, and those pins carry no hashes — a hashed requirements.txt would put pip in --require-hashes mode, where every unhashed line is a hard error. Both sources resolve from the same uv.lock, so the duplicate pins agree. The Docker path keeps hashes; it feeds the file straight to pip install -r with nothing unhashed mixed in.

python_version: 3.13, against an action default of 3.8. The PEX files are resolved for one interpreter, and requires-python is >= 3.13.

Verification

Check Result
actionlint on both workflows clean
Merged requirement set resolves for x86_64-unknown-linux-gnu / py3.13 194 packages
Same resolve with --no-build 194 — every dependency ships a linux/cp313 wheel
source.pex built from a clean clone 9.3M, ocotilloapi==1.2.0 installed
Pex contents automated_ingestion/, db/, domain/, services/, core/, schemas/, plus working_directory/root/

Because all dependencies are wheels there is no sdist compile. The runner is ubuntu-latest (24.04), so the action builds deps.pex inside a python:3.13-slim container to match the serverless base image — but only when the dependency hash changes. ubuntu-22.04 would skip that container too; it is not used here because that runner label is being retired.

One layout difference worth knowing: core/lexicon.json lands only under working_directory/root/, not in the installed core/ package, so services/lexicon_helper.py:121 would not find it. Harmless — automated_ingestion/ imports exactly one repo module, domain.van_essen, and never touches services/ or core/. The same gap already exists in the Docker image.

Measured on this PR

This PR matches the branch workflow's path filter, so it deployed itself through the new PEX path three times.

Run deps.pex Duration
first (cold) built in the manylinux container, 2m36s 5m51s
second skipping rebuild — cache hit 1m07s

Same dependency hash (31ffa29d…) both times, and identical to what the same export reproduces locally. source.pex builds in ~5s.

The heartbeat now materializes as the last step of the PEX path, so every ingestion PR proves a step process can execute and not merely that the agent could load the location. Verified on branch deployment 4206bfdf…: run 3194f52c-5866-4daa-9533-a56f6dee4280 finished successfully.

One thing reviewers should know

launch_job is not used, and the success assertion is hand-rolled, because the vendor's gate does not work. Its run.sh captures the CLI output in a command substitution and never checks the exit code — it decides success by regexing a run id out of the text. Underneath, dagster-cloud job launch --wait reports a failed run via ui.error(...), and ui.error only returns an exception rather than raising it, so the CLI exits 0 too. The action documents "fail if the run fails"; at neither layer can it. The workflow therefore requires the finished successfully line itself.

Still open

A green heartbeat proves the step process runs user code under PEX. It does not prove db and domain are importable from a step: the asset reports db_on_path / domain_on_path via find_spec as metadata and does not fail when either is False. Both packages are present at the root of source.pex (verified by unzipping it), which is on sys.path, but the direct confirmation is those two metadata values on run 3194f52c… in the Dagster+ UI. Worth a glance before merge.

🤖 Generated with Claude Code

Both Dagster+ CD workflows built and pushed a container image to ECR on
every run, which dominated deploy time even when only ingestion source
changed. Serverless deploys can instead publish two PEX files -- deps.pex
and source.pex -- and reuse a cached deps.pex when the resolved
requirements are unchanged, so a source-only change uploads ~9MB rather
than rebuilding a full image.

Each workflow now runs actions/utils/prerun, which reports pex-deploy or
docker-deploy from ENABLE_FAST_DEPLOYS. The PEX path is the default; the
old image build survives verbatim in a second job reached by setting that
variable to 'false', as the escape hatch for anything PEX cannot express
(a dependency with no Linux wheel that also fails to build from source, a
system package needing apt). Every dependency currently resolves to a
linux/cp313 wheel, so neither case applies today.

Two details are load-bearing:

`--no-hashes` on the PEX export, and only there. The PEX builder unions
requirements.txt with [project].dependencies from pyproject.toml, and
those pins carry no hashes -- a hashed requirements.txt would put pip in
--require-hashes mode, where every unhashed line is a hard error. Both
sources resolve from the same uv.lock, so the duplicate pins agree. The
Docker path keeps hashes; it feeds the file straight to pip install -r
with nothing unhashed mixed in.

python_version 3.13, against an action default of 3.8. The PEX files are
resolved for one interpreter, and requires-python is >= 3.13.

On the branch workflow, prerun also handles the closed-PR teardown: it
runs `ci branch-deployment` to mark the deployment closed and reports
skip, so the docker action's own closed-PR branch is no longer reached.

dagster_cloud_post_install.sh is unchanged apart from its header. Only
the Docker path still runs it; the source-pex builder performs the same
`uv pip install --no-deps .` of this repository itself, which is what
keeps db/ and domain/ importable from the process that executes a step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Coverage

78.59% total — gate is 75%.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Coverage

78.60% total — gate is 75%.

Coverage for the Python files changed in this PR
Name Stmts Miss Cover Missing
automated_ingestion/defs/definitions.py 6 0 100%
automated_ingestion/defs/jobs/heartbeat.py 3 0 100%
TOTAL 9 0 100%

jirhiker and others added 2 commits August 19, 2026 14:14
A successful deploy is weaker evidence than a successful run. The agent
loading the code location proves the loader process can import the
package; it says nothing about the process that executes a step, which is
a different process with a different sys.path. That gap is why
assets/heartbeat.py exists, and it matters more now that the code location
ships as PEX files rather than an image, because the two package the
repository by different routes.

Nothing could launch that asset from CI. dagster-cloud-action's launch_job
identifies what to run by job name and exposes no asset selection, so an
asset reachable only through the implicit __ASSET_JOB is unreachable. So
wrap it in a named job, ingestion_heartbeat_check, and add a
workflow_dispatch workflow that launches it against a deployment given as
an input -- a branch deployment id or prod -- with wait: true so the
workflow result is the materialization result rather than just "a run was
launched".

Dispatch-only on purpose. It costs a Dagster+ run, and the interesting
time to spend one is after a deploy that changed how the code location is
packaged, not on every push.

No retry policy on the job: a retry would mask exactly the failure it
exists to surface, since an import that works at load time and fails at
execution does so deterministically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dispatch-only smoke test cannot cover the case it was written for.
GitHub offers workflow_dispatch only for workflows present on the default
branch, so a PR that changes how the code location is packaged -- exactly
when the check is worth running -- cannot run it.

So run it from the deploy workflow, as the last step of the PEX path. That
also fixes the ordering for free: a separate workflow would race the
deploy and could launch the job before the agent has synced the new code
location, or before the job exists in it at all.

The deployment name has to be resolved rather than assumed. It is derived
from the branch and build_deploy_python_executable does not report it, so
ask for it with the same `ci branch-deployment` call the deploy makes
internally; that call is idempotent and returns the existing deployment
for the PR.

smoke_dagster_location.yml stays, with its purpose narrowed to the cases
the deploy workflow does not reach: prod, and branch deployments that
predate this step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Coverage

78.60% total — gate is 75%.

Coverage for the Python files changed in this PR
Name Stmts Miss Cover Missing
automated_ingestion/defs/definitions.py 6 0 100%
automated_ingestion/defs/jobs/heartbeat.py 3 0 100%
TOTAL 9 0 100%

The previous commit claimed a green check meant the asset materialized.
It did not. `launch_job`'s run.sh captures the CLI output in a command
substitution and never checks the exit code, deciding success by whether
it can regex a run id out of the text. Underneath, `dagster-cloud job
launch --wait` reports a failed run with `ui.error(...)` -- and `ui.error`
only returns an exception rather than raising it, so the CLI exits 0 as
well. The action documents "fail if the run fails"; at neither layer can
it. A failed materialization produced a passing step.

Call the CLI directly and require the "finished successfully" line.
Matching on output is not lovely, but it is the only signal either layer
emits, and an assertion that can fail is worth more than one that reads
better.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Coverage

78.60% total — gate is 75%.

Coverage for the Python files changed in this PR
Name Stmts Miss Cover Missing
automated_ingestion/defs/definitions.py 6 0 100%
automated_ingestion/defs/jobs/heartbeat.py 3 0 100%
TOTAL 9 0 100%

@jirhiker
jirhiker merged commit 3cfae73 into staging Aug 19, 2026
12 checks passed
@jirhiker
jirhiker deleted the ci/dagster-pex-fast-deploys branch August 19, 2026 21:30
@jirhiker
jirhiker restored the ci/dagster-pex-fast-deploys branch August 19, 2026 23:23
jirhiker added a commit that referenced this pull request Aug 19, 2026
Picks up the Dagster PEX branch-deploy work from #863. The branch-deploy CI
step that materializes `ingestion_heartbeat_check` came from staging via the
pull_request merge ref, but the job it launches is defined in
automated_ingestion/defs/jobs/heartbeat.py, which this branch predated -- so
the deployed code location had no such job and the launch failed with
PipelineNotFoundError. Merging brings the definition along with the step.
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