Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,8 @@ A ``Dag`` is declared on this side rather than in Python: its schedule, its task
the edges between them are all written in TypeScript. The surface is still growing, so a Dag declared
this way is not served to Airflow yet.

``dag.task(taskId, handler)`` returns a *factory*. Calling it places the task in the Dag and supplies the
handler's arguments, so the call graph is the task graph:
``dag.task(taskId, handler)`` returns a *factory*. A handler takes one object of named arguments, and
calling the factory names each input, so the call graph is the task graph:

.. code-block:: typescript

Expand All @@ -257,26 +257,38 @@ handler's arguments, so the call graph is the task graph:
const dag = new Dag("ts_etl");

const extract = dag.task("extract", async (): Promise<number> => 42);
const transform = dag.task("transform", async (rows: number, region: string) => rows * 2);
const load = dag.task("load", async (total: number) => {});
const transform = dag.task(
"transform",
async ({ rows, region }: { rows: number; region: string }) => rows * 2,
);
const load = dag.task("load", async ({ total }: { total: number }) => {});

load(transform(extract(), "us"));
const extracted = extract();
const total = transform({ rows: extracted, region: "us" });
load({ total });

Arguments are passed in the order the handler declares them. A handler that declares a single object of
named arguments can also be called with that object, which names each input instead of ordering it:

.. code-block:: typescript

const store = dag.task("store", async ({ total }: { total: number }) => {});

store({ total: extract() });
Naming the inputs is how a task is called. A handler that takes no arguments is called with none, and
a single argument is named like any other, ``load({ total })``. The compiler checks the call: it reports
an argument left out, a misspelled one, and a literal of the wrong type.

Each argument takes either an upstream reference or a literal JSON value. A reference has to be the
argument itself: one buried inside an array or an object is a literal, and draws no edge.

Every task has to be called exactly once. An uncalled task fails when the Dag is read, so none can be
left out of the graph by accident.

The task id may be omitted, in which case it is the handler's function name:

.. code-block:: typescript

const extract = dag.task(async function extract(): Promise<number> {
return 42;
});

``airflow-ts-pack`` keeps function names intact, so bundling cannot rename a task. A handler with no
name of its own, such as an arrow function passed inline, has nothing to take an id from and needs
one: either positionally or as ``taskId`` in its spec. Give it in one place only, not both.

``new Dag`` and ``dag.task`` both take a trailing spec of Airflow options:
``{ schedule: "@daily", tags: ["etl"] }`` for the Dag, ``{ retries: 2, retryDelay: 30 }`` for a task.

Expand Down Expand Up @@ -398,9 +410,8 @@ layout header. The layout records the byte ranges and SHA-256 digests of the man
so there is one file to deploy, with no separate manifest or ``node_modules``.

The code is minified because an integrity digest is only worth taking over an artifact nobody is expected to
read or edit in place. The ``/*! */`` license banners of bundled dependencies are kept. Nothing is identified by
a function name, so minified names are safe: a Dag and a task are named by the string ids their registration
states, and a handler is dispatched by reference.
read or edit in place. Function names are kept through minification, since a task id defaults to its
handler's name. The ``/*! */`` license banners of bundled dependencies are kept.

Because the shipped code is not the code anyone wrote, the packer also embeds the entry module verbatim in a
``/*# airflowSource ... #*/`` block comment, verified by its own digest, so Airflow has something readable to
Expand Down
1 change: 1 addition & 0 deletions docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,7 @@ midnights
milli
millis
milton
minification
minikube
misconfiguration
misconfigured
Expand Down
58 changes: 25 additions & 33 deletions ts-sdk/adr/0002-native-dag-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,13 @@ Proposed. Revised after the review on #72047.

1. **`dag.task(handler)` returns a factory, and the task id is optional.** With no id the task takes
the handler's function name (`dag.task(extract)` → task `"extract"`); `dag.task(taskId, handler)`
sets it explicitly, which an anonymous handler must do. Calling the factory both places the task in
the Dag and supplies its arguments, in the order the handler declares them
(`load(transform(extract(), "us"))`). A handler that declares a single object of named arguments can
also be called with that object — the shape Python TaskFlow uses for
`load(transformed=transform(...))`.
2. **The call graph is the task graph.** `tsc` checks every wired key against the handler's own
parameter type, and a `TaskRef` exists only once its producing call has returned, so a cycle
through arguments is unrepresentable rather than rejected by a validator. A reference passed by
position is checked against the argument's own type, which is what tells the two call shapes apart
when a handler declares a single argument.
sets it explicitly, which an anonymous handler must do. A handler takes one object of named
arguments, and calling the factory both places the task in the Dag and names each of its inputs
(`load({ total: transform({ rows: extract(), region: "us" }) })`) — the shape Python TaskFlow uses
for `load(transformed=transform(...))`.
2. **The call graph is the task graph.** `tsc` checks every named input against the handler's own
argument type, and a `TaskRef` exists only once its producing call has returned, so a cycle
through arguments is unrepresentable rather than rejected by a validator.
3. **Every task is called exactly once.** An uncalled task fails when the Dag is read, so none can be
silently left out of the graph.
4. **`before` and `after` draw order-only edges** — the TypeScript pair for `>>` and `<<`, both
Expand Down Expand Up @@ -107,13 +104,12 @@ const extract = dag.task(async function extract(): Promise<number> {
// task id "extract"
```

The id comes from the handler's *source* name, resolved when the bundle is packed and written into
the registration — not from `handler.name` at runtime, which minification renames (see
Implementation Notes). A handler with no source name — a bare anonymous arrow passed inline,
`dag.task(async () => 42)` — has nothing to resolve and is a compile error until given an explicit
id. This default is for native Dags, where both ends of every name are TypeScript; a mixed-language
handler names the Python-owned task explicitly and does not default from the handler's function
name ([ADR-0001](0001-mixed-lang-dag-interface.md), decision 3).
The id is the handler's `name`, which `airflow-ts-pack` keeps through minification (see
Implementation Notes). A handler with no name, such as a bare anonymous arrow passed inline,
`dag.task(async () => 42)`, has nothing to take an id from and fails when the Dag is declared until
given an explicit id. This default is for native Dags, where both ends of every name are
TypeScript; a mixed-language handler names the Python-owned task explicitly and does not default
from the handler's function name ([ADR-0001](0001-mixed-lang-dag-interface.md), decision 3).

The `TaskSpec` also carries the task id, so it can be set alongside the other task options:

Expand Down Expand Up @@ -149,20 +145,19 @@ convention.
by design. Native declaration is what fills them, generated from the serialized-Dag JSON schema the
way `src/generated/supervisor.ts` is. This ADR does not choose those fields; it fixes where an
author writes them.
- `TaskOptions` carries the spec and the handler's positional argument names, which the packer fills in
from the parameter list so the Dag names each argument as its handler does. With wiring moved to the
factory call, `inputs` is no longer an option.
- `TaskOptions` carries the task's spec and nothing else: the names on the wire are the keys of the
call itself. With wiring moved to the factory call, `inputs` is no longer an option.
- `TaskHandlerArgs` is removed from the public API, `DagRegistry` becomes `Bundle`, and
`serveDags(registry)` becomes `bundle.serve()`, which breaks
0.1.0-beta1 authors; see [ADR-0001](0001-mixed-lang-dag-interface.md) for the shipped call sites
that change.

## Alternatives

- **Named-only wiring**, rejected in the review on #73435: naming every input reads well at twenty
tasks but forces an object around a single argument, and positional calls are what TypeScript
authors write. Both are offered, and the handler's own parameter list decides which one a task can
use.
- **Positional handlers**, `async (rows: number, region: string) => ...`, offered first and then
dropped: a positional parameter list has no names on the wire unless the SDK reads them out of the
handler's source, and a single object of named arguments is what a TypeScript library takes
anyway.
- **Injected `ctx`/`client` arguments**, mimicking the Python signature. Rejected, per the above and
because feeling native to TypeScript matters more than matching Python's parameter list.

Expand All @@ -181,17 +176,14 @@ convention.
- **The spec argument already has its slot.** `dag.task(taskId, handler, options)` reads `{ spec = {} }`
and runs `validateEmptySpec` on it (`ts-sdk/src/sdk/dag.ts`), so task fields land on a path that
exists rather than a new one.
- **A positional argument binds by order, and its name is a label.** The serialized Dag names each
argument, so the packer reads the names from the handler's parameter list; `arg0`, `arg1` and so on
stand in for a name it cannot see, without changing which value reaches which argument.
- **A `TaskRef` is inert** — a handle for wiring, not a promise. Nothing in a Dag file executes a task
body.
- **A defaulted task id is resolved at pack time, not read at runtime.** esbuild renames function
identifiers, so `handler.name` in a packed bundle is the minified name, not the author's. The pack
step (`ts-sdk/src/cli/pack.ts`) therefore reads an omitted id from the handler's declared name in
source and writes it into the registration, rather than depending on `handler.name` or enabling
esbuild's `keepNames` across the whole bundle. A handler with no source name leaves nothing to
read, which is why an anonymous handler must state its id.
- **A defaulted task id is read off the handler itself.** The pack step (`ts-sdk/src/cli/pack.ts`)
minifies and passes esbuild's `keepNames`, so `handler.name` is the author's in a packed bundle as
much as in one run from source. Rewriting the call at pack time was tried first and dropped: it
needed a TypeScript parser in the packer to tell a real `.task(` from one inside a string or a
comment, and it could not see a handler declared in another module. A handler with no name leaves
nothing to read, which is why an anonymous handler must state its id.
- **`withArgNames` and the name folding behind it** ([ADR-0001](0001-mixed-lang-dag-interface.md))
exist for the mixed-language case and are never needed here: both ends of every name are
TypeScript, so `tsc` checks the wiring end to end and there is no foreign name to reconcile.
Expand Down
4 changes: 4 additions & 0 deletions ts-sdk/src/cli/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ export async function runPack(argv: readonly string[]): Promise<void> {
target: "node22",
// A digest is only worth taking over an artifact nobody reads or edits in place.
minify: true,
// A task id comes from the handler's name, and bundling renames a symbol
// that two modules both declare, so the name the SDK reads is pinned to
// the one the author wrote.
keepNames: true,
// The manifest is read by running the staged bundle, so the metadata describes what ships.
outfile: stagingPath,
});
Expand Down
1 change: 0 additions & 1 deletion ts-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export type { ArgNameMap } from "./sdk/arg-names.js";
export type { Registerable } from "./sdk/bundle.js";
export type {
DagSpec,
PositionalInputs,
TaskFactory,
TaskInput,
TaskInputs,
Expand Down
Loading