Scope runtime checkpoint storage to its owning run
Close the stream-drop race where a dropped run's deferred async-generator finalizer could leave a runtime checkpoint storage override set (inherited by a new run) or clear a successor run's storage. run() now defensively clears any stale override before starting, and _run_core only clears the override if this run still owns it (mirroring the _active_run ownership guard). Adds regression tests for both the inheritance and clobber cases.
This commit is contained in:
@@ -371,6 +371,12 @@ class Workflow(DictConvertible):
|
||||
# so a subsequent ``run()`` is allowed.
|
||||
self._active_run: weakref.ref[ResponseStream[WorkflowEvent, WorkflowRunResult]] | None = None
|
||||
|
||||
# Identifies which run currently owns the runtime checkpoint storage override
|
||||
# on the RunnerContext (keyed by that run's ``_active_run`` weakref). Used to
|
||||
# scope clearing so a deferred async-generator finalizer from a dropped run
|
||||
# cannot clobber a successor run's storage.
|
||||
self._runtime_storage_owner: weakref.ref[ResponseStream[WorkflowEvent, WorkflowRunResult]] | None = None
|
||||
|
||||
@property
|
||||
def status(self) -> WorkflowRunState:
|
||||
"""Return the current run-level status of this workflow instance.
|
||||
@@ -756,6 +762,13 @@ class Workflow(DictConvertible):
|
||||
"Workflow is already running; concurrent runs are not allowed on the same instance."
|
||||
)
|
||||
|
||||
# No run is active, so any runtime checkpoint storage override still set on the
|
||||
# context is stale - left over from a prior run whose stream was dropped before
|
||||
# its async-generator finalizer ran. Clear it so this run starts clean and does
|
||||
# not silently inherit the prior run's runtime checkpoint storage.
|
||||
self._runner.context.clear_runtime_checkpoint_storage()
|
||||
self._runtime_storage_owner = None
|
||||
|
||||
response_stream = ResponseStream[WorkflowEvent, WorkflowRunResult](
|
||||
self._run_core(
|
||||
message=message,
|
||||
@@ -790,10 +803,6 @@ class Workflow(DictConvertible):
|
||||
Yields:
|
||||
WorkflowEvent: The events generated during the workflow execution.
|
||||
"""
|
||||
# Enable runtime checkpointing if storage provided
|
||||
if checkpoint_storage is not None:
|
||||
self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage)
|
||||
|
||||
# Capture the weakref instance ``run()`` installed for *this* run. We
|
||||
# compare by object identity in the finally so a stale finalizer (e.g.
|
||||
# the caller dropped this stream after partial iteration, then started
|
||||
@@ -804,6 +813,13 @@ class Workflow(DictConvertible):
|
||||
# here it already points at our own ``ResponseStream``.
|
||||
my_active_run = self._active_run
|
||||
|
||||
# Enable runtime checkpointing if storage provided, and record that this run
|
||||
# owns the override so the ownership-guarded clear in the finally only fires
|
||||
# for this run (and a deferred finalizer can't clear a successor's storage).
|
||||
if checkpoint_storage is not None:
|
||||
self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage)
|
||||
self._runtime_storage_owner = my_active_run
|
||||
|
||||
try:
|
||||
# Async validation: a fresh-message run is only allowed when the
|
||||
# runner context has fully drained from any prior run. If it still
|
||||
@@ -853,8 +869,12 @@ class Workflow(DictConvertible):
|
||||
# it would silently break the successor's concurrency guard.
|
||||
if self._active_run is my_active_run:
|
||||
self._active_run = None
|
||||
if checkpoint_storage is not None:
|
||||
# Only clear the runtime checkpoint storage if this run still owns it. A
|
||||
# dropped run's deferred finalizer must not clear a successor run's storage
|
||||
# (mirrors the ``_active_run`` ownership guard above).
|
||||
if checkpoint_storage is not None and self._runtime_storage_owner is my_active_run:
|
||||
self._runner.context.clear_runtime_checkpoint_storage()
|
||||
self._runtime_storage_owner = None
|
||||
|
||||
@staticmethod
|
||||
def _finalize_events(
|
||||
|
||||
@@ -994,6 +994,86 @@ async def test_workflow_partial_stream_does_not_clobber_successor_active_run() -
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
async def test_workflow_stale_runtime_checkpoint_storage_not_inherited() -> None:
|
||||
"""A new run must not inherit a prior run's leftover runtime checkpoint storage.
|
||||
|
||||
If a run that set a runtime ``checkpoint_storage`` override is dropped before
|
||||
its async-generator finalizer clears it, the override can linger on the
|
||||
``RunnerContext`` while ``_is_run_active()`` already reports False. ``run()``
|
||||
defensively clears that stale override so a subsequent run that does not pass
|
||||
its own ``checkpoint_storage`` does not silently checkpoint into it.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
leftover_storage = FileCheckpointStorage(temp_dir)
|
||||
executor = IncrementExecutor(id="stale_storage_exec", limit=3, increment=1)
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
# Simulate a leftover runtime override from a dropped prior run.
|
||||
workflow._runner.context.set_runtime_checkpoint_storage(leftover_storage) # type: ignore[attr-defined]
|
||||
|
||||
# A fresh run without its own checkpoint_storage must not use the leftover.
|
||||
result = await workflow.run(NumberMessage(data=0))
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
checkpoints = await leftover_storage.list_checkpoints(workflow_name=workflow.name)
|
||||
assert checkpoints == [], "Stale runtime checkpoint storage must not be inherited by a new run"
|
||||
assert workflow._runner.context._runtime_checkpoint_storage is None # type: ignore[attr-defined]
|
||||
|
||||
|
||||
async def test_workflow_partial_stream_does_not_clobber_successor_runtime_storage() -> None:
|
||||
"""A stale ``_run_core`` finalizer must not clear a successor's runtime storage.
|
||||
|
||||
Same GC-finalizer race as
|
||||
``test_workflow_partial_stream_does_not_clobber_successor_active_run`` but for the
|
||||
runtime checkpoint storage override: the dropped run's deferred ``finally`` must
|
||||
only clear the override if it still owns it, otherwise it wipes the successor
|
||||
run's storage.
|
||||
"""
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as temp_dir_a,
|
||||
tempfile.TemporaryDirectory() as temp_dir_b,
|
||||
):
|
||||
storage_a = FileCheckpointStorage(temp_dir_a)
|
||||
storage_b = FileCheckpointStorage(temp_dir_b)
|
||||
executor = IncrementExecutor(id="storage_finalizer_exec", limit=100, increment=1)
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
context = workflow._runner.context # type: ignore[attr-defined]
|
||||
|
||||
# Step 1: drive stream A's body to its first yield so it set storage_a.
|
||||
stream_a = workflow.run(NumberMessage(data=0), checkpoint_storage=storage_a, stream=True)
|
||||
aiter_a = stream_a.__aiter__()
|
||||
await aiter_a.__anext__()
|
||||
assert context._runtime_checkpoint_storage is storage_a
|
||||
|
||||
# Step 2: drop stream A; the weakref dies and async-gen close is scheduled
|
||||
# but not run inline.
|
||||
del stream_a
|
||||
del aiter_a
|
||||
gc.collect()
|
||||
|
||||
# Step 3: synchronously start stream B with its own storage and drive it to
|
||||
# its first yield so it set storage_b and took ownership of the override.
|
||||
stream_b = workflow.run(NumberMessage(data=0), checkpoint_storage=storage_b, stream=True)
|
||||
aiter_b = stream_b.__aiter__()
|
||||
await aiter_b.__anext__()
|
||||
assert context._runtime_checkpoint_storage is storage_b
|
||||
|
||||
# Step 4: yield enough for stream A's scheduled aclose to drive its body
|
||||
# through ``GeneratorExit`` and into its ``finally``.
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# With the ownership guard, stream B's override survives. Without it, A's
|
||||
# stale finalizer would have cleared it.
|
||||
assert context._runtime_checkpoint_storage is storage_b
|
||||
|
||||
# Tear down stream B.
|
||||
del stream_b
|
||||
del aiter_b
|
||||
gc.collect()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
class _StreamingTestAgent(BaseAgent):
|
||||
"""Test agent that supports both streaming and non-streaming modes."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user