This is an automated email from the ASF dual-hosted git repository.
wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new 550d50def [runtime][python] Keep durable call failures whose exception
can't be pickled or rebuilt (#1182)
550d50def is described below
commit 550d50defa76d988a144df697a7059278f5121fe
Author: Sangkyoon Nam <[email protected]>
AuthorDate: Fri Oct 9 11:42:36 2026 +0900
[runtime][python] Keep durable call failures whose exception can't be
pickled or rebuilt (#1182)
Generated-by: Claude Code 2.1.284 (Claude Fable 5.1)
---
docs/content/docs/development/workflow_agent.md | 1 +
python/flink_agents/api/runner_context.py | 8 +
python/flink_agents/runtime/durable_exception.py | 207 +++++++
.../flink_agents/runtime/flink_runner_context.py | 48 +-
.../runtime/tests/test_durable_exception.py | 659 +++++++++++++++++++++
.../test_flink_runner_context_reconcilable.py | 5 +-
6 files changed, 920 insertions(+), 8 deletions(-)
diff --git a/docs/content/docs/development/workflow_agent.md
b/docs/content/docs/development/workflow_agent.md
index 264cec27d..48f1a6243 100644
--- a/docs/content/docs/development/workflow_agent.md
+++ b/docs/content/docs/development/workflow_agent.md
@@ -473,6 +473,7 @@ on how to setup and configure the external action state
store.
- Results may not be reused if call order or arguments change
(non-deterministic actions), which clears subsequent cached results and
re-executes.
- If a failure happens after a function starts but before it completes and its
result is persisted, the call will be re-executed. See the "With a reconciler"
section below.
- In Python async actions, if `ctx.durable_execute_async(...)` is not awaited,
the result is not recorded and cannot be replayed.
+- In Python, a recorded failure is replayed as the original exception when it
can be pickled and rebuilt. Otherwise it is replayed as a `RuntimeError` with
the message `<class>: <message>`, so code that catches a specific exception
type may take a different path during replay. A failure that cannot be pickled
is recorded this way too, so the call is not executed again on recovery.
Failures recorded by a version that stored only the pickled exception have no
class name or message to fall [...]
**With a reconciler:**
diff --git a/python/flink_agents/api/runner_context.py
b/python/flink_agents/api/runner_context.py
index 5b5cdc84c..d14e171a7 100644
--- a/python/flink_agents/api/runner_context.py
+++ b/python/flink_agents/api/runner_context.py
@@ -100,6 +100,8 @@ class DurableFuture(ABC, Generic[T]):
except Exception as error:
# Control-flow BaseExceptions leave the handle unresolved so a
later
# await or gather cannot mistake cancellation for a durable
outcome.
+ if self._is_cancellation(error):
+ raise
self._error = error
self._done = True
raise
@@ -117,6 +119,12 @@ class DurableFuture(ABC, Generic[T]):
self._error = outcome.error
self._done = True
+ def _is_cancellation(self, error: Exception) -> bool:
+ """Return whether ``error`` reports a cancelled attempt rather than the
+ outcome of the call. Such an error leaves the handle unresolved.
+ """
+ return False
+
@abstractmethod
def _resolve(self) -> Any:
"""Resolve this future and return a generator consumed by
``__await__``."""
diff --git a/python/flink_agents/runtime/durable_exception.py
b/python/flink_agents/runtime/durable_exception.py
new file mode 100644
index 000000000..ecdf6d8ae
--- /dev/null
+++ b/python/flink_agents/runtime/durable_exception.py
@@ -0,0 +1,207 @@
+################################################################################
+# 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.
+#################################################################################
+"""Persisted form of an exception raised by a durable call.
+
+An exception is stored as a marker followed by a pickled dict that keeps the
class
+name and message next to the pickled exception. Pickle alone is not enough:
some
+exceptions cannot be pickled, and others pickle but cannot be rebuilt. With the
+class name and message stored separately, replay can always raise something
that
+names the recorded failure.
+"""
+
+import logging
+from typing import Any
+
+import cloudpickle
+
+logger = logging.getLogger(__name__)
+
+# A pickle stream never starts with these bytes, so a payload without them is
one
+# written before this format existed: the pickled exception itself.
+_MARKER = b"FLINK-AGENTS-EXC\x00"
+_VERSION = 1
+# The message is stored next to the pickle, which usually holds it too. The
copy is
+# bounded so a long message does not double the record; the fallback only needs
+# enough of it to name the failure.
+_MAX_MESSAGE_LENGTH = 16 * 1024
+_TRUNCATION_MARK = "... [truncated]"
+
+# java.io.InterruptedIOException is left out on purpose. HTTP stacks raise it
for an
+# ordinary network timeout, which must stay a recorded failure. Same choice as
+# ModelRoutingResolver.isCancellation on the Java side.
+_JAVA_INTERRUPTION_CLASS_NAMES = frozenset(
+ {
+ "java.lang.InterruptedException",
+ "java.nio.channels.ClosedByInterruptException",
+ }
+)
+_MAX_DEPTH = 64
+
+
+def _class_name(exception: BaseException) -> str:
+ cls = type(exception)
+ return f"{cls.__module__}.{cls.__qualname__}"
+
+
+def durable_exception_message(exception: BaseException) -> str:
+ """Return the message of ``exception``, tolerating a formatter that
raises."""
+ try:
+ return str(exception)
+ except Exception:
+ return "<message unavailable>"
+
+
+def _recorded_message(exception: BaseException) -> str:
+ message = durable_exception_message(exception)
+ if len(message) > _MAX_MESSAGE_LENGTH:
+ return message[:_MAX_MESSAGE_LENGTH] + _TRUNCATION_MARK
+ return message
+
+
+def serialize_durable_exception(exception: BaseException) -> bytes:
+ """Serialize ``exception`` for the durable call record. Never raises for an
+ exception that cannot be pickled; the class name and message are kept
instead.
+ """
+ try:
+ pickled = cloudpickle.dumps(exception)
+ except Exception:
+ logger.debug(
+ "Durable call exception %s cannot be pickled; "
+ "recording its class name and message only.",
+ _class_name(exception),
+ exc_info=True,
+ )
+ pickled = None
+ return _MARKER + cloudpickle.dumps(
+ {
+ "version": _VERSION,
+ "class": _class_name(exception),
+ "message": _recorded_message(exception),
+ "pickle": pickled,
+ }
+ )
+
+
+def deserialize_durable_exception(payload: bytes) -> BaseException:
+ """Rebuild the exception recorded for a durable call.
+
+ Returns the original exception when it can be rebuilt, and otherwise a
+ ``RuntimeError`` carrying the recorded class name and message.
+ """
+ payload = bytes(payload)
+ if not payload.startswith(_MARKER):
+ return cloudpickle.loads(payload)
+
+ try:
+ record = cloudpickle.loads(payload[len(_MARKER) :])
+ version = record["version"]
+ except Exception as e:
+ msg = "The recorded durable call exception cannot be read."
+ raise RuntimeError(msg) from e
+ if type(version) is not int or version != _VERSION:
+ msg = f"Unsupported durable call exception record version: {version}"
+ raise RuntimeError(msg)
+ try:
+ class_name = record["class"]
+ message = record["message"]
+ pickled = record["pickle"]
+ except Exception as e:
+ msg = "The recorded durable call exception is incomplete."
+ raise RuntimeError(msg) from e
+ if not (
+ isinstance(class_name, str)
+ and isinstance(message, str)
+ and (pickled is None or isinstance(pickled, bytes))
+ ):
+ msg = "The recorded durable call exception is malformed."
+ raise RuntimeError(msg)
+
+ if pickled is not None:
+ try:
+ exception = cloudpickle.loads(pickled)
+ except Exception:
+ logger.debug(
+ "Recorded durable call exception %s cannot be rebuilt; "
+ "raising its class name and message instead.",
+ class_name,
+ exc_info=True,
+ )
+ else:
+ if isinstance(exception, BaseException):
+ return exception
+ return RuntimeError(f"{class_name}: {message}")
+
+
+def _is_pemja_object(obj: Any) -> bool:
+ cls = type(obj)
+ return cls.__name__ == "PyJObject" and cls.__module__.split(".")[0] ==
"pemja"
+
+
+def _java_class_is_interruption(j_class: Any) -> bool:
+ depth = 0
+ while j_class is not None and depth < _MAX_DEPTH:
+ if str(j_class.getName()) in _JAVA_INTERRUPTION_CLASS_NAMES:
+ return True
+ j_class = j_class.getSuperclass()
+ depth += 1
+ return False
+
+
+def _java_throwable_is_interruption(obj: Any) -> bool:
+ try:
+ depth = 0
+ while obj is not None and depth < _MAX_DEPTH and _is_pemja_object(obj):
+ if _java_class_is_interruption(obj.getClass()):
+ return True
+ obj = obj.getCause()
+ depth += 1
+ except BaseException:
+ # Not a throwable, or the Java call failed: nothing to conclude from
it.
+ # BaseException is caught on purpose, here and in
is_java_interruption: the
+ # caller is about to record or re-raise a failure, and anything raised
while
+ # looking at it would replace that failure.
+ logger.debug("Failed to inspect a Java object.", exc_info=True)
+ return False
+
+
+def is_java_interruption(exception: BaseException | None) -> bool:
+ """Whether ``exception`` carries a Java throwable that is, or was caused
by, a
+ thread interruption.
+
+ Pemja raises a Java exception in Python as ``RuntimeError`` whose only
argument
+ is the Java throwable, so the throwable's class and cause chain are
checked,
+ never the message. Such a failure belongs to a cancelled attempt and must
not
+ be recorded as the result of the call.
+
+ Only the explicit cause chain (``raise ... from error``) is followed. The
+ implicit context is not: a failure raised while an interruption was being
+ handled is a failure of its own. An exception that cannot be inspected is
not
+ an interruption, and the inspection never replaces it with a failure of its
+ own.
+ """
+ try:
+ current = exception
+ depth = 0
+ while current is not None and depth < _MAX_DEPTH:
+ if any(_java_throwable_is_interruption(arg) for arg in
current.args):
+ return True
+ current = current.__cause__
+ depth += 1
+ except BaseException:
+ logger.debug("Failed to inspect a durable call exception.",
exc_info=True)
+ return False
diff --git a/python/flink_agents/runtime/flink_runner_context.py
b/python/flink_agents/runtime/flink_runner_context.py
index c385b7b82..048ba04bc 100644
--- a/python/flink_agents/runtime/flink_runner_context.py
+++ b/python/flink_agents/runtime/flink_runner_context.py
@@ -45,6 +45,12 @@ from flink_agents.api.runner_context import (
RunnerContext,
)
from flink_agents.api.trace import ExecutionReporter
+from flink_agents.runtime.durable_exception import (
+ deserialize_durable_exception,
+ durable_exception_message,
+ is_java_interruption,
+ serialize_durable_exception,
+)
from flink_agents.runtime.durable_execution import (
_validate_reconciler_callable,
durable_identity_for_call,
@@ -175,7 +181,7 @@ class _DurableExecutionException(Exception):
exception: BaseException,
record_callback: Callable,
) -> None:
- super().__init__(str(exception))
+ super().__init__(durable_exception_message(exception))
self.func = func
self.args = args
self.kwargs = kwargs
@@ -382,6 +388,10 @@ class _SingleDurableFuture(DurableFuture[Any]):
self._ctx = ctx
self._call = call
+ @override
+ def _is_cancellation(self, error: Exception) -> bool:
+ return is_java_interruption(error)
+
def _resolve(self) -> Any:
return (yield from
self._ctx._resolve_durable_call(self._call).__await__())
@@ -394,6 +404,10 @@ class _GatherDurableFuture(DurableFuture[list[Outcome]]):
self._ctx = ctx
self._futures = futures
+ @override
+ def _is_cancellation(self, error: Exception) -> bool:
+ return is_java_interruption(error)
+
def _resolve(self) -> Any:
outcomes_by_index: dict[int, Outcome] = {}
unresolved_futures = []
@@ -901,7 +915,9 @@ class FlinkRunnerContext(RunnerContext, ExecutionReporter):
if is_hit:
if exception_payload is not None:
# Store cached exception to re-raise outside try block
- cached_exception =
cloudpickle.loads(bytes(exception_payload))
+ cached_exception = deserialize_durable_exception(
+ exception_payload
+ )
elif result_payload is not None:
return True, cloudpickle.loads(bytes(result_payload))
else:
@@ -942,11 +958,17 @@ class FlinkRunnerContext(RunnerContext,
ExecutionReporter):
exception : BaseException | None
The exception raised by the function (None if successful).
"""
+ if is_java_interruption(exception):
+ # The attempt was cancelled: record nothing, so recovery executes
the
+ # call again instead of replaying the interruption.
+ return
+
function_id = durable_identity_for_call(func, args, kwargs)
try:
- result_payload = None if exception else cloudpickle.dumps(result)
- exception_payload = cloudpickle.dumps(exception) if exception else
None
+ result_payload, exception_payload = self._serialize_call_payloads(
+ result, exception
+ )
self._j_runner_context.recordCallCompletion(
function_id, result_payload, exception_payload
@@ -962,7 +984,9 @@ class FlinkRunnerContext(RunnerContext, ExecutionReporter):
exception: BaseException | None,
) -> tuple[bytes | None, bytes | None]:
result_payload = None if exception else cloudpickle.dumps(result)
- exception_payload = cloudpickle.dumps(exception) if exception else None
+ exception_payload = (
+ serialize_durable_exception(exception) if exception else None
+ )
return result_payload, exception_payload
def _read_call_result_at(self, index: int) -> _PersistedCallResult | None:
@@ -1012,6 +1036,11 @@ class FlinkRunnerContext(RunnerContext,
ExecutionReporter):
result: Any,
exception: BaseException | None,
) -> None:
+ if is_java_interruption(exception):
+ # The attempt was cancelled: leave the slot pending, so recovery
+ # executes or reconciles the call instead of replaying the
interruption.
+ return
+
function_id = durable_identity_for_call(func, args, kwargs)
result_payload, exception_payload = self._serialize_call_payloads(
result,
@@ -1203,7 +1232,9 @@ class FlinkRunnerContext(RunnerContext,
ExecutionReporter):
def _read_terminal_outcome(self, current: _PersistedCallResult) -> Outcome:
try:
if current.exception_payload is not None:
- return
Outcome.failure(cloudpickle.loads(current.exception_payload))
+ return Outcome.failure(
+ deserialize_durable_exception(current.exception_payload)
+ )
if current.result_payload is None:
return Outcome.success(None)
return Outcome.success(cloudpickle.loads(current.result_payload))
@@ -1290,6 +1321,11 @@ class FlinkRunnerContext(RunnerContext,
ExecutionReporter):
if not started[i]:
outcomes[call_index] = outcome
continue
+ if is_java_interruption(outcome.error):
+ # The attempt was cancelled: leave this slot and the ones
after it
+ # pending and propagate, as the Java batch does. Slots
finalized
+ # earlier in this loop keep their outcome.
+ raise outcome.error
try:
result_payload, exception_payload =
self._serialize_call_payloads(
outcome.value,
diff --git a/python/flink_agents/runtime/tests/test_durable_exception.py
b/python/flink_agents/runtime/tests/test_durable_exception.py
new file mode 100644
index 000000000..1c2e37571
--- /dev/null
+++ b/python/flink_agents/runtime/tests/test_durable_exception.py
@@ -0,0 +1,659 @@
+################################################################################
+# 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 threading
+from typing import Any
+
+import cloudpickle
+import pytest
+
+from flink_agents.runtime.durable_exception import (
+ deserialize_durable_exception,
+ is_java_interruption,
+ serialize_durable_exception,
+)
+from flink_agents.runtime.tests.test_flink_runner_context_reconcilable import (
+ _close_runner_context,
+ _create_runner_context,
+ _FakeJavaRunnerContext,
+ _preload_pending,
+ _run_async,
+)
+
+
+class _KeywordOnlyError(Exception):
+ """Pickles, but cannot be rebuilt: unpickling calls ``cls(*args)``."""
+
+ def __init__(self, message: str, *, status: int) -> None:
+ super().__init__(message)
+ self.status = status
+
+
+class _UnpicklableError(Exception):
+ def __init__(self, message: str) -> None:
+ super().__init__(message)
+ self.lock = threading.Lock()
+
+
+_KEYWORD_ONLY_NAME = f"{__name__}._KeywordOnlyError"
+_UNPICKLABLE_NAME = f"{__name__}._UnpicklableError"
+
+
+def _refuse_pickling(self: Any) -> None:
+ msg = "cannot pickle 'pemja.PyJObject' object"
+ raise TypeError(msg)
+
+
+def _fake_java_throwable(class_name: str, cause: Any = None) -> Any:
+ """Build an object shaped like the ``pemja.PyJObject`` of a Java
throwable: it
+ prints as the Java throwable does and cannot be pickled.
+ """
+
+ def j_class(name: str | None) -> Any:
+ if name is None:
+ return None
+ parent = "java.lang.Exception" if name != "java.lang.Exception" else
None
+ return type(
+ "PyJObject",
+ (),
+ {
+ "__module__": "pemja",
+ "getName": lambda self: name,
+ "getSuperclass": lambda self: j_class(parent),
+ },
+ )()
+
+ return type(
+ "PyJObject",
+ (),
+ {
+ "__module__": "pemja",
+ "__reduce_ex__": lambda self, protocol: _refuse_pickling(self),
+ "__str__": lambda self: f"{class_name}: call failed",
+ "getClass": lambda self: j_class(class_name),
+ "getCause": lambda self: cause,
+ },
+ )()
+
+
+_INTERRUPTED = "java.lang.InterruptedException"
+_CLOSED_BY_INTERRUPT = "java.nio.channels.ClosedByInterruptException"
+_INTERRUPTED_IO = "java.io.InterruptedIOException"
+_ILLEGAL_STATE = "java.lang.IllegalStateException"
+
+
+def _java_error(class_name: str, cause: Any = None) -> RuntimeError:
+ return RuntimeError(_fake_java_throwable(class_name, cause))
+
+
+def _execute(ctx: Any, asynchronous: bool, func: Any, **kwargs: Any) -> Any:
+ if asynchronous:
+ return _run_async(ctx.durable_execute_async(func, **kwargs))
+ return ctx.durable_execute(func, **kwargs)
+
+
+def _run_twice(
+ store: _FakeJavaRunnerContext, asynchronous: bool, func: Any, **kwargs: Any
+) -> list[BaseException]:
+ """Execute the call, then replay it in a new context over the same
store."""
+ raised = []
+ for _ in range(2):
+ store.current_call_index = 0
+ ctx = _create_runner_context(store)
+ try:
+ with pytest.raises(BaseException) as error:
+ _execute(ctx, asynchronous, func, **kwargs)
+ raised.append(error.value)
+ finally:
+ _close_runner_context(ctx)
+ return raised
+
+
+def test_picklable_exception_round_trips_with_its_type() -> None:
+ restored = deserialize_durable_exception(
+ serialize_durable_exception(ValueError("boom"))
+ )
+
+ assert type(restored) is ValueError
+ assert restored.args == ("boom",)
+
+
+def test_exception_that_cannot_be_rebuilt_falls_back_to_class_and_message() ->
None:
+ restored = deserialize_durable_exception(
+ serialize_durable_exception(_KeywordOnlyError("rate limited",
status=429))
+ )
+
+ assert type(restored) is RuntimeError
+ assert str(restored) == f"{_KEYWORD_ONLY_NAME}: rate limited"
+
+
+def test_exception_that_cannot_be_pickled_falls_back_to_class_and_message() ->
None:
+ restored = deserialize_durable_exception(
+ serialize_durable_exception(_UnpicklableError("payment declined"))
+ )
+
+ assert type(restored) is RuntimeError
+ assert str(restored) == f"{_UNPICKLABLE_NAME}: payment declined"
+
+
+def test_payload_written_before_the_envelope_still_loads() -> None:
+ restored =
deserialize_durable_exception(cloudpickle.dumps(ValueError("old")))
+
+ assert type(restored) is ValueError
+ assert restored.args == ("old",)
+
+
[email protected]("asynchronous", [False, True])
+def test_replay_of_exception_that_cannot_be_rebuilt(asynchronous: bool) ->
None:
+ calls = []
+
+ def call() -> None:
+ calls.append(1)
+ msg = "rate limited"
+ raise _KeywordOnlyError(msg, status=429)
+
+ store = _FakeJavaRunnerContext()
+ first, replayed = _run_twice(store, asynchronous, call)
+
+ assert type(first) is _KeywordOnlyError
+ assert type(replayed) is RuntimeError
+ assert str(replayed) == f"{_KEYWORD_ONLY_NAME}: rate limited"
+ assert len(calls) == 1
+ assert [slot.status for slot in store.call_results] == ["FAILED"]
+
+
+def test_gather_replays_exception_that_cannot_be_rebuilt() -> None:
+ calls = []
+
+ def call() -> None:
+ calls.append(1)
+ msg = "rate limited"
+ raise _KeywordOnlyError(msg, status=429)
+
+ store = _FakeJavaRunnerContext()
+ errors = []
+ for _ in range(2):
+ store.current_call_index = 0
+ ctx = _create_runner_context(store)
+ try:
+ outcomes = _run_async(ctx.gather(ctx.durable_execute_async(call)))
+ errors.append(outcomes[0].error)
+ finally:
+ _close_runner_context(ctx)
+
+ assert type(errors[0]) is _KeywordOnlyError
+ assert type(errors[1]) is RuntimeError
+ assert str(errors[1]) == f"{_KEYWORD_ONLY_NAME}: rate limited"
+ assert len(calls) == 1
+
+
[email protected]("asynchronous", [False, True])
+def test_exception_that_cannot_be_pickled_is_recorded(asynchronous: bool) ->
None:
+ calls = []
+
+ def call() -> None:
+ calls.append(1)
+ msg = "payment declined"
+ raise _UnpicklableError(msg)
+
+ store = _FakeJavaRunnerContext()
+ first, replayed = _run_twice(store, asynchronous, call)
+
+ assert type(first) is _UnpicklableError
+ assert type(replayed) is RuntimeError
+ assert str(replayed) == f"{_UNPICKLABLE_NAME}: payment declined"
+ assert len(calls) == 1
+ assert [slot.status for slot in store.call_results] == ["FAILED"]
+
+
[email protected]("asynchronous", [False, True])
[email protected]("use_reconciler", [False, True])
+def test_pending_slot_keeps_exception_that_cannot_be_pickled(
+ use_reconciler: bool, asynchronous: bool
+) -> None:
+ def call() -> None:
+ msg = "payment declined"
+ raise _UnpicklableError(msg)
+
+ def reconciler() -> None:
+ msg = "payment declined"
+ raise _UnpicklableError(msg)
+
+ store = _FakeJavaRunnerContext()
+ _preload_pending(store, call)
+ ctx = _create_runner_context(store)
+ kwargs = {"reconciler": reconciler} if use_reconciler else {}
+ try:
+ with pytest.raises(_UnpicklableError, match="payment declined"):
+ _execute(ctx, asynchronous, call, **kwargs)
+ finally:
+ _close_runner_context(ctx)
+
+ assert [slot.status for slot in store.call_results] == ["FAILED"]
+ restored =
deserialize_durable_exception(store.call_results[0].exception_payload)
+ assert str(restored) == f"{_UNPICKLABLE_NAME}: payment declined"
+
+
[email protected](
+ "error",
+ [
+ _java_error(_INTERRUPTED),
+ _java_error(_CLOSED_BY_INTERRUPT),
+ _java_error("java.lang.RuntimeException",
_fake_java_throwable(_INTERRUPTED)),
+ ],
+ ids=["direct", "closed-by-interrupt", "wrapped"],
+)
+def test_java_interruption_is_recognized(error: RuntimeError) -> None:
+ wrapped = ConnectionError("call failed")
+ wrapped.__cause__ = error
+
+ assert is_java_interruption(error)
+ assert is_java_interruption(wrapped)
+
+
[email protected](
+ "error",
+ [
+ _java_error(_ILLEGAL_STATE),
+ # An ordinary network timeout is an InterruptedIOException too.
+ _java_error(_INTERRUPTED_IO),
+ RuntimeError("java.lang.InterruptedException: sleep interrupted"),
+ ValueError("boom"),
+ None,
+ ],
+ ids=["other-java", "interrupted-io", "message-only", "python", "none"],
+)
+def test_other_failures_are_not_java_interruptions(error: Any) -> None:
+ assert not is_java_interruption(error)
+
+
+def test_java_interruption_is_not_recorded() -> None:
+ calls = []
+
+ def call() -> str:
+ calls.append(1)
+ if len(calls) == 1:
+ raise _java_error(_INTERRUPTED)
+ return "done"
+
+ store = _FakeJavaRunnerContext()
+ ctx = _create_runner_context(store)
+ try:
+ with pytest.raises(RuntimeError):
+ ctx.durable_execute(call)
+ finally:
+ _close_runner_context(ctx)
+ assert store.call_results == []
+
+ store.current_call_index = 0
+ ctx = _create_runner_context(store)
+ try:
+ assert ctx.durable_execute(call) == "done"
+ finally:
+ _close_runner_context(ctx)
+ assert len(calls) == 2
+ assert [slot.status for slot in store.call_results] == ["SUCCEEDED"]
+
+
[email protected]("use_reconciler", [False, True])
+def test_java_interruption_leaves_pending_slot(use_reconciler: bool) -> None:
+ def call() -> None:
+ raise _java_error(_INTERRUPTED)
+
+ store = _FakeJavaRunnerContext()
+ _preload_pending(store, call)
+ ctx = _create_runner_context(store)
+ kwargs = {"reconciler": call} if use_reconciler else {}
+ try:
+ with pytest.raises(RuntimeError):
+ ctx.durable_execute(call, **kwargs)
+ finally:
+ _close_runner_context(ctx)
+
+ assert [slot.status for slot in store.call_results] == ["PENDING"]
+ assert store.current_call_index == 0
+
+
+def test_other_java_failure_is_recorded() -> None:
+ calls = []
+
+ def call() -> None:
+ calls.append(1)
+ raise _java_error(_ILLEGAL_STATE)
+
+ store = _FakeJavaRunnerContext()
+ first, replayed = _run_twice(store, False, call)
+
+ assert type(first) is RuntimeError
+ assert type(replayed) is RuntimeError
+ assert str(replayed) == f"builtins.RuntimeError: {_ILLEGAL_STATE}: call
failed"
+ assert len(calls) == 1
+ assert [slot.status for slot in store.call_results] == ["FAILED"]
+
+
+class _UnprintableError(Exception):
+ def __str__(self) -> str:
+ msg = "formatter failed"
+ raise ValueError(msg)
+
+
+def test_exception_whose_message_cannot_be_formatted_round_trips() -> None:
+ restored = deserialize_durable_exception(
+ serialize_durable_exception(_UnprintableError())
+ )
+
+ assert type(restored) is _UnprintableError
+
+
[email protected]("pending", [False, True])
+def test_exception_whose_message_cannot_be_formatted_is_recorded(
+ pending: bool,
+) -> None:
+ def call() -> None:
+ raise _UnprintableError
+
+ store = _FakeJavaRunnerContext()
+ if pending:
+ _preload_pending(store, call)
+ ctx = _create_runner_context(store)
+ try:
+ with pytest.raises(_UnprintableError):
+ ctx.durable_execute(call)
+ finally:
+ _close_runner_context(ctx)
+
+ assert [slot.status for slot in store.call_results] == ["FAILED"]
+
+
+def test_non_ascii_message_survives_the_fallback() -> None:
+ restored = deserialize_durable_exception(
+ serialize_durable_exception(_UnpicklableError("결제 거절: café"))
+ )
+
+ assert str(restored) == f"{_UNPICKLABLE_NAME}: 결제 거절: café"
+
+
[email protected]("protocol", range(cloudpickle.pickle.HIGHEST_PROTOCOL
+ 1))
+def test_payload_written_with_any_pickle_protocol_is_read_as_legacy(
+ protocol: int,
+) -> None:
+ payload = cloudpickle.dumps(ValueError("old"), protocol=protocol)
+
+ restored = deserialize_durable_exception(payload)
+
+ assert type(restored) is ValueError
+
+
[email protected]("wrapper", [bytes, bytearray, memoryview])
+def test_payload_is_read_from_any_bytes_like_object(wrapper: Any) -> None:
+ payload = wrapper(serialize_durable_exception(ValueError("boom")))
+
+ assert type(deserialize_durable_exception(payload)) is ValueError
+
+
[email protected](
+ "payload",
+ [
+ b"FLINK-AGENTS-EXC\x00not a pickle",
+ b"FLINK-AGENTS-EXC\x00" + cloudpickle.dumps(["not", "a", "record"]),
+ b"FLINK-AGENTS-EXC\x00" + cloudpickle.dumps({"version": 99}),
+ b"FLINK-AGENTS-EXC\x00" + cloudpickle.dumps({"version": 1}),
+ b"FLINK-AGENTS-EXC\x00"
+ + cloudpickle.dumps(
+ {"version": 1, "class": None, "message": None, "pickle": None}
+ ),
+ b"FLINK-AGENTS-EXC\x00"
+ + cloudpickle.dumps(
+ {"version": True, "class": "E", "message": "m", "pickle": None}
+ ),
+ ],
+ ids=[
+ "corrupt",
+ "not-a-record",
+ "unknown-version",
+ "incomplete",
+ "malformed",
+ "boolean-version",
+ ],
+)
+def test_unreadable_record_is_reported(payload: bytes) -> None:
+ with pytest.raises(RuntimeError, match="durable call exception"):
+ deserialize_durable_exception(payload)
+
+
+def test_failure_raised_while_handling_an_interruption_is_not_one() -> None:
+ try:
+ try:
+ raise _java_error(_INTERRUPTED)
+ except RuntimeError:
+ msg = "declined"
+ raise ValueError(msg) # noqa: B904
+ except ValueError as error:
+ failure = error
+
+ assert failure.__context__ is not None
+ assert not is_java_interruption(failure)
+
+
+def test_interruption_is_found_past_an_argument_that_cannot_be_inspected() ->
None:
+ not_a_throwable = type("PyJObject", (), {"__module__": "pemja"})()
+ error = ConnectionError(not_a_throwable)
+ error.__cause__ = _java_error(_INTERRUPTED)
+
+ assert is_java_interruption(error)
+
+
[email protected]("use_reconciler", [False, True])
+def test_java_interruption_is_not_recorded_by_async_call(
+ use_reconciler: bool,
+) -> None:
+ def call() -> None:
+ raise _java_error(_INTERRUPTED)
+
+ store = _FakeJavaRunnerContext()
+ ctx = _create_runner_context(store)
+ kwargs = {"reconciler": call} if use_reconciler else {}
+ try:
+ with pytest.raises(RuntimeError):
+ _run_async(ctx.durable_execute_async(call, **kwargs))
+ finally:
+ _close_runner_context(ctx)
+
+ expected = ["PENDING"] if use_reconciler else []
+ assert [slot.status for slot in store.call_results] == expected
+ assert store.current_call_index == 0
+
+
[email protected]("asynchronous", [False, True])
[email protected]("use_reconciler", [False, True])
+def test_java_interruption_leaves_preloaded_pending_slot(
+ use_reconciler: bool, asynchronous: bool
+) -> None:
+ interruption = _java_error(_INTERRUPTED)
+
+ def call() -> None:
+ raise interruption
+
+ store = _FakeJavaRunnerContext()
+ _preload_pending(store, call)
+ ctx = _create_runner_context(store)
+ kwargs = {"reconciler": call} if use_reconciler else {}
+ try:
+ with pytest.raises(RuntimeError) as raised:
+ _execute(ctx, asynchronous, call, **kwargs)
+ finally:
+ _close_runner_context(ctx)
+
+ assert raised.value is interruption
+ assert [slot.status for slot in store.call_results] == ["PENDING"]
+ assert store.current_call_index == 0
+
+
+def test_interrupted_future_is_executed_again_when_awaited_again() -> None:
+ calls = []
+
+ def call() -> str:
+ calls.append(1)
+ if len(calls) == 1:
+ raise _java_error(_INTERRUPTED)
+ return "done"
+
+ store = _FakeJavaRunnerContext()
+ ctx = _create_runner_context(store)
+ try:
+ future = ctx.durable_execute_async(call)
+ with pytest.raises(RuntimeError):
+ _run_async(future)
+ outcomes = _run_async(ctx.gather(future))
+ finally:
+ _close_runner_context(ctx)
+
+ assert outcomes[0].value == "done"
+ assert len(calls) == 2
+ assert [slot.status for slot in store.call_results] == ["SUCCEEDED"]
+
+
+def
test_exception_whose_message_cannot_be_formatted_is_recorded_by_async_call() ->
(
+ None
+):
+ def call() -> None:
+ raise _UnprintableError
+
+ store = _FakeJavaRunnerContext()
+ ctx = _create_runner_context(store)
+ try:
+ with pytest.raises(_UnprintableError):
+ _run_async(ctx.durable_execute_async(call))
+ finally:
+ _close_runner_context(ctx)
+
+ assert [slot.status for slot in store.call_results] == ["FAILED"]
+
+
+def test_java_interruption_in_gather_leaves_the_slot_pending() -> None:
+ interruption = _java_error(_INTERRUPTED)
+
+ def interrupted() -> None:
+ raise interruption
+
+ def declined() -> None:
+ msg = "payment declined"
+ raise _UnpicklableError(msg)
+
+ def completed() -> str:
+ return "done"
+
+ store = _FakeJavaRunnerContext()
+ ctx = _create_runner_context(store)
+ try:
+ with pytest.raises(RuntimeError) as raised:
+ _run_async(
+ ctx.gather(
+ ctx.durable_execute_async(declined),
+ ctx.durable_execute_async(interrupted),
+ ctx.durable_execute_async(completed),
+ )
+ )
+ finally:
+ _close_runner_context(ctx)
+
+ assert raised.value is interruption
+ assert [slot.status for slot in store.call_results] == [
+ "FAILED",
+ "PENDING",
+ "PENDING",
+ ]
+ assert store.current_call_index == 0
+
+
+def test_gather_records_exception_that_cannot_be_pickled() -> None:
+ calls = []
+
+ def declined() -> None:
+ calls.append(1)
+ msg = "payment declined"
+ raise _UnpicklableError(msg)
+
+ store = _FakeJavaRunnerContext()
+ errors = []
+ for _ in range(2):
+ store.current_call_index = 0
+ ctx = _create_runner_context(store)
+ try:
+ outcomes =
_run_async(ctx.gather(ctx.durable_execute_async(declined)))
+ errors.append(outcomes[0].error)
+ finally:
+ _close_runner_context(ctx)
+
+ assert type(errors[0]) is _UnpicklableError
+ assert str(errors[1]) == f"{_UNPICKLABLE_NAME}: payment declined"
+ assert len(calls) == 1
+
+
+def test_interrupted_gather_is_executed_again_when_awaited_again() -> None:
+ calls = []
+
+ def call() -> str:
+ calls.append(1)
+ if len(calls) == 1:
+ raise _java_error(_INTERRUPTED)
+ return "done"
+
+ store = _FakeJavaRunnerContext()
+ ctx = _create_runner_context(store)
+ try:
+ gathered = ctx.gather(ctx.durable_execute_async(call))
+ with pytest.raises(RuntimeError):
+ _run_async(gathered)
+ outcomes = _run_async(gathered)
+ finally:
+ _close_runner_context(ctx)
+
+ assert outcomes[0].value == "done"
+ assert len(calls) == 2
+ assert [slot.status for slot in store.call_results] == ["SUCCEEDED"]
+
+
+def test_inspection_never_replaces_the_failure() -> None:
+ class _Hostile(Exception):
+ @property
+ def args(self) -> tuple:
+ raise KeyboardInterrupt
+
+ assert not is_java_interruption(_Hostile())
+
+
+def test_long_message_is_stored_once() -> None:
+ message = "x" * 450_000
+ error = ValueError(message)
+
+ payload = serialize_durable_exception(error)
+ restored = deserialize_durable_exception(payload)
+
+ assert len(payload) < len(cloudpickle.dumps(error)) + 20 * 1024
+ assert restored.args == (message,)
+
+
+def test_long_message_of_unpicklable_exception_is_truncated_in_the_fallback()
-> None:
+ restored = deserialize_durable_exception(
+ serialize_durable_exception(_UnpicklableError("x" * 20_000))
+ )
+
+ text = str(restored)
+ assert text.startswith(f"{_UNPICKLABLE_NAME}: " + "x" * 16 * 1024)
+ assert text.endswith("... [truncated]")
+ assert len(text) < 17 * 1024
diff --git
a/python/flink_agents/runtime/tests/test_flink_runner_context_reconcilable.py
b/python/flink_agents/runtime/tests/test_flink_runner_context_reconcilable.py
index f034ae44a..7c272e5eb 100644
---
a/python/flink_agents/runtime/tests/test_flink_runner_context_reconcilable.py
+++
b/python/flink_agents/runtime/tests/test_flink_runner_context_reconcilable.py
@@ -26,6 +26,7 @@ import cloudpickle
import pytest
from flink_agents.plan.configuration import AgentConfiguration
+from flink_agents.runtime.durable_exception import
deserialize_durable_exception
from flink_agents.runtime.durable_execution import (
durable_identity_for_call,
)
@@ -352,7 +353,7 @@ def
test_flink_runner_context_sync_reconciler_exception_persists_failure() -> No
assert call_count == 0
assert j_runner_context.operations == ["peek", "finalize"]
assert j_runner_context.call_results[0].status == "FAILED"
- persisted_exception = cloudpickle.loads(
+ persisted_exception = deserialize_durable_exception(
j_runner_context.call_results[0].exception_payload
)
assert isinstance(persisted_exception, ValueError)
@@ -822,7 +823,7 @@ def
test_flink_runner_context_async_reconciler_exception_persists_failure() -> N
assert call_count == 0
assert j_runner_context.operations == ["peek", "finalize"]
assert j_runner_context.call_results[0].status == "FAILED"
- persisted_exception = cloudpickle.loads(
+ persisted_exception = deserialize_durable_exception(
j_runner_context.call_results[0].exception_payload
)
assert isinstance(persisted_exception, RuntimeError)