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 b5d9259f4f5 TS SDK: replace DagRegistry with Bundle, and serveDags
with bundle.serve (#73187)
b5d9259f4f5 is described below
commit b5d9259f4f5766bb41eaf37dab3a254e33051e1f
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Thu Sep 17 11:40:00 2026 +0800
TS SDK: replace DagRegistry with Bundle, and serveDags with bundle.serve
(#73187)
A bundle process was described entirely in terms of Dags: `DagRegistry` held
them, `serveDags(registry)` served them, and the pack error told an author
to
pass them to `serveDags(new DagRegistry(...))`. A mixed-language bundle
provides task handlers for a Dag that Python owns and registers no Dag at
all,
so all three are about to be wrong.
The collection becomes a `Bundle`, and it serves itself.
`register(...items: Registerable[])` is its one registration verb, over a
union
that gains an arm per kind rather than a second verb per kind. Today the
union
has one arm, `Dag`, and registering keeps the path it already had.
`bundle.serve()` replaces the free function. The existing intent was that
Dag
authors reach the runtime through one call and never name the coordinator; a
method on the object that already holds everything keeps that while dropping
the function, and the coordinator subpath now exports no entry point at all.
The latch that makes a serve one-shot moves to `coordinator/serve.ts`,
beside
the sockets it protects rather than on the bundle that holds none.
The guard `serveDags` performed on its argument survives as a guard on the
receiver, which is where it still has something to catch: `const { serve } =
bundle` detaches the method, and a bundle from a second resolved copy is
reported by its cause rather than as a missing private field.
`Bundle.serve()` reaches `serveBundle` through an inline `await
import(...)`.
The coordinator reads a bundle to dispatch and to enumerate it, so a static
import in the other direction would leave the authoring surface and the
coordinator mutually dependent. The module graph stays acyclic instead.
`tests/cli/fixtures/bundle-v1.mjs` is regenerated. That golden bundle embeds
the byte ranges and SHA-256 digest of `empty-entry.ts`, which this change
rewrites onto the new surface, so its header moves with it.
---
.../language-sdks/typescript.rst | 25 +--
ts-sdk/README.md | 40 ++---
ts-sdk/api-docs/dag-authoring-api.ts | 3 +-
ts-sdk/docs/index.md | 8 +-
ts-sdk/example/src/main.ts | 6 +-
ts-sdk/scripts/verify-package.mjs | 2 +-
ts-sdk/src/cli/pack.ts | 4 +-
ts-sdk/src/coordinator/index.ts | 6 +-
ts-sdk/src/coordinator/manifest.ts | 6 +-
ts-sdk/src/coordinator/runtime.ts | 87 ++--------
ts-sdk/src/coordinator/serve.ts | 61 +++++++
ts-sdk/src/index.ts | 5 +-
ts-sdk/src/sdk/brand.ts | 4 +-
ts-sdk/src/sdk/bundle.ts | 176 +++++++++++++++++++++
ts-sdk/src/sdk/dag.ts | 2 +-
ts-sdk/src/sdk/registry.ts | 124 ---------------
ts-sdk/tests/cli/fixtures/bundle-v1.mjs | 6 +-
ts-sdk/tests/cli/fixtures/empty-entry.ts | 4 +-
ts-sdk/tests/cli/fixtures/entry.ts | 4 +-
ts-sdk/tests/cli/fixtures/noisy-entry.ts | 4 +-
ts-sdk/tests/cli/pack.test.ts | 20 +--
ts-sdk/tests/coordinator/integration.test.ts | 14 +-
ts-sdk/tests/coordinator/protocol.test.ts | 6 +-
ts-sdk/tests/coordinator/public-api.test.ts | 13 +-
ts-sdk/tests/coordinator/runtime-manifest.test.ts | 26 +--
ts-sdk/tests/public-api.test.ts | 91 ++++++-----
.../tests/sdk/{registry.test.ts => bundle.test.ts} | 98 +++++++-----
ts-sdk/tests/sdk/dag.test.ts | 26 +--
28 files changed, 482 insertions(+), 389 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 a1d1137dd7f..9bfc50e0f67 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
@@ -37,7 +37,7 @@ The SDK is the ``apache-airflow-ts-sdk`` package (ESM-only).
It is currently in
.. seealso::
- For the full TypeScript API reference (``Dag``, ``DagRegistry``,
``serveDags``, the task handler getters,
+ For the full TypeScript API reference (``Dag``, ``Bundle``, the task handler
getters,
``TaskClient``, supporting types, and exceptions),
see the `TypeScript SDK API reference
<https://airflow.apache.org/docs/ts-sdk/stable/>`__.
@@ -97,12 +97,12 @@ A task is an ordinary (usually ``async``) function taking
no arguments:
``getContext()`` and ``getClient()`` reach the runtime from inside the call,
so nothing the SDK supplies is a parameter.
Create a ``Dag`` with the ``dag_id`` it implements, attach each handler with
``dag.task``,
-collect the Dags in a ``DagRegistry``, then serve them to Airflow with
``serveDags``.
+register it on a ``Bundle``, then serve it to Airflow with ``bundle.serve()``.
That top-level ``await`` makes the module a runnable bundle entry point.
.. code-block:: typescript
- import { Dag, DagRegistry, getClient, serveDags } from
"apache-airflow-ts-sdk";
+ import { Bundle, Dag, getClient } from "apache-airflow-ts-sdk";
export async function buildMessage() {
const client = getClient();
@@ -117,16 +117,19 @@ That top-level ``await`` makes the module a runnable
bundle entry point.
const dag = new Dag("typescript_example");
dag.task("build_message", buildMessage);
- await serveDags(new DagRegistry(dag));
+ const bundle = new Bundle();
+ bundle.register(dag);
+ await bundle.serve();
The ``dagId`` passed to ``new Dag(...)`` must match the ``dag_id`` of the
Python Dag, and each ``taskId``
-passed to ``dag.task`` must match a ``@task.stub`` function in that Dag. The
registry passed to
-``serveDags`` is the bundle's complete set of Dags; a second ``serveDags``
call is rejected. A Dag left out
-of the registry is not part of the packed bundle, and its tasks are marked
removed at runtime.
-
-``DagRegistry`` holds no sockets and starts nothing, so a unit test can build
one and dispatch a handler
-through ``registry.getTaskHandler(dagId, taskId)`` without a coordinator
runtime. A bundle that collects
-its Dags across several modules can add them incrementally with
``registry.register(...)``.
+passed to ``dag.task`` must match a ``@task.stub`` function in that Dag. What
the bundle holds is its
+complete set of Dags; a second ``bundle.serve()`` call is rejected. A Dag left
unregistered is not part of
+the packed bundle, and its tasks are marked removed at runtime.
+
+``register`` is the bundle's one registration verb, and takes any number of
items, so a bundle that
+collects what it provides across several modules can call it repeatedly
instead of passing everything to
+the constructor. 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.
``new Dag`` and ``dag.task`` take a trailing options object: ``spec`` on both,
plus ``inputs`` on a task.
These are not used yet; do not set them. Any other key is rejected.
diff --git a/ts-sdk/README.md b/ts-sdk/README.md
index 2c5b9345748..4cc5c9b4e53 100644
--- a/ts-sdk/README.md
+++ b/ts-sdk/README.md
@@ -35,7 +35,7 @@ npm install [email protected]
## Task Handlers
```ts
-import { Dag, DagRegistry, getClient, getContext, serveDags } from
"apache-airflow-ts-sdk";
+import { Bundle, Dag, getClient, getContext } from "apache-airflow-ts-sdk";
export async function sayHello() {
const greeting = await getClient().getVariable("greeting");
@@ -45,7 +45,9 @@ export async function sayHello() {
const dag = new Dag("example_dag");
dag.task("say_hello", sayHello);
-await serveDags(new DagRegistry(dag));
+const bundle = new Bundle();
+bundle.register(dag);
+await bundle.serve();
```
A handler is a plain function. `getContext()` and `getClient()` reach the
runtime from inside the call,
@@ -103,7 +105,7 @@ Airflow metadata in the bundle itself.
TypeScript entrypoint:
```ts
-import { Dag, DagRegistry, getClient, serveDags } from "apache-airflow-ts-sdk";
+import { Bundle, Dag, getClient } from "apache-airflow-ts-sdk";
export async function extract() {
const client = getClient();
@@ -131,22 +133,22 @@ const salesPipeline = new Dag("sales_pipeline");
salesPipeline.task("extract", extract);
salesPipeline.task("transform", transform);
-await serveDags(new DagRegistry(salesPipeline));
+const bundle = new Bundle();
+bundle.register(salesPipeline);
+await bundle.serve();
```
The Python stub defines the Dag dependency graph. The TypeScript handler does
the work and uses `TaskClient` for task-time Airflow data access. Create a
`Dag` with the Python Dag's `dag_id` and attach each handler with the stub
task's `task_id`. The handler function is the reusable task implementation;
-`dag.task` binds that handler to a Python stub task identity, a `DagRegistry`
-collects the Dags this bundle can execute, and `serveDags` serves them to
-Airflow.
+`dag.task` binds that handler to a Python stub task identity, a `Bundle` holds
+what this bundle process provides, and `bundle.serve()` serves it to Airflow.
-`serveDags` is the entrypoint, and the registry it is given is the whole
bundle:
-a Dag left out of the registry is not part of the bundle, and its tasks are
-marked removed at runtime. The registry itself holds no sockets and starts
-nothing, so a unit test can build one and dispatch through
-`registry.getTaskHandler(dagId, taskId)` without any runtime involved.
+`bundle.serve()` is the entrypoint: a Dag left unregistered is not part of the
bundle,
+and its tasks are marked removed at runtime.
+Registering holds no sockets and starts nothing, so a unit test can build a
bundle
+and dispatch through `bundle.getTaskHandler(dagId, taskId)` without any
runtime involved.
`new Dag` and `dag.task` take a trailing options object: `spec` on both, plus
`inputs` on a task.
These are not used yet; do not set them.
@@ -157,18 +159,18 @@ entrypoint that serves them all:
```ts
import { salesDag } from "./sales/dag";
import { billingDag } from "./billing/dag";
-import { DagRegistry, serveDags } from "apache-airflow-ts-sdk";
+import { Bundle } from "apache-airflow-ts-sdk";
-await serveDags(new DagRegistry(salesDag, billingDag));
+await new Bundle(salesDag, billingDag).serve();
```
-A bundle that collects its Dags across several modules can add them
-incrementally with `registry.register(...)` instead of passing them all to the
-constructor.
+`register` is the bundle's one registration verb: a bundle that collects what
it
+provides across several modules can call it repeatedly instead of passing
+everything to the constructor.
Airflow launches the bundled entrypoint with `--comm=host:port` and
-`--logs=host:port`. `serveDags()` connects to those sockets, receives the task
-startup message, finds the registered handler for the Dag/task pair, and
+`--logs=host:port`. `bundle.serve()` connects to those sockets, receives the
+task startup message, finds the registered handler for the Dag/task pair, and
reports the terminal task state back to Airflow.
See [`example/`](https://github.com/apache/airflow/tree/main/ts-sdk/example)
for
diff --git a/ts-sdk/api-docs/dag-authoring-api.ts
b/ts-sdk/api-docs/dag-authoring-api.ts
index 065cd0ae689..b1e92900f56 100644
--- a/ts-sdk/api-docs/dag-authoring-api.ts
+++ b/ts-sdk/api-docs/dag-authoring-api.ts
@@ -19,9 +19,10 @@
/** @module Authoring */
-export { Dag, DagRegistry, getClient, getContext, serveDags } from
"../src/index.js";
+export { Bundle, Dag, getClient, getContext } from "../src/index.js";
export type {
DagSpec,
+ Registerable,
TaskClient,
TaskContext,
TaskFunction,
diff --git a/ts-sdk/docs/index.md b/ts-sdk/docs/index.md
index 5082acd4a5f..e53f5b8dc86 100644
--- a/ts-sdk/docs/index.md
+++ b/ts-sdk/docs/index.md
@@ -35,14 +35,14 @@ Install the beta package from npm:
npm install [email protected]
```
-Define a Dag and register its task handlers.
+Define a Dag, register it on a `Bundle`, and serve it.
A handler is a plain function: `getContext()` returns the `TaskContext` and
`getClient()` the `TaskClient`
for as long as it runs, so neither is a parameter.
Any non-`undefined` return value is pushed to XCom under the `"return_value"`
key by the active runtime,
matching Python `@task` behavior:
```ts
-import { Dag, DagRegistry, getClient, getContext, serveDags } from
"apache-airflow-ts-sdk";
+import { Bundle, Dag, getClient, getContext } from "apache-airflow-ts-sdk";
export async function sayHello() {
const greeting = await getClient().getVariable("greeting");
@@ -52,7 +52,9 @@ export async function sayHello() {
const dag = new Dag("example_dag");
dag.task("say_hello", sayHello);
-await serveDags(new DagRegistry(dag));
+const bundle = new Bundle();
+bundle.register(dag);
+await bundle.serve();
```
## Coordinators
diff --git a/ts-sdk/example/src/main.ts b/ts-sdk/example/src/main.ts
index e98be36b92e..3914148937a 100644
--- a/ts-sdk/example/src/main.ts
+++ b/ts-sdk/example/src/main.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { Dag, DagRegistry, getClient, serveDags } from "apache-airflow-ts-sdk";
+import { Bundle, Dag, getClient } from "apache-airflow-ts-sdk";
const dag = new Dag("typescript_example");
@@ -53,4 +53,6 @@ export async function readConnection() {
dag.task("build_message", buildMessage);
dag.task("read_connection", readConnection);
-await serveDags(new DagRegistry(dag));
+const bundle = new Bundle();
+bundle.register(dag);
+await bundle.serve();
diff --git a/ts-sdk/scripts/verify-package.mjs
b/ts-sdk/scripts/verify-package.mjs
index 5655310a646..e8064682da4 100644
--- a/ts-sdk/scripts/verify-package.mjs
+++ b/ts-sdk/scripts/verify-package.mjs
@@ -34,7 +34,7 @@ export const REQUIRED_ROOT_FILES = ["LICENSE", "NOTICE",
"README.md", "package.j
// The Dag-authoring entrypoints a consumer must be able to reach from the
package root.
// The two getters are here because a handler cannot reach the runtime without
them: a
// published build that dropped them would still import, and fail at the first
task.
-export const REQUIRED_ROOT_EXPORTS = ["Dag", "DagRegistry", "getClient",
"getContext", "serveDags"];
+export const REQUIRED_ROOT_EXPORTS = ["Bundle", "Dag", "getClient",
"getContext"];
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
diff --git a/ts-sdk/src/cli/pack.ts b/ts-sdk/src/cli/pack.ts
index 040c80e4414..5acea1b9626 100644
--- a/ts-sdk/src/cli/pack.ts
+++ b/ts-sdk/src/cli/pack.ts
@@ -18,7 +18,7 @@
*/
// airflow-ts-pack: bundle a TypeScript entrypoint into the single-file
-// artifact NodeCoordinator consumes — `bundle.mjs` with metadata and an
+// artifact NodeCoordinator consumes: `bundle.mjs` with metadata and an
// integrity layout descriptor embedded in JavaScript comments.
//
// Build first, then run the built bundle with --airflow-metadata so the
@@ -200,7 +200,7 @@ export async function runPack(argv: readonly string[]):
Promise<void> {
const manifest = readBundleManifest(stagingPath);
const dagEntries = Object.entries(manifest.dags);
if (dagEntries.length === 0) {
- throw new Error(`${args.entry} served no Dags; pass them to
serveDags(new DagRegistry(...))`);
+ throw new Error(`${args.entry} served no Dags; register them with
bundle.register(...)`);
}
// Warn rather than fail, as airflow-go-pack does: the shared schema
allows a
// Dag with no tasks.
diff --git a/ts-sdk/src/coordinator/index.ts b/ts-sdk/src/coordinator/index.ts
index f06214a4c2f..50996b9b275 100644
--- a/ts-sdk/src/coordinator/index.ts
+++ b/ts-sdk/src/coordinator/index.ts
@@ -22,11 +22,9 @@
// TaskClient and related types are exported from the package root. This
// barrel only exports coordinator-specific entry points.
//
-// `startCoordinator` is deliberately not exported: Dag authors reach the
-// runtime through `serveDags()`, and never name the coordinator itself.
+// Dag authors reach the runtime through `bundle.serve()`.
-export { serveDags } from "./runtime.js";
/** Cadwyn schema version this SDK was generated against. Not sent on
- * the wire — exposed so callers can read it for bundle metadata,
+ * the wire, but exposed so callers can read it for bundle metadata,
* health checks, or to confirm which schema their build is pinned to. */
export { SUPERVISOR_API_VERSION } from "./protocol.js";
diff --git a/ts-sdk/src/coordinator/manifest.ts
b/ts-sdk/src/coordinator/manifest.ts
index 145cd350167..3a35fc46e09 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 { listRegistryDags, type DagRegistry } from "../sdk/registry.js";
+import { listBundleDags, type Bundle } from "../sdk/bundle.js";
export const AIRFLOW_METADATA_FLAG = "--airflow-metadata";
@@ -35,9 +35,9 @@ export interface BundleManifest {
dags: Record<string, { tasks: string[] }>;
}
-export function buildBundleManifest(registry: DagRegistry): BundleManifest {
+export function buildBundleManifest(bundle: Bundle): BundleManifest {
const dags: BundleManifest["dags"] = {};
- for (const { dagId, tasks } of listRegistryDags(registry)) {
+ for (const { dagId, tasks } of listBundleDags(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 79a273e4278..0e05ec24b58 100644
--- a/ts-sdk/src/coordinator/runtime.ts
+++ b/ts-sdk/src/coordinator/runtime.ts
@@ -25,9 +25,9 @@
//
// where `my-bundle.mjs` is a user-bundled Node script that imports
// the SDK, creates `Dag` objects, attaches a handler per task with
-// `dag.task(...)`, collects them in a `DagRegistry`, then awaits
-// `serveDags(registry)`. Each handler runs inside a task scope, which is what
-// `getContext()` and `getClient()` read.
+// `dag.task(...)`, registers them on a `Bundle`, then awaits `bundle.serve()`.
+// Each handler runs inside a task scope, which is what `getContext()` and
+// `getClient()` read.
//
// Lifecycle:
// 1. Parse --comm / --logs from argv
@@ -53,68 +53,13 @@ import {
type RuntimeTaskState,
type StartupDetails,
} from "./protocol.js";
-import { DagRegistry, isDagRegistry, listRegistryTasks } from
"../sdk/registry.js";
-import { DUPLICATE_COPY_HINT } from "../sdk/brand.js";
+import { listBundleTasks, type Bundle } from "../sdk/bundle.js";
import { runInTaskScope, type TaskContext } from "../sdk/task.js";
import type { JsonValue } from "../sdk/client-types.js";
export const ABORT_GRACE_PERIOD_MS = 30_000;
export const COORDINATOR_RESPONSE_TIMEOUT_MS = 30_000;
-// What must not happen twice is one process connecting two pairs of sockets,
so
-// this is keyed globally rather than held in a module variable: two resolved
-// copies of the package would each get their own, and both would be first.
-const SERVED = Symbol.for("airflow.ts-sdk.served");
-
-function serveLatch(): Record<symbol, boolean | undefined> {
- return globalThis as unknown as Record<symbol, boolean | undefined>;
-}
-
-/**
- * Serve a bundle's Dags to Airflow. The entry point of a TypeScript Dag
bundle.
- *
- * Build the Dags, attach a handler per task with `dag.task(...)`, collect them
- * in a {@link DagRegistry}, then await this at module top level:
- *
- * ```ts
- * const dag = new Dag("my_dag");
- * dag.task("extract", extractFn);
- * await serveDags(new DagRegistry(dag));
- * ```
- *
- * The registry is the bundle's complete set of Dags: this process serves one
- * supervisor request, so a second call, which would connect a second pair of
- * sockets, is rejected. A call that fails is not a serve, and may be retried.
- * Resolves when Airflow's supervisor has been sent the terminal frame for the
- * work this process was started for; the same call also answers the build-time
- * `--airflow-metadata` query `airflow-ts-pack` makes.
- */
-export async function serveDags(registry: DagRegistry): Promise<void> {
- // Checked here rather than at first use: a bad argument otherwise surfaces
- // only after the sockets are up, as a missing method deep in the runtime.
- if (!(registry instanceof DagRegistry)) {
- throw new Error(
- isDagRegistry(registry)
- ? `The registry passed to serveDags(...) ${DUPLICATE_COPY_HINT}`
- : "serveDags(...) takes a DagRegistry; build one with new
DagRegistry(...dags)",
- );
- }
- const latch = serveLatch();
- if (latch[SERVED]) {
- throw new Error("serveDags(...) was already called; serve every Dag from a
single registry");
- }
- // Set before the first await, so two concurrent calls cannot both pass.
- latch[SERVED] = true;
- try {
- await startCoordinator(registry);
- } catch (err) {
- // startCoordinator closes both sockets on its way out, so a failed serve
- // holds nothing open and may be retried.
- latch[SERVED] = undefined;
- throw err;
- }
-}
-
/** Options for `startCoordinator()`. */
export interface StartCoordinatorOptions {
/** Comm socket address (host:port). Must be supplied together with
`logsAddr`; otherwise parsed from argv. */
@@ -165,20 +110,20 @@ export function parseArgs(argv: readonly string[]):
ParsedArgs {
return { commAddr, logsAddr };
}
-/** Start the coordinator runtime, dispatching to `registry`. Resolves when the
+/** Start the coordinator runtime, dispatching to `bundle`. Resolves when the
* subprocess has delivered its terminal frame and closed both sockets.
*
- * Internal: `serveDags()` is the entry point Dag authors call, so this is
+ * Internal: `bundle.serve()` is the entry point Dag authors call, so this is
* deliberately absent from the package `"exports"` map. Tests drive it
* directly to supply explicit socket addresses. */
export async function startCoordinator(
- registry: DagRegistry,
+ bundle: Bundle,
opts: StartCoordinatorOptions = {},
): Promise<void> {
const argv = opts.argv ?? process.argv;
if (argv.includes(AIRFLOW_METADATA_FLAG)) {
process.stdout.write(
-
`${AIRFLOW_METADATA_SENTINEL}${JSON.stringify(buildBundleManifest(registry))}\n`,
+
`${AIRFLOW_METADATA_SENTINEL}${JSON.stringify(buildBundleManifest(bundle))}\n`,
);
return;
}
@@ -201,7 +146,7 @@ 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 = listRegistryTasks(registry);
+ const tasks = listBundleTasks(bundle);
runtimeLogs.info("Coordinator runtime started", {
registered_tasks: tasks,
count: tasks.length,
@@ -223,7 +168,7 @@ export async function startCoordinator(
file: body.file,
bundle_path: body.bundle_path,
});
- const response = handleParse(body, registry, runtimeLogs);
+ const response = handleParse(body, bundle, runtimeLogs);
await sendSupervisorResponse(firstFrame.id, response, comm, runtimeLogs);
} else if (body.type === "StartupDetails") {
runtimeLogs.info("Received task startup details", {
@@ -236,7 +181,7 @@ export async function startCoordinator(
});
const response = await handleTask(
body,
- registry,
+ bundle,
comm,
runtimeLogs,
logs.child("client"),
@@ -317,13 +262,13 @@ export function createRuntimeAbort(
function handleParse(
request: { file: string; bundle_path: string },
- registry: DagRegistry,
+ bundle: Bundle,
logs: LogChannel,
): RuntimeDagFileParsingResult {
// 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: listRegistryTasks(registry),
+ registered_tasks: listBundleTasks(bundle),
});
const response: RuntimeDagFileParsingResult = {
type: "DagFileParsingResult",
@@ -335,20 +280,20 @@ function handleParse(
async function handleTask(
details: StartupDetails,
- registry: DagRegistry,
+ bundle: Bundle,
comm: CommChannel,
logs: LogChannel,
clientLogs: LogChannel,
signal: AbortSignal,
): Promise<RuntimeSucceedTask | RuntimeRetryTask | RuntimeTaskState> {
const ti = details.ti;
- const handler = registry.getTaskHandler(ti.dag_id, ti.task_id);
+ const handler = bundle.getTaskHandler(ti.dag_id, ti.task_id);
if (!handler) {
logs.warning("No handler registered for task", {
dag_id: ti.dag_id,
task_id: ti.task_id,
- available: listRegistryTasks(registry),
+ available: listBundleTasks(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/coordinator/serve.ts b/ts-sdk/src/coordinator/serve.ts
new file mode 100644
index 00000000000..2429e571ef0
--- /dev/null
+++ b/ts-sdk/src/coordinator/serve.ts
@@ -0,0 +1,61 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// What `bundle.serve()` does: the one-shot latch, and the call into the
+// coordinator. Kept out of `sdk/bundle.ts` so the authoring surface never
+// imports the coordinator, and out of `runtime.ts` so importing it back from
+// the SDK side cannot close a cycle.
+
+import { startCoordinator } from "./runtime.js";
+import type { Bundle } from "../sdk/bundle.js";
+
+// What must not happen twice is one process connecting two pairs of sockets,
so
+// this is keyed globally rather than held in a module variable: two resolved
+// copies of the package would each get their own, and both would be first.
+const SERVED = Symbol.for("airflow.ts-sdk.served");
+
+function serveLatch(): Record<symbol, boolean | undefined> {
+ return globalThis as unknown as Record<symbol, boolean | undefined>;
+}
+
+/**
+ * Internal: serve `bundle` to Airflow. The latch lives here, with the sockets
+ * it protects, rather than on the bundle that holds none.
+ *
+ * Not exported from the package root: a Dag author reaches the runtime through
+ * `bundle.serve()`.
+ */
+export async function serveBundle(bundle: Bundle): Promise<void> {
+ const latch = serveLatch();
+ if (latch[SERVED]) {
+ throw new Error(
+ "bundle.serve() was already called; serve everything a bundle provides
from a single bundle",
+ );
+ }
+ // Set before the first await, so two concurrent calls cannot both pass.
+ latch[SERVED] = true;
+ try {
+ await startCoordinator(bundle);
+ } catch (err) {
+ // startCoordinator closes both sockets on its way out, so a failed serve
+ // holds nothing open and may be retried.
+ latch[SERVED] = undefined;
+ throw err;
+ }
+}
diff --git a/ts-sdk/src/index.ts b/ts-sdk/src/index.ts
index b4a412f172e..edf2ceaafc9 100644
--- a/ts-sdk/src/index.ts
+++ b/ts-sdk/src/index.ts
@@ -18,10 +18,11 @@
*/
export { Dag } from "./sdk/dag.js";
-export { DagRegistry } from "./sdk/registry.js";
+export { Bundle } from "./sdk/bundle.js";
export { getClient, getContext } from "./sdk/task.js";
export { ConnectionNotFoundError, VariableNotFoundError } from
"./sdk/client.js";
-export { serveDags, SUPERVISOR_API_VERSION } from "./coordinator/index.js";
+export { SUPERVISOR_API_VERSION } from "./coordinator/index.js";
+export type { Registerable } from "./sdk/bundle.js";
export type { DagSpec, 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";
diff --git a/ts-sdk/src/sdk/brand.ts b/ts-sdk/src/sdk/brand.ts
index a785f736c02..5e09cb978a4 100644
--- a/ts-sdk/src/sdk/brand.ts
+++ b/ts-sdk/src/sdk/brand.ts
@@ -21,8 +21,8 @@
//
// Brands live in the cross-realm symbol registry, which two resolved copies of
// this package share even though each gets its own class object. That does not
-// make another copy's objects usable — `Dag` and `DagRegistry` read private
-// state keyed to the class that declared it — so callers still guard with
+// make another copy's objects usable, since `Dag` and `Bundle` read private
+// state keyed to the class that declared it, so callers still guard with
// `instanceof` and use these only to tell the two failures apart.
const PREFIX = "airflow.ts-sdk.";
diff --git a/ts-sdk/src/sdk/bundle.ts b/ts-sdk/src/sdk/bundle.ts
new file mode 100644
index 00000000000..615fe971ee9
--- /dev/null
+++ b/ts-sdk/src/sdk/bundle.ts
@@ -0,0 +1,176 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// 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 type { TaskFunction } from "./task.js";
+
+// Assigned inside Bundle's static block, as Dag does for its tasks.
+let dagsOf: (bundle: Bundle) => ReadonlyMap<string, Dag>;
+
+/**
+ * Anything {@link Bundle.register} accepts.
+ *
+ * A union rather than a base class or an interface: TypeScript's equivalent of
+ * the sealed interface the Go SDK uses for the same purpose. Registering gains
+ * a kind by gaining an arm here, never a second verb.
+ */
+export type Registerable = Dag;
+
+/** 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 registered Dag with its task IDs, as {@link listBundleDags}
reports it.
+ * A task-less 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.
+ *
+ * A bundle entry point builds one, registers what it provides, and awaits
+ * `serve()` at module top level:
+ *
+ * ```ts
+ * const dag = new Dag("my_dag");
+ * dag.task("extract", extractFn);
+ *
+ * const bundle = new Bundle();
+ * bundle.register(dag);
+ * await bundle.serve();
+ * ```
+ *
+ * Registering holds no sockets and starts nothing, so a test can build a
bundle
+ * and invoke a handler through {@link getTaskHandler} without any runtime in
+ * scope. Only `serve()` connects to Airflow.
+ *
+ * Lookups delegate live to each Dag's task map, so tasks added to a Dag
+ * after registration are visible: the bundle records Dag identity, not
+ * a snapshot of its tasks.
+ */
+export class Bundle {
+ readonly #dags = new Map<string, Dag>();
+
+ static {
+ dagsOf = (bundle) => bundle.#dags;
+ }
+
+ /** Registers `items`, on the same terms as {@link register}. */
+ constructor(...items: Registerable[]) {
+ brand(this, "Bundle");
+ this.register(...items);
+ }
+
+ /** Register what this bundle provides. Registering an already-registered
+ * `dagId` throws, and a call that throws registers none of its items.
+ *
+ * The constructor covers the common case; this is for a bundle that
+ * collects what it provides across several modules. */
+ register(...items: Registerable[]): void {
+ const incoming = new Set<string>();
+ for (const item of items) {
+ // Typed as Registerable, so narrowing it would collapse to never; these
+ // guard callers reaching this from plain JavaScript.
+ const candidate: unknown = item;
+ // Another copy's Dag cannot be registered, since lookups read a private
+ // task map keyed to this copy's class, so it is rejected by its cause.
+ if (!(candidate instanceof Dag)) {
+ throw new Error(
+ isDag(candidate)
+ ? `Dag "${candidate.dagId}" ${DUPLICATE_COPY_HINT}`
+ : "only Dag instances can be registered",
+ );
+ }
+ if (this.#dags.has(item.dagId) || incoming.has(item.dagId)) {
+ throw new Error(`Dag "${item.dagId}" is already registered`);
+ }
+ incoming.add(item.dagId);
+ }
+ for (const item of items) {
+ this.#dags.set(item.dagId, item);
+ }
+ }
+
+ /**
+ * Serve this bundle to Airflow. The entry point of a TypeScript Dag bundle.
+ *
+ * A bundle process serves one supervisor request, so a second call, which
+ * would connect a second pair of sockets, is rejected. A call that fails is
+ * not a serve, and may be retried. Resolves once the supervisor has been
sent
+ * the terminal frame for the work this process was started for, and the same
+ * call answers the build-time `--airflow-metadata` query `airflow-ts-pack`
+ * makes.
+ *
+ * Everything this bundle provides must be registered before `serve()` is
+ * awaited: what is left out is not part of the bundle, and its tasks are
+ * marked removed at runtime.
+ */
+ async serve(): Promise<void> {
+ // `const { serve } = bundle` detaches the method, which would otherwise
+ // fail deep in the runtime on a missing private field rather than here.
+ validateOwnBundle(this, "bundle.serve()");
+ // Imported here rather than at module top: the coordinator reads a bundle,
+ // so a static import would make the authoring surface and the coordinator
+ // mutually dependent for the sake of one call.
+ const { serveBundle } = await import("../coordinator/serve.js");
+ await serveBundle(this);
+ }
+
+ /** 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 dag = this.#dags.get(dagId);
+ return dag ? getDagTaskRecords(dag).get(taskId)?.fn : undefined;
+ }
+}
+
+/** Internal: reject a `this` that is not a Bundle built by this copy, naming
+ * the cause. */
+export function validateOwnBundle(value: unknown, accessor: string): asserts
value is Bundle {
+ if (value instanceof Bundle) return;
+ throw new Error(
+ isBundle(value)
+ ? `The bundle ${accessor} was called on ${DUPLICATE_COPY_HINT}`
+ : `${accessor} must be called on a Bundle; build one with new
Bundle(...)`,
+ );
+}
+
+/** Internal: the task handles across a bundle's Dags. Not re-exported from the
+ * package root: enumerating what the runtime dispatches is the runtime's
job. */
+export function listBundleTasks(bundle: Bundle): TaskRef[] {
+ return [...dagsOf(bundle).values()].flatMap((dag) =>
+ [...getDagTaskRecords(dag).values()].map((record) => record.task),
+ );
+}
+
+/** Internal: every registered Dag with its task IDs, empty Dags included. */
+export function listBundleDags(bundle: Bundle): RegisteredDag[] {
+ return [...dagsOf(bundle).values()].map((dag) => ({
+ dagId: dag.dagId,
+ tasks: [...dag.taskIds],
+ }));
+}
diff --git a/ts-sdk/src/sdk/dag.ts b/ts-sdk/src/sdk/dag.ts
index a329aa8c869..7374bf64883 100644
--- a/ts-sdk/src/sdk/dag.ts
+++ b/ts-sdk/src/sdk/dag.ts
@@ -130,7 +130,7 @@ export function isDag(value: unknown): value is Dag {
* TypeScript Dag declaration.
*
* Constructing a Dag has no effect beyond the instance itself. Collect the
ones
- * a bundle should serve in a `DagRegistry` and pass it to `serveDags(...)`.
+ * 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`. */
diff --git a/ts-sdk/src/sdk/registry.ts b/ts-sdk/src/sdk/registry.ts
deleted file mode 100644
index 9d9259ad540..00000000000
--- a/ts-sdk/src/sdk/registry.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/*!
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import { brand, DUPLICATE_COPY_HINT, hasBrand } from "./brand.js";
-import { Dag, getDagTaskRecords, isDag, type TaskRef } from "./dag.js";
-import type { TaskFunction } from "./task.js";
-
-// Assigned inside DagRegistry's static block, as Dag does for its tasks.
-let dagsOf: (registry: DagRegistry) => ReadonlyMap<string, Dag>;
-
-/** Internal: whether `value` is a DagRegistry built by any copy of this
package. */
-export function isDagRegistry(value: unknown): value is DagRegistry {
- return hasBrand(value, "DagRegistry");
-}
-
-/** Internal: a registered Dag with its task IDs, as {@link listRegistryDags}
reports it.
- * A task-less 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[];
-}
-
-/**
- * The Dags a bundle process can execute, keyed by Dag ID.
- *
- * This is what a bundle entry point builds and hands to `serveDags(registry)`:
- *
- * ```ts
- * const dag = new Dag("my_dag");
- * dag.task("extract", extractFn);
- * await serveDags(new DagRegistry(dag));
- * ```
- *
- * It holds no sockets and starts nothing, so a test can build one and invoke a
- * handler through {@link getTaskHandler} without any runtime in scope.
- *
- * Lookups delegate live to each Dag's task map, so tasks added to a Dag
- * after registration are visible: the registry records Dag identity, not
- * a snapshot of its tasks.
- */
-export class DagRegistry {
- readonly #dags = new Map<string, Dag>();
-
- static {
- dagsOf = (registry) => registry.#dags;
- }
-
- /** Registers `dags`, on the same terms as {@link register}. */
- constructor(...dags: Dag[]) {
- brand(this, "DagRegistry");
- this.register(...dags);
- }
-
- /** Register Dags. Registering an already-registered `dagId` throws,
- * and a call that throws registers none of its Dags.
- *
- * The constructor covers the common case; this is for a bundle that
- * collects its Dags across several modules. */
- register(...dags: Dag[]): void {
- const incoming = new Set<string>();
- for (const dag of dags) {
- // Typed as Dag, so narrowing it would collapse to never; these guard
- // callers reaching this from plain JavaScript.
- const candidate: unknown = dag;
- // Another copy's Dag cannot be registered, since lookups read a private
- // task map keyed to this copy's class, so it is rejected by its cause.
- if (!(candidate instanceof Dag)) {
- throw new Error(
- isDag(candidate)
- ? `Dag "${candidate.dagId}" ${DUPLICATE_COPY_HINT}`
- : "only Dag instances can be registered",
- );
- }
- if (this.#dags.has(dag.dagId) || incoming.has(dag.dagId)) {
- throw new Error(`Dag "${dag.dagId}" is already registered`);
- }
- incoming.add(dag.dagId);
- }
- for (const dag of dags) {
- this.#dags.set(dag.dagId, dag);
- }
- }
-
- /** 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 dag = this.#dags.get(dagId);
- return dag ? getDagTaskRecords(dag).get(taskId)?.fn : undefined;
- }
-}
-
-/** Internal: the task handles across a registry's Dags. Not re-exported from
the
- * package root: enumerating what the runtime dispatches is the runtime's
job. */
-export function listRegistryTasks(registry: DagRegistry): TaskRef[] {
- return [...dagsOf(registry).values()].flatMap((dag) =>
- [...getDagTaskRecords(dag).values()].map((record) => record.task),
- );
-}
-
-/** Internal: every registered Dag with its task IDs, empty Dags included. */
-export function listRegistryDags(registry: DagRegistry): RegisteredDag[] {
- return [...dagsOf(registry).values()].map((dag) => ({
- dagId: dag.dagId,
- tasks: [...dag.taskIds],
- }));
-}
diff --git a/ts-sdk/tests/cli/fixtures/bundle-v1.mjs
b/ts-sdk/tests/cli/fixtures/bundle-v1.mjs
index af75e60c244..9edb3b8d3c2 100644
--- a/ts-sdk/tests/cli/fixtures/bundle-v1.mjs
+++ b/ts-sdk/tests/cli/fixtures/bundle-v1.mjs
@@ -1,4 +1,4 @@
-//#
airflowBundle={"code":{"start":"0000000000000203","end":"0000000000000592","sha256":"f814358e0d4aa5d38c10c365515179049171bb9d4bba5b80c06ea549d6f16337"},"metadata":{"start":"000000000000013e","end":"0000000000000202","sha256":"a51dfd6f0c9e8ea867900e55c0387b556d3cb0e98321b62d4625f522ed465041"}}
+//#
airflowBundle={"code":{"start":"0000000000000203","end":"000000000000057a","sha256":"bd26f9a6295069aef9eff45213377a49a448dcc0973da182a29ffb927f259e8e"},"metadata":{"start":"000000000000013e","end":"0000000000000202","sha256":"a51dfd6f0c9e8ea867900e55c0387b556d3cb0e98321b62d4625f522ed465041"}}
//#
airflowMetadata={"airflow_bundle_metadata_version":"1.0","sdk":{"language":"typescript","version":"0.1.0","supervisor_schema_version":"2026-06-16"},"source":"entry.ts","dags":{"test_dag":{"tasks":["test_task"]}}}
/*!
* Licensed to the Apache Software Foundation (ASF) under one
@@ -19,6 +19,6 @@
* under the License.
*/
-import { DagRegistry, serveDags } from "../../../src/index.js";
+import { Bundle } from "../../../src/index.js";
-await serveDags(new DagRegistry());
+await new Bundle().serve();
diff --git a/ts-sdk/tests/cli/fixtures/empty-entry.ts
b/ts-sdk/tests/cli/fixtures/empty-entry.ts
index c48c6c24cf8..098b671188f 100644
--- a/ts-sdk/tests/cli/fixtures/empty-entry.ts
+++ b/ts-sdk/tests/cli/fixtures/empty-entry.ts
@@ -17,6 +17,6 @@
* under the License.
*/
-import { DagRegistry, serveDags } from "../../../src/index.js";
+import { Bundle } from "../../../src/index.js";
-await serveDags(new DagRegistry());
+await new Bundle().serve();
diff --git a/ts-sdk/tests/cli/fixtures/entry.ts
b/ts-sdk/tests/cli/fixtures/entry.ts
index 8549fb56a32..bd388974a2e 100644
--- a/ts-sdk/tests/cli/fixtures/entry.ts
+++ b/ts-sdk/tests/cli/fixtures/entry.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { Dag, DagRegistry, serveDags } from "../../../src/index.js";
+import { Bundle, Dag } from "../../../src/index.js";
const fixtureDag = new Dag("fixture_dag");
fixtureDag.task("extract", async () => "extracted");
@@ -25,4 +25,4 @@ fixtureDag.task("transform", async () => "transformed");
const otherDag = new Dag("other_dag");
otherDag.task("solo", async () => undefined);
-await serveDags(new DagRegistry(fixtureDag, otherDag));
+await new Bundle(fixtureDag, otherDag).serve();
diff --git a/ts-sdk/tests/cli/fixtures/noisy-entry.ts
b/ts-sdk/tests/cli/fixtures/noisy-entry.ts
index dcd7b0d8c0b..370022287c7 100644
--- a/ts-sdk/tests/cli/fixtures/noisy-entry.ts
+++ b/ts-sdk/tests/cli/fixtures/noisy-entry.ts
@@ -18,11 +18,11 @@
* under the License.
*/
-import { Dag, DagRegistry, serveDags } from "../../../src/index.js";
+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);
-await serveDags(new DagRegistry(noisyDag));
+await new Bundle(noisyDag).serve();
diff --git a/ts-sdk/tests/cli/pack.test.ts b/ts-sdk/tests/cli/pack.test.ts
index 51928359c03..cd5dc1ca0b6 100644
--- a/ts-sdk/tests/cli/pack.test.ts
+++ b/ts-sdk/tests/cli/pack.test.ts
@@ -188,7 +188,7 @@ describe("runPack", () => {
if (outdir) rmSync(outdir, { recursive: true, force: true });
});
- it("bundles the entry and embeds metadata from the bundle's registry", async
() => {
+ it("bundles the entry and embeds metadata from the bundle's bundle", async
() => {
outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
const nested = path.join(outdir, "dist");
await runPack([FIXTURE_ENTRY, "--outdir", nested]);
@@ -272,10 +272,10 @@ describe("runPack", () => {
writeFileSync(
entry,
[
- `import { Dag, DagRegistry, serveDags } from
${JSON.stringify(SDK_INDEX)};`,
+ `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);',
- "await serveDags(new DagRegistry(bigDag));",
+ "await new Bundle(bigDag).serve();",
].join("\n"),
);
@@ -315,10 +315,10 @@ describe("runPack", () => {
writeFileSync(
entry,
[
- `import { Dag, DagRegistry, serveDags } from
${JSON.stringify(SDK_INDEX)};`,
+ `import { Bundle, Dag } from ${JSON.stringify(SDK_INDEX)};`,
`const suspiciousDag = new Dag(${JSON.stringify(dagId)});`,
`suspiciousDag.task(${JSON.stringify(taskId)}, async () =>
undefined);`,
- "await serveDags(new DagRegistry(suspiciousDag));",
+ "await new Bundle(suspiciousDag).serve();",
].join("\n"),
);
const stderr = captureStderr();
@@ -382,10 +382,10 @@ describe("runPack", () => {
writeFileSync(
entry,
[
- `import { Dag, DagRegistry, serveDags } from
${JSON.stringify(SDK_INDEX)};`,
+ `import { Bundle, Dag } from ${JSON.stringify(SDK_INDEX)};`,
'const salesDag = new Dag("sales_dag");',
'salesDag.task("extract", async () => undefined);',
- 'await serveDags(new DagRegistry(salesDag, new Dag("empty_dag")));',
+ 'await new Bundle(salesDag, new Dag("empty_dag")).serve();',
].join("\n"),
);
const stderr = captureStderr();
@@ -399,18 +399,18 @@ describe("runPack", () => {
);
});
- it("packs only the Dags the served registry holds", async () => {
+ it("packs only the Dags the served bundle holds", async () => {
outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
const entry = path.join(outdir, "forgotten-entry.ts");
writeFileSync(
entry,
[
- `import { Dag, DagRegistry, serveDags } from
${JSON.stringify(SDK_INDEX)};`,
+ `import { Bundle, Dag } from ${JSON.stringify(SDK_INDEX)};`,
'const salesDag = new Dag("sales_dag");',
'salesDag.task("extract", async () => undefined);',
'const billingDag = new Dag("billing_dag");',
'billingDag.task("charge", async () => undefined);',
- "await serveDags(new DagRegistry(salesDag));",
+ "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 a3293a57847..e6294154187 100644
--- a/ts-sdk/tests/coordinator/integration.test.ts
+++ b/ts-sdk/tests/coordinator/integration.test.ts
@@ -36,15 +36,15 @@ import {
startCoordinator,
} from "../../src/coordinator/runtime.js";
import { Dag } from "../../src/sdk/dag.js";
-import { DagRegistry } from "../../src/sdk/registry.js";
+import { Bundle } from "../../src/sdk/bundle.js";
import { getClient, getContext } from "../../src/sdk/task.js";
const testDag = new Dag("test_dag");
const otherDag = new Dag("other_dag");
-// The registry the runtime dispatches through. startCoordinator() is driven
-// directly rather than through serveDags(), so these tests can supply mock
+// The bundle the runtime dispatches through. startCoordinator() is driven
+// directly rather than through bundle.serve(), so these tests can supply mock
// socket addresses.
-const registry = new DagRegistry(testDag, otherDag);
+const bundle = new Bundle(testDag, otherDag);
interface MockResult {
firstResponse: { id: number; body: unknown; isResponse: boolean } | null;
@@ -150,7 +150,7 @@ async function driveSupervisor(initialFrame: unknown,
responder?: Responder): Pr
const commAccept = acceptOne(comm.server);
const logsAccept = acceptOne(logs.server);
- const runtimeDone = startCoordinator(registry, {
+ const runtimeDone = startCoordinator(bundle, {
commAddr: `127.0.0.1:${comm.port}`,
logsAddr: `127.0.0.1:${logs.port}`,
argv: [],
@@ -221,7 +221,7 @@ describe("coordinator runtime integration", () => {
const logsSockPromise = acceptOne(logs.server);
const commSockPromise = acceptOne(comm.server);
- const runtimeDone = startCoordinator(registry, {
+ const runtimeDone = startCoordinator(bundle, {
commAddr: `127.0.0.1:${comm.port}`,
logsAddr: `127.0.0.1:${logs.port}`,
argv: [],
@@ -297,7 +297,7 @@ describe("coordinator runtime integration", () => {
const logsAccept = acceptOne(logs.server);
testDag.task("terminal_timeout", async () => undefined);
- const runtimeDone = startCoordinator(registry, {
+ const runtimeDone = startCoordinator(bundle, {
commAddr: `127.0.0.1:${comm.port}`,
logsAddr: `127.0.0.1:${logs.port}`,
argv: [],
diff --git a/ts-sdk/tests/coordinator/protocol.test.ts
b/ts-sdk/tests/coordinator/protocol.test.ts
index 8089a595a5d..12ba947eeb3 100644
--- a/ts-sdk/tests/coordinator/protocol.test.ts
+++ b/ts-sdk/tests/coordinator/protocol.test.ts
@@ -20,7 +20,7 @@
import { describe, expect, it } from "vitest";
import { asMsgFromSupervisor } from "../../src/coordinator/protocol.js";
import { parseArgs, startCoordinator } from "../../src/coordinator/runtime.js";
-import { DagRegistry } from "../../src/sdk/registry.js";
+import { Bundle } from "../../src/sdk/bundle.js";
describe("protocol decode", () => {
it("accepts StartupDetails", () => {
@@ -86,14 +86,14 @@ describe("runtime arg parser", () => {
it("requires commAddr and logsAddr overrides to be supplied together", async
() => {
await expect(
- startCoordinator(new DagRegistry(), {
+ startCoordinator(new Bundle(), {
commAddr: "127.0.0.1:5001",
argv: ["node", "bundle.mjs", "--logs=127.0.0.1:5002"],
}),
).rejects.toThrow(/Missing --comm/);
await expect(
- startCoordinator(new DagRegistry(), {
+ startCoordinator(new Bundle(), {
logsAddr: "127.0.0.1:5002",
argv: ["node", "bundle.mjs", "--comm=127.0.0.1:5001"],
}),
diff --git a/ts-sdk/tests/coordinator/public-api.test.ts
b/ts-sdk/tests/coordinator/public-api.test.ts
index 16e91d4b31f..a4176952ced 100644
--- a/ts-sdk/tests/coordinator/public-api.test.ts
+++ b/ts-sdk/tests/coordinator/public-api.test.ts
@@ -18,14 +18,17 @@
*/
import { describe, expect, expectTypeOf, it } from "vitest";
-import type { DagRegistry } from "../../src/sdk/registry.js";
import * as coordinator from "../../src/coordinator/index.js";
-import { serveDags } from "../../src/coordinator/index.js";
describe("coordinator public API", () => {
- it("exposes serveDags, not the coordinator itself, from the coordinator
subpath", () => {
- expectTypeOf<typeof serveDags>().toEqualTypeOf<(registry: DagRegistry) =>
Promise<void>>();
- expect("startCoordinator" in coordinator).toBe(false);
+ it("names neither the coordinator nor the serve it performs", () => {
+ // A Dag author reaches the runtime through `bundle.serve()`. The subpath
+ // carries only the schema version, so nothing here is an entry point.
+ for (const name of ["startCoordinator", "serveBundle", "serveDags"]) {
+ expect(name in coordinator).toBe(false);
+ }
expectTypeOf<typeof coordinator>().not.toHaveProperty("startCoordinator");
+ expectTypeOf<typeof coordinator>().not.toHaveProperty("serveBundle");
+ expectTypeOf<typeof
coordinator>().toHaveProperty("SUPERVISOR_API_VERSION");
});
});
diff --git a/ts-sdk/tests/coordinator/runtime-manifest.test.ts
b/ts-sdk/tests/coordinator/runtime-manifest.test.ts
index f1e4ea76b17..09f9ee1a274 100644
--- a/ts-sdk/tests/coordinator/runtime-manifest.test.ts
+++ b/ts-sdk/tests/coordinator/runtime-manifest.test.ts
@@ -23,7 +23,7 @@ import { AIRFLOW_METADATA_SENTINEL, buildBundleManifest }
from "../../src/coordi
import { startCoordinator } from "../../src/coordinator/runtime.js";
import { SUPERVISOR_API_VERSION } from "../../src/coordinator/protocol.js";
import { Dag } from "../../src/sdk/dag.js";
-import { DagRegistry } from "../../src/sdk/registry.js";
+import { Bundle } from "../../src/sdk/bundle.js";
function buildDag(dagId: string, ...taskIds: string[]): Dag {
const dag = new Dag(dagId);
@@ -34,9 +34,9 @@ function buildDag(dagId: string, ...taskIds: string[]): Dag {
}
describe("buildBundleManifest", () => {
- it("maps a registry's Dags to their tasks under the SDK's schema version",
() => {
- const registry = new DagRegistry(buildDag("dag_a", "t1", "t3"),
buildDag("dag_b", "t2"));
- expect(buildBundleManifest(registry)).toEqual({
+ it("maps a bundle's Dags to their tasks under the SDK's schema version", ()
=> {
+ const bundle = new Bundle(buildDag("dag_a", "t1", "t3"), buildDag("dag_b",
"t2"));
+ expect(buildBundleManifest(bundle)).toEqual({
supervisor_schema_version: SUPERVISOR_API_VERSION,
dags: {
dag_a: { tasks: ["t1", "t3"] },
@@ -46,23 +46,23 @@ describe("buildBundleManifest", () => {
});
it("keeps a registered Dag without tasks visible in the manifest", () => {
- expect(buildBundleManifest(new
DagRegistry(buildDag("empty_dag"))).dags).toEqual({
+ expect(buildBundleManifest(new
Bundle(buildDag("empty_dag"))).dags).toEqual({
empty_dag: { tasks: [] },
});
});
it("keeps a Dag named __proto__ visible in serialized metadata", () => {
- const manifest = buildBundleManifest(new DagRegistry(buildDag("__proto__",
"task")));
+ const manifest = buildBundleManifest(new Bundle(buildDag("__proto__",
"task")));
const serializedDags = JSON.parse(JSON.stringify(manifest)).dags;
expect(Object.keys(serializedDags)).toEqual(["__proto__"]);
expect(serializedDags["__proto__"]).toEqual({ tasks: ["task"] });
});
- it("reports only the Dags the registry was given", () => {
- const registry = new DagRegistry(buildDag("dag_a", "t1"));
+ it("reports only the Dags the bundle was given", () => {
+ const bundle = new Bundle(buildDag("dag_a", "t1"));
buildDag("dag_b", "t2");
- expect(Object.keys(buildBundleManifest(registry).dags)).toEqual(["dag_a"]);
+ expect(Object.keys(buildBundleManifest(bundle).dags)).toEqual(["dag_a"]);
});
// The server would reject these ids. The manifest keeps them and
@@ -70,7 +70,7 @@ describe("buildBundleManifest", () => {
it.each(["", " ", "\t", "my dag", "a/b", "task@1", "d".repeat(251)])(
"keeps a dagId the server would reject visible in the manifest: %j",
(dagId) => {
- const manifest = buildBundleManifest(new DagRegistry(buildDag(dagId,
"t1")));
+ const manifest = buildBundleManifest(new Bundle(buildDag(dagId, "t1")));
expect(manifest.dags[dagId]).toEqual({ tasks: ["t1"] });
},
);
@@ -80,7 +80,7 @@ describe("buildBundleManifest", () => {
it.each([" ", "\t", "my task", "a/b", "task@1", "t".repeat(251)])(
"keeps a taskId the server would reject visible in the manifest: %j",
(taskId) => {
- const manifest = buildBundleManifest(new
DagRegistry(buildDag("example_dag", taskId)));
+ const manifest = buildBundleManifest(new Bundle(buildDag("example_dag",
taskId)));
expect(manifest.dags["example_dag"]).toEqual({ tasks: [taskId] });
},
);
@@ -88,7 +88,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);
- expect(() => buildBundleManifest(new DagRegistry(dag))).toThrowError(/Dag
ID must be a string/);
+ expect(() => buildBundleManifest(new Bundle(dag))).toThrowError(/Dag ID
must be a string/);
});
});
@@ -100,7 +100,7 @@ describe("startCoordinator --airflow-metadata", () => {
it("dumps the manifest to stdout and returns without connecting", async ()
=> {
const write = vi.spyOn(process.stdout, "write").mockReturnValue(true);
- await startCoordinator(new DagRegistry(buildDag("metadata_dag", "only")), {
+ await startCoordinator(new Bundle(buildDag("metadata_dag", "only")), {
argv: ["node", "bundle.mjs", "--airflow-metadata"],
});
diff --git a/ts-sdk/tests/public-api.test.ts b/ts-sdk/tests/public-api.test.ts
index 9f0f732f838..ad94cd389a0 100644
--- a/ts-sdk/tests/public-api.test.ts
+++ b/ts-sdk/tests/public-api.test.ts
@@ -25,6 +25,7 @@ import type {
GetXComOpts,
SetXComOpts,
TaskClient,
+ Registerable,
TaskContext,
TaskFunction,
TaskInputs,
@@ -34,12 +35,11 @@ import type {
} from "../src/index.js";
import * as sdk from "../src/index.js";
import {
+ Bundle,
ConnectionNotFoundError,
Dag,
- DagRegistry,
getClient,
getContext,
- serveDags,
SUPERVISOR_API_VERSION,
VariableNotFoundError,
} from "../src/index.js";
@@ -54,29 +54,33 @@ describe("public API", () => {
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"]);
- // serveDags hands the registry to the runtime, which needs the
supervisor's
+ // serve() hands the bundle to the runtime, which needs the supervisor's
// socket addresses that Airflow puts on argv.
- await expect(serveDags(new DagRegistry(dag))).rejects.toThrow("Missing
--comm");
+ await expect(new Bundle(dag).serve()).rejects.toThrow("Missing --comm");
});
- // The registry guard runs before the already-served latch, so this holds
- // regardless of whether another test in this file already served a registry.
+ // A detached `const { serve } = bundle` must say so here rather than fail
+ // deep in the runtime. The guard runs before the already-served latch, so
+ // this holds however many bundles earlier tests in this file served.
it.each([
- ["a bare Dag", new Dag("not_a_registry_dag")],
+ ["a bare Dag", new Dag("not_a_bundle_dag")],
["a plain object", { register: () => {} }],
["null", null],
- ])("rejects a %s in place of a registry", async (_label, value) => {
- await expect(serveDags(value as unknown as DagRegistry)).rejects.toThrow(
- /serveDags\(\.\.\.\) takes a DagRegistry/,
+ ["undefined, as a detached serve receives", undefined],
+ ])("rejects %s as the receiver of serve()", async (_label, value) => {
+ const detached = Bundle.prototype.serve;
+ await expect(detached.call(value as unknown as Bundle)).rejects.toThrow(
+ /bundle\.serve\(\) must be called on a Bundle/,
);
});
- it("names the duplicate-copy cause for a registry built by another copy",
async () => {
- // Stands in for a registry from a second resolved copy: same brand, other
+ it("names the duplicate-copy cause for a bundle built by another copy",
async () => {
+ // Stands in for a bundle from a second resolved copy: same brand, other
// class. It still cannot be served, so the point is only that it says why.
const foreign = {};
- Object.defineProperty(foreign, Symbol.for("airflow.ts-sdk.DagRegistry"), {
value: true });
- await expect(serveDags(foreign as unknown as DagRegistry)).rejects.toThrow(
+ Object.defineProperty(foreign, Symbol.for("airflow.ts-sdk.Bundle"), {
value: true });
+ const detached = Bundle.prototype.serve;
+ await expect(detached.call(foreign as unknown as Bundle)).rejects.toThrow(
/different copy of apache-airflow-ts-sdk/,
);
});
@@ -95,9 +99,9 @@ describe("public API", () => {
process.argv = [...argv, AIRFLOW_METADATA_FLAG];
vi.spyOn(process.stdout, "write").mockReturnValue(true);
try {
- await serveDags(new DagRegistry(new Dag("served_dag")));
- await expect(serveDags(new DagRegistry(new
Dag("second_call_dag")))).rejects.toThrow(
- /serveDags\(\.\.\.\) was already called/,
+ await new Bundle(new Dag("served_dag")).serve();
+ await expect(new Bundle(new
Dag("second_call_dag")).serve()).rejects.toThrow(
+ /bundle\.serve\(\) was already called/,
);
} finally {
process.argv = argv;
@@ -106,34 +110,30 @@ describe("public API", () => {
});
it("releases the latch when a serve fails, so the call can be retried",
async () => {
- await expect(serveDags(new DagRegistry(new
Dag("first_try")))).rejects.toThrow(
- "Missing --comm",
- );
+ await expect(new Bundle(new
Dag("first_try")).serve()).rejects.toThrow("Missing --comm");
// The retry reports why it actually failed, not "already called".
- await expect(serveDags(new DagRegistry(new
Dag("second_try")))).rejects.toThrow(
- "Missing --comm",
- );
+ await expect(new Bundle(new
Dag("second_try")).serve()).rejects.toThrow("Missing --comm");
});
});
- it("exports DagRegistry as the Dag collection a bundle serves", () => {
- const dag = new Dag("registry_api_dag");
+ it("exports Bundle as the thing that holds what a bundle provides and serves
it", () => {
+ const dag = new Dag("bundle_api_dag");
const handler = async () => "hello";
dag.task("extract", handler);
- // Building a registry starts nothing, so a test can dispatch through it
+ // Registering starts nothing, so a test can dispatch through a bundle
// exactly as the runtime does, with no sockets in scope.
- const registry = new DagRegistry(dag);
- expect(registry.getTaskHandler("registry_api_dag",
"extract")).toBe(handler);
- registry.register(new Dag("late_dag"));
- expect(registry.getTaskHandler("late_dag", "extract")).toBeUndefined();
+ const bundle = new Bundle(dag);
+ expect(bundle.getTaskHandler("bundle_api_dag", "extract")).toBe(handler);
+ bundle.register(new Dag("late_dag"));
+ expect(bundle.getTaskHandler("late_dag", "extract")).toBeUndefined();
});
- it("keeps registry enumeration out of the public surface", () => {
- const registry = new DagRegistry();
+ it("keeps bundle enumeration out of the public surface", () => {
+ const bundle = new Bundle();
for (const name of ["listTasks", "listDags"]) {
- expect(name in registry).toBe(false);
+ expect(name in bundle).toBe(false);
}
- expectTypeOf<keyof DagRegistry>().toEqualTypeOf<"register" |
"getTaskHandler">();
+ expectTypeOf<keyof Bundle>().toEqualTypeOf<"register" | "serve" |
"getTaskHandler">();
});
it("does not export the removed registerTask surface or the coordinator
itself", () => {
@@ -188,9 +188,18 @@ describe("public API", () => {
expect(connErr.connId).toBe("missing_conn");
});
- it("reaches the runtime only through serveDags, which takes a registry", ()
=> {
- expectTypeOf<typeof serveDags>().toEqualTypeOf<(registry: DagRegistry) =>
Promise<void>>();
- expectTypeOf<ConstructorParameters<typeof
DagRegistry>>().toEqualTypeOf<Dag[]>();
+ it("reaches the runtime only through bundle.serve(), which takes nothing",
() => {
+ // One verb in and one verb out: `serveDags` is gone, and the coordinator
+ // stays unnamed because the object that holds the Dags serves them itself.
+ expectTypeOf<Bundle["serve"]>().toEqualTypeOf<() => Promise<void>>();
+ expectTypeOf<Bundle["register"]>().toEqualTypeOf<(...items:
Registerable[]) => void>();
+ expectTypeOf<ConstructorParameters<typeof
Bundle>>().toEqualTypeOf<Registerable[]>();
+ expectTypeOf<Registerable>().toEqualTypeOf<Dag>();
+ for (const name of ["serveDags", "DagRegistry"]) {
+ expect(name in sdk).toBe(false);
+ }
+ expectTypeOf<typeof sdk>().not.toHaveProperty("serveDags");
+ expectTypeOf<typeof sdk>().not.toHaveProperty("DagRegistry");
expectTypeOf(SUPERVISOR_API_VERSION).toMatchTypeOf<string>();
});
@@ -317,10 +326,10 @@ describe("public API", () => {
dag.task("transform3", async () => undefined, { spec: { retries: 2 } });
// @ts-expect-error the TaskRef handle is data, not callable.
upstream();
- // @ts-expect-error serveDags takes the registry, not a bare Dag.
- serveDags(dag);
- // @ts-expect-error a registry is built from Dags, not from task handles.
- new DagRegistry(upstream);
+ // @ts-expect-error a bundle is built from Dags, not from task handles.
+ new Bundle(upstream);
+ // @ts-expect-error serve() takes nothing; the bundle already holds it
all.
+ new Bundle(dag).serve(dag);
};
void rejectsPositionalMisuse;
// @ts-expect-error the TaskRef handle is opaque and does not expose the
handler.
diff --git a/ts-sdk/tests/sdk/registry.test.ts b/ts-sdk/tests/sdk/bundle.test.ts
similarity index 57%
rename from ts-sdk/tests/sdk/registry.test.ts
rename to ts-sdk/tests/sdk/bundle.test.ts
index 201e559800c..e5725cb7810 100644
--- a/ts-sdk/tests/sdk/registry.test.ts
+++ b/ts-sdk/tests/sdk/bundle.test.ts
@@ -19,54 +19,54 @@
import { describe, it, expect } from "vitest";
import { Dag } from "../../src/sdk/dag.js";
-import { DagRegistry, listRegistryDags, listRegistryTasks } from
"../../src/sdk/registry.js";
+import { Bundle, listBundleDags, listBundleTasks } from
"../../src/sdk/bundle.js";
-describe("DagRegistry", () => {
+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);
- const registry = new DagRegistry();
- registry.register(dag);
- expect(registry.getTaskHandler("example_dag", "my_task")).toBe(handler);
+ const bundle = new Bundle();
+ bundle.register(dag);
+ expect(bundle.getTaskHandler("example_dag", "my_task")).toBe(handler);
});
it("registers the Dags passed to its constructor", () => {
const handler = async () => "hello";
const dagA = new Dag("dag_a");
dagA.task("a", handler);
- const registry = new DagRegistry(dagA, new Dag("dag_b"));
- expect(registry.getTaskHandler("dag_a", "a")).toBe(handler);
- expect(listRegistryDags(registry)).toEqual([
+ 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: [] },
]);
});
it("rejects duplicate dagIds passed to the constructor", () => {
- expect(() => new DagRegistry(new Dag("example_dag"), new
Dag("example_dag"))).toThrowError(
+ expect(() => new Bundle(new Dag("example_dag"), new
Dag("example_dag"))).toThrowError(
/already registered/,
);
});
it("rejects constructor values that are not Dag instances", () => {
- expect(() => new DagRegistry({ dagId: "example_dag" } as unknown as
Dag)).toThrowError(
+ expect(() => new Bundle({ dagId: "example_dag" } as unknown as
Dag)).toThrowError(
/only Dag instances can be registered/,
);
});
it("returns undefined for unknown taskIds and dagIds", () => {
- const registry = new DagRegistry();
+ const bundle = new Bundle();
const dag = new Dag("example_dag");
dag.task("my_task", async () => undefined);
- registry.register(dag);
- expect(registry.getTaskHandler("example_dag", "nope")).toBeUndefined();
- expect(registry.getTaskHandler("unknown_dag", "my_task")).toBeUndefined();
+ 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", () => {
- const registry = new DagRegistry();
- expect(listRegistryTasks(registry)).toEqual([]);
+ const bundle = new Bundle();
+ expect(listBundleTasks(bundle)).toEqual([]);
});
it("lists tasks across registered Dags", () => {
@@ -74,46 +74,46 @@ describe("DagRegistry", () => {
dagA.task("a", async () => undefined);
const dagB = new Dag("dag_b");
dagB.task("b", async () => undefined);
- const registry = new DagRegistry();
- registry.register(dagA, dagB);
- const registered = listRegistryTasks(registry);
+ 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" });
});
it("rejects registering the same dagId in separate calls", () => {
- const registry = new DagRegistry();
- registry.register(new Dag("example_dag"));
- expect(() => registry.register(new
Dag("example_dag"))).toThrowError(/already registered/);
+ const bundle = new Bundle();
+ bundle.register(new Dag("example_dag"));
+ expect(() => bundle.register(new
Dag("example_dag"))).toThrowError(/already registered/);
});
it("rejects duplicate dagIds within a single call", () => {
- const registry = new DagRegistry();
- expect(() => registry.register(new Dag("example_dag"), new
Dag("example_dag"))).toThrowError(
+ const bundle = new Bundle();
+ expect(() => bundle.register(new Dag("example_dag"), new
Dag("example_dag"))).toThrowError(
/already registered/,
);
});
it("rejects registering the same Dag instance twice", () => {
- const registry = new DagRegistry();
+ const bundle = new Bundle();
const dag = new Dag("example_dag");
- registry.register(dag);
- expect(() => registry.register(dag)).toThrowError(/already registered/);
+ bundle.register(dag);
+ expect(() => bundle.register(dag)).toThrowError(/already registered/);
});
it("registers none of the Dags when a call throws", () => {
- const registry = new DagRegistry();
+ const bundle = new Bundle();
const dag = new Dag("dag_a");
dag.task("a", async () => undefined);
- expect(() => registry.register(dag, new
Dag("dag_a"))).toThrowError(/already registered/);
- expect(registry.getTaskHandler("dag_a", "a")).toBeUndefined();
- expect(listRegistryTasks(registry)).toEqual([]);
+ expect(() => bundle.register(dag, new Dag("dag_a"))).toThrowError(/already
registered/);
+ expect(bundle.getTaskHandler("dag_a", "a")).toBeUndefined();
+ expect(listBundleTasks(bundle)).toEqual([]);
});
it("rejects values that are not Dag instances", () => {
- const registry = new DagRegistry();
- expect(() => registry.register({ dagId: "example_dag" } as unknown as
Dag)).toThrowError(
+ const bundle = new Bundle();
+ expect(() => bundle.register({ dagId: "example_dag" } as unknown as
Dag)).toThrowError(
/only Dag instances can be registered/,
);
});
@@ -122,7 +122,7 @@ describe("DagRegistry", () => {
// Stands in for a Dag from a second resolved copy: same brand, other
class.
const foreign = { dagId: "foreign_dag" };
Object.defineProperty(foreign, Symbol.for("airflow.ts-sdk.Dag"), { value:
true });
- expect(() => new DagRegistry(foreign as unknown as Dag)).toThrowError(
+ expect(() => new Bundle(foreign as unknown as Dag)).toThrowError(
/different copy of apache-airflow-ts-sdk/,
);
});
@@ -131,24 +131,38 @@ describe("DagRegistry", () => {
const dagA = new Dag("dag_a");
dagA.task("a1", async () => undefined);
dagA.task("a2", async () => undefined);
- const registry = new DagRegistry();
- registry.register(dagA, new Dag("empty_dag"));
- expect(listRegistryDags(registry)).toEqual([
+ 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: [] },
]);
});
+ it("accepts a call that registers nothing", () => {
+ // `bundle.register(...maybeDags)` with an empty list should not need a
+ // guard at the call site.
+ const bundle = new Bundle();
+ expect(() => bundle.register()).not.toThrow();
+ expect(listBundleDags(bundle)).toEqual([]);
+ });
+
+ it("carries the brand its own serve guard reads", () => {
+ // The brand is how a bundle from a second resolved copy is told apart from
+ // a plain object; `serve()` reports the two differently.
+ expect(Symbol.for("airflow.ts-sdk.Bundle") in new Bundle()).toBe(true);
+ });
+
it("sees tasks added to a Dag after registration", () => {
- const registry = new DagRegistry();
+ const bundle = new Bundle();
const dag = new Dag("example_dag");
- registry.register(dag);
- expect(listRegistryTasks(registry)).toEqual([]);
+ bundle.register(dag);
+ expect(listBundleTasks(bundle)).toEqual([]);
const handler = async () => "late";
dag.task("late_task", handler);
- expect(registry.getTaskHandler("example_dag", "late_task")).toBe(handler);
- expect(listRegistryTasks(registry)).toContainEqual({
+ expect(bundle.getTaskHandler("example_dag", "late_task")).toBe(handler);
+ expect(listBundleTasks(bundle)).toContainEqual({
dagId: "example_dag",
taskId: "late_task",
});
diff --git a/ts-sdk/tests/sdk/dag.test.ts b/ts-sdk/tests/sdk/dag.test.ts
index 6177cf28b02..4dfe1652652 100644
--- a/ts-sdk/tests/sdk/dag.test.ts
+++ b/ts-sdk/tests/sdk/dag.test.ts
@@ -19,7 +19,7 @@
import { describe, it, expect } from "vitest";
import { Dag, getDagTaskRecords, type TaskRef } from "../../src/sdk/dag.js";
-import { DagRegistry } from "../../src/sdk/registry.js";
+import { Bundle } from "../../src/sdk/bundle.js";
describe("Dag", () => {
it("returns a frozen TaskRef handle with the Dag and task identity", () => {
@@ -202,19 +202,19 @@ describe("Dag", () => {
firstDag.task("extract", first);
secondDag.task("extract", second);
- const registry = new DagRegistry();
- registry.register(firstDag, secondDag);
- expect(registry.getTaskHandler("first_dag", "extract")).toBe(first);
- expect(registry.getTaskHandler("second_dag", "extract")).toBe(second);
+ const bundle = new Bundle();
+ bundle.register(firstDag, secondDag);
+ expect(bundle.getTaskHandler("first_dag", "extract")).toBe(first);
+ expect(bundle.getTaskHandler("second_dag", "extract")).toBe(second);
});
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);
- const registry = new DagRegistry();
- registry.register(dag);
- expect(registry.getTaskHandler("café_dag", "任務")).toBe(handler);
+ const bundle = new Bundle();
+ bundle.register(dag);
+ expect(bundle.getTaskHandler("café_dag", "任務")).toBe(handler);
});
it("rejects non-function handlers", () => {
@@ -227,11 +227,11 @@ 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");
- const registry = new DagRegistry();
- registry.register(dag);
- expect(registry.getTaskHandler("example_dag",
"transforms.normalize")).toBeDefined();
+ const bundle = new Bundle();
+ bundle.register(dag);
+ expect(bundle.getTaskHandler("example_dag",
"transforms.normalize")).toBeDefined();
// Should NOT accidentally match the prefix alone
- expect(registry.getTaskHandler("example_dag",
"transforms")).toBeUndefined();
- expect(registry.getTaskHandler("example_dag",
"normalize")).toBeUndefined();
+ expect(bundle.getTaskHandler("example_dag", "transforms")).toBeUndefined();
+ expect(bundle.getTaskHandler("example_dag", "normalize")).toBeUndefined();
});
});