This is an automated email from the ASF dual-hosted git repository.

jason810496 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new c6a547ea138 [v3-4-test] TypeScript SDK: take the task state store 
retention in seconds (#75445) (#75460)
c6a547ea138 is described below

commit c6a547ea1383f3a8ecf2c0dca0989c3b2aa2895d
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Oct 23 20:04:11 2026 +0800

    [v3-4-test] TypeScript SDK: take the task state store retention in seconds 
(#75445) (#75460)
    
    * TypeScript SDK: take the task state store retention in seconds
    
    retentionMs was the one duration in the SDK that was not seconds, next to
    executionTimeout, retryDelay and dagrunTimeout, which all are. Rename it to
    retentionSeconds so every duration carries the same unit.
    
    The HITL example modules now export their Dags, so another bundle can serve
    them.
    
    * TypeScript SDK: keep the HITL example Dags unexported
    
    Importing them would run the module-level serve() call, so the importing
    bundle's own serve() would then fail.
    
    * TypeScript SDK: name the task state store option retention
    
    Every other public duration is a bare name in seconds, and the Python
    argument is retention too.
    
    * TypeScript SDK: report an overflowing retention in seconds
    
    The caller passes seconds, so the error now uses the same unit.
    
    * TypeScript SDK docs: state the seconds rule once in a Durations section
    
    The task state store section links to it instead of carrying the rule.
    
    * TypeScript SDK: name the operator timeout option timeout
    
    Every duration in the SDK is now a bare name in seconds, as defer's
    timeout is in Python.
    
    ---------
    (cherry picked from commit ccbd215ff291801ddbc3599167b4287a5551c161)
    
    Co-authored-by: Jason(Zhe-You) Liu 
<[email protected]>
---
 .../language-sdks/typescript.rst                   | 25 ++++++++++++-----
 ts-sdk/example/src/main.ts                         |  2 +-
 ts-sdk/src/coordinator/client.ts                   | 32 ++++++++++++----------
 ts-sdk/src/coordinator/operator-runner.ts          |  4 +--
 ts-sdk/src/hitl/operator.ts                        |  2 +-
 ts-sdk/src/sdk/client-types.ts                     | 10 +++----
 ts-sdk/src/sdk/client.ts                           |  6 ++--
 ts-sdk/src/sdk/operator.ts                         |  8 +++---
 ts-sdk/tests/coordinator/client.test.ts            | 28 ++++++++++++-------
 ts-sdk/tests/coordinator/operator-runner.test.ts   |  8 +++---
 ts-sdk/tests/public-api.test.ts                    |  2 +-
 11 files changed, 75 insertions(+), 52 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 b9937aa8829..7a5699dcb13 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
@@ -565,7 +565,7 @@ The ``TaskClient``
    * - ``setXCom({ key, value, ... })``
      - Publishes an XCom value.
    * - ``taskStateStore``
-     - This task instance's key/value store, with ``get<T>(key)``, ``set(key, 
value, { retentionMs? })``,
+     - This task instance's key/value store, with ``get<T>(key)``, ``set(key, 
value, { retention? })``,
        ``delete(key)`` and ``clear()``. See 
:ref:`typescript-sdk/task-state-store`.
 
 .. note::
@@ -595,7 +595,7 @@ for the full concept, including the equivalent Python API.
       let jobId = await store.get<string>("job_id");
       if (jobId == null) {
         jobId = await submitSparkJob();
-        await store.set("job_id", jobId, { retentionMs: NEVER_EXPIRE });
+        await store.set("job_id", jobId, { retention: NEVER_EXPIRE });
       }
 
       const result = await waitForSparkJob(jobId);
@@ -603,15 +603,17 @@ for the full concept, including the equivalent Python API.
       return result;
     }
 
-**Retention.** ``retentionMs`` is milliseconds to retain the key, counted from 
the time of the write:
+**Retention.** ``retention`` is how long to keep the key, in seconds (see 
:ref:`typescript-sdk/durations`),
+counted from the time of the write:
 
-* A number retains the key for that many milliseconds from now; ``retentionMs: 
0`` expires the key
+* A number retains the key for that many seconds from now; ``retention: 0`` 
expires the key
   immediately.
 * ``NEVER_EXPIRE`` (imported from ``apache-airflow-ts-sdk``) stores the key 
with no expiry, regardless of
   the deployment default.
-* When ``retentionMs`` is omitted the runtime reads 
``AIRFLOW__STATE_STORE__DEFAULT_RETENTION_DAYS``, which
-  the coordinator sets from ``[state_store] default_retention_days``. A value 
of ``0`` there means the key
-  never expires. This is not the same as ``retentionMs: 0`` above, which 
expires the key immediately.
+* When ``retention`` is omitted the runtime reads 
``AIRFLOW__STATE_STORE__DEFAULT_RETENTION_DAYS``,
+  which the coordinator sets from ``[state_store] default_retention_days``. A 
value of ``0`` there means the
+  key never expires. This is not the same as ``retention: 0`` above, which 
expires the key
+  immediately.
 
 **Keys** must be non-empty strings of at most 512 characters; they may contain 
slashes. **Values** must be
 JSON-compatible (see :ref:`typescript-sdk/types`), and can never be ``null`` — 
``set`` rejects a ``null``
@@ -632,6 +634,15 @@ Anything a task writes to stdout or stderr, such as 
``console.log`` or ``console
 Airflow task log: stdout at ``INFO`` level, stderr at ``ERROR``. There is no 
dedicated structured-logging API
 yet.
 
+.. _typescript-sdk/durations:
+
+Durations
+---------
+
+Every duration in the SDK is a number of seconds, with no unit in its name: 
the Dag and task options
+``retryDelay``, ``executionTimeout`` and ``dagrunTimeout``, 
``responseTimeout`` on a human-in-the-loop task,
+``pokeInterval`` on ``triggerDagRun``, and ``retention`` in the task state 
store.
+
 .. _typescript-sdk/types:
 
 XCom type mapping
diff --git a/ts-sdk/example/src/main.ts b/ts-sdk/example/src/main.ts
index 1fd2c38b17a..756ec0fb762 100644
--- a/ts-sdk/example/src/main.ts
+++ b/ts-sdk/example/src/main.ts
@@ -66,7 +66,7 @@ export async function writeAndReadTaskState() {
   const store = getClient().taskStateStore;
   const { runId, tryNumber } = getContext();
 
-  await store.set(JOB_ID_KEY, runId, { retentionMs: NEVER_EXPIRE });
+  await store.set(JOB_ID_KEY, runId, { retention: NEVER_EXPIRE });
   await store.set(STATE_SCRATCH_KEY, { attempt: tryNumber });
 
   const jobId = await store.get<string>(JOB_ID_KEY);
diff --git a/ts-sdk/src/coordinator/client.ts b/ts-sdk/src/coordinator/client.ts
index 025dd29d68c..7584a23e0f9 100644
--- a/ts-sdk/src/coordinator/client.ts
+++ b/ts-sdk/src/coordinator/client.ts
@@ -142,7 +142,8 @@ const TASK_STATE_STORE_ABSENT_POLICY: AbsentRowPolicy = {
 // `[state_store] default_retention_days` at launch
 // (task-sdk/src/airflow/sdk/coordinators/_subprocess.py).
 const RETENTION_DAYS_ENV_VAR = "AIRFLOW__STATE_STORE__DEFAULT_RETENTION_DAYS";
-const MS_PER_DAY = 24 * 60 * 60 * 1000;
+const MS_PER_SECOND = 1000;
+const SECONDS_PER_DAY = 24 * 60 * 60;
 
 function assertKey(key: string): void {
   if (typeof key !== "string") {
@@ -157,28 +158,29 @@ function assertKey(key: string): void {
 // past 9999, and the supervisor silently drops a frame it cannot decode
 // rather than replying with an error, so the task would hang instead of
 // failing fast. Route both retention paths through this check.
-function toExpiresAt(ms: number): string {
-  const at = new Date(Date.now() + ms);
+function toExpiresAt(seconds: number): string {
+  const at = new Date(Date.now() + seconds * MS_PER_SECOND);
   if (Number.isNaN(at.getTime()) || at.getUTCFullYear() > 9999) {
     throw new RangeError(
-      `retention of ${ms}ms overflows the wire timestamp; use NEVER_EXPIRE 
instead`,
+      `a retention that ends ${seconds}s from now overflows the wire 
timestamp; ` +
+        "use NEVER_EXPIRE instead",
     );
   }
   return at.toISOString();
 }
 
-/** Resolve `retentionMs` (or the deployment default) to a wire `expires_at`. 
*/
-function resolveExpiresAt(retentionMs: number | typeof NEVER_EXPIRE | 
undefined): string | null {
-  if (retentionMs === undefined) {
+/** Resolve `retention` (or the deployment default) to a wire `expires_at`. */
+function resolveExpiresAt(seconds: number | typeof NEVER_EXPIRE | undefined): 
string | null {
+  if (seconds === undefined) {
     return resolveDefaultExpiresAt();
   }
-  if (retentionMs === NEVER_EXPIRE) {
+  if (seconds === NEVER_EXPIRE) {
     return null;
   }
-  if (!Number.isFinite(retentionMs) || retentionMs < 0) {
-    throw new RangeError(`retentionMs must be >= 0 or NEVER_EXPIRE, got 
${String(retentionMs)}`);
+  if (!Number.isFinite(seconds) || seconds < 0) {
+    throw new RangeError(`retention must be >= 0 or NEVER_EXPIRE, got 
${String(seconds)}`);
   }
-  return toExpiresAt(retentionMs);
+  return toExpiresAt(seconds);
 }
 
 // The coordinator always passes the setting, so an absent or malformed value 
is
@@ -189,8 +191,8 @@ function resolveDefaultExpiresAt(): string | null {
   if (raw === undefined) {
     throw new Error(
       `${RETENTION_DAYS_ENV_VAR} is not set, so the default retention is 
unknown. It carries ` +
-        "[state_store] default_retention_days to the runtime; pass retentionMs 
or NEVER_EXPIRE to " +
-        "set the expiry explicitly.",
+        "[state_store] default_retention_days to the runtime; pass retention 
or " +
+        "NEVER_EXPIRE to set the expiry explicitly.",
     );
   }
   const days = Number(raw);
@@ -202,7 +204,7 @@ function resolveDefaultExpiresAt(): string | null {
       `[state_store] default_retention_days must be >= 0, got ${raw}. Set to 0 
to disable expiry.`,
     );
   }
-  return days === 0 ? null : toExpiresAt(days * MS_PER_DAY);
+  return days === 0 ? null : toExpiresAt(days * SECONDS_PER_DAY);
 }
 
 export function createCoordinatorClient(
@@ -277,7 +279,7 @@ export function createCoordinatorClient(
         ti_id: tiId,
         key,
         value,
-        expires_at: resolveExpiresAt(opts.retentionMs),
+        expires_at: resolveExpiresAt(opts.retention),
       };
       await rpc("SetTaskStateStore", "OKResponse", msg, () => undefined, 
"throw");
     },
diff --git a/ts-sdk/src/coordinator/operator-runner.ts 
b/ts-sdk/src/coordinator/operator-runner.ts
index 6365770765a..bcb0cd47836 100644
--- a/ts-sdk/src/coordinator/operator-runner.ts
+++ b/ts-sdk/src/coordinator/operator-runner.ts
@@ -108,7 +108,7 @@ export function buildOperatorContext(deps: 
OperatorContextDeps): OperatorContext
       return {
         type: "AwaitInputTask",
         state: "awaiting_input",
-        timeout: toIsoDuration(opts.timeoutSeconds),
+        timeout: toIsoDuration(opts.timeout),
         next_method: EXECUTE_COMPLETE,
         next_kwargs: opts.kwargs ?? {},
       };
@@ -119,7 +119,7 @@ export function buildOperatorContext(deps: 
OperatorContextDeps): OperatorContext
         state: "deferred",
         classpath: opts.classpath,
         trigger_kwargs: opts.kwargs,
-        trigger_timeout: toIsoDuration(opts.timeoutSeconds),
+        trigger_timeout: toIsoDuration(opts.timeout),
         // `_defer_task` hands the trigger the task's queue only when 
triggerer queues are enabled.
         queue:
           opts.queue !== undefined
diff --git a/ts-sdk/src/hitl/operator.ts b/ts-sdk/src/hitl/operator.ts
index dcea541ea94..6671f21360a 100644
--- a/ts-sdk/src/hitl/operator.ts
+++ b/ts-sdk/src/hitl/operator.ts
@@ -93,7 +93,7 @@ async function executeHITL(task: HITLTask<never>, op: 
OperatorContext): Promise<
   });
 
   logs.info("Waiting for response", { task_id: ctx.taskId });
-  return op.awaitInput({ timeoutSeconds: task.responseTimeout });
+  return op.awaitInput({ timeout: task.responseTimeout });
 }
 
 /**
diff --git a/ts-sdk/src/sdk/client-types.ts b/ts-sdk/src/sdk/client-types.ts
index 37c3b7b0e42..436921d808b 100644
--- a/ts-sdk/src/sdk/client-types.ts
+++ b/ts-sdk/src/sdk/client-types.ts
@@ -51,8 +51,8 @@ export interface SetXComOpts {
 }
 
 /**
- * Pass as {@link TaskStateStoreSetOpts.retentionMs} to store a task state 
store
- * key with no expiry, regardless of the deployment default.
+ * Pass as {@link TaskStateStoreSetOpts.retention} to store a task state
+ * store key with no expiry, regardless of the deployment default.
  *
  * A symbol rather than a number, so a retention that reaches `Infinity` by
  * accident (a division by zero, for example) is rejected instead of silently
@@ -63,14 +63,14 @@ export const NEVER_EXPIRE: unique symbol = 
Symbol("apache-airflow-ts-sdk/NEVER_E
 /** Options for a task state store write. */
 export interface TaskStateStoreSetOpts {
   /**
-   * Milliseconds to retain the key, counted from the write; `0` expires it
-   * immediately.
+   * How long to keep the key, in seconds, counted from the write; `0` expires
+   * it immediately.
    *
    * Omitting it follows the deployment's `[state_store] 
default_retention_days`,
    * which the coordinator passes to the runtime. Pass {@link NEVER_EXPIRE} to
    * store the key with no expiry regardless of that default.
    */
-  retentionMs?: number | typeof NEVER_EXPIRE;
+  retention?: number | typeof NEVER_EXPIRE;
 }
 
 /** Airflow Connection details returned by a task client. */
diff --git a/ts-sdk/src/sdk/client.ts b/ts-sdk/src/sdk/client.ts
index 6927fc47831..962638ee1c3 100644
--- a/ts-sdk/src/sdk/client.ts
+++ b/ts-sdk/src/sdk/client.ts
@@ -133,7 +133,7 @@ export interface TaskClient {
  * let jobId = await store.get<string>("job_id");
  * if (jobId == null) {
  *   jobId = await submit();
- *   await store.set("job_id", jobId, { retentionMs: NEVER_EXPIRE });
+ *   await store.set("job_id", jobId, { retention: NEVER_EXPIRE });
  * }
  * ```
  *
@@ -157,9 +157,11 @@ export interface TaskStateStore {
   /**
    * Store a value, replacing any existing value for the key.
    *
+   * `opts.retention` is how long to keep the key, in seconds.
+   *
    * @throws `TypeError` when `key` is not a string, or `value` is null,
    * undefined, NaN, or Infinity.
-   * @throws `RangeError` when `key` is empty; when `retentionMs` is
+   * @throws `RangeError` when `key` is empty; when `retention` is
    * negative, NaN, or Infinity; when the deployment's
    * `[state_store] default_retention_days` is invalid; or when the resolved
    * expiry would overflow the wire timestamp (year 9999).
diff --git a/ts-sdk/src/sdk/operator.ts b/ts-sdk/src/sdk/operator.ts
index 92468ac61ba..3184d0da55a 100644
--- a/ts-sdk/src/sdk/operator.ts
+++ b/ts-sdk/src/sdk/operator.ts
@@ -61,19 +61,19 @@ export interface OperatorContext {
   fail(message: string): RuntimeRetryTask | RuntimeTaskState;
   /** Skip `taskIds`, as `SkipMixin.skip` does. */
   skip(taskIds: readonly string[]): Promise<void>;
-  /** Park in `awaiting_input`. The next run calls `executeComplete`. */
+  /** Park in `awaiting_input`. The next run calls `executeComplete`. 
`timeout` is in seconds. */
   awaitInput(opts?: {
-    timeoutSeconds?: number;
+    timeout?: number;
     kwargs?: Record<string, JsonValue>;
   }): RuntimeAwaitInputTask;
   /**
    * Defer to a Python trigger. The next run calls `executeComplete`. Without 
a `queue`, the
-   * trigger gets the task's queue when triggerer queues are enabled.
+   * trigger gets the task's queue when triggerer queues are enabled. 
`timeout` is in seconds.
    */
   defer(opts: {
     classpath: string;
     kwargs: Record<string, JsonValue>;
-    timeoutSeconds?: number;
+    timeout?: number;
     queue?: string | null;
   }): RuntimeDeferTask;
 }
diff --git a/ts-sdk/tests/coordinator/client.test.ts 
b/ts-sdk/tests/coordinator/client.test.ts
index f5e864f6629..92fa03fcedf 100644
--- a/ts-sdk/tests/coordinator/client.test.ts
+++ b/ts-sdk/tests/coordinator/client.test.ts
@@ -165,7 +165,7 @@ describe("writes do not read a supervisor 404 as absence", 
() => {
     [
       "taskStateStore.set",
       "SetTaskStateStore",
-      (c: TaskClient) => c.taskStateStore.set("k", 1, { retentionMs: 60_000 }),
+      (c: TaskClient) => c.taskStateStore.set("k", 1, { retention: 60 }),
     ],
     [
       "taskStateStore.delete",
@@ -446,7 +446,7 @@ describe("taskStateStore.set retention", () => {
     vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
     const { client: c, sent } = recordingClient({ type: "OKResponse", ok: true 
});
 
-    await c.taskStateStore.set("k", 1, { retentionMs: NEVER_EXPIRE });
+    await c.taskStateStore.set("k", 1, { retention: NEVER_EXPIRE });
 
     expect(sent[0]).toEqual({
       type: "SetTaskStateStore",
@@ -457,22 +457,22 @@ describe("taskStateStore.set retention", () => {
     });
   });
 
-  it("sends now + retentionMs for a finite retentionMs", async () => {
+  it("sends now + retention for a finite retention", async () => {
     vi.useFakeTimers();
     vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
     const { client: c, sent } = recordingClient({ type: "OKResponse", ok: true 
});
 
-    await c.taskStateStore.set("k", 1, { retentionMs: 60_000 });
+    await c.taskStateStore.set("k", 1, { retention: 60 });
 
     expect(sent[0]).toMatchObject({ expires_at: "2026-01-01T00:01:00.000Z" });
   });
 
-  it("sends now (expire immediately) for retentionMs 0", async () => {
+  it("sends now (expire immediately) for retention 0", async () => {
     vi.useFakeTimers();
     vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
     const { client: c, sent } = recordingClient({ type: "OKResponse", ok: true 
});
 
-    await c.taskStateStore.set("k", 1, { retentionMs: 0 });
+    await c.taskStateStore.set("k", 1, { retention: 0 });
 
     expect(sent[0]).toMatchObject({ expires_at: "2026-01-01T00:00:00.000Z" });
   });
@@ -535,15 +535,23 @@ describe("taskStateStore.set guards", () => {
   it.each([
     ["negative", -1],
     ["NaN", Number.NaN],
-  ])("rejects retentionMs=%s without sending a request", async (_label, 
retentionMs) => {
+  ])("rejects retention=%s without sending a request", async (_label, 
retention) => {
     const { client: c, sent } = recordingClient();
-    await expect(c.taskStateStore.set("k", 1, { retentionMs 
})).rejects.toThrow(RangeError);
+    await expect(c.taskStateStore.set("k", 1, { retention 
})).rejects.toThrow(RangeError);
+    expect(sent).toHaveLength(0);
+  });
+
+  it("reports an overflowing retention in seconds", async () => {
+    const { client: c, sent } = recordingClient();
+    await expect(c.taskStateStore.set("k", 1, { retention: 1e12 
})).rejects.toThrow(
+      "a retention that ends 1000000000000s from now",
+    );
     expect(sent).toHaveLength(0);
   });
 
   it("rejects an empty key without sending a request", async () => {
     const { client: c, sent } = recordingClient();
-    await expect(c.taskStateStore.set("", 1, { retentionMs: 60_000 
})).rejects.toThrow(RangeError);
+    await expect(c.taskStateStore.set("", 1, { retention: 60 
})).rejects.toThrow(RangeError);
     expect(sent).toHaveLength(0);
   });
 });
@@ -577,7 +585,7 @@ describe("task state store key type guard", () => {
       "set",
       (c: TaskClient) => {
         // @ts-expect-error exercising the runtime guard against an untyped 
caller.
-        return c.taskStateStore.set(undefined, 1, { retentionMs: 60_000 });
+        return c.taskStateStore.set(undefined, 1, { retention: 60 });
       },
     ],
     [
diff --git a/ts-sdk/tests/coordinator/operator-runner.test.ts 
b/ts-sdk/tests/coordinator/operator-runner.test.ts
index 5aa5c76785e..17ba55717e0 100644
--- a/ts-sdk/tests/coordinator/operator-runner.test.ts
+++ b/ts-sdk/tests/coordinator/operator-runner.test.ts
@@ -157,13 +157,13 @@ describe("buildOperatorContext", () => {
   it.each([
     [undefined, null],
     [30, "PT30S"],
-  ])("parks with a timeout of %s as %s", (timeoutSeconds, timeout) => {
+  ])("parks with a timeout of %s as %s", (timeout, expected) => {
     const { op } = makeContext();
 
-    expect(op.awaitInput({ timeoutSeconds })).toEqual({
+    expect(op.awaitInput({ timeout })).toEqual({
       type: "AwaitInputTask",
       state: "awaiting_input",
-      timeout,
+      timeout: expected,
       next_method: "execute_complete",
       next_kwargs: {},
     });
@@ -178,7 +178,7 @@ describe("buildOperatorContext", () => {
   it("defers to the trigger, resuming through execute_complete", () => {
     const { op } = makeContext();
 
-    expect(op.defer({ classpath: "pkg.Trigger", kwargs: { a: 1 }, 
timeoutSeconds: 5 })).toEqual({
+    expect(op.defer({ classpath: "pkg.Trigger", kwargs: { a: 1 }, timeout: 5 
})).toEqual({
       type: "DeferTask",
       state: "deferred",
       classpath: "pkg.Trigger",
diff --git a/ts-sdk/tests/public-api.test.ts b/ts-sdk/tests/public-api.test.ts
index 163cb14471a..828fa7a32f4 100644
--- a/ts-sdk/tests/public-api.test.ts
+++ b/ts-sdk/tests/public-api.test.ts
@@ -449,7 +449,7 @@ describe("public API", () => {
     >();
     expectTypeOf<TaskClient["deleteVariable"]>().toEqualTypeOf<(key: string) 
=> Promise<void>>();
     expectTypeOf<TaskStateStoreSetOpts>().toEqualTypeOf<{
-      retentionMs?: number | typeof NEVER_EXPIRE;
+      retention?: number | typeof NEVER_EXPIRE;
     }>();
     
expectTypeOf<TaskClient["taskStateStore"]>().toEqualTypeOf<TaskStateStore>();
     expectTypeOf<TaskStateStore["get"]>().toEqualTypeOf<

Reply via email to