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 fd4969bd3c7 Add an e2e test for the Java SDK task state store (#74403)
fd4969bd3c7 is described below

commit fd4969bd3c773906e0f084c36087812f4e7685bd
Author: Andrew Chang <[email protected]>
AuthorDate: Tue Oct 13 17:04:50 2026 +0800

    Add an e2e test for the Java SDK task state store (#74403)
    
    The Java SDK task state store had unit tests only. Nothing checked
    that a real Java task reaches the database through the supervisor, or
    that the coordinator passes [state_store] default_retention_days to
    the JVM. The Go SDK already has an e2e test for both.
---
 .../org/apache/airflow/e2e/TestBundleBuilder.java  | 36 ++++++++++-
 .../src/resources/dags/java_test_dags.py           | 12 ++++
 .../tests/airflow_e2e_tests/conftest.py            | 17 +++--
 .../tests/airflow_e2e_tests/constants.py           |  6 +-
 .../go_sdk_tests/test_go_sdk_task_state.py         |  6 +-
 .../java_sdk_tests/test_java_sdk_dag.py            | 72 +++++++++++++++++++++-
 6 files changed, 134 insertions(+), 15 deletions(-)

diff --git 
a/airflow-e2e-tests/java-test-bundle/src/java/org/apache/airflow/e2e/TestBundleBuilder.java
 
b/airflow-e2e-tests/java-test-bundle/src/java/org/apache/airflow/e2e/TestBundleBuilder.java
index ad33a15708a..39c4bd46afc 100644
--- 
a/airflow-e2e-tests/java-test-bundle/src/java/org/apache/airflow/e2e/TestBundleBuilder.java
+++ 
b/airflow-e2e-tests/java-test-bundle/src/java/org/apache/airflow/e2e/TestBundleBuilder.java
@@ -20,12 +20,14 @@
 package org.apache.airflow.e2e;
 
 import java.util.List;
+import java.util.Map;
 import org.apache.airflow.sdk.*;
 import org.jetbrains.annotations.NotNull;
 
 /**
  * Bundle for the runner-behaviour E2E tests: deliberately broken task classes 
that exercise
- * instantiation failures, and a task that round-trips Airflow Variables 
through the supervisor.
+ * instantiation failures, and tasks that round-trip Airflow Variables and 
task state through the
+ * supervisor.
  */
 public class TestBundleBuilder implements BundleBuilder {
   public static class MissingNoArgConstructor implements Task {
@@ -60,6 +62,34 @@ public class TestBundleBuilder implements BundleBuilder {
     }
   }
 
+  /**
+   * Writes task state keys the E2E test reads back through the REST API, and 
reads them back
+   * here to exercise the get path. No clear: the E2E test reads the keys 
after the task ends.
+   */
+  public static class RoundtripTaskState implements Task {
+    public void execute(@NotNull Context context, Client client) {
+      var store = client.getTaskStateStore();
+      var runId = context.dagRun.runId;
+      store.set("java_e2e_run_id", runId, TaskStateStore.NEVER_EXPIRE);
+      store.set("java_e2e_counter", Map.of("processed", 3, "cursor", 
"abc-123"));
+      store.set("java_e2e_retained", "retained");
+      store.set("java_e2e_scratch", "scratch");
+      store.delete("java_e2e_scratch");
+
+      var readBack = store.get("java_e2e_run_id");
+      if (!runId.equals(readBack)) {
+        throw new IllegalStateException("java_e2e_run_id: got " + readBack + 
", want " + runId);
+      }
+      var counter = store.get("java_e2e_counter");
+      if (!Map.of("processed", 3L, "cursor", "abc-123").equals(counter)) {
+        throw new IllegalStateException("java_e2e_counter: got " + counter);
+      }
+      if (store.get("java_e2e_scratch") != null) {
+        throw new IllegalStateException("java_e2e_scratch survived its 
delete");
+      }
+    }
+  }
+
   @NotNull
   @Override
   public Iterable<DagDef> getDags() {
@@ -68,7 +98,9 @@ public class TestBundleBuilder implements BundleBuilder {
     uninstantiable.addTask("non_static_inner", NonStaticInner.class);
     var variableWrite = new DagDef("java_variable_write");
     variableWrite.addTask("write_and_delete", WriteAndDeleteVariable.class);
-    return List.of(uninstantiable, variableWrite);
+    var taskState = new DagDef("java_task_state");
+    taskState.addTask("roundtrip_task_state", RoundtripTaskState.class);
+    return List.of(uninstantiable, variableWrite, taskState);
   }
 
   public static void main(String[] args) {
diff --git 
a/airflow-e2e-tests/java-test-bundle/src/resources/dags/java_test_dags.py 
b/airflow-e2e-tests/java-test-bundle/src/resources/dags/java_test_dags.py
index d4ee554d66e..8a4818923f0 100644
--- a/airflow-e2e-tests/java-test-bundle/src/resources/dags/java_test_dags.py
+++ b/airflow-e2e-tests/java-test-bundle/src/resources/dags/java_test_dags.py
@@ -48,3 +48,15 @@ def java_variable_write():
 
 
 java_variable_write()
+
+
[email protected](queue="java-test")
+def roundtrip_task_state(): ...
+
+
+@dag(dag_id="java_task_state")
+def java_task_state():
+    roundtrip_task_state()
+
+
+java_task_state()
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py 
b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
index beb1376e7ea..38ea7bd8141 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
@@ -47,7 +47,6 @@ from airflow_e2e_tests.constants import (
     GO_SDK_NATIVE_BUNDLE_NAME,
     GO_SDK_NATIVE_BUNDLE_PKG,
     GO_SDK_ROOT_PATH,
-    GO_SDK_STATE_STORE_RETENTION_DAYS,
     JAVA_COMPOSE_PATH,
     JAVA_DOCKERFILE_PATH,
     JAVA_NATIVE_BUNDLE_LIBS_PATH,
@@ -61,6 +60,7 @@ from airflow_e2e_tests.constants import (
     JAVA_TEST_BUNDLE_ROOT_PATH,
     KAFKA_DIR_PATH,
     LANG_SDK_NATIVE_TOOLCHAIN,
+    LANG_SDK_STATE_STORE_RETENTION_DAYS,
     LOCALSTACK_PATH,
     LOGS_FOLDER,
     NODE_IMAGE,
@@ -314,6 +314,14 @@ def _build_dag_bundle_config(artifact_bundles: dict[str, 
str]) -> str:
     return json.dumps(bundles)
 
 
+def _write_state_store_retention_config(tmp_dir):
+    # Config file, not an env var: the supervisor copies the worker 
environment into the
+    # language SDK subprocess, so an env var would reach it even without the 
propagation under test.
+    (tmp_dir / "config" / "airflow.cfg").write_text(
+        f"[state_store]\ndefault_retention_days = 
{LANG_SDK_STATE_STORE_RETENTION_DAYS}\n"
+    )
+
+
 def _run_java_sdk_gradle(workdir, *gradle_argv, capture_output=False, 
native=False):
     """Run the Java SDK Gradle wrapper natively or inside the pinned JDK 
container.
 
@@ -556,6 +564,7 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir):
         "AIRFLOW_VAR_MY_VARIABLE=test_value\n"
     )
     os.environ["ENV_FILE_PATH"] = str(dot_env_file)
+    _write_state_store_retention_config(tmp_dir)
 
 
 def _run_go_sdk_pack(package, output_path, *, capture_output=False, 
native=False):
@@ -713,11 +722,7 @@ def _setup_go_sdk_integration(dot_env_file, tmp_dir):
     )
     os.environ["ENV_FILE_PATH"] = str(dot_env_file)
 
-    # Config file, not an env var: the supervisor copies the worker 
environment into the Go
-    # subprocess, so an env var would reach Go even without the propagation 
under test.
-    (tmp_dir / "config" / "airflow.cfg").write_text(
-        f"[state_store]\ndefault_retention_days = 
{GO_SDK_STATE_STORE_RETENTION_DAYS}\n"
-    )
+    _write_state_store_retention_config(tmp_dir)
 
 
 def _setup_openlineage_integration(dot_env_file, tmp_dir, compose_file_names):
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py 
b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
index 024a90fb01b..7fd1323cc3f 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
@@ -62,6 +62,9 @@ OPENLINEAGE_COMPAT_DB_COMPOSE_PATH = (
 # CI sets this (the same switch the lang-SDK k8s job uses) to build the 
lang-SDK
 # artifacts with the host toolchain instead of ephemeral toolchain containers.
 LANG_SDK_NATIVE_TOOLCHAIN = os.environ.get("LANG_SDK_NATIVE_TOOLCHAIN", 
"").lower() == "true"
+# Far from the supervisor's own 30-day config fallback, so the e2e test can 
tell a
+# propagated value from it.
+LANG_SDK_STATE_STORE_RETENTION_DAYS = 7
 
 # Java SDK E2E test paths
 JAVA_SDK_ROOT_PATH = AIRFLOW_ROOT_PATH / "java-sdk"
@@ -103,9 +106,6 @@ GO_SDK_NATIVE_BUNDLE_NAME = "go_native_dags"
 # Where airflow-go-pack writes the packed bundle inside the repo (go-sdk/bin 
is gitignored).
 GO_SDK_BIN_PATH = GO_SDK_ROOT_PATH / "bin"
 GO_COMPOSE_PATH = AIRFLOW_ROOT_PATH / "airflow-e2e-tests" / "docker" / "go.yml"
-# Far from the supervisor's own 30-day config fallback, so the e2e test can 
tell a
-# propagated value from it.
-GO_SDK_STATE_STORE_RETENTION_DAYS = 7
 # Go toolchain image used to build the bundle in the containerized path (i.e. 
unless
 # LANG_SDK_NATIVE_TOOLCHAIN is set); must satisfy go-sdk/go.mod's toolchain.
 # The Alpine variant is ~7x smaller than the Debian one and is safe here 
because the
diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_task_state.py
 
b/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_task_state.py
index 3a8f2a81afb..d0b63455016 100644
--- 
a/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_task_state.py
+++ 
b/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_task_state.py
@@ -29,7 +29,7 @@ from http import HTTPStatus
 import pytest
 import requests
 
-from airflow_e2e_tests.constants import GO_SDK_STATE_STORE_RETENTION_DAYS
+from airflow_e2e_tests.constants import LANG_SDK_STATE_STORE_RETENTION_DAYS
 from airflow_e2e_tests.e2e_test_utils.clients import AirflowClient
 
 _GO_TASK_TIMEOUT = 300
@@ -106,9 +106,9 @@ def test_default_retention_applied(completed_run: 
_CompletedRun):
     entry = completed_run.state_store("go_e2e_retained")
     assert entry.get("expires_at") is not None, entry
     gap = _parse_timestamp(entry["expires_at"]) - 
_parse_timestamp(entry["updated_at"])
-    expected = timedelta(days=GO_SDK_STATE_STORE_RETENTION_DAYS)
+    expected = timedelta(days=LANG_SDK_STATE_STORE_RETENTION_DAYS)
     assert abs(gap - expected) <= timedelta(minutes=5), (
-        f"expected ~{GO_SDK_STATE_STORE_RETENTION_DAYS} days, got {gap / 
timedelta(days=1):.1f} days; "
+        f"expected ~{LANG_SDK_STATE_STORE_RETENTION_DAYS} days, got {gap / 
timedelta(days=1):.1f} days; "
         "if ~30, the supervisor fell back to its own config default, so it did 
not see "
         f"[state_store] default_retention_days. entry: {entry!r}"
     )
diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py 
b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
index fcd8d8d01fa..ec2f1408f91 100644
--- 
a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
+++ 
b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
@@ -69,13 +69,14 @@ from __future__ import annotations
 
 import time
 from dataclasses import dataclass, field
-from datetime import UTC, datetime
+from datetime import UTC, datetime, timedelta
 from http import HTTPStatus
 from typing import TYPE_CHECKING
 
 import pytest
 import requests
 
+from airflow_e2e_tests.constants import LANG_SDK_STATE_STORE_RETENTION_DAYS
 from airflow_e2e_tests.e2e_test_utils.clients import AirflowClient
 
 if TYPE_CHECKING:
@@ -95,6 +96,8 @@ _XCOM_CASTING_DAG_ID = "java_xcom_casting_example"
 _SCALA_SPARK_DAG_ID = "scala_spark_example"
 _VARIABLE_WRITE_DAG_ID = "java_variable_write"
 _VARIABLE_WRITE_DESCRIPTION = "written by the Java SDK e2e test"
+_TASK_STATE_DAG_ID = "java_task_state"
+_TASK_STATE_TASK_ID = "roundtrip_task_state"
 
 
 @dataclass
@@ -128,6 +131,14 @@ class _CompletedRun:
             key=key,
         ).get("value")
 
+    def get_task_state_store(self, task_id: str, key: str) -> dict:
+        self._raise_if_failed()
+        if self.run_id is None:
+            raise RuntimeError(f"Dag {self.dag_id!r} did not produce a run ID")
+        return self.client.get_task_state_store(
+            dag_id=self.dag_id, run_id=self.run_id, task_id=task_id, key=key
+        )
+
     def wait_for_log_record(
         self, task_id: str, try_number: int, match: Callable[[dict], bool]
     ) -> tuple[dict | None, list[dict]]:
@@ -175,6 +186,10 @@ def _trigger_and_wait_for_dag(dag_id: str, timeout: int) 
-> _CompletedRun:
     )
 
 
+def _parse_timestamp(value: str) -> datetime:
+    return datetime.fromisoformat(value.replace("Z", "+00:00"))
+
+
 @pytest.fixture(scope="module")
 def annotation_example_run() -> _CompletedRun:
     """Trigger the annotation example once for all of its assertions."""
@@ -205,6 +220,12 @@ def variable_write_run() -> _CompletedRun:
     return _trigger_and_wait_for_dag(_VARIABLE_WRITE_DAG_ID, 
_JAVA_TASK_TIMEOUT)
 
 
[email protected](scope="module")
+def task_state_run() -> _CompletedRun:
+    """Trigger the task state Dag once for all of its assertions."""
+    return _trigger_and_wait_for_dag(_TASK_STATE_DAG_ID, _JAVA_TASK_TIMEOUT)
+
+
 class TestJavaSDKAnnotationExample:
     """Verify the annotation-based Java SDK example executes correctly."""
 
@@ -480,3 +501,52 @@ class TestJavaSDKScalaSparkExample:
         assert load_xcom == _SPARK_EXPECTED_TOTAL_REVENUE, (
             f"Expected spark_load to return total revenue 
{_SPARK_EXPECTED_TOTAL_REVENUE}, got {load_xcom!r}"
         )
+
+
+class TestJavaSDKTaskStateStore:
+    """Verify a Java task can set, get and delete task state store keys.
+
+    The task lives in the java-test-bundle fixture project (served on the
+    dedicated "java-test" queue). It also reads its keys back and fails if a
+    value does not match, so ``success`` covers the get path. Assertions here
+    read the store through the REST API, so the data is proven to have reached
+    the database.
+    """
+
+    def _assert_task_succeeded(self, run: _CompletedRun) -> None:
+        ti = run.get_task_instance(_TASK_STATE_TASK_ID)
+        assert ti.get("state") == "success", (
+            f"Java {_TASK_STATE_TASK_ID!r} task did not succeed.\n"
+            f"  task state : {ti.get('state')!r}\n"
+            f"  dag state  : {run.state!r}\n"
+            f"  all tasks  : {run.ti_states}"
+        )
+
+    def test_never_expire_key_holds_run_id(self, task_state_run: 
_CompletedRun):
+        self._assert_task_succeeded(task_state_run)
+        entry = task_state_run.get_task_state_store(_TASK_STATE_TASK_ID, 
"java_e2e_run_id")
+        assert entry.get("value") == task_state_run.run_id, entry
+        assert entry.get("expires_at") is None, entry
+
+    def test_structured_value_roundtrips(self, task_state_run: _CompletedRun):
+        self._assert_task_succeeded(task_state_run)
+        entry = task_state_run.get_task_state_store(_TASK_STATE_TASK_ID, 
"java_e2e_counter")
+        assert entry.get("value") == {"processed": 3, "cursor": "abc-123"}, 
entry
+
+    def test_deleted_key_is_gone(self, task_state_run: _CompletedRun):
+        self._assert_task_succeeded(task_state_run)
+        with pytest.raises(requests.HTTPError) as excinfo:
+            task_state_run.get_task_state_store(_TASK_STATE_TASK_ID, 
"java_e2e_scratch")
+        assert excinfo.value.response.status_code == HTTPStatus.NOT_FOUND
+
+    def test_default_retention_applied(self, task_state_run: _CompletedRun):
+        self._assert_task_succeeded(task_state_run)
+        entry = task_state_run.get_task_state_store(_TASK_STATE_TASK_ID, 
"java_e2e_retained")
+        assert entry.get("expires_at") is not None, entry
+        gap = _parse_timestamp(entry["expires_at"]) - 
_parse_timestamp(entry["updated_at"])
+        expected = timedelta(days=LANG_SDK_STATE_STORE_RETENTION_DAYS)
+        assert abs(gap - expected) <= timedelta(minutes=5), (
+            f"expected ~{LANG_SDK_STATE_STORE_RETENTION_DAYS} days, got {gap / 
timedelta(days=1):.1f} days; "
+            "if ~30, the coordinator passed Airflow's config default, so the 
JVM did not see "
+            f"[state_store] default_retention_days. entry: {entry!r}"
+        )

Reply via email to