This is an automated email from the ASF dual-hosted git repository.
jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new b6538c32c8b TS SDK: declare task dependencies by calling tasks (#73435)
b6538c32c8b is described below
commit b6538c32c8b1cdcb7b818e174a8b09d69ae18ad6
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Wed Sep 23 10:33:07 2026 +0800
TS SDK: declare task dependencies by calling tasks (#73435)
---
.../language-sdks/typescript.rst | 49 ++-
ts-sdk/adr/0002-native-dag-interface.md | 32 +-
ts-sdk/src/coordinator/manifest.ts | 7 +-
ts-sdk/src/coordinator/runtime.ts | 12 +-
ts-sdk/src/index.ts | 11 +-
ts-sdk/src/sdk/bundle.ts | 195 ++++-----
ts-sdk/src/sdk/dag.ts | 437 +++++++++++++++++----
ts-sdk/tests/cli/fixtures/entry.ts | 2 +-
ts-sdk/tests/cli/fixtures/noisy-entry.ts | 2 +-
ts-sdk/tests/cli/pack.test.ts | 10 +-
ts-sdk/tests/coordinator/integration.test.ts | 43 +-
ts-sdk/tests/coordinator/runtime-manifest.test.ts | 4 +-
ts-sdk/tests/public-api.test.ts | 89 ++++-
ts-sdk/tests/sdk/bundle.test.ts | 82 ++--
ts-sdk/tests/sdk/dag.test.ts | 366 ++++++++++++++---
ts-sdk/tests/sdk/task-handler.test.ts | 64 ++-
16 files changed, 1039 insertions(+), 366 deletions(-)
diff --git
a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
index b954ff9cd6c..aad721a1a4f 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
@@ -121,16 +121,12 @@ That top-level ``await`` makes the module a runnable
bundle entry point.
The ``dagId`` a handler binds must match the ``dag_id`` of the Python Dag, and
the ``taskId`` a
``@task.stub`` function in that Dag, including any TaskGroup prefix.
-``register`` takes any number of task handlers and ``bundle.serve()`` serves
exactly what is registered,
-so a task left out is not part of the packed bundle and is marked removed at
runtime.
+``register`` takes any number of task handlers and Dags, and
``bundle.serve()`` serves exactly what is
+registered, so a task left out is not part of the packed bundle and is marked
removed at runtime.
A second ``bundle.serve()`` call is rejected.
Registering holds no sockets and starts nothing, so a unit test can build a
bundle and dispatch a handler
through ``bundle.getTaskHandler(dagId, taskId)`` without a coordinator runtime.
-``Dag`` is another interface, for a Dag declared in TypeScript rather than in
Python, and is still a work
-in progress. ``new Dag`` and ``dag.task`` take a trailing options object
(``spec`` on both, plus
-``inputs`` on a task) that is not used yet; do not set them.
-
TaskFlow arguments
~~~~~~~~~~~~~~~~~~
@@ -242,6 +238,47 @@ task instance.
inside the scheduler process, so they must be present where the scheduler
can read them. The API server
and Dag processor do not need them.
+.. _typescript-sdk/native-dag:
+
+Declaring a Dag in TypeScript
+-----------------------------
+
+A ``Dag`` is declared on this side rather than in Python: its tasks and the
edges between them are
+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:
+
+.. code-block:: typescript
+
+ import { Dag } from "apache-airflow-ts-sdk";
+
+ 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) => {});
+
+ load(transform(extract(), "us"));
+
+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() });
+
+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.
+
+``new Dag`` and ``dag.task`` also take a trailing ``spec`` object that is not
used yet; do not set it.
+
Writing tasks
-------------
diff --git a/ts-sdk/adr/0002-native-dag-interface.md
b/ts-sdk/adr/0002-native-dag-interface.md
index 2c2a1a3df1b..eca1f5e2ea2 100644
--- a/ts-sdk/adr/0002-native-dag-interface.md
+++ b/ts-sdk/adr/0002-native-dag-interface.md
@@ -28,11 +28,15 @@ 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 by name — the shape Python TaskFlow uses
for
+ 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.
+ 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.
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
@@ -145,9 +149,9 @@ 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` collapses into `TaskSpec`. The shipped third argument to
`dag.task` is
- `{ inputs, spec }`; with wiring moved to the factory call, `inputs` is no
longer an option and the
- third argument is the spec itself.
+- `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.
- `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
@@ -155,11 +159,10 @@ convention.
## Alternatives
-- **Positional wiring** (`load(transform(extract()))`), which becomes
expressible once data no longer
- shares an object with `ctx`/`client`, since a handler can then take its
arguments positionally and
- `Parameters<typeof handler>` is a real tuple. Rejected: it removes the key
names from every call
- site, and those names are what keeps flat, one-statement-per-task wiring
readable at twenty tasks.
- A handler may still take several positional arguments; only the *wiring*
stays named.
+- **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.
- **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.
@@ -175,9 +178,12 @@ convention.
the same object, which forced every typed handler to declare `TArgs &
TaskHandlerArgs` and left the
top-level argument namespace open to collisions with an author's own
parameter names. Getters close
both.
-- **The spec argument already has its slot.** `dag.task(taskId, handler,
options)` reads
- `{ inputs = {}, spec = {} }` and runs `validateEmptySpec` on the spec today
- (`ts-sdk/src/sdk/dag.ts`), so task fields land on a path that exists rather
than a new one.
+- **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
diff --git a/ts-sdk/src/coordinator/manifest.ts
b/ts-sdk/src/coordinator/manifest.ts
index 97661257efd..523385236a3 100644
--- a/ts-sdk/src/coordinator/manifest.ts
+++ b/ts-sdk/src/coordinator/manifest.ts
@@ -18,7 +18,7 @@
*/
import { SUPERVISOR_API_VERSION } from "./protocol.js";
-import { listBundleDags, type Bundle } from "../sdk/bundle.js";
+import { bundleDagTaskIds, finalizeBundleDags, type Bundle } from
"../sdk/bundle.js";
export const AIRFLOW_METADATA_FLAG = "--airflow-metadata";
@@ -36,7 +36,10 @@ export interface BundleManifest {
export function buildBundleManifest(bundle: Bundle): BundleManifest {
const taskHandlers: BundleManifest["task_handlers"] = {};
- for (const { dagId, tasks } of listBundleDags(bundle)) {
+ // The manifest is the bundle reporting what it provides, so this is where
its
+ // Dags are finalized: a Dag missing an edge is reported here rather than
packed.
+ finalizeBundleDags(bundle);
+ for (const [dagId, tasks] of bundleDagTaskIds(bundle)) {
if (typeof dagId !== "string") {
throw new Error("Dag ID must be a string");
}
diff --git a/ts-sdk/src/coordinator/runtime.ts
b/ts-sdk/src/coordinator/runtime.ts
index deb0cb485a0..bf9764f3e66 100644
--- a/ts-sdk/src/coordinator/runtime.ts
+++ b/ts-sdk/src/coordinator/runtime.ts
@@ -55,7 +55,7 @@ import {
type StartupDetails,
} from "./protocol.js";
import { getArgNames } from "../sdk/arg-names.js";
-import { listBundleTasks, type Bundle } from "../sdk/bundle.js";
+import { bundleDagTaskIds, type Bundle } from "../sdk/bundle.js";
import { runInTaskScope, type TaskContext } from "../sdk/task.js";
import type { JsonValue } from "../sdk/client-types.js";
@@ -148,10 +148,10 @@ export async function startCoordinator(
const runtimeLogs = logs.child("runtime");
runtimeLogs.debug("Connecting log socket", { logs_addr: parsed.logsAddr });
await logs.connect(parsed.logsAddr);
- const tasks = listBundleTasks(bundle);
+ const byDag = bundleDagTaskIds(bundle);
runtimeLogs.info("Coordinator runtime started", {
- registered_tasks: tasks,
- count: tasks.length,
+ registered_tasks: Object.fromEntries(byDag),
+ count: [...byDag.values()].reduce((total, tasks) => total +
tasks.length, 0),
// Cadwyn schema version this SDK was generated against. Logged
// for operator visibility; not sent on the wire.
supervisor_api_version: SUPERVISOR_API_VERSION,
@@ -270,7 +270,7 @@ function handleParse(
// TypeScript-native Dag parsing is not yet supported.
// Respond with an empty result so the Python-stub-Dag workflow works.
logs.info("Parse-mode response (TS Dag parsing not yet supported)", {
- registered_tasks: listBundleTasks(bundle),
+ registered_tasks: Object.fromEntries(bundleDagTaskIds(bundle)),
});
const response: RuntimeDagFileParsingResult = {
type: "DagFileParsingResult",
@@ -295,7 +295,7 @@ async function handleTask(
logs.warning("No handler registered for task", {
dag_id: ti.dag_id,
task_id: ti.task_id,
- available: listBundleTasks(bundle),
+ available: Object.fromEntries(bundleDagTaskIds(bundle)),
});
// A missing handler means this bundle cannot run the task, so retrying the
// same bundle/configuration mismatch would not help.
diff --git a/ts-sdk/src/index.ts b/ts-sdk/src/index.ts
index e6d162155e3..d63bdf24186 100644
--- a/ts-sdk/src/index.ts
+++ b/ts-sdk/src/index.ts
@@ -26,7 +26,16 @@ export { ConnectionNotFoundError, VariableNotFoundError }
from "./sdk/client.js"
export { SUPERVISOR_API_VERSION } from "./coordinator/index.js";
export type { ArgNameMap } from "./sdk/arg-names.js";
export type { Registerable } from "./sdk/bundle.js";
-export type { DagSpec, TaskInputs, TaskOptions, TaskRef, TaskSpec } from
"./sdk/dag.js";
+export type {
+ DagSpec,
+ PositionalInputs,
+ TaskFactory,
+ TaskInput,
+ TaskInputs,
+ TaskOptions,
+ TaskRef,
+ TaskSpec,
+} from "./sdk/dag.js";
export type { TaskClient } from "./sdk/client.js";
export type { ConnectionResult, GetXComOpts, JsonValue, SetXComOpts } from
"./sdk/client-types.js";
export type { TaskContext, TaskFunction } from "./sdk/task.js";
diff --git a/ts-sdk/src/sdk/bundle.ts b/ts-sdk/src/sdk/bundle.ts
index ad99d0c8f34..2aad2aa03bc 100644
--- a/ts-sdk/src/sdk/bundle.ts
+++ b/ts-sdk/src/sdk/bundle.ts
@@ -20,12 +20,13 @@
// The bundle: what a TypeScript bundle process provides, and how it serves it.
import { brand, DUPLICATE_COPY_HINT, hasBrand } from "./brand.js";
-import { Dag, getDagTaskRecords, isDag, type TaskRef } from "./dag.js";
+import { Dag, finalizeDag, getDagTaskRecords, isDag } from "./dag.js";
import { getTaskHandlerFunction, isTaskHandler, TaskHandler } from
"./task-handler.js";
import type { TaskFunction } from "./task.js";
// Assigned inside Bundle's static block, as Dag does for its tasks.
-let entriesOf: (bundle: Bundle) => ReadonlyMap<string, BundleEntry>;
+let dagsOf: (bundle: Bundle) => ReadonlyMap<string, Dag>;
+let taskHandlersOf: (bundle: Bundle) => ReadonlyMap<string,
ReadonlyMap<string, TaskFunction>>;
/**
* What {@link Bundle.register} and the {@link Bundle} constructor accept: a
@@ -37,32 +38,11 @@ let entriesOf: (bundle: Bundle) => ReadonlyMap<string,
BundleEntry>;
// is assignable to, `TaskHandler<TransformArgs>` included.
export type Registerable = Dag | TaskHandler<never, unknown>;
-// What a bundle holds per dag_id. The two arms are exclusive by construction:
-// a Dag is the native case and owns its own tasks, while task handlers supply
-// bodies for a Dag that Python declares, so one dag_id is never both.
-type BundleEntry =
- | { readonly kind: "dag"; readonly dag: Dag }
- | { readonly kind: "handlers"; readonly handlers: Map<string, TaskFunction>
};
-
-function entryTaskIds(entry: BundleEntry): string[] {
- return entry.kind === "dag" ? [...entry.dag.taskIds] :
[...entry.handlers.keys()];
-}
-
/** Internal: whether `value` is a Bundle built by any copy of this package. */
export function isBundle(value: unknown): value is Bundle {
return hasBrand(value, "Bundle");
}
-/** Internal: a Dag this bundle provides for, with its task IDs, as
- * {@link listBundleDags} reports it. A task-less native Dag is included, so
- * the bundle manifest keeps it visible. */
-export interface RegisteredDag {
- /** Identifier of the registered Dag. */
- readonly dagId: string;
- /** Airflow task IDs, including any TaskGroup prefix. */
- readonly tasks: string[];
-}
-
/**
* What a bundle process provides to Airflow, and the thing that serves it.
*
@@ -87,12 +67,15 @@ export interface RegisteredDag {
* a snapshot of its tasks.
*/
export class Bundle {
- // Keyed by dag_id and insertion-ordered, so the bundle manifest lists what
- // this process provides in the order the entry point registered it.
- readonly #entries = new Map<string, BundleEntry>();
+ // Dags declared in TypeScript, keyed by dag_id in registration order.
+ #dags = new Map<string, Dag>();
+ // Handlers for the tasks a Python Dag declares, keyed by dag_id and then by
+ // task_id, both in registration order.
+ #taskHandlers = new Map<string, Map<string, TaskFunction>>();
static {
- entriesOf = (bundle) => bundle.#entries;
+ dagsOf = (bundle) => bundle.#dags;
+ taskHandlersOf = (bundle) => bundle.#taskHandlers;
}
/** Registers `items`, on the same terms as {@link register}. */
@@ -107,11 +90,13 @@ export class Bundle {
* The constructor covers the common case; this is for a bundle that
* collects what it provides across several modules. */
register(...items: Registerable[]): void {
- // Validated against what is already held *and* against this call, in full,
- // before anything is written: a call that throws registers none of its
- // items, so a bundle never half-provides what its author listed once.
- const incomingDags = new Set<string>();
- const incomingTaskHandlers = new Set<string>();
+ // Staged on copies and committed once every item is accepted, so a call
+ // that throws registers none of its items and a bundle never
+ // half-provides what its author listed in one call.
+ const dags = new Map(this.#dags);
+ const taskHandlers = new Map(
+ [...this.#taskHandlers].map(([dagId, handlers]) => [dagId, new
Map(handlers)] as const),
+ );
for (const item of items) {
// Typed as Registerable, so narrowing it would collapse to never; these
// guard callers reaching this from plain JavaScript.
@@ -119,9 +104,9 @@ export class Bundle {
// Another copy's value cannot be registered, since both kinds read
// private state keyed to this copy's class, so it is rejected by its
cause.
if (candidate instanceof Dag) {
- this.#checkDag(candidate, incomingDags);
+ stageDag(dags, taskHandlers, candidate);
} else if (candidate instanceof TaskHandler) {
- this.#checkTaskHandler(candidate, incomingDags, incomingTaskHandlers);
+ stageTaskHandler(dags, taskHandlers, candidate);
} else if (isDag(candidate)) {
throw new Error(`Dag "${(candidate as Dag).dagId}"
${DUPLICATE_COPY_HINT}`);
} else if (isTaskHandler(candidate)) {
@@ -133,61 +118,8 @@ export class Bundle {
throw new Error("only Dag and TaskHandler instances can be
registered");
}
}
- for (const item of items) {
- if (item instanceof Dag) {
- this.#entries.set(item.dagId, { kind: "dag", dag: item });
- } else {
- const existing = this.#entries.get(item.dagId);
- const handlers =
- existing?.kind === "handlers" ? existing.handlers : new Map<string,
TaskFunction>();
- handlers.set(item.taskId, getTaskHandlerFunction(item));
- if (existing === undefined) {
- this.#entries.set(item.dagId, { kind: "handlers", handlers });
- }
- }
- }
- }
-
- #checkDag(dag: Dag, incomingDags: Set<string>): void {
- if (this.#entries.get(dag.dagId)?.kind === "handlers") {
- throw new Error(
- `Dag "${dag.dagId}" already has registered task handlers; a Dag
declared in ` +
- "TypeScript owns its own tasks, so one Dag ID cannot have both",
- );
- }
- if (this.#entries.has(dag.dagId) || incomingDags.has(dag.dagId)) {
- throw new Error(`Dag "${dag.dagId}" is already registered`);
- }
- incomingDags.add(dag.dagId);
- }
-
- #checkTaskHandler(
- handler: TaskHandler,
- incomingDags: Set<string>,
- incomingTaskHandlers: Set<string>,
- ): void {
- // A native Dag attaches its tasks with dag.task(...), so a task handler
for
- // the same Dag ID would be a second, disagreeing source for its task list.
- if (this.#entries.get(handler.dagId)?.kind === "dag" ||
incomingDags.has(handler.dagId)) {
- throw new Error(
- `Dag "${handler.dagId}" is declared in TypeScript; attach its tasks
with ` +
- "dag.task(...) rather than registering task handlers for them",
- );
- }
- const entry = this.#entries.get(handler.dagId);
- // Keyed on the pair, not the task ID: one bundle serves several Dags, and
- // the same task_id under two of them is two different handlers.
- const pair = `${handler.dagId}\u0000${handler.taskId}`;
- if (
- entry?.kind === "handlers"
- ? entry.handlers.has(handler.taskId)
- : incomingTaskHandlers.has(pair)
- ) {
- throw new Error(
- `A handler for Dag "${handler.dagId}" task "${handler.taskId}" is
already registered`,
- );
- }
- incomingTaskHandlers.add(pair);
+ this.#dags = dags;
+ this.#taskHandlers = taskHandlers;
}
/**
@@ -218,12 +150,54 @@ export class Bundle {
/** Look up a registered handler, the way the runtime dispatches a task.
* Returns `undefined` when no handler exists. */
getTaskHandler(dagId: string, taskId: string): TaskFunction | undefined {
- const entry = this.#entries.get(dagId);
- if (entry === undefined) return undefined;
- return entry.kind === "dag"
- ? getDagTaskRecords(entry.dag).get(taskId)?.fn
- : entry.handlers.get(taskId);
+ const dag = this.#dags.get(dagId);
+ return dag === undefined
+ ? this.#taskHandlers.get(dagId)?.get(taskId)
+ : getDagTaskRecords(dag).get(taskId)?.fn;
+ }
+}
+
+// A Dag declared in TypeScript owns its own tasks, so a dag_id it holds and a
+// dag_id task handlers hold are two disagreeing sources for one task list.
+// Each kind is therefore rejected for a dag_id the other one already holds,
+// whichever order they were registered in.
+
+function stageDag(
+ dags: Map<string, Dag>,
+ taskHandlers: ReadonlyMap<string, ReadonlyMap<string, TaskFunction>>,
+ dag: Dag,
+): void {
+ if (taskHandlers.has(dag.dagId)) {
+ throw new Error(
+ `Dag "${dag.dagId}" already has registered task handlers; a Dag declared
in ` +
+ "TypeScript owns its own tasks, so one Dag ID cannot have both",
+ );
+ }
+ if (dags.has(dag.dagId)) {
+ throw new Error(`Dag "${dag.dagId}" is already registered`);
+ }
+ dags.set(dag.dagId, dag);
+}
+
+function stageTaskHandler(
+ dags: ReadonlyMap<string, Dag>,
+ taskHandlers: Map<string, Map<string, TaskFunction>>,
+ handler: TaskHandler,
+): void {
+ if (dags.has(handler.dagId)) {
+ throw new Error(
+ `Dag "${handler.dagId}" is declared in TypeScript; attach its tasks with
` +
+ "dag.task(...) rather than registering task handlers for them",
+ );
}
+ const forDag = taskHandlers.get(handler.dagId) ?? new Map<string,
TaskFunction>();
+ if (forDag.has(handler.taskId)) {
+ throw new Error(
+ `A handler for Dag "${handler.dagId}" task "${handler.taskId}" is
already registered`,
+ );
+ }
+ forDag.set(handler.taskId, getTaskHandlerFunction(handler));
+ taskHandlers.set(handler.dagId, forDag);
}
/** Internal: reject a `this` that is not a Bundle built by this copy, naming
@@ -237,21 +211,24 @@ export function validateOwnBundle(value: unknown,
accessor: string): asserts val
);
}
-/** Internal: every task handle this bundle can dispatch, across both kinds.
Not
- * re-exported from the package root: enumerating what the runtime dispatches
- * is the runtime's job. */
-export function listBundleTasks(bundle: Bundle): TaskRef[] {
- return [...entriesOf(bundle)].flatMap(([dagId, entry]) =>
- entryTaskIds(entry).map((taskId) => ({ dagId, taskId })),
- );
+/** Internal: finalize every Dag this bundle declared in TypeScript, so no task
+ * can be added or wired afterwards. */
+export function finalizeBundleDags(bundle: Bundle): void {
+ for (const dag of dagsOf(bundle).values()) {
+ finalizeDag(dag);
+ }
}
-/** Internal: every Dag this bundle provides for, with its task IDs. A native
- * Dag with no tasks is included; a Dag known only through task handlers
always
- * has at least one, since a handler is what put it here. */
-export function listBundleDags(bundle: Bundle): RegisteredDag[] {
- return [...entriesOf(bundle)].map(([dagId, entry]) => ({
- dagId,
- tasks: entryTaskIds(entry),
- }));
+/** Internal: the task IDs this bundle can dispatch, per Dag: the Dags declared
+ * in TypeScript first, then the Dags its task handlers name, each in
+ * registration order. A Dag declared in TypeScript with no tasks is
included. */
+export function bundleDagTaskIds(bundle: Bundle): Map<string, string[]> {
+ const byDag = new Map<string, string[]>();
+ for (const [dagId, dag] of dagsOf(bundle)) {
+ byDag.set(dagId, [...dag.taskIds]);
+ }
+ for (const [dagId, handlers] of taskHandlersOf(bundle)) {
+ byDag.set(dagId, [...handlers.keys()]);
+ }
+ return byDag;
}
diff --git a/ts-sdk/src/sdk/dag.ts b/ts-sdk/src/sdk/dag.ts
index 1da149473bb..94e2a45ec63 100644
--- a/ts-sdk/src/sdk/dag.ts
+++ b/ts-sdk/src/sdk/dag.ts
@@ -17,9 +17,13 @@
* under the License.
*/
-// The Dag authoring surface: `new Dag(dagId)` plus `dag.task(taskId,
handler)`.
+// The Dag authoring surface: `new Dag(dagId)`, `dag.task(taskId, handler)`,
and
+// the factory it returns. Calling a factory declares the task's place in the
+// Dag and supplies its arguments, the way calling a TaskFlow function does in
+// Python.
-import { brand, hasBrand } from "./brand.js";
+import { brand, DUPLICATE_COPY_HINT, hasBrand } from "./brand.js";
+import type { JsonValue } from "./client-types.js";
import type { TaskFunction } from "./task.js";
function isPlainRecord(value: unknown): value is Record<string, unknown> {
@@ -54,66 +58,195 @@ export type DagSpec = Record<string, never>;
*/
export type TaskSpec = Record<string, never>;
+// Carries a reference's return type without carrying a value. Not exported, so
+// the property cannot be read or written from outside; it exists only so
+// `TaskRef<boolean>` and `TaskRef<number>` are different types to the
compiler.
+declare const RETURN_TYPE: unique symbol;
+
/**
- * A reference to a task registered on a {@link Dag}, returned by
`dag.task(...)`.
+ * A reference to the result of one task, returned by calling that task.
+ *
+ * Identity only: the handler and the value are deliberately not exposed. Pass
a
+ * reference as an input of a downstream task to make that task depend on it.
*
- * Identity only: the handler is deliberately not exposed.
+ * `TReturn` is the handler's return type, so a construct that needs a
+ * particular one can ask for it. A reference of a narrower type is usable
+ * wherever a wider one is: a `TaskRef<number>` is a `TaskRef<unknown>`.
*
- * References are what `inputs` accepts, and what native Dag declaration will
- * use to wire dependencies.
+ * A reference carries a hidden brand, so a hand-written `{dagId, taskId}`
+ * object is not one: an input also takes a plain JSON value, and without the
+ * brand such an object could not be told apart from an upstream reference.
*/
-export interface TaskRef {
+export interface TaskRef<TReturn = unknown> {
/** Identifier of the Dag this task belongs to. */
readonly dagId: string;
/** Airflow task ID, including any TaskGroup prefix. */
readonly taskId: string;
+ /** @internal Never set; see {@link RETURN_TYPE}. */
+ readonly [RETURN_TYPE]?: TReturn;
+}
+
+/** Whether `value` is a TaskRef returned by any copy of this package. */
+function isTaskRef(value: unknown): value is TaskRef {
+ return hasBrand(value, "TaskRef");
+}
+
+/**
+ * The first reference reachable inside `value`, or `undefined` when there is
+ * none.
+ *
+ * A reference is an edge, and an edge has to be an input in its own right: one
+ * buried in an array or an object is data the serializer would write into the
+ * Dag verbatim, leaving the task running without the upstream it was given.
+ * TypeScript rejects that for a well-typed argument, so this catches the
`any`,
+ * the cast and the plain-JavaScript caller.
+ */
+function findNestedTaskRef(value: unknown, seen: WeakSet<object>): TaskRef |
undefined {
+ if (typeof value !== "object" || value === null) return undefined;
+ if (isTaskRef(value)) return value;
+ // A literal that refers back to itself is not JSON either, but it reaches
+ // here before anything else has rejected it.
+ if (seen.has(value)) return undefined;
+ seen.add(value);
+ for (const nested of Array.isArray(value) ? value : Object.values(value)) {
+ const found = findNestedTaskRef(nested, seen);
+ if (found) return found;
+ }
+ return undefined;
}
/**
- * Task references keyed by input name.
+ * The part of `T` a literal can express, matched structurally.
+ *
+ * `Extract<T, JsonValue>` would do for a type literal but collapses an
+ * `interface` to `never`, since an interface gets no implicit index signature
+ * and so never matches `JsonValue`'s object arm. An object survives only if
+ * every property does, which leaves types JSON cannot carry — a `Date`, a
+ * method — as `never`, so such an argument can only be given a reference.
+ */
+type JsonCompatible<T> = T extends JsonValue
+ ? T
+ : // A function is an object with no keys, so it would otherwise map to `{}`
+ // and take every method of a class along with it.
+ T extends (...args: never[]) => unknown
+ ? never
+ : T extends readonly (infer TElement)[]
+ ? readonly JsonCompatible<TElement>[]
+ : T extends object
+ ? T extends { [K in keyof T]: JsonCompatible<T[K]> }
+ ? { [K in keyof T]: JsonCompatible<T[K]> }
+ : never
+ : never;
+
+/**
+ * One input of a task: the upstream task that produces the value, or the
value.
+ *
+ * A literal is restricted to the JSON-compatible part of the argument's type,
+ * because it has to survive the trip through the serialized Dag. An argument
+ * that cannot be expressed as JSON at all, such as a `Date`, can only be given
+ * a reference.
+ */
+export type TaskInput<TValue> = TaskRef<TValue> | JsonCompatible<TValue>;
+
+/** The inputs of a task that declares several arguments, in declaration
order. */
+export type PositionalInputs<TParams extends readonly unknown[]> = {
+ [K in keyof TParams]: TaskInput<TParams[K]>;
+};
+
+/** The inputs of a task that declares one object of named arguments, by name.
*/
+export type TaskInputs<TArgs> = {
+ [K in keyof TArgs]: TaskRef | JsonCompatible<TArgs[K]>;
+};
+
+// Offered only where it means something: `TaskInputs<number>` would map over
+// `number`'s own methods and accept `{ toFixed: ... }`.
+type NamedInputs<TOnly> = [TOnly] extends [object] ? TaskInputs<TOnly> : never;
+
+/**
+ * What `dag.task(...)` returns: call it to declare where the task sits in the
Dag.
+ *
+ * Pass one input per argument the handler declares, in order. An input is
+ * either another task's reference, which makes this task wait for that task
and
+ * receive its result, or a literal value:
+ *
+ * ```ts
+ * const extract = dag.task("extract", async (): Promise<number> => 42);
+ * const transform = dag.task("transform", async (rows: number, region:
string) => rows);
+ * const load = dag.task("load", async (total: number) => {});
+ *
+ * load(transform(extract(), "us"));
+ * ```
+ *
+ * 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:
*
- * Stored and validated, but they do not create dependencies or pass values yet
- * (see {@link TaskOptions.inputs}). Each must identify an earlier task in the
- * same Dag. Literal values are not supported.
+ * ```ts
+ * const store = dag.task("store", async ({ total }: { total: number }) => {});
+ *
+ * store({ total: extract() });
+ * ```
+ *
+ * The compiler checks that every argument is supplied and that each literal
+ * matches its argument's type. A reference passed by position is checked
against
+ * the argument's type as well, which is what tells the two call shapes apart
+ * when a handler declares a single argument.
*/
-export type TaskInputs = Readonly<Record<string, TaskRef>>;
+export type TaskFactory<TParams extends readonly unknown[], TReturn = unknown>
= [TParams] extends [
+ readonly [],
+]
+ ? () => TaskRef<TReturn>
+ : TParams extends readonly [infer TOnly]
+ ? (input: TaskInput<TOnly> | NamedInputs<TOnly>) => TaskRef<TReturn>
+ : (...inputs: PositionalInputs<TParams>) => TaskRef<TReturn>;
/**
* Named options for `dag.task()`.
*
- * Keyword-only so neither field has to be positioned around the other, and so
- * future fields can be added without a new parameter. Unknown keys are
- * rejected, so a typo fails at import time rather than being ignored.
+ * Keyword-only so future fields can be added without a new parameter. Unknown
+ * keys are rejected, so a typo fails at import time rather than being ignored.
*/
export interface TaskOptions {
+ /** Task-level options. Stored, but not used yet — see {@link TaskSpec}. */
+ readonly spec?: TaskSpec;
/**
- * References to the upstream tasks this task consumes.
+ * Names for the handler's positional arguments, in declaration order.
*
- * Not used yet: a handler takes no arguments, and the Python stub Dag
- * defines task order. Read an upstream return value explicitly instead, with
- * `getClient().getXCom({ key: "return_value", taskId: "extract" })`, where
- * omitting `taskId` reads the *running* task's own XCom, not the upstream.
- *
- * In the future these will declare dependencies in native TypeScript Dags.
+ * `airflow-ts-pack` fills this in from the handler's parameter list, so the
+ * Dag names each argument as its handler does. Positional inputs bind by
+ * order, so a name left out only costs the label: `arg0`, `arg1` and so on
+ * stand in for it.
*/
- readonly inputs?: TaskInputs;
- /** Task-level options. Stored, but not used yet (see {@link TaskSpec}). */
- readonly spec?: TaskSpec;
+ readonly argNames?: readonly string[];
}
-/** Per-task record a Dag retains: the handle, the function, its spec, and the
- * upstream handles feeding it. */
+/** Per-task record a Dag retains: the reference, the handler, and its spec. */
export interface TaskRecord {
readonly task: TaskRef;
readonly fn: TaskFunction;
readonly spec: TaskSpec;
- /** Upstream handles keyed by input name; empty when the task has no inputs.
*/
- readonly inputs: TaskInputs;
}
-// Assigned inside Dag's static block: gives package-internal code read access
-// to the #tasks private field without a public accessor on the Dag class.
+/**
+ * Internal: what one call to a task factory recorded, by argument name.
+ *
+ * A {@link TaskRef} is an edge from the upstream task; anything else is a
+ * constant argument.
+ *
+ * Recorded for the serializer that will turn a native Dag into serialized Dag
+ * JSON; nothing reads them at execution time. What a running task is called
+ * with comes from the supervisor's `arg_bindings`, which are derived from the
+ * serialized Dag and resolved per task instance — see decision G of
+ * `airflow-core/adr/lang-sdk/0007-taskflow-across-language-boundary.md`. Those
+ * bindings therefore win over anything recorded here, and the two never have
to
+ * be reconciled: a native Dag's wiring is what produced its bindings.
+ */
+export type RecordedInputs = Readonly<Record<string, TaskRef | JsonValue>>;
+
+// Assigned inside Dag's static block: gives package-internal code access to
+// Dag's private state without public accessors on the class.
let taskRecordsOf: (dag: Dag) => ReadonlyMap<string, TaskRecord>;
+let inputsOf: (dag: Dag) => ReadonlyMap<string, RecordedInputs>;
+let finalizeOf: (dag: Dag) => void;
/** Internal: whether `value` is a Dag built by any copy of this package. */
export function isDag(value: unknown): value is Dag {
@@ -123,33 +256,44 @@ export function isDag(value: unknown): value is Dag {
/**
* A Dag declared in TypeScript.
*
- * Today the Dag structure itself is still declared by a Python stub file; a
- * `Dag` instance binds TypeScript handlers to that stub's Dag/task IDs. The
- * instance retains its `spec` and every task's `(taskId, handler, spec)` so a
- * future `serialize()` can produce the serialized Dag JSON for native
- * TypeScript Dag declaration.
+ * Declare the tasks with `dag.task(taskId, handler)`, then call what each
+ * returns to lay the Dag out:
+ *
+ * ```ts
+ * const extract = dag.task("extract", async (): Promise<number> => 42);
+ * const load = dag.task("load", async ({ rows }: { rows: number }) => {});
+ * load({ rows: extract() });
+ * ```
+ *
+ * Every task has to be called exactly once, so none can be left out of the Dag
+ * by accident. A TypeScript handler for a task that a *Python* Dag declares is
+ * a `TaskHandler` instead, not a task on a `Dag`.
*
* Constructing a Dag has no effect beyond the instance itself. Collect the
ones
* a bundle should serve on a `Bundle` and await `bundle.serve()`.
*/
export class Dag {
- /** Identifier of this Dag. Must match the Python Dag's `dag_id`. */
+ /** Identifier of this Dag. */
readonly dagId: string;
/** Dag-level options this instance was constructed with, copied and frozen.
*/
readonly spec: DagSpec;
readonly #tasks = new Map<string, TaskRecord>();
+ readonly #inputs = new Map<string, RecordedInputs>();
+ #finalized = false;
static {
taskRecordsOf = (dag) => dag.#tasks;
+ inputsOf = (dag) => dag.#inputs;
+ finalizeOf = (dag) => dag.#finalize();
}
constructor(dagId: string, spec: DagSpec = {}) {
validateEmptySpec(`spec for Dag "${dagId}"`, spec);
brand(this, "Dag");
this.dagId = dagId;
- // Copied and frozen, as task specs and inputs are: nothing reads a spec
- // until the bundle manifest is built, long after the user's module has
run,
- // so a later mutation of their object would silently change what is
packed.
+ // Copied and frozen, as task specs are: nothing reads a spec until the
+ // bundle manifest is built, long after the user's module has run, so a
+ // later mutation of their object would silently change what is packed.
// Shallow, so a nested value in a future generated spec stays mutable.
this.spec = Object.freeze({ ...spec });
}
@@ -160,82 +304,215 @@ export class Dag {
}
/**
- * Register a TypeScript handler for a task of this Dag.
+ * Declare a task of this Dag, and return the factory that places it.
*
- * `taskId` must match the Dag-side operator's `task_id` exactly, including
- * any TaskGroup prefix. Returns this task's handle.
+ * Every argument the handler declares becomes an input of the returned
+ * {@link TaskFactory}; `getContext()` and `getClient()` reach the runtime
+ * from inside the call, so neither is an argument.
*/
- task<TArgs = void, TReturn = unknown>(
+ task<TParams extends readonly unknown[] = [], TReturn = unknown>(
taskId: string,
- handler: TaskFunction<TArgs, TReturn>,
+ handler: (...args: TParams) => TReturn | Promise<TReturn>,
options: TaskOptions = {},
- ): TaskRef {
+ ): TaskFactory<TParams, TReturn> {
if (typeof handler !== "function") {
throw new Error(`handler for Dag "${this.dagId}" task "${taskId}" must
be a function`);
}
if (this.#tasks.has(taskId)) {
throw new Error(`Task "${taskId}" is already registered for Dag
"${this.dagId}"`);
}
+ // A task added after the Dag was read could no longer be wired into it,
and
+ // would sit in the Dag unplaced and unreported.
+ if (this.#finalized) {
+ throw new Error(
+ `Task "${taskId}" cannot be added to Dag "${this.dagId}" after the Dag
was read; ` +
+ "declare every task while the module is loading",
+ );
+ }
this.#validateOptions(taskId, options);
- const { inputs = {}, spec = {} } = options;
+ const { spec = {} } = options;
validateEmptySpec(`spec for Dag "${this.dagId}" task "${taskId}"`, spec);
- this.#validateInputs(taskId, inputs);
- const task: TaskRef = Object.freeze({ dagId: this.dagId, taskId });
+ const argNames = this.#validateArgNames(taskId, options.argNames);
+ const task = createTaskRef(this.dagId, taskId);
this.#tasks.set(taskId, {
task,
- fn: handler as TaskFunction,
+ // The runtime dispatches every handler through one instantiation, as it
+ // does a registered TaskHandler; a positional one is wrapped at wiring.
+ fn: handler as unknown as TaskFunction,
spec: Object.freeze({ ...spec }),
- inputs: Object.freeze({ ...inputs }),
});
- return task;
+ return ((...inputs: unknown[]) => {
+ this.#wire(taskId, inputs, argNames);
+ return task;
+ }) as TaskFactory<TParams, TReturn>;
}
- // TypeScript is bypassable from plain JavaScript or an `as TaskOptions`
- // cast, so an unknown key is rejected rather than silently ignored.
+ // TypeScript is bypassable — from plain JavaScript, or an `as TaskOptions`
+ // cast — so an unknown key is rejected rather than silently ignored.
#validateOptions(taskId: string, options: TaskOptions): void {
const value: unknown = options;
if (!isPlainRecord(value)) {
throw new Error(`options for Dag "${this.dagId}" task "${taskId}" must
be an object`);
}
for (const key of Object.keys(value)) {
- if (key !== "inputs" && key !== "spec") {
+ if (key !== "spec" && key !== "argNames") {
throw new Error(`Unknown option "${key}" for Dag "${this.dagId}" task
"${taskId}"`);
}
}
}
- #validateInputs(taskId: string, inputs: TaskInputs): void {
- if (!isPlainRecord(inputs)) {
- throw new Error(`inputs for Dag "${this.dagId}" task "${taskId}" must be
an object`);
+ // TypeScript is bypassable, and these names become the keys the arguments
are
+ // recorded under, so an integer-like one would reorder what it labels.
+ #validateArgNames(taskId: string, names: unknown): readonly string[] |
undefined {
+ if (names === undefined) return undefined;
+ const describe = `argNames for Dag "${this.dagId}" task "${taskId}"`;
+ if (!Array.isArray(names)) throw new Error(`${describe} must be an array
of names`);
+ const seen = new Set<string>();
+ for (const name of names as unknown[]) {
+ if (typeof name !== "string" || name.length === 0 || /^\d+$/.test(name))
{
+ throw new Error(
+ `${describe} holds ${JSON.stringify(name)}; each name must be a
non-empty ` +
+ "string that is not a number",
+ );
+ }
+ if (seen.has(name)) throw new Error(`${describe} names "${name}" twice`);
+ seen.add(name);
+ }
+ return Object.freeze([...(names as string[])]);
+ }
+
+ #wire(taskId: string, inputs: readonly unknown[], argNames: readonly
string[] | undefined): void {
+ if (this.#finalized) {
+ throw new Error(
+ `Task "${taskId}" of Dag "${this.dagId}" was called after the Dag was
read; ` +
+ "call every task while the module is loading",
+ );
+ }
+ if (this.#inputs.has(taskId)) {
+ throw new Error(
+ `Task "${taskId}" of Dag "${this.dagId}" was already called; a task
holds one place ` +
+ "in a Dag, so call it once and reuse the reference",
+ );
}
- for (const [name, upstream] of Object.entries(inputs)) {
- if (
- upstream == null ||
- typeof upstream.dagId !== "string" ||
- typeof upstream.taskId !== "string"
- ) {
+ const positional = !isNamedCall(inputs);
+ const recorded = this.#checkInputs(
+ taskId,
+ positional ? positionalInputs(inputs, argNames) : (inputs[0] as
Record<string, unknown>),
+ );
+ this.#inputs.set(taskId, recorded);
+ if (positional && inputs.length > 0) {
+ const record = this.#tasks.get(taskId)!;
+ this.#tasks.set(taskId, { ...record, fn: spreadArgs(record.fn) });
+ }
+ }
+
+ #checkInputs(taskId: string, inputs: Record<string, unknown>):
RecordedInputs {
+ // Every own key, not just the enumerable string ones: a symbol key would
be
+ // copied by the spread below and so has to be checked, not stepped over.
+ for (const key of Reflect.ownKeys(inputs)) {
+ if (typeof key === "symbol") {
+ throw new Error(
+ `Input "${String(key)}" of task "${taskId}" is keyed by a symbol; ` +
+ "an argument name is a string",
+ );
+ }
+ const value = inputs[key];
+ // Anything unbranded is a literal argument, including a look-alike
+ // `{dagId, taskId}` object: only a real reference makes an edge.
+ if (!isTaskRef(value)) {
+ const nested = findNestedTaskRef(value, new WeakSet());
+ if (nested) {
+ throw new Error(
+ `Input "${key}" of task "${taskId}" holds a reference to
"${nested.taskId}" inside a ` +
+ "literal value, which draws no edge; pass the reference as the
input itself, or " +
+ "give each upstream its own argument",
+ );
+ }
+ continue;
+ }
+ if (value.dagId !== this.dagId) {
+ throw new Error(
+ `Input "${key}" of task "${taskId}" comes from Dag "${value.dagId}",
not "${this.dagId}"`,
+ );
+ }
+ const upstream = this.#tasks.get(value.taskId);
+ if (!upstream) {
throw new Error(
- `Input "${name}" of task "${taskId}" must be a task handle returned
by dag.task(...)`,
+ `Input "${key}" of task "${taskId}" refers to unregistered task
"${value.taskId}"`,
);
}
- if (upstream.dagId !== this.dagId) {
+ // Identity, not just the ID pair: two Dag objects can carry the same
+ // dagId, and a second resolved copy of this package brands its own
+ // references, so matching IDs do not make a reference this Dag handed
out.
+ if (upstream.task !== value) {
throw new Error(
- `Input "${name}" of task "${taskId}" comes from Dag
"${upstream.dagId}", not "${this.dagId}"`,
+ `Input "${key}" of task "${taskId}" was not returned by this Dag's
"${value.taskId}"; ` +
+ `it comes from another Dag object with the same ID, or
${DUPLICATE_COPY_HINT}`,
);
}
- // An input can only name a task registered earlier on this Dag, which
- // makes self-references and cycles unrepresentable.
- if (!this.#tasks.has(upstream.taskId)) {
+ }
+ return Object.freeze({ ...inputs }) as RecordedInputs;
+ }
+
+ #finalize(): void {
+ if (this.#finalized) return;
+ for (const taskId of this.#tasks.keys()) {
+ if (!this.#inputs.has(taskId)) {
throw new Error(
- `Input "${name}" of task "${taskId}" refers to unregistered task
"${upstream.taskId}"`,
+ `Task "${taskId}" of Dag "${this.dagId}" is never called, so it has
no place in the ` +
+ "Dag; call the factory dag.task(...) returned",
);
}
}
+ // Last, so a Dag that failed the check reports that same failure again
+ // rather than reporting itself as already read.
+ this.#finalized = true;
}
}
/**
- * Internal: the task records of a Dag, for registry lookups.
+ * Whether a call named its inputs rather than ordering them.
+ *
+ * One plain object is the named form. A reference is a plain object too, so it
+ * is ruled out first: `load(extract())` is one positional input, not a map of
+ * argument names.
+ */
+function isNamedCall(inputs: readonly unknown[]): boolean {
+ return inputs.length === 1 && !isTaskRef(inputs[0]) &&
isPlainRecord(inputs[0]);
+}
+
+function positionalInputs(
+ inputs: readonly unknown[],
+ argNames: readonly string[] | undefined,
+): Record<string, unknown> {
+ const byName: Record<string, unknown> = {};
+ inputs.forEach((value, index) => {
+ byName[argNames?.[index] ?? `arg${index}`] = value;
+ });
+ return byName;
+}
+
+/**
+ * Dispatch a positional handler through the one call shape the runtime uses.
+ *
+ * A task is called with its bound arguments as a single object, in the order
+ * they were recorded, so spreading its values back restores the argument list
+ * the handler declared.
+ */
+function spreadArgs(fn: TaskFunction): TaskFunction {
+ const handler = fn as unknown as (...args: unknown[]) => unknown;
+ return ((args: Record<string, unknown>) =>
+ handler(...Object.values(args ?? {}))) as unknown as TaskFunction;
+}
+
+function createTaskRef(dagId: string, taskId: string): TaskRef {
+ const task: TaskRef = { dagId, taskId };
+ brand(task, "TaskRef");
+ return Object.freeze(task);
+}
+
+/**
+ * Internal: the task records of a Dag, for bundle lookups.
*
* Not re-exported from the package root, and the package `"exports"` map
* blocks deep imports, so this is unreachable from outside the SDK.
@@ -243,3 +520,19 @@ export class Dag {
export function getDagTaskRecords(dag: Dag): ReadonlyMap<string, TaskRecord> {
return taskRecordsOf(dag);
}
+
+/** Internal: what each task of a Dag was called with, keyed by task ID.
+ * A task that has not been called is absent. */
+export function getDagTaskInputs(dag: Dag): ReadonlyMap<string,
RecordedInputs> {
+ return inputsOf(dag);
+}
+
+/**
+ * Internal: check that `dag` is fully laid out, then finalize it against
further wiring.
+ *
+ * Idempotent, and never part of the public surface: a Dag is finished when its
+ * module has finished loading, so there is nothing for an author to call.
+ */
+export function finalizeDag(dag: Dag): void {
+ finalizeOf(dag);
+}
diff --git a/ts-sdk/tests/cli/fixtures/entry.ts
b/ts-sdk/tests/cli/fixtures/entry.ts
index 607c62cdf0b..77fe93fcd9a 100644
--- a/ts-sdk/tests/cli/fixtures/entry.ts
+++ b/ts-sdk/tests/cli/fixtures/entry.ts
@@ -22,7 +22,7 @@ import { Bundle, Dag, TaskHandler } from
"../../../src/index.js";
// A mixed bundle: task handlers for a Python-owned Dag, plus a natively
// declared one, so packing covers both paths into the manifest.
const otherDag = new Dag("other_dag");
-otherDag.task("solo", async () => undefined);
+otherDag.task("solo", async () => undefined)();
await new Bundle(
new TaskHandler("fixture_dag", "extract", async () => "extracted"),
diff --git a/ts-sdk/tests/cli/fixtures/noisy-entry.ts
b/ts-sdk/tests/cli/fixtures/noisy-entry.ts
index 370022287c7..ed7ab0683c3 100644
--- a/ts-sdk/tests/cli/fixtures/noisy-entry.ts
+++ b/ts-sdk/tests/cli/fixtures/noisy-entry.ts
@@ -23,6 +23,6 @@ import { Bundle, Dag } from "../../../src/index.js";
console.log("noise from an import-time dependency");
const noisyDag = new Dag("noisy_dag");
-noisyDag.task("only", async () => undefined);
+noisyDag.task("only", async () => undefined)();
await new Bundle(noisyDag).serve();
diff --git a/ts-sdk/tests/cli/pack.test.ts b/ts-sdk/tests/cli/pack.test.ts
index fad4d50a8a8..c9d8e6a9ea6 100644
--- a/ts-sdk/tests/cli/pack.test.ts
+++ b/ts-sdk/tests/cli/pack.test.ts
@@ -427,7 +427,7 @@ describe("runPack", () => {
[
`import { Bundle, Dag } from ${JSON.stringify(SDK_INDEX)};`,
'const bigDag = new Dag("big_dag");',
- 'for (let i = 0; i < 5000; i += 1) bigDag.task(String(i).padStart(240,
"t"), async () => undefined);',
+ 'for (let i = 0; i < 5000; i += 1) bigDag.task(String(i).padStart(240,
"t"), async () => undefined)();',
"await new Bundle(bigDag).serve();",
].join("\n"),
);
@@ -472,7 +472,7 @@ describe("runPack", () => {
[
`import { Bundle, Dag } from ${JSON.stringify(SDK_INDEX)};`,
`const suspiciousDag = new Dag(${JSON.stringify(dagId)});`,
- `suspiciousDag.task(${JSON.stringify(taskId)}, async () =>
undefined);`,
+ `suspiciousDag.task(${JSON.stringify(taskId)}, async () =>
undefined)();`,
"await new Bundle(suspiciousDag).serve();",
].join("\n"),
);
@@ -542,7 +542,7 @@ describe("runPack", () => {
[
`import { Bundle, Dag } from ${JSON.stringify(SDK_INDEX)};`,
'const salesDag = new Dag("sales_dag");',
- 'salesDag.task("extract", async () => undefined);',
+ 'salesDag.task("extract", async () => undefined)();',
'await new Bundle(salesDag, new Dag("empty_dag")).serve();',
].join("\n"),
);
@@ -565,9 +565,9 @@ describe("runPack", () => {
[
`import { Bundle, Dag } from ${JSON.stringify(SDK_INDEX)};`,
'const salesDag = new Dag("sales_dag");',
- 'salesDag.task("extract", async () => undefined);',
+ 'salesDag.task("extract", async () => undefined)();',
'const billingDag = new Dag("billing_dag");',
- 'billingDag.task("charge", async () => undefined);',
+ 'billingDag.task("charge", async () => undefined)();',
"await new Bundle(salesDag).serve();",
].join("\n"),
);
diff --git a/ts-sdk/tests/coordinator/integration.test.ts
b/ts-sdk/tests/coordinator/integration.test.ts
index c5a6ffc1eaf..d0b6d53f83f 100644
--- a/ts-sdk/tests/coordinator/integration.test.ts
+++ b/ts-sdk/tests/coordinator/integration.test.ts
@@ -28,7 +28,7 @@
// No Python, no Airflow install, but exercises the same wire format
// the real coordinator speaks.
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createServer, Socket as NetSocket, type Server, type Socket } from
"node:net";
import { encode, decode } from "@msgpack/msgpack";
import {
@@ -49,13 +49,22 @@ interface BoundTransformArgs {
dryRun: boolean;
}
-const testDag = new Dag("test_dag");
-const otherDag = new Dag("other_dag");
// The bundle the runtime dispatches through. startCoordinator() is driven
// directly rather than through bundle.serve(), so these tests can supply mock
// socket addresses. Both authoring kinds are registered in one call, since the
// runtime dispatches through one lookup regardless of which put a task there.
-const bundle = new Bundle(testDag, otherDag);
+//
+// Rebuilt per test: dispatching reads the bundle, which finalizes every native
+// Dag in it against further tasks, so one Dag cannot collect a task per test.
+let testDag: Dag;
+let otherDag: Dag;
+let bundle: Bundle;
+
+beforeEach(() => {
+ testDag = new Dag("test_dag");
+ otherDag = new Dag("other_dag");
+ bundle = new Bundle(testDag, otherDag);
+});
interface MockResult {
firstResponse: { id: number; body: unknown; isResponse: boolean } | null;
@@ -254,7 +263,7 @@ describe("coordinator runtime integration", () => {
testDag.task("say_hello", async () => {
observedCtx = getContext();
return "ok";
- });
+ })();
const result = await driveSupervisor(makeStartupDetails("say_hello"));
@@ -307,7 +316,7 @@ describe("coordinator runtime integration", () => {
const commAccept = acceptOne(comm.server);
const logsAccept = acceptOne(logs.server);
- testDag.task("terminal_timeout", async () => undefined);
+ testDag.task("terminal_timeout", async () => undefined)();
const runtimeDone = startCoordinator(bundle, {
commAddr: `127.0.0.1:${comm.port}`,
logsAddr: `127.0.0.1:${logs.port}`,
@@ -339,7 +348,7 @@ describe("coordinator runtime integration", () => {
it("returns TaskState=failed when the handler throws", async () => {
testDag.task("boom", async () => {
throw new Error("boom");
- });
+ })();
const result = await driveSupervisor(makeStartupDetails("boom"));
@@ -352,7 +361,7 @@ describe("coordinator runtime integration", () => {
it("returns RetryTask when the handler throws and Airflow says the failure
is retryable", async () => {
testDag.task("boom_retry", async () => {
throw new Error("boom");
- });
+ })();
const result = await driveSupervisor(
makeStartupDetails("boom_retry", "test_dag", "r1", {
@@ -608,7 +617,7 @@ describe("coordinator runtime integration", () => {
process.emit("SIGTERM");
sawAbort = getContext().signal.aborted;
throw new Error("interrupted");
- });
+ })();
const result = await
driveSupervisor(makeStartupDetails("aborted_then_failed"));
@@ -626,7 +635,7 @@ describe("coordinator runtime integration", () => {
process.emit("SIGTERM");
sawAbort = getContext().signal.aborted;
throw new Error("interrupted");
- });
+ })();
const result = await driveSupervisor(
makeStartupDetails("aborted_then_failed_retry", "test_dag", "r1", {
@@ -649,7 +658,7 @@ describe("coordinator runtime integration", () => {
process.emit("SIGTERM");
sawAbort = getContext().signal.aborted;
return "completed";
- });
+ })();
const result = await
driveSupervisor(makeStartupDetails("completed_after_sigterm"));
@@ -703,7 +712,7 @@ describe("coordinator runtime integration", () => {
if (back !== `node says: ${observedGreeting}`) {
throw new Error(`xcom round-trip mismatch: ${back}`);
}
- });
+ })();
const responder: Responder = (msgType, body) => {
if (msgType === "GetVariable") {
@@ -753,7 +762,7 @@ describe("coordinator runtime integration", () => {
let observed: string | null = "<unset>";
testDag.task("missing_variable", async () => {
observed = await getClient().getVariable("missing_key");
- });
+ })();
const responder: Responder = (msgType) => {
if (msgType === "GetVariable") {
@@ -778,10 +787,10 @@ describe("coordinator runtime integration", () => {
let calledSecondDag = false;
testDag.task("shared_task", async () => {
calledFirstDag = true;
- });
+ })();
otherDag.task("shared_task", async () => {
calledSecondDag = true;
- });
+ })();
await driveSupervisor(makeStartupDetails("shared_task"));
@@ -804,7 +813,7 @@ describe("coordinator runtime integration", () => {
});
it("auto-pushes return_value XCom when handler returns a value", async () =>
{
- testDag.task("pusher", async () => "my-result");
+ testDag.task("pusher", async () => "my-result")();
const responder: Responder = (msgType, _body) => {
if (msgType === "SetXCom") return { body: null };
@@ -826,7 +835,7 @@ describe("coordinator runtime integration", () => {
it("does NOT push return_value XCom when handler returns undefined", async
() => {
testDag.task("void_task", async () => {
// no return value
- });
+ })();
const result = await driveSupervisor(makeStartupDetails("void_task"));
diff --git a/ts-sdk/tests/coordinator/runtime-manifest.test.ts
b/ts-sdk/tests/coordinator/runtime-manifest.test.ts
index b7e030f43e7..afca723af21 100644
--- a/ts-sdk/tests/coordinator/runtime-manifest.test.ts
+++ b/ts-sdk/tests/coordinator/runtime-manifest.test.ts
@@ -28,7 +28,7 @@ import { Bundle } from "../../src/sdk/bundle.js";
function buildDag(dagId: string, ...taskIds: string[]): Dag {
const dag = new Dag(dagId);
for (const taskId of taskIds) {
- dag.task(taskId, async () => undefined);
+ dag.task(taskId, async () => undefined)();
}
return dag;
}
@@ -87,7 +87,7 @@ describe("buildBundleManifest", () => {
it("rejects a non-string dagId before object-key coercion hides it", () => {
const dag = new Dag(123 as unknown as string);
- dag.task("t1", async () => undefined);
+ dag.task("t1", async () => undefined)();
expect(() => buildBundleManifest(new Bundle(dag))).toThrowError(/Dag ID
must be a string/);
});
});
diff --git a/ts-sdk/tests/public-api.test.ts b/ts-sdk/tests/public-api.test.ts
index 2a86f09d669..b62075579f1 100644
--- a/ts-sdk/tests/public-api.test.ts
+++ b/ts-sdk/tests/public-api.test.ts
@@ -27,8 +27,11 @@ import type {
SetXComOpts,
TaskClient,
Registerable,
+ PositionalInputs,
TaskContext,
+ TaskFactory,
TaskFunction,
+ TaskInput,
TaskInputs,
TaskOptions,
TaskRef,
@@ -50,10 +53,13 @@ import {
describe("public API", () => {
it("exports the Dag authoring surface", async () => {
const dag = new Dag("public_api_dag");
- const upstream = dag.task("public_api_task", async () => undefined);
- const downstream = dag.task("public_api_downstream", async () =>
undefined, {
- inputs: { upstream },
- });
+ const upstreamTask = dag.task("public_api_task", async () => undefined);
+ const downstreamTask = dag.task(
+ "public_api_downstream",
+ async (_: { upstream: undefined }) => undefined,
+ );
+ const upstream = upstreamTask();
+ const downstream = downstreamTask({ upstream });
expect(upstream).toEqual({ dagId: "public_api_dag", taskId:
"public_api_task" });
expect(downstream).toEqual({ dagId: "public_api_dag", taskId:
"public_api_downstream" });
expect(dag.taskIds).toEqual(["public_api_task", "public_api_downstream"]);
@@ -278,22 +284,41 @@ describe("public API", () => {
});
it("keeps the Dag authoring signatures extensible via trailing specs", () =>
{
- expectTypeOf<TaskRef>().toEqualTypeOf<{
- readonly dagId: string;
- readonly taskId: string;
- }>();
- expectTypeOf<TaskInputs>().toEqualTypeOf<Readonly<Record<string,
TaskRef>>>();
+ expectTypeOf<TaskRef["dagId"]>().toEqualTypeOf<string>();
+ expectTypeOf<TaskRef["taskId"]>().toEqualTypeOf<string>();
+ // A reference carries its handler's return type, so a construct that needs
+ // a particular one can ask for it: a narrower reference is usable wherever
+ // a wider one is, and not the other way round.
+ expectTypeOf<TaskRef<boolean>>().toMatchTypeOf<TaskRef>();
+ expectTypeOf<TaskRef>().not.toMatchTypeOf<TaskRef<boolean>>();
+ // Wiring moved to the factory call, so `inputs` is no longer an option and
+ // the only remaining one is the spec.
expectTypeOf<TaskOptions>().toEqualTypeOf<{
- readonly inputs?: TaskInputs;
readonly spec?: TaskSpec;
+ readonly argNames?: readonly string[];
}>();
+ // Each named argument takes any upstream reference or a literal of its
own type.
+ expectTypeOf<TaskInputs<{ rows: number }>>().toEqualTypeOf<{ rows: TaskRef
| number }>();
+ // A positional argument takes a literal or a reference of the argument's
own
+ // type, which is what tells a one-argument positional call from a named
one.
+ expectTypeOf<TaskInput<number>>().toEqualTypeOf<TaskRef<number> |
number>();
+ expectTypeOf<PositionalInputs<[number, string]>>().toEqualTypeOf<
+ [TaskRef<number> | number, TaskRef<string> | string]
+ >();
+ // A handler with no arguments is called with none; one with several is
+ // called with a value per argument, in order.
+ expectTypeOf<TaskFactory<[]>>().toEqualTypeOf<() => TaskRef>();
+ expectTypeOf<TaskFactory<[], boolean>>().toEqualTypeOf<() =>
TaskRef<boolean>>();
+ expectTypeOf<TaskFactory<[number, string]>>().toEqualTypeOf<
+ (...inputs: [TaskRef<number> | number, TaskRef<string> | string]) =>
TaskRef
+ >();
expectTypeOf<ConstructorParameters<typeof Dag>>().toEqualTypeOf<[string,
DagSpec?]>();
expectTypeOf<Dag["task"]>().toEqualTypeOf<
- <TArgs = void, TReturn = unknown>(
+ <TParams extends readonly unknown[] = [], TReturn = unknown>(
taskId: string,
- handler: TaskFunction<TArgs, TReturn>,
+ handler: (...args: TParams) => TReturn | Promise<TReturn>,
options?: TaskOptions,
- ) => TaskRef
+ ) => TaskFactory<TParams, TReturn>
>();
expectTypeOf<Dag["taskIds"]>().toEqualTypeOf<readonly string[]>();
// Reserved with no fields yet, so only `{}` is expressible. Generated
specs
@@ -391,21 +416,43 @@ describe("public API", () => {
// @ts-expect-error a task handler is required.
new Dag("example").task("extract");
const dag = new Dag("example");
- const upstream = dag.task("extract", async () => undefined);
- // @ts-expect-error inputs must be task handles, not arbitrary values.
+ const extract = dag.task("extract", async () => undefined);
+ // @ts-expect-error wiring belongs to the factory call, not the options.
dag.task("transform", async () => undefined, { inputs: { count: 1 } });
- // @ts-expect-error inputs and spec are keyword-only, not positional.
- dag.task("transform2", async () => undefined, { upstream });
+ // @ts-expect-error the spec is keyword-only, not positional.
+ dag.task("transform2", async () => undefined, { extract });
// @ts-expect-error a Dag spec is an options object, not a primitive.
new Dag("spec_dag", 42);
// @ts-expect-error DagSpec has no fields yet, so a schedule cannot be
declared here.
new Dag("spec_dag", { schedule: "@daily" });
// @ts-expect-error TaskSpec has no fields yet, so retries cannot be
declared here.
dag.task("transform3", async () => undefined, { spec: { retries: 2 } });
- // @ts-expect-error the TaskRef handle is data, not callable.
- upstream();
- // @ts-expect-error a bundle is built from Dags, not from task handles.
- new Bundle(upstream);
+ // @ts-expect-error a handler with no arguments is called with none.
+ extract({ rows: 1 });
+ const transform = dag.task("transform4", async (_: { rows: number }) =>
undefined);
+ // @ts-expect-error every argument the handler declares has to be
supplied.
+ transform({});
+ // @ts-expect-error a literal has to match its argument's type.
+ transform({ rows: "many" });
+ const totals = dag.task("totals", async (): Promise<{ rows: number }> =>
({ rows: 1 }));
+ // A single object of named arguments is given by name or by position.
+ transform({ rows: 1 });
+ transform(totals());
+ // @ts-expect-error a positional reference has to return the argument's
type.
+ transform(extract());
+ const pair = dag.task("pair", async (rows: number, region: string) =>
`${region}${rows}`);
+ pair(1, "us");
+ // @ts-expect-error a positional argument cannot be skipped.
+ pair(1);
+ // @ts-expect-error each positional literal has to match its own
argument.
+ pair("many", "us");
+ const stamp = dag.task("stamp", async (_: { at: Date }) => undefined);
+ // @ts-expect-error a Date cannot survive the serialized Dag, so only a
reference will do.
+ stamp({ at: new Date() });
+ // The reference an upstream call returns is always accepted.
+ stamp({ at: extract() });
+ // @ts-expect-error a bundle is built from Dags, not from task factories.
+ new Bundle(extract);
// @ts-expect-error serve() takes nothing; the bundle already holds it
all.
new Bundle(dag).serve(dag);
};
diff --git a/ts-sdk/tests/sdk/bundle.test.ts b/ts-sdk/tests/sdk/bundle.test.ts
index 37f6f68b663..24998153c27 100644
--- a/ts-sdk/tests/sdk/bundle.test.ts
+++ b/ts-sdk/tests/sdk/bundle.test.ts
@@ -19,13 +19,13 @@
import { describe, it, expect } from "vitest";
import { Dag } from "../../src/sdk/dag.js";
-import { Bundle, listBundleDags, listBundleTasks } from
"../../src/sdk/bundle.js";
+import { Bundle, bundleDagTaskIds, finalizeBundleDags } from
"../../src/sdk/bundle.js";
describe("Bundle", () => {
it("registers a Dag and retrieves its handlers", () => {
const handler = async () => "hello";
const dag = new Dag("example_dag");
- dag.task("my_task", handler);
+ dag.task("my_task", handler)();
const bundle = new Bundle();
bundle.register(dag);
expect(bundle.getTaskHandler("example_dag", "my_task")).toBe(handler);
@@ -34,13 +34,15 @@ describe("Bundle", () => {
it("registers the Dags passed to its constructor", () => {
const handler = async () => "hello";
const dagA = new Dag("dag_a");
- dagA.task("a", handler);
+ dagA.task("a", handler)();
const bundle = new Bundle(dagA, new Dag("dag_b"));
expect(bundle.getTaskHandler("dag_a", "a")).toBe(handler);
- expect(listBundleDags(bundle)).toEqual([
- { dagId: "dag_a", tasks: ["a"] },
- { dagId: "dag_b", tasks: [] },
- ]);
+ expect(bundleDagTaskIds(bundle)).toEqual(
+ new Map([
+ ["dag_a", ["a"]],
+ ["dag_b", []],
+ ]),
+ );
});
it("rejects duplicate dagIds passed to the constructor", () => {
@@ -58,28 +60,30 @@ describe("Bundle", () => {
it("returns undefined for unknown taskIds and dagIds", () => {
const bundle = new Bundle();
const dag = new Dag("example_dag");
- dag.task("my_task", async () => undefined);
+ dag.task("my_task", async () => undefined)();
bundle.register(dag);
expect(bundle.getTaskHandler("example_dag", "nope")).toBeUndefined();
expect(bundle.getTaskHandler("unknown_dag", "my_task")).toBeUndefined();
});
- it("returns an empty list when no Dags are registered", () => {
+ it("returns nothing when no Dags are registered", () => {
const bundle = new Bundle();
- expect(listBundleTasks(bundle)).toEqual([]);
+ expect(bundleDagTaskIds(bundle)).toEqual(new Map());
});
it("lists tasks across registered Dags", () => {
const dagA = new Dag("dag_a");
- dagA.task("a", async () => undefined);
+ dagA.task("a", async () => undefined)();
const dagB = new Dag("dag_b");
- dagB.task("b", async () => undefined);
+ dagB.task("b", async () => undefined)();
const bundle = new Bundle();
bundle.register(dagA, dagB);
- const registered = listBundleTasks(bundle);
- expect(registered).toHaveLength(2);
- expect(registered).toContainEqual({ dagId: "dag_a", taskId: "a" });
- expect(registered).toContainEqual({ dagId: "dag_b", taskId: "b" });
+ expect(bundleDagTaskIds(bundle)).toEqual(
+ new Map([
+ ["dag_a", ["a"]],
+ ["dag_b", ["b"]],
+ ]),
+ );
});
it("rejects registering the same dagId in separate calls", () => {
@@ -105,10 +109,10 @@ describe("Bundle", () => {
it("registers none of the Dags when a call throws", () => {
const bundle = new Bundle();
const dag = new Dag("dag_a");
- dag.task("a", async () => undefined);
+ dag.task("a", async () => undefined)();
expect(() => bundle.register(dag, new Dag("dag_a"))).toThrowError(/already
registered/);
expect(bundle.getTaskHandler("dag_a", "a")).toBeUndefined();
- expect(listBundleTasks(bundle)).toEqual([]);
+ expect(bundleDagTaskIds(bundle)).toEqual(new Map());
});
it("rejects values that are neither a Dag nor a task handler", () => {
@@ -129,14 +133,16 @@ describe("Bundle", () => {
it("lists every registered Dag with its tasks, empty Dags included", () => {
const dagA = new Dag("dag_a");
- dagA.task("a1", async () => undefined);
- dagA.task("a2", async () => undefined);
+ dagA.task("a1", async () => undefined)();
+ dagA.task("a2", async () => undefined)();
const bundle = new Bundle();
bundle.register(dagA, new Dag("empty_dag"));
- expect(listBundleDags(bundle)).toEqual([
- { dagId: "dag_a", tasks: ["a1", "a2"] },
- { dagId: "empty_dag", tasks: [] },
- ]);
+ expect(bundleDagTaskIds(bundle)).toEqual(
+ new Map([
+ ["dag_a", ["a1", "a2"]],
+ ["empty_dag", []],
+ ]),
+ );
});
it("accepts a call that registers nothing", () => {
@@ -144,7 +150,7 @@ describe("Bundle", () => {
// guard at the call site.
const bundle = new Bundle();
expect(() => bundle.register()).not.toThrow();
- expect(listBundleDags(bundle)).toEqual([]);
+ expect(bundleDagTaskIds(bundle)).toEqual(new Map());
});
it("carries the brand its own serve guard reads", () => {
@@ -153,18 +159,30 @@ describe("Bundle", () => {
expect(Symbol.for("airflow.ts-sdk.Bundle") in new Bundle()).toBe(true);
});
- it("sees tasks added to a Dag after registration", () => {
+ it("sees tasks added to a Dag between registration and the first read", ()
=> {
+ // Registration records Dag identity rather than a snapshot of its tasks,
so
+ // a Dag assembled across several modules is still complete when it is
read.
const bundle = new Bundle();
const dag = new Dag("example_dag");
bundle.register(dag);
- expect(listBundleTasks(bundle)).toEqual([]);
const handler = async () => "late";
- dag.task("late_task", handler);
+ dag.task("late_task", handler)();
expect(bundle.getTaskHandler("example_dag", "late_task")).toBe(handler);
- expect(listBundleTasks(bundle)).toContainEqual({
- dagId: "example_dag",
- taskId: "late_task",
- });
+ expect(bundleDagTaskIds(bundle).get("example_dag")).toContain("late_task");
+ });
+
+ it("rejects a task added to a Dag the bundle has already reported", () => {
+ const dag = new Dag("example_dag");
+ dag.task("extract", async () => undefined)();
+ const bundle = new Bundle(dag);
+ // Reporting what the bundle provides is what finalizes a native Dag;
+ // enumerating what it can dispatch does not.
+ expect(bundleDagTaskIds(bundle)).toEqual(new Map([["example_dag",
["extract"]]]));
+ finalizeBundleDags(bundle);
+
+ expect(() => dag.task("late_task", async () => "late")).toThrowError(
+ /Task "late_task" cannot be added to Dag "example_dag" after the Dag was
read/,
+ );
});
});
diff --git a/ts-sdk/tests/sdk/dag.test.ts b/ts-sdk/tests/sdk/dag.test.ts
index 4dfe1652652..5c772eb57c8 100644
--- a/ts-sdk/tests/sdk/dag.test.ts
+++ b/ts-sdk/tests/sdk/dag.test.ts
@@ -18,53 +18,104 @@
*/
import { describe, it, expect } from "vitest";
-import { Dag, getDagTaskRecords, type TaskRef } from "../../src/sdk/dag.js";
-import { Bundle } from "../../src/sdk/bundle.js";
+import {
+ Dag,
+ finalizeDag,
+ getDagTaskInputs,
+ getDagTaskRecords,
+ type TaskRef,
+} from "../../src/sdk/dag.js";
+import { Bundle, finalizeBundleDags } from "../../src/sdk/bundle.js";
describe("Dag", () => {
- it("returns a frozen TaskRef handle with the Dag and task identity", () => {
+ it("returns a factory whose call yields a frozen TaskRef with the Dag and
task identity", () => {
const dag = new Dag("example_dag");
- const task = dag.task("my_task", async () => "hello");
- expect(task).toEqual({ dagId: "example_dag", taskId: "my_task" });
- expect(Object.isFrozen(task)).toBe(true);
+ const myTask = dag.task("my_task", async () => "hello");
+ expect(typeof myTask).toBe("function");
+
+ const ref = myTask();
+ expect(ref).toEqual({ dagId: "example_dag", taskId: "my_task" });
+ expect(Object.isFrozen(ref)).toBe(true);
});
- it("chains upstream handles into downstream task inputs", () => {
+ it("chains upstream references into downstream task inputs", () => {
const dag = new Dag("chained_dag");
- const extracted = dag.task("extract", async () => ({ rows: 1 }));
- const transformed = dag.task("transform", async () => undefined, { inputs:
{ extracted } });
- const loaded = dag.task("load", async () => undefined, { inputs: {
transformed }, spec: {} });
+ const extract = dag.task("extract", async () => ({ rows: 1 }));
+ const transform = dag.task(
+ "transform",
+ async (_: { extracted: { rows: number } }) => undefined,
+ );
+ const load = dag.task("load", async (_: { transformed: undefined }) =>
undefined, { spec: {} });
+
+ const extracted = extract();
+ const transformed = transform({ extracted });
+ const loaded = load({ transformed });
expect(extracted).toEqual({ dagId: "chained_dag", taskId: "extract" });
expect(transformed).toEqual({ dagId: "chained_dag", taskId: "transform" });
expect(loaded).toEqual({ dagId: "chained_dag", taskId: "load" });
- const records = getDagTaskRecords(dag);
- expect(records.get("extract")?.inputs).toEqual({});
- expect(records.get("transform")?.inputs).toEqual({ extracted });
- expect(records.get("load")?.inputs).toEqual({ transformed });
+ const inputs = getDagTaskInputs(dag);
+ expect(inputs.get("extract")).toEqual({});
+ expect(inputs.get("transform")).toEqual({ extracted });
+ expect(inputs.get("load")).toEqual({ transformed });
});
- it("accepts several named inputs for one task", () => {
- const dag = new Dag("fan_in_dag");
- const extracted = dag.task("extract", async () => undefined);
- const otherTaskResult = dag.task("other_task", async () => undefined);
- dag.task("transform", async () => undefined, { inputs: { extracted,
otherTaskResult } });
+ it("records a literal argument as a value rather than an edge", () => {
+ const dag = new Dag("literal_dag");
+ const extract = dag.task("extract", async () => 1);
+ const transform = dag.task(
+ "transform",
+ async (_: { extracted: number; regionCode: string; limits: number[] })
=> undefined,
+ );
+
+ const extracted = extract();
+ transform({ extracted, regionCode: "us", limits: [1, 2] });
- expect(getDagTaskRecords(dag).get("transform")?.inputs).toEqual({
+ expect(getDagTaskInputs(dag).get("transform")).toEqual({
extracted,
- otherTaskResult,
+ regionCode: "us",
+ limits: [1, 2],
});
});
+ it("treats an unbranded look-alike reference as a literal, not an edge", ()
=> {
+ const dag = new Dag("lookalike_dag");
+ const transform = dag.task("transform", async (_: { upstream: unknown })
=> undefined);
+ const lookalike = { dagId: "lookalike_dag", taskId: "ghost" };
+
+ transform({ upstream: lookalike });
+
+ expect(getDagTaskInputs(dag).get("transform")).toEqual({ upstream:
lookalike });
+ });
+
+ it("accepts several named inputs for one task", () => {
+ const dag = new Dag("fan_in_dag");
+ const extract = dag.task("extract", async () => undefined);
+ const other = dag.task("other_task", async () => undefined);
+ const transform = dag.task(
+ "transform",
+ async (_: { extracted: undefined; otherTaskResult: undefined }) =>
undefined,
+ );
+
+ const extracted = extract();
+ const otherTaskResult = other();
+ transform({ extracted, otherTaskResult });
+
+ expect(getDagTaskInputs(dag).get("transform")).toEqual({ extracted,
otherTaskResult });
+ });
+
it("records frozen inputs that later mutation of the caller's object cannot
change", () => {
const dag = new Dag("example_dag");
- const extracted = dag.task("extract", async () => undefined);
+ const extract = dag.task("extract", async () => undefined);
+ const transform = dag.task("transform", async (_: { extracted: undefined
}) => undefined);
+
+ const extracted = extract();
const inputs: Record<string, TaskRef> = { extracted };
- dag.task("transform", async () => undefined, { inputs });
+ transform(inputs as { extracted: TaskRef });
inputs.sneaky = extracted;
- const recorded = getDagTaskRecords(dag).get("transform")!.inputs;
+ const recorded = getDagTaskInputs(dag).get("transform")!;
expect(recorded).toEqual({ extracted });
expect(Object.isFrozen(recorded)).toBe(true);
});
@@ -72,36 +123,237 @@ describe("Dag", () => {
it("rejects an input taken from another Dag", () => {
const first = new Dag("first_dag");
const second = new Dag("second_dag");
- const extracted = first.task("extract", async () => undefined);
- expect(() =>
- second.task("transform", async () => undefined, { inputs: { extracted }
}),
- ).toThrowError(
+ const extracted = first.task("extract", async () => undefined)();
+ const transform = second.task("transform", async (_: { extracted: TaskRef
}) => undefined);
+
+ expect(() => transform({ extracted })).toThrowError(
/Input "extracted" of task "transform" comes from Dag "first_dag", not
"second_dag"/,
);
});
+ it("rejects a reference from another Dag object carrying the same Dag ID",
() => {
+ const first = new Dag("same_id");
+ const second = new Dag("same_id");
+ first.task("extract", async () => undefined);
+ const extracted = second.task("extract", async () => undefined)();
+ const transform = first.task("transform", async (_: { extracted: TaskRef
}) => undefined);
+
+ expect(() => transform({ extracted })).toThrowError(
+ /Input "extracted" of task "transform" was not returned by this Dag's
"extract"/,
+ );
+ });
+
+ it("rejects a reference to a task this Dag never registered", () => {
+ const first = new Dag("same_id");
+ const second = new Dag("same_id");
+ const ghost = second.task("ghost", async () => undefined)();
+ const transform = first.task("transform", async (_: { ghost: TaskRef }) =>
undefined);
+
+ expect(() => transform({ ghost })).toThrowError(
+ /Input "ghost" of task "transform" refers to unregistered task "ghost"/,
+ );
+ });
+
+ it("rejects a reference buried inside a literal input", () => {
+ // It would draw no edge, so the task would run without the upstream it was
+ // given. TypeScript rejects it for a well-typed argument, which leaves the
+ // `any`, the cast and the plain-JavaScript caller to this check.
+ const dag = new Dag("nested_dag");
+ const extract = dag.task("extract", async () => 1);
+ const other = dag.task("other", async () => 2);
+ const fan = dag.task("fan", async (_: { sources: unknown }) => undefined);
+ const sources = [extract(), other()];
+
+ expect(() => fan({ sources } as unknown as { sources: never
})).toThrowError(
+ /Input "sources" of task "fan" holds a reference to "extract" inside a
literal value/,
+ );
+ });
+
+ it("finds a reference nested several levels down, and survives a
self-reference", () => {
+ const dag = new Dag("deep_dag");
+ const extract = dag.task("extract", async () => 1);
+ const fan = dag.task("fan", async (_: { config: unknown }) => undefined);
+ const config: Record<string, unknown> = { outer: { inner: [{ from:
extract() }] } };
+ config["self"] = config;
+
+ expect(() => fan({ config } as unknown as { config: never })).toThrowError(
+ /holds a reference to "extract" inside a literal value/,
+ );
+ });
+
+ it("rejects an input keyed by a symbol", () => {
+ const dag = new Dag("symbol_dag");
+ const transform = dag.task("transform", async (_: { real: string }) =>
undefined);
+ const inputs = { real: "ok", [Symbol("sneaky")]: "value" };
+
+ expect(() => transform(inputs)).toThrowError(
+ /Input "Symbol\(sneaky\)" of task "transform" is keyed by a symbol; an
argument name is a string/,
+ );
+ });
+
+ it("records a positional call in argument order", () => {
+ const dag = new Dag("example_dag");
+ const extract = dag.task("extract", async (): Promise<number> => 1);
+ const transform = dag.task("transform", async (rows: number, region:
string) => `${region}`);
+
+ const extracted = extract();
+ transform(extracted, "us");
+
+ expect(getDagTaskInputs(dag).get("transform")).toEqual({ arg0: extracted,
arg1: "us" });
+
expect(Object.keys(getDagTaskInputs(dag).get("transform")!)).toEqual(["arg0",
"arg1"]);
+ });
+
+ it("names positional arguments from argNames", () => {
+ const dag = new Dag("example_dag");
+ const extract = dag.task("extract", async (): Promise<number> => 1);
+ const transform = dag.task("transform", async (rows: number, region:
string) => `${region}`, {
+ argNames: ["rows", "region"],
+ });
+
+ const extracted = extract();
+ transform(extracted, "us");
+
+ expect(getDagTaskInputs(dag).get("transform")).toEqual({ rows: extracted,
region: "us" });
+ });
+
+ it("labels the arguments argNames does not reach", () => {
+ const dag = new Dag("example_dag");
+ const transform = dag.task("transform", async (rows: number, region:
string) => `${region}`, {
+ argNames: ["rows"],
+ });
+
+ transform(1, "us");
+
+ expect(getDagTaskInputs(dag).get("transform")).toEqual({ rows: 1, arg1:
"us" });
+ });
+
it.each([
- ["a plain string", "extract"],
- ["an object without a dagId", { taskId: "extract" }],
- ["null", null],
- ])("rejects an input that is not a task handle: %s", (_label, value) => {
+ ["not an array", { argNames: 1 }, /argNames for Dag "d" task "t" must be
an array of names/],
+ ["not a string", { argNames: [1] }, /holds 1; each name must be a
non-empty string/],
+ ["empty", { argNames: [""] }, /holds ""; each name must be a non-empty
string/],
+ ["a number", { argNames: ["0"] }, /holds "0"; each name must be a
non-empty string/],
+ ["a duplicate", { argNames: ["a", "a"] }, /argNames for Dag "d" task "t"
names "a" twice/],
+ ])("rejects argNames that are %s", (_label, options, expected) => {
+ const dag = new Dag("d");
+
+ expect(() => dag.task("t", async (a: number) => a, options as
never)).toThrowError(expected);
+ });
+
+ it("reads a single argument that is not a map of names as one positional
input", () => {
const dag = new Dag("example_dag");
- const extracted = value as unknown as TaskRef;
- expect(() =>
- dag.task("transform", async () => undefined, { inputs: { extracted } }),
- ).toThrowError(
- /Input "extracted" of task "transform" must be a task handle returned by
dag\.task\(\.\.\.\)/,
+ const when = new Date();
+ const transform = dag.task("transform", async (at: Date) => at);
+
+ transform(when as never);
+
+ expect(getDagTaskInputs(dag).get("transform")).toEqual({ arg0: when });
+ });
+
+ it("reads a single reference as one positional input rather than a map of
names", () => {
+ const dag = new Dag("example_dag");
+ const extract = dag.task("extract", async (): Promise<{ rows: number }> =>
({ rows: 1 }));
+ const load = dag.task("load", async (totals: { rows: number }) =>
totals.rows);
+
+ const extracted = extract();
+ load(extracted);
+
+ expect(getDagTaskInputs(dag).get("load")).toEqual({ arg0: extracted });
+ });
+
+ it("spreads a positional task's bound arguments back into its argument
list", async () => {
+ const dag = new Dag("example_dag");
+ const seen: unknown[] = [];
+ const transform = dag.task(
+ "transform",
+ async (rows: number, region: string) => {
+ seen.push(rows, region);
+ },
+ { argNames: ["rows", "region"] },
);
- expect(getDagTaskRecords(dag).has("transform")).toBe(false);
+
+ transform(1, "us");
+ // The order the runtime hands the bound arguments over in, which is the
+ // order they were recorded.
+ await new Bundle(dag).getTaskHandler("example_dag", "transform")!({
+ rows: 1,
+ region: "us",
+ } as never);
+
+ expect(seen).toEqual([1, "us"]);
});
- it("rejects an input referring to a task that is not registered yet", () => {
+ it("calls a task declaring one object of named arguments with that object",
async () => {
const dag = new Dag("example_dag");
- expect(() =>
- dag.task("transform", async () => undefined, {
- inputs: { ghost: { dagId: "example_dag", taskId: "ghost" } },
- }),
- ).toThrowError(/Input "ghost" of task "transform" refers to unregistered
task "ghost"/);
+ const seen: unknown[] = [];
+ const store = dag.task("store", async ({ rows }: { rows: number }) => {
+ seen.push(rows);
+ });
+
+ store({ rows: 1 });
+ await new Bundle(dag).getTaskHandler("example_dag", "store")!({ rows: 7 }
as never);
+
+ expect(seen).toEqual([7]);
+ });
+
+ it("rejects calling the same task twice", () => {
+ const dag = new Dag("example_dag");
+ const extract = dag.task("extract", async () => undefined);
+ extract();
+
+ expect(() => extract()).toThrowError(
+ /Task "extract" of Dag "example_dag" was already called; a task holds
one place in a Dag/,
+ );
+ });
+
+ it("fails when the Dag is read with a task that was never called", () => {
+ const dag = new Dag("unplaced_dag");
+ dag.task("extract", async () => undefined)();
+ dag.task("orphan", async () => undefined);
+
+ expect(() => finalizeDag(dag)).toThrowError(
+ /Task "orphan" of Dag "unplaced_dag" is never called, so it has no place
in the Dag/,
+ );
+ });
+
+ it("reports the same failure on a second read rather than reporting itself
as read", () => {
+ const dag = new Dag("unplaced_dag");
+ dag.task("orphan", async () => undefined);
+
+ expect(() => finalizeDag(dag)).toThrowError(/is never called/);
+ expect(() => finalizeDag(dag)).toThrowError(/is never called/);
+ });
+
+ it("surfaces an uncalled task when a bundle reports what it provides", () =>
{
+ const dag = new Dag("served_dag");
+ dag.task("orphan", async () => undefined);
+ const bundle = new Bundle(dag);
+
+ expect(() => finalizeBundleDags(bundle)).toThrowError(
+ /Task "orphan" of Dag "served_dag" is never called/,
+ );
+ });
+
+ it("rejects a task added after the Dag was read", () => {
+ const dag = new Dag("closed_dag");
+ dag.task("extract", async () => undefined)();
+ finalizeDag(dag);
+
+ expect(() => dag.task("late", async () => undefined)).toThrowError(
+ /Task "late" cannot be added to Dag "closed_dag" after the Dag was read/,
+ );
+ });
+
+ it("rejects wiring through a factory after the Dag was read", () => {
+ // A factory outlives the module that built it, so a stray later call has
to
+ // be rejected rather than silently rewiring a Dag Airflow already read.
+ const dag = new Dag("closed_dag");
+ const extract = dag.task("extract", async () => undefined);
+ extract();
+ finalizeDag(dag);
+
+ expect(() => extract()).toThrowError(
+ /Task "extract" of Dag "closed_dag" was called after the Dag was read/,
+ );
});
it("retains its spec and each task's handler and spec, copied and frozen",
() => {
@@ -109,7 +361,7 @@ describe("Dag", () => {
const taskSpec = {};
const handler = async () => "hello";
const dag = new Dag("example_dag", dagSpec);
- dag.task("my_task", handler, { spec: taskSpec });
+ dag.task("my_task", handler, { spec: taskSpec })();
expect(dag.dagId).toBe("example_dag");
expect(dag.spec).toEqual(dagSpec);
@@ -155,9 +407,9 @@ describe("Dag", () => {
});
it.each([
- ["a misspelled inputs key", { input: {} }],
+ ["inputs, which the factory call now carries", { inputs: {} }],
["a misspelled spec key", { specs: {} }],
- ["an upstream handle passed positionally", { upstream: { dagId: "d",
taskId: "t" } }],
+ ["an upstream reference passed as an option", { upstream: { dagId: "d",
taskId: "t" } }],
])("rejects %s in the task options", (_label, options) => {
const dag = new Dag("example_dag");
expect(() =>
@@ -169,7 +421,7 @@ describe("Dag", () => {
it.each([
["null", null],
["an array", []],
- ["a string", "inputs"],
+ ["a string", "spec"],
["a non-plain object", new Date()],
])("rejects task options that are not an options object: %s", (_label,
options) => {
const dag = new Dag("example_dag");
@@ -178,16 +430,6 @@ describe("Dag", () => {
).toThrowError(/options for Dag "example_dag" task "transform" must be an
object/);
});
- it("rejects task inputs that are not a plain object", () => {
- const dag = new Dag("example_dag");
- expect(() =>
- dag.task("transform", async () => undefined, {
- inputs: new Date() as unknown as Record<string, TaskRef>,
- }),
- ).toThrowError(/inputs for Dag "example_dag" task "transform" must be an
object/);
- expect(dag.taskIds).toEqual([]);
- });
-
it("rejects duplicate taskIds within a Dag", () => {
const dag = new Dag("example_dag");
dag.task("dup", async () => undefined);
@@ -199,8 +441,8 @@ describe("Dag", () => {
const second = async () => "second";
const firstDag = new Dag("first_dag");
const secondDag = new Dag("second_dag");
- firstDag.task("extract", first);
- secondDag.task("extract", second);
+ firstDag.task("extract", first)();
+ secondDag.task("extract", second)();
const bundle = new Bundle();
bundle.register(firstDag, secondDag);
@@ -211,7 +453,7 @@ describe("Dag", () => {
it("accepts a Unicode dagId that Python's word-character rule allows", () =>
{
const handler = async () => undefined;
const dag = new Dag("café_dag");
- dag.task("任務", handler);
+ dag.task("任務", handler)();
const bundle = new Bundle();
bundle.register(dag);
expect(bundle.getTaskHandler("café_dag", "任務")).toBe(handler);
@@ -226,7 +468,7 @@ describe("Dag", () => {
it("treats a dotted TaskGroup taskId as a single taskId (group.task)", () =>
{
const dag = new Dag("example_dag");
- dag.task("transforms.normalize", async () => "ok");
+ dag.task("transforms.normalize", async () => "ok")();
const bundle = new Bundle();
bundle.register(dag);
expect(bundle.getTaskHandler("example_dag",
"transforms.normalize")).toBeDefined();
diff --git a/ts-sdk/tests/sdk/task-handler.test.ts
b/ts-sdk/tests/sdk/task-handler.test.ts
index 78bb1541802..bf406321f0e 100644
--- a/ts-sdk/tests/sdk/task-handler.test.ts
+++ b/ts-sdk/tests/sdk/task-handler.test.ts
@@ -19,7 +19,7 @@
import { describe, expect, it } from "vitest";
-import { Bundle, listBundleDags, listBundleTasks } from
"../../src/sdk/bundle.js";
+import { Bundle, bundleDagTaskIds } from "../../src/sdk/bundle.js";
import { Dag } from "../../src/sdk/dag.js";
import { getTaskHandlerFunction, TaskHandler } from
"../../src/sdk/task-handler.js";
@@ -66,7 +66,7 @@ describe("TaskHandler", () => {
describe("a bundle of task handlers", () => {
it("registers Dags and task handlers in one call", () => {
const nativeDag = new Dag("native_etl");
- nativeDag.task("extract", async () => undefined);
+ nativeDag.task("extract", async () => undefined)();
const transform = async () => "transformed";
const bundle = new Bundle();
@@ -94,9 +94,9 @@ describe("a bundle of task handlers", () => {
expect(bundle.getTaskHandler("etl", "unknown")).toBeUndefined();
});
- it("lists every Dag it provides for, in registration order", () => {
+ it("lists every Dag it provides for, the ones declared in TypeScript first",
() => {
const nativeDag = new Dag("native_etl");
- nativeDag.task("extract", async () => undefined);
+ nativeDag.task("extract", async () => undefined)();
const bundle = new Bundle(
new TaskHandler("py_etl", "transform", async () => undefined),
@@ -104,16 +104,12 @@ describe("a bundle of task handlers", () => {
new TaskHandler("py_etl", "report", async () => undefined),
);
- // A Dag registered through handlers keeps its place from the first handler
- // that named it, so a later one does not reorder the manifest.
- expect(listBundleDags(bundle)).toEqual([
- { dagId: "py_etl", tasks: ["transform", "report"] },
- { dagId: "native_etl", tasks: ["extract"] },
- ]);
- expect(listBundleTasks(bundle)).toEqual([
- { dagId: "py_etl", taskId: "transform" },
- { dagId: "py_etl", taskId: "report" },
- { dagId: "native_etl", taskId: "extract" },
+ // Compared as entries rather than as a Map, which would ignore the order.
+ // A Dag its handlers name keeps its place from the first handler that
named
+ // it, so a later one does not reorder the manifest.
+ expect([...bundleDagTaskIds(bundle)]).toEqual([
+ ["native_etl", ["extract"]],
+ ["py_etl", ["transform", "report"]],
]);
});
@@ -122,7 +118,7 @@ describe("a bundle of task handlers", () => {
bundle.register(new TaskHandler("py_etl", "transform", async () =>
undefined));
bundle.register(new TaskHandler("py_etl", "report", async () =>
undefined));
- expect(listBundleDags(bundle)).toEqual([{ dagId: "py_etl", tasks:
["transform", "report"] }]);
+ expect(bundleDagTaskIds(bundle)).toEqual(new Map([["py_etl", ["transform",
"report"]]]));
});
it("rejects a second handler for the same Dag and task", () => {
@@ -150,7 +146,7 @@ describe("a bundle of task handlers", () => {
new TaskHandler("etl", "transform", async () => undefined),
),
).toThrowError(/already registered/);
- expect(listBundleDags(bundle)).toEqual([]);
+ expect(bundleDagTaskIds(bundle)).toEqual(new Map());
expect(bundle.getTaskHandler("etl", "transform")).toBeUndefined();
});
@@ -180,6 +176,42 @@ describe("a bundle of task handlers", () => {
);
});
+ it("rejects a Dag whose ID the same call already gave a handler", () => {
+ // The mirror of the Dag-first case, which the validation pass has to catch
+ // too: writing the Dag entry would otherwise replace the handler map and
+ // drop the handler, leaving its task with no body at run time.
+ const dag = new Dag("py_etl");
+ dag.task("extract", async () => undefined)();
+ expect(
+ () => new Bundle(new TaskHandler("py_etl", "legacy", async () =>
undefined), dag),
+ ).toThrowError(/already has registered task handlers/);
+ });
+
+ it("registers neither kind when a mixed call throws", () => {
+ const bundle = new Bundle();
+ const dag = new Dag("py_etl");
+ dag.task("extract", async () => undefined)();
+ expect(() =>
+ bundle.register(new TaskHandler("py_etl", "legacy", async () =>
undefined), dag),
+ ).toThrowError(/already has registered task handlers/);
+
+ expect(bundleDagTaskIds(bundle)).toEqual(new Map());
+ expect(bundle.getTaskHandler("py_etl", "legacy")).toBeUndefined();
+ });
+
+ it("keeps the handlers it already held when a later call throws", () => {
+ const bundle = new Bundle(new TaskHandler("py_etl", "transform", async ()
=> undefined));
+ expect(() =>
+ bundle.register(
+ new TaskHandler("py_etl", "report", async () => undefined),
+ new TaskHandler("py_etl", "report", async () => undefined),
+ ),
+ ).toThrowError(/already registered/);
+
+ expect(bundleDagTaskIds(bundle)).toEqual(new Map([["py_etl",
["transform"]]]));
+ expect(bundle.getTaskHandler("py_etl", "report")).toBeUndefined();
+ });
+
it("names the duplicate-copy cause for a handler carrying the brand but not
this class", () => {
// Stands in for a handler from a second resolved copy: same brand, other
// class. Its private function field is unreadable here, so the point is