Skip to content

TS SDK: embed one source region per native Dag file - #73723

Merged
jason810496 merged 8 commits into
apache:mainfrom
astronomer:ts-sdk-multi-source-repro
Sep 30, 2026
Merged

jason810496 merged 8 commits into
apache:mainfrom
astronomer:ts-sdk-multi-source-repro

Conversation

@pierrejeambrun

@pierrejeambrun pierrejeambrun commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

A bundle whose native Dags live in more than one file only carried its
entrypoint's source in the airflowSource region. Airflow's Code tab
would show the entrypoint for every Dag: the file for the ones declared
there, and something unrelated for the ones declared in imported
modules. Multi-Dag bundles were effectively single-source in the UI.

What changes

  • Each Dag's source file is embedded, one per unique path. The
    bundle header's single source region becomes sources: [{path, start, end, sha256}]. Each region has its own /*# airflowSource:<path> … #*/ block comment so the path is visible in the raw bundle too.
  • Metadata gains dag_source_paths: Record<dag_id, path> so a
    reader can pick the right source per Dag without guessing.
  • Dag's constructor records which file it ran in. It reads a
    cross-realm global slot (Symbol.for("airflow.ts-sdk.current-module-source"))
    that airflow-ts-pack's new onLoad plugin writes at the top of each
    author-owned source file. ES module hoisting is what makes the slot
    the current file for every top-level new Dag(...) call, so the
    constructor just reads it. Same Symbol.for pattern the SDK already uses for
    the task-scope AsyncLocalStorage.
  • Files that only supply utilities or types are not embedded. Only
    the files whose new Dag(...) constructor ran end up in the per-Dag
    source regions — matches Python's dag.fileloc behavior.
  • The entrypoint is always embedded, as a fallback. entrypoint_path
    is recorded in the metadata and the entry file's contents are packed as a
    source region. It is the fallback for a native TypeScript Dag with no
    attributed file of its own — one built dynamically that the packer could
    not tie to a file (the reference the single-source format gave).
  • Mixed-language bundles (Python owns the Dag, TS supplies handlers)
    have an empty dag_source_paths; their only embedded source is the
    entrypoint. A source read passing no dag_id returns None (see below) —
    Python owns its Code tab, so the bundle offers no TypeScript source.

Bundle shape — before / after

Layout used for both: src/main.ts declares orders_dag inline, src/dags/reports.ts
declares reports_dag and is imported from main.ts.

Before:

//# airflowBundle={"code":{…},"metadata":{…},"source":{"start":"…","end":"…","sha256":"…"}}
//# airflowMetadata={…,"source":"main.ts","task_handlers":{"orders_dag":{…},"reports_dag":{…}}}
/*# airflowSource
…contents of src/main.ts…            ← only the entry file
#*/
<executable>

After:

//# airflowBundle={"code":{…},"metadata":{…},"sources":[
  {"path":"src/main.ts","start":"…","end":"…","sha256":"…"},
  {"path":"src/dags/reports.ts","start":"…","end":"…","sha256":"…"}
]}
//# airflowMetadata={…,"entrypoint_path":"src/main.ts",
  "dag_source_paths":{"orders_dag":"src/main.ts","reports_dag":"src/dags/reports.ts"},
  "task_handlers":{"orders_dag":{…},"reports_dag":{…}}}
/*# airflowSource:src/main.ts
…contents of src/main.ts…
#*/
/*# airflowSource:src/dags/reports.ts
…contents of src/dags/reports.ts…    ← now present, keyed by path
#*/
<executable>

Task SDK bundle reader — clean break (separate commit)

The writer above is only half of the contract. NodeCoordinator reads every
.min.mjs bundle at task-launch time — it parses the layout, verifies each
section's SHA-256, and matches the requested dag_id before spawning node
(task-sdk/…/coordinators/node/_bundle_reader.py). That reader still hard-required
the old singular source layout section, so the moment the packer switched to the
sources array every bundle was rejected with bundle layout is missing the source section and no TypeScript task could launch. This is what the ts_sdk e2e
job was failing on — the reader lives in a different distribution and did not move
with the format.

Commit cd1c307 teaches the reader only the new layout — no released bundle
uses the old shape, so it is a clean break rather than a dual-format reader:

  • Parses the sources array (per-region path + byte range + digest), verifies
    each region's digest, and accepts an empty array defensively.
  • Reads each /*# airflowSource:<path> … #*/ region back-to-back and checks the
    marker's path against the declared path.
  • read_bundle_source(bundle_path, dag_id=None) resolves a Dag's source through
    dag_source_paths (see below for how the entrypoint and None fit in).

Entrypoint reference and per-Dag source (separate commits)

Two refinements build on the reader:

878d082 always embeds the entrypoint. The single-source format always
carried the entry file; the per-Dag format dropped it whenever the packer could not
attribute a Dag to a file. The packer now records entrypoint_path in the metadata
and packs the entry file's contents as a source region (encodeBundle rejects an
entrypoint missing from the packed sources).

f9da0fe lets the caller choose when the bundle serves a Dag's source.
read_bundle_source(bundle_path, dag_id=None):

  • dag_id omitted → None. A mixed-language Dag is defined in Python, which owns
    its Code tab; the caller (which knows the Dag's Python fileloc) asks for no
    TypeScript source and gets None rather than an unrelated file.
  • dag_id mapped in dag_source_paths → that Dag's own source file.
  • dag_id given but not mapped (a native Dag built dynamically that the packer
    could not attribute) → the always-embedded entrypoint source, as a fallback.

So the entrypoint is a fallback only for a Dag the caller identifies as TypeScript-owned;
a Python-owned Dag never resolves to it.

Verified against a freshly packed ts-sdk/example bundle: it registers only
handlers for two Python-owned Dags (no native new Dag(...)), so
dag_source_paths is empty. It packs the entrypoint (src/main.ts) and records
entrypoint_path; read_bundle returns both dag_ids; read_bundle_source with no
dag_id returns None, and with a dag_id falls back to the entrypoint source. (The
old reader crashed on this bundle with bundle layout is missing the source section.)

Test coverage

  • buildBundleManifest records paths from the module-source slot.
  • Encoder: one region per file, path-tagged marker, the mixed-lang case
    (only the entrypoint region, empty dag_source_paths), and rejection of
    an entrypoint missing from the packed sources.
  • Pack: multi-Dag bundle where two files declare Dags across an import
    boundary — asserts both sources are embedded and dag_source_paths
    maps each dag_id to its file; a mixed-language entry (only task
    handlers) still embeds its entrypoint source with an empty
    dag_source_paths.
  • Existing shebang test still passes: the plugin's slot-write goes
    after the shebang line so #!/usr/bin/env node stays valid.
  • Reader (task-sdk): reads the new sources layout and the real encoder
    fixture, resolves per-Dag source via dag_source_paths, returns None
    when no dag_id is supplied, falls back to entrypoint_path for a
    supplied-but-unmapped Dag, and rejects duplicate paths, marker/path
    mismatches, and per-region digest mismatches.

Not in scope

  • A UI / Code-tab consumer of the embedded source is future work; no
    endpoint surfaces it to users yet. The reader exposes the primitives —
    per-Dag source (or None) and the entrypoint_path reference — and the
    consumer composes them with Airflow's Python-side fileloc knowledge;
    this PR does not add the UI side.

Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Opus 4.7)

Generated-by: Claude Code (Opus 4.7) following the guidelines

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Critical and moderate compatibility, encoding, escaping, size-limit, and module-source scoping issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 3 High severity · 2 Medium severity · 1 Low severity

Open (6)
What changed in this PR

Updates TypeScript bundle packaging to preserve each native Dag’s defining source file across imported modules.

Changes:

  • Adds per-Dag source paths and path-tagged source regions.
  • Introduces module-source tracking during Dag construction.
  • Updates manifests, encoder behavior, tests, fixtures, and documentation.
File Summary
ts-sdk/​tests/​coordinator/​runtime-manifest.test.ts Tests source-path manifest records.
ts-sdk/​tests/​cli/​pack.test.ts Tests multi-source packing and metadata.
ts-sdk/​tests/​cli/​fixtures/​bundle-v1.min.mjs Updates the bundled fixture.
ts-sdk/​src/​sdk/​module-source.ts Defines the shared module-source slot.
ts-sdk/​src/​sdk/​dag.ts Captures Dag source paths.
ts-sdk/​src/​sdk/​bundle.ts Exposes native Dags for manifest generation.
ts-sdk/​src/​coordinator/​manifest.ts Adds Dag-to-source metadata.
ts-sdk/​src/​cli/​pack.ts Tags modules and reads Dag sources; global-slot scoping and rationale require changes.
ts-sdk/​src/​cli/​bundle-encoder.ts Encodes multiple source regions; compatibility, encoding, path escaping, and payload limits require changes.
airflow-core/​docs/​authoring-and-scheduling/​language-sdks/​typescript.rst Documents per-Dag source embedding.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ts-sdk/src/cli/bundle-encoder.ts Resolved
Comment thread ts-sdk/src/cli/bundle-encoder.ts Resolved
Comment thread ts-sdk/src/cli/pack.ts Resolved
Comment thread ts-sdk/src/cli/bundle-encoder.ts Resolved
Comment thread ts-sdk/src/cli/bundle-encoder.ts Resolved
Comment thread ts-sdk/src/cli/pack.ts Resolved
A bundle whose native Dags live in more than one file only carried its
entrypoint's source in the airflowSource region. The Code tab would show
the entrypoint for every Dag, which is the file for the ones declared
there and unrelated for the ones declared in imported modules. Multi-Dag
bundles were effectively single-source in the UI.

Every Dag file that declares at least one native Dag now ships its own
source region, mapped to its Dags by dag_id. The `Dag` constructor
records which file it ran in via a cross-realm global slot that
`airflow-ts-pack`'s onLoad plugin writes at the top of each author-owned
source file. `Function.prototype.toString` and stack-trace parsing stay
out — ES module hoisting is what makes the slot the current file for
every top-level `new Dag(...)` call, and the constructor just reads it.
Per-file source-size cap, back to the same 1 MB limit each source region
already had. The bundle grows with the number of Dags, not with any
aggregate cap on their sum.

`EMBEDDED_SOURCE_OPEN_PREFIX`/`_SUFFIX` collapse to a single
`EMBEDDED_SOURCE_MARKER`; the trailing newline is a formatting detail
of the concatenation, not a named constant.

`entrypointName` and its `--source` CLI option are gone. The metadata
`entrypoint` field they filled is only useful as a fallback for
"no per-Dag source", and each native Dag now carries its own source path.
Bundles that serve nothing still fail at pack time; mixed-lang bundles
still work with an empty `dag_source_paths`.
A source path holding `*/` would close the marker's own block comment
early, splicing the rest of the region into executable position; the
packer now rejects such paths (and stray newlines) rather than trying
to escape them.

`renderHeader` was writing the layout line with `Buffer.from(..., "ascii")`,
which silently truncated a non-ASCII path like `données.ts` and broke
the metadata-to-region round trip. Encode the header as utf-8.

Drops the whitespace/syntax/keepNames minify split back to plain
`minify: true`: nothing in this PR depends on identifier preservation,
so the earlier split needed a rationale it did not have.
The tag used to only write. When A imported B, B's write overwrote A's,
and any `new Dag(...)` in A after the import was attributed to B. The
tag now captures the previous value on entry and restores it after the
module's top-level code returns, so nested imports and top-level `await`
within a single module hand the slot back on the way out.

Two scoping limits remain and are documented in module-source.ts: a Dag
constructed in a scheduled callback (setTimeout, microtask) fires after
the module's epilog has restored, so it lands under whichever module
touched the slot most recently; and two modules genuinely evaluated in
parallel through Promise.all([import, import]) can interleave. Both are
non-standard authoring patterns.
The airflow-ts-pack encoder now writes a `sources` array — one embedded region
per native Dag file, keyed by `dag_source_paths`, and empty for a bundle whose
Dags are all authored in another language — in place of the single `source`
section. The Task SDK bundle reader still demanded that singular section, so
every bundle the current packer produces was rejected with "bundle layout is
missing the source section" and no TypeScript task could launch. Teach the
reader only the new layout; no released bundle uses the old one, so this is a
clean break rather than carrying both formats.
With one source region per native Dag, a Dag the packer cannot attribute to a
file — one constructed dynamically in a callback, or a mixed-language Dag owned
by Python — had no embedded source at all, so reading its source failed. Restore
the earlier guarantee that the entry file is always embedded: record its path as
entrypoint_path in the metadata and pack its contents as a source region, so a
reader falls back to it whenever a Dag has no attributed file of its own.
read_bundle_source fell back to the bundle entrypoint for any Dag it could not
map to a source file. That is wrong for a mixed-language Dag: the Dag is defined
in Python, which owns its Code tab, and the TypeScript bundle only supplies task
handlers — showing the TypeScript entrypoint for it would be misleading. Return
None instead, so the caller can show the owning language's source. The entrypoint
stays embedded and recorded as entrypoint_path, a bundle-level reference a caller
can fall back to for a native TypeScript Dag that has no source of its own.
A mixed-language Dag is defined in Python, which owns its Code tab, so the
TypeScript bundle should offer no source for it. The caller signals that by
omitting dag_id, and the reader returns None instead of an unrelated file. When a
dag_id is given the caller is asking for a Dag it treats as TypeScript-defined, so
a Dag missing from dag_source_paths — one built dynamically that the packer could
not attribute — falls back to the embedded entrypoint source rather than nothing.
@jason810496
jason810496 force-pushed the ts-sdk-multi-source-repro branch from f9da0fe to f2e7668 Compare September 30, 2026 03:33

@jason810496 jason810496 left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks.

Rest of the SDKs should follow the convention of this PR introduce.

TODO items after this PR get merged:

  • Add an ADR for recoding the decision of storing file content of each Dag definition with best effort and fallback to entrypoint file content if the dag_id: file_content pair is not found for the given dag_id.
  • How should rest of the SDKs (Go, Java) implement this sementic?
    • Note: each SDK need to de-duplicated the file content just like the TS implementation (e.g. multiple Dag constructor definitions point to same file path, we should only store that file content once instead of multiple time in the artifact)

@jason810496
jason810496 merged commit a8a6257 into apache:main Sep 30, 2026
108 checks passed
@jason810496
jason810496 deleted the ts-sdk-multi-source-repro branch September 30, 2026 04:30
pierrejeambrun added a commit to astronomer/airflow that referenced this pull request Sep 30, 2026
The decision behind apache#73723 — a native Lang-SDK bundle embeds each
Dag-defining source file, de-duplicated by path, always embeds the entry file as
a fallback, and carries no Lang-SDK source for a mixed-language Dag — lived only
in the PR. Capture it as a cross-SDK ADR so Go and Java adopt the same shape, and
so the native-Dag source question ADR-0010 left open has a settled answer.
pierrejeambrun added a commit that referenced this pull request Sep 30, 2026
* Record per-Dag source in the Lang-SDK bundle artifact as an ADR

The decision behind #73723 — a native Lang-SDK bundle embeds each
Dag-defining source file, de-duplicated by path, always embeds the entry file as
a fallback, and carries no Lang-SDK source for a mixed-language Dag — lived only
in the PR. Capture it as a cross-SDK ADR so Go and Java adopt the same shape, and
so the native-Dag source question ADR-0010 left open has a settled answer.

* Refine the per-Dag source ADR wording after review

Address review feedback on the ADR: frame the mixed-language case as tasks (the
Dag is Python's; the bundle only supplies its task handlers) and drop the native
Dag lookup detail from that section as out of place there, and replace
"attributed" with "resolved" for a source file throughout, which reads clearer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants