Repository navigation
TS SDK: embed one source region per native Dag file - #73723
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
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
Open (6)
Producer and consumer source size limits are inconsistent · New Unescaped source paths can terminate block-comment markers · New Global source-path slot breaks across suspended module evaluation · New Bundle contract changes without a metadata version bump · New Non-ASCII source paths are corrupted by ASCII header encoding · New Comment incorrectly claims function names determine task identity · New
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.
1 task done
pierrejeambrun
requested review from
jason810496 and
uranusjr
as code owners
September 29, 2026 10:18
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
force-pushed
the
ts-sdk-multi-source-repro
branch
from
September 30, 2026 03:33
f9da0fe to
f2e7668
Compare
jason810496
approved these changes
Sep 30, 2026
Member
There was a problem hiding this comment.
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)
1 task done
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.
This was referenced Oct 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



A bundle whose native Dags live in more than one file only carried its
entrypoint's source in the
airflowSourceregion. Airflow's Code tabwould 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
bundle header's single
sourceregion becomessources: [{path, start, end, sha256}]. Each region has its own/*# airflowSource:<path> … #*/block comment so the path is visible in the raw bundle too.dag_source_paths: Record<dag_id, path>so areader can pick the right source per Dag without guessing.
Dag's constructor records which file it ran in. It reads across-realm global slot (
Symbol.for("airflow.ts-sdk.current-module-source"))that
airflow-ts-pack's new onLoad plugin writes at the top of eachauthor-owned source file. ES module hoisting is what makes the slot
the current file for every top-level
new Dag(...)call, so theconstructor just reads it. Same
Symbol.forpattern the SDK already uses forthe task-scope AsyncLocalStorage.
the files whose
new Dag(...)constructor ran end up in the per-Dagsource regions — matches Python's
dag.filelocbehavior.entrypoint_pathis 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-
sourceformat gave).have an empty
dag_source_paths; their only embedded source is theentrypoint. A source read passing no
dag_idreturnsNone(see below) —Python owns its Code tab, so the bundle offers no TypeScript source.
Bundle shape — before / after
Layout used for both:
src/main.tsdeclaresorders_daginline,src/dags/reports.tsdeclares
reports_dagand is imported frommain.ts.Before:
After:
Task SDK bundle reader — clean break (separate commit)
The writer above is only half of the contract.
NodeCoordinatorreads every.min.mjsbundle at task-launch time — it parses the layout, verifies eachsection's SHA-256, and matches the requested
dag_idbefore spawningnode(
task-sdk/…/coordinators/node/_bundle_reader.py). That reader still hard-requiredthe old singular
sourcelayout section, so the moment the packer switched to thesourcesarray every bundle was rejected withbundle layout is missing the source sectionand no TypeScript task could launch. This is what thets_sdke2ejob was failing on — the reader lives in a different distribution and did not move
with the format.
Commit
cd1c307teaches the reader only the new layout — no released bundleuses the old shape, so it is a clean break rather than a dual-format reader:
sourcesarray (per-regionpath+ byte range + digest), verifieseach region's digest, and accepts an empty array defensively.
/*# airflowSource:<path> … #*/region back-to-back and checks themarker's path against the declared path.
read_bundle_source(bundle_path, dag_id=None)resolves a Dag's source throughdag_source_paths(see below for how the entrypoint andNonefit in).Entrypoint reference and per-Dag source (separate commits)
Two refinements build on the reader:
878d082always embeds the entrypoint. The single-sourceformat alwayscarried 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_pathin the metadataand packs the entry file's contents as a source region (
encodeBundlerejects anentrypoint missing from the packed sources).
f9da0felets the caller choose when the bundle serves a Dag's source.read_bundle_source(bundle_path, dag_id=None):dag_idomitted →None. A mixed-language Dag is defined in Python, which ownsits Code tab; the caller (which knows the Dag's Python
fileloc) asks for noTypeScript source and gets
Nonerather than an unrelated file.dag_idmapped indag_source_paths→ that Dag's own source file.dag_idgiven but not mapped (a native Dag built dynamically that the packercould 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/examplebundle: it registers onlyhandlers for two Python-owned Dags (no native
new Dag(...)), sodag_source_pathsis empty. It packs the entrypoint (src/main.ts) and recordsentrypoint_path;read_bundlereturns bothdag_ids;read_bundle_sourcewith nodag_idreturnsNone, and with adag_idfalls back to the entrypoint source. (Theold reader crashed on this bundle with
bundle layout is missing the source section.)Test coverage
buildBundleManifestrecords paths from the module-source slot.(only the entrypoint region, empty
dag_source_paths), and rejection ofan entrypoint missing from the packed sources.
boundary — asserts both sources are embedded and
dag_source_pathsmaps each
dag_idto its file; a mixed-language entry (only taskhandlers) still embeds its entrypoint source with an empty
dag_source_paths.after the shebang line so
#!/usr/bin/env nodestays valid.sourceslayout and the real encoderfixture, resolves per-Dag source via
dag_source_paths, returnsNonewhen no
dag_idis supplied, falls back toentrypoint_pathfor asupplied-but-unmapped Dag, and rejects duplicate paths, marker/path
mismatches, and per-region digest mismatches.
Not in scope
endpoint surfaces it to users yet. The reader exposes the primitives —
per-Dag source (or
None) and theentrypoint_pathreference — and theconsumer composes them with Airflow's Python-side
filelocknowledge;this PR does not add the UI side.
Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code (Opus 4.7) following the guidelines