Skip to content

Commit e7d5230

Browse files
authored
Merge branch 'main' into airflow-db2-adapter
2 parents 943e34f + 29dd99d commit e7d5230

26 files changed

Lines changed: 1450 additions & 310 deletions

File tree

airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,14 @@ value routes the task to the Node.js coordinator.
8686
TypeScript implementation
8787
~~~~~~~~~~~~~~~~~~~~~~~~~
8888

89-
A task is an ordinary (usually ``async``) function receiving ``TaskHandlerArgs``. Register it with the
90-
``dag_id`` and ``task_id`` it implements, then start the coordinator runtime; the registrations and the
91-
top-level ``await startCoordinator()`` make the module a runnable bundle entry point.
89+
A task is an ordinary (usually ``async``) function receiving ``TaskHandlerArgs``. Create a ``Dag`` with
90+
the ``dag_id`` it implements, attach each handler with ``dag.task``, collect the Dags in a ``DagRegistry``,
91+
then serve them to Airflow with ``serveDags``; that top-level ``await`` makes the module a runnable bundle
92+
entry point.
9293

9394
.. code-block:: typescript
9495
95-
import { registerTask, startCoordinator, type TaskHandlerArgs } from "@apache-airflow/ts-sdk";
96+
import { Dag, DagRegistry, serveDags, type TaskHandlerArgs } from "@apache-airflow/ts-sdk";
9697
9798
export async function buildMessage({ ctx, client }: TaskHandlerArgs) {
9899
const upstream = await client.getXCom<string>({
@@ -103,12 +104,22 @@ top-level ``await startCoordinator()`` make the module a runnable bundle entry p
103104
return `${greeting ?? "hello from TypeScript"}; upstream=${upstream ?? "missing"}`;
104105
}
105106
106-
registerTask({ dagId: "typescript_example", taskId: "build_message" }, buildMessage);
107+
const dag = new Dag("typescript_example");
108+
dag.task("build_message", buildMessage);
107109
108-
await startCoordinator();
110+
await serveDags(new DagRegistry(dag));
109111
110-
The ``dagId`` passed to ``registerTask`` must match the ``dag_id`` of the Python Dag, and each ``taskId``
111-
must match a ``@task.stub`` function in that Dag.
112+
The ``dagId`` passed to ``new Dag(...)`` must match the ``dag_id`` of the Python Dag, and each ``taskId``
113+
passed to ``dag.task`` must match a ``@task.stub`` function in that Dag. The registry passed to
114+
``serveDags`` is the bundle's complete set of Dags; a second ``serveDags`` call is rejected. A Dag left out
115+
of the registry is not part of the packed bundle, and its tasks are marked removed at runtime.
116+
117+
``DagRegistry`` holds no sockets and starts nothing, so a unit test can build one and dispatch a handler
118+
through ``registry.getTaskHandler(dagId, taskId)`` without a coordinator runtime. A bundle that collects
119+
its Dags across several modules can add them incrementally with ``registry.register(...)``.
120+
121+
``new Dag`` and ``dag.task`` take a trailing options object — ``spec`` on both, plus ``inputs`` on a task.
122+
These are not used yet; do not set them. Any other key is rejected.
112123

113124
.. note::
114125

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The scheduler's Dag cache is now a bounded LRU of 512 versions, so scheduler memory no longer grows with every Dag version the process has ever seen.

airflow-core/src/airflow/jobs/scheduler_job_runner.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@
155155
TASK_STUCK_IN_QUEUED_RESCHEDULE_EVENT = "stuck in queued reschedule"
156156
""":meta private:"""
157157

158+
SCHEDULER_DAG_CACHE_SIZE = 512
159+
"""
160+
Max deserialized Dag versions the scheduler keeps in memory.
161+
162+
The scheduler reaches its DagBag through the Dag version of each active Dag run, so an
163+
unbounded cache retains every version the process has ever seen and grows for the life of
164+
the process. Sized to sit above the versions-with-runs-in-flight working set of a typical
165+
deployment, so eviction costs a re-fetch only where that working set is genuinely larger.
166+
167+
:meta private:
168+
"""
169+
158170
# Per-tick cap on pending AssetPartitionDagRun rows the scheduler evaluates.
159171
# Bounds the per-tick transaction so executor heartbeats and regular scheduling
160172
# aren't starved; remaining APDRs drain across subsequent ticks.
@@ -370,7 +382,7 @@ def __init__(
370382
if log:
371383
self._log = log
372384

373-
self.scheduler_dag_bag = DBDagBag(load_op_links=False)
385+
self.scheduler_dag_bag = DBDagBag(load_op_links=False, cache_size=SCHEDULER_DAG_CACHE_SIZE)
374386

375387
# Set of (dag_id, asset_name, asset_uri) tuples for trigger policies that
376388
# are permanently unreachable for the rollup window's cardinality — the

airflow-core/tests/unit/jobs/test_scheduler_job.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
from airflow.executors.executor_utils import ExecutorName
6565
from airflow.executors.local_executor import LocalExecutor
6666
from airflow.jobs.job import Job, run_job
67-
from airflow.jobs.scheduler_job_runner import SchedulerJobRunner
67+
from airflow.jobs.scheduler_job_runner import SCHEDULER_DAG_CACHE_SIZE, SchedulerJobRunner
6868
from airflow.models.asset import (
6969
AssetActive,
7070
AssetAliasModel,
@@ -414,6 +414,15 @@ def test_executor_loaded_in_scheduler_job(self, mock_init_executors, mock_defaul
414414
assert scheduler_job.executor == mock_local_executor
415415
assert scheduler_job.executors == [mock_local_executor]
416416

417+
def test_scheduler_dag_bag_is_bounded(self):
418+
"""The scheduler's Dag cache must evict, or it retains every version it has ever seen."""
419+
from cachetools import LRUCache
420+
421+
job_runner = SchedulerJobRunner(Job())
422+
423+
assert isinstance(job_runner.scheduler_dag_bag._dags, LRUCache)
424+
assert job_runner.scheduler_dag_bag._dags.maxsize == SCHEDULER_DAG_CACHE_SIZE
425+
417426
@pytest.mark.parametrize(
418427
"heartrate",
419428
[10, 5],

ts-sdk/README.md

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,17 @@ runtime used to execute registered TypeScript handlers from Airflow.
3333
## Task Handlers
3434

3535
```ts
36-
import { registerTask, type TaskHandlerArgs } from "@apache-airflow/ts-sdk";
36+
import { Dag, DagRegistry, serveDags, type TaskHandlerArgs } from "@apache-airflow/ts-sdk";
3737

3838
export async function sayHello({ ctx, client }: TaskHandlerArgs) {
3939
const greeting = await client.getVariable("greeting");
4040
return { message: `Hello from ${ctx.taskId}: ${greeting}` };
4141
}
4242

43-
registerTask({ dagId: "example_dag", taskId: "say_hello" }, sayHello);
43+
const dag = new Dag("example_dag");
44+
dag.task("say_hello", sayHello);
45+
46+
await serveDags(new DagRegistry(dag));
4447
```
4548

4649
Non-`undefined` return values are pushed to XCom under the `"return_value"`
@@ -95,7 +98,7 @@ Airflow metadata in the bundle itself.
9598
TypeScript entrypoint:
9699

97100
```ts
98-
import { registerTask, startCoordinator, type TaskHandlerArgs } from "@apache-airflow/ts-sdk";
101+
import { Dag, DagRegistry, serveDags, type TaskHandlerArgs } from "@apache-airflow/ts-sdk";
99102

100103
export async function extract({ client }: TaskHandlerArgs) {
101104
const connection = await client.getConnection("sales_db");
@@ -118,33 +121,49 @@ export async function transform({ client }: TaskHandlerArgs) {
118121
};
119122
}
120123

121-
registerTask({ dagId: "sales_pipeline", taskId: "extract" }, extract);
122-
registerTask({ dagId: "sales_pipeline", taskId: "transform" }, transform);
124+
const salesPipeline = new Dag("sales_pipeline");
125+
salesPipeline.task("extract", extract);
126+
salesPipeline.task("transform", transform);
123127

124-
await startCoordinator();
128+
await serveDags(new DagRegistry(salesPipeline));
125129
```
126130

127131
The Python stub defines the Dag dependency graph. The TypeScript handler does
128-
the work and uses `TaskClient` for task-time Airflow data access. Register each
129-
handler with the Python Dag's `dag_id` and the stub task's `task_id`. The
130-
handler function is the reusable task implementation; `registerTask` binds that
131-
handler to a Python stub Dag/task identity for coordinator mode.
132+
the work and uses `TaskClient` for task-time Airflow data access. Create a
133+
`Dag` with the Python Dag's `dag_id` and attach each handler with the stub
134+
task's `task_id`. The handler function is the reusable task implementation;
135+
`dag.task` binds that handler to a Python stub task identity, a `DagRegistry`
136+
collects the Dags this bundle can execute, and `serveDags` serves them to
137+
Airflow.
138+
139+
`serveDags` is the entrypoint, and the registry it is given is the whole bundle:
140+
a Dag left out of the registry is not part of the bundle, and its tasks are
141+
marked removed at runtime. The registry itself holds no sockets and starts
142+
nothing, so a unit test can build one and dispatch through
143+
`registry.getTaskHandler(dagId, taskId)` without any runtime involved.
132144

133-
For larger projects, keep one Airflow entrypoint that imports every module that
134-
registers tasks, then starts the coordinator:
145+
`new Dag` and `dag.task` take a trailing options object — `spec` on both, plus
146+
`inputs` on a task. These are not used yet; do not set them.
147+
148+
For larger projects, declare each Dag in its own module and keep one Airflow
149+
entrypoint that serves them all:
135150

136151
```ts
137-
import "./sales/tasks";
138-
import "./billing/tasks";
139-
import { startCoordinator } from "@apache-airflow/ts-sdk";
152+
import { salesDag } from "./sales/dag";
153+
import { billingDag } from "./billing/dag";
154+
import { DagRegistry, serveDags } from "@apache-airflow/ts-sdk";
140155

141-
await startCoordinator();
156+
await serveDags(new DagRegistry(salesDag, billingDag));
142157
```
143158

159+
A bundle that collects its Dags across several modules can add them
160+
incrementally with `registry.register(...)` instead of passing them all to the
161+
constructor.
162+
144163
Airflow launches the bundled entrypoint with `--comm=host:port` and
145-
`--logs=host:port`. `startCoordinator()` connects to those sockets, receives
146-
the task startup message, finds the registered handler for the Dag/task pair,
147-
and reports the terminal task state back to Airflow.
164+
`--logs=host:port`. `serveDags()` connects to those sockets, receives the task
165+
startup message, finds the registered handler for the Dag/task pair, and
166+
reports the terminal task state back to Airflow.
148167

149168
See [`example/`](example/) for a coordinator-runtime example that packs a
150169
bundle with `airflow-ts-pack` and uses a Python stub Dag.

ts-sdk/docs/index.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,23 @@ The SDK is currently distributed as source in the `ts-sdk/` directory of the
3434
Apache Airflow repository. Build it there and add it as a local dependency of
3535
your task bundle; it is not yet published to a public npm registry.
3636

37-
Register a task handler. Handlers receive a `TaskContext` and a `TaskClient`;
38-
any non-`undefined` return value is pushed to XCom under the `"return_value"`
39-
key by the active runtime, matching Python `@task` behavior:
37+
Define a Dag and register its task handlers. Handlers receive a `TaskContext`
38+
and a `TaskClient`; any non-`undefined` return value is pushed to XCom under
39+
the `"return_value"` key by the active runtime, matching Python `@task`
40+
behavior:
4041

4142
```ts
42-
import { registerTask, type TaskHandlerArgs } from "@apache-airflow/ts-sdk";
43+
import { Dag, DagRegistry, serveDags, type TaskHandlerArgs } from "@apache-airflow/ts-sdk";
4344

4445
export async function sayHello({ ctx, client }: TaskHandlerArgs) {
4546
const greeting = await client.getVariable("greeting");
4647
return { message: `Hello from ${ctx.taskId}: ${greeting}` };
4748
}
4849

49-
registerTask({ dagId: "example_dag", taskId: "say_hello" }, sayHello);
50+
const dag = new Dag("example_dag");
51+
dag.task("say_hello", sayHello);
52+
53+
await serveDags(new DagRegistry(dag));
5054
```
5155

5256
## Coordinators

ts-sdk/example/src/main.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
* under the License.
1818
*/
1919

20-
import { registerTask, startCoordinator, type TaskHandlerArgs } from "@apache-airflow/ts-sdk";
20+
import { Dag, DagRegistry, serveDags, type TaskHandlerArgs } from "@apache-airflow/ts-sdk";
2121

22-
const DAG_ID = "typescript_example";
22+
const dag = new Dag("typescript_example");
2323

2424
export async function buildMessage({ client }: TaskHandlerArgs) {
2525
const upstream = await client.getXCom<string>({
@@ -49,7 +49,7 @@ export async function readConnection({ client }: TaskHandlerArgs) {
4949
};
5050
}
5151

52-
registerTask({ dagId: DAG_ID, taskId: "build_message" }, buildMessage);
53-
registerTask({ dagId: DAG_ID, taskId: "read_connection" }, readConnection);
52+
dag.task("build_message", buildMessage);
53+
dag.task("read_connection", readConnection);
5454

55-
await startCoordinator();
55+
await serveDags(new DagRegistry(dag));

ts-sdk/src/cli/pack.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,16 @@ function readBundleManifest(bundlePath: string): BundleManifest {
129129
maxBuffer: MANIFEST_MAX_BUFFER_BYTES,
130130
});
131131
} catch (error) {
132-
throw new Error(`Running the bundle with ${AIRFLOW_METADATA_FLAG} failed: ${String(error)}`, {
133-
cause: error,
134-
});
132+
const stderr = (error as { stderr?: string }).stderr ?? "";
133+
const reported = stderr
134+
.split("\n")
135+
.reverse()
136+
.find((line) => /^\w*Error: /.test(line.trim()))
137+
?.trim();
138+
throw new Error(
139+
reported ?? `Running the bundle with ${AIRFLOW_METADATA_FLAG} failed: ${String(error)}`,
140+
{ cause: error },
141+
);
135142
}
136143

137144
// Import-time logging from user code lands on stdout too; pick the sentinel line.
@@ -143,20 +150,52 @@ function readBundleManifest(bundlePath: string): BundleManifest {
143150
throw new Error(`Bundle produced no ${AIRFLOW_METADATA_FLAG} output`);
144151
}
145152

146-
let manifest: BundleManifest;
153+
let parsed: unknown;
147154
try {
148-
manifest = JSON.parse(line.slice(AIRFLOW_METADATA_SENTINEL.length)) as BundleManifest;
155+
parsed = JSON.parse(line.slice(AIRFLOW_METADATA_SENTINEL.length));
149156
} catch (error) {
150157
throw new Error(`Bundle produced invalid ${AIRFLOW_METADATA_FLAG} output: ${String(error)}`, {
151158
cause: error,
152159
});
153160
}
154-
if (!manifest.supervisor_schema_version || !manifest.dags || typeof manifest.dags !== "object") {
161+
if (!isBundleManifest(parsed)) {
155162
throw new Error(`Bundle produced incomplete ${AIRFLOW_METADATA_FLAG} output`);
156163
}
164+
const manifest = parsed;
165+
// The line is whatever the bundle printed and nothing downstream re-validates
166+
// it, so check each Dag entry down to the task-id element.
167+
for (const [dagId, dag] of Object.entries(manifest.dags)) {
168+
if (dag == null || !isTaskIdList(dag.tasks)) {
169+
throw new Error(
170+
`Bundle produced ${AIRFLOW_METADATA_FLAG} output with a malformed entry for Dag "${dagId}"`,
171+
);
172+
}
173+
}
157174
return manifest;
158175
}
159176

177+
// The document is checked before anything is read off it: JSON.parse also yields
178+
// null and primitives, and `null.supervisor_schema_version` would surface as a
179+
// raw TypeError rather than a report about the bundle.
180+
function isBundleManifest(value: unknown): value is BundleManifest {
181+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
182+
const { supervisor_schema_version: version, dags } = value as Partial<BundleManifest>;
183+
return (
184+
// Rendered into the manifest verbatim, where the schema requires a non-empty
185+
// string, so a truthy number or boolean would travel to Airflow as-is.
186+
typeof version === "string" &&
187+
version.length > 0 &&
188+
typeof dags === "object" &&
189+
dags !== null &&
190+
// An array would pass the typeof check and yield Dags named "0", "1", ...
191+
!Array.isArray(dags)
192+
);
193+
}
194+
195+
function isTaskIdList(value: unknown): value is string[] {
196+
return Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0);
197+
}
198+
160199
// esbuild keeps an entry hashbang as line 1, where the metadata comment must go;
161200
// NodeCoordinator always runs the bundle through `node`, so drop it.
162201
function stripShebang(bundle: string): string {
@@ -193,10 +232,16 @@ export async function runPack(argv: readonly string[]): Promise<void> {
193232
});
194233

195234
const manifest = readBundleManifest(stagingPath);
196-
if (Object.keys(manifest.dags).length === 0) {
197-
throw new Error(
198-
`${args.entry} registered no tasks; call registerTask(...) before startCoordinator()`,
199-
);
235+
const dagEntries = Object.entries(manifest.dags);
236+
if (dagEntries.length === 0) {
237+
throw new Error(`${args.entry} served no Dags; pass them to serveDags(new DagRegistry(...))`);
238+
}
239+
// Warn rather than fail, as airflow-go-pack does: the shared schema allows a
240+
// Dag with no tasks.
241+
for (const [dagId, dag] of dagEntries) {
242+
if (dag.tasks.length === 0) {
243+
process.stderr.write(`warning: dag ${JSON.stringify(dagId)} has no tasks\n`);
244+
}
200245
}
201246

202247
const metadataYaml = renderMetadataYaml({

ts-sdk/src/coordinator/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@
2121
//
2222
// TaskClient and related types are exported from the package root. This
2323
// barrel only exports coordinator-specific entry points.
24+
//
25+
// `startCoordinator` is deliberately not exported: Dag authors reach the
26+
// runtime through `serveDags()`, and never name the coordinator itself.
2427

25-
export { startCoordinator, type StartCoordinatorOptions } from "./runtime.js";
28+
export { serveDags } from "./runtime.js";
2629
/** Cadwyn schema version this SDK was generated against. Not sent on
2730
* the wire — exposed so callers can read it for bundle metadata,
2831
* health checks, or to confirm which schema their build is pinned to. */

0 commit comments

Comments
 (0)