Skip to content

Commit fb0a8e2

Browse files
committed
jank
1 parent 31ead4f commit fb0a8e2

10 files changed

Lines changed: 192 additions & 33 deletions

File tree

packages/llama-index-workflows/src/workflows/context/context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ def _workflow_run(
283283
start_event=start_event,
284284
serialized_state=pre.serialized_state,
285285
serializer=pre.serializer,
286+
adapter_state=pre.init_snapshot.adapter_state,
286287
)
287288

288289
# TODO(v3): Remove mutation. Handler will just be the external face.

packages/llama-index-workflows/src/workflows/context/context_types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ class SerializedContext(BaseModel):
131131
# Maps step_name -> SerializedStepWorkerState
132132
workers: dict[str, SerializedStepWorkerState] = Field(default_factory=dict)
133133

134+
# Opaque adapter state for runtime-specific pending data (e.g., receive_queue contents).
135+
# Each adapter defines its own structure. None means no pending state.
136+
adapter_state: Optional[dict[str, Any]] = Field(default=None)
137+
134138
@staticmethod
135139
def from_v0(v0: SerializedContextV0) -> "SerializedContext":
136140
"""Convert V0 format to current format.

packages/llama-index-workflows/src/workflows/context/external_context.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,14 @@ def to_dict(self, serializer: BaseSerializer | None = None) -> dict[str, Any]:
165165

166166
context = broker_state.to_serialized(active_serializer)
167167
context.state = state_data
168+
169+
# Get pending adapter state (e.g., events in receive_queue)
170+
snapshottable = as_snapshottable_adapter(self._external_adapter)
171+
if snapshottable is not None:
172+
adapter_state = snapshottable.get_pending_state()
173+
if adapter_state is not None:
174+
context.adapter_state = adapter_state
175+
168176
return context.model_dump(mode="python")
169177

170178
def cancel(self) -> None:

packages/llama-index-workflows/src/workflows/plugins/basic.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
as_step_worker_functions,
3434
create_workflow_run_function,
3535
)
36-
from workflows.runtime.types.ticks import WorkflowTick
36+
from workflows.runtime.types.ticks import WorkflowTick, tick_from_dict, tick_to_dict
3737
from workflows.workflow import Workflow
3838

3939

@@ -53,11 +53,13 @@ def __init__(
5353
run_id: str,
5454
init_state: BrokerState,
5555
state_store: InMemoryStateStore[Any] | None = None,
56+
serializer: BaseSerializer | None = None,
5657
):
5758
self.run_id = run_id
5859
self.init_state = init_state
5960
self.ticks: list[WorkflowTick] = []
6061
self.state_store = state_store
62+
self.serializer = serializer or JsonSerializer()
6163

6264
# created lazily via cached_property for Python 3.14+ compatibility (they require a running event loop)
6365
@functools.cached_property
@@ -193,6 +195,44 @@ def abort(self) -> None:
193195
def init_state(self) -> BrokerState:
194196
return self._queues.init_state
195197

198+
def get_pending_state(self) -> dict[str, Any] | None:
199+
"""Snapshot receive_queue contents for serialization.
200+
201+
Returns serialized pending ticks that haven't been processed yet.
202+
These are events sent via ctx.send_event() that are still in the queue.
203+
204+
Note: TickCancelRun is filtered out since cancel is a transient operation
205+
that shouldn't survive serialization/resume cycles.
206+
"""
207+
from workflows.runtime.types.ticks import TickCancelRun
208+
209+
# Access the internal deque of asyncio.Queue
210+
# This is a private attribute but stable across Python versions
211+
queue = self._queues.receive_queue
212+
pending_ticks = [
213+
tick
214+
for tick in queue._queue # type: ignore[attr-defined]
215+
if not isinstance(tick, TickCancelRun)
216+
]
217+
if not pending_ticks:
218+
return None
219+
return {
220+
"pending_ticks": [
221+
tick_to_dict(tick, self._queues.serializer) for tick in pending_ticks
222+
]
223+
}
224+
225+
def restore_pending_state(self, state: dict[str, Any]) -> None:
226+
"""Restore pending ticks to receive_queue on resume.
227+
228+
Re-populates the receive_queue with ticks that were pending
229+
at serialization time.
230+
"""
231+
pending_ticks_data = state.get("pending_ticks", [])
232+
for tick_data in pending_ticks_data:
233+
tick = tick_from_dict(tick_data, self._queues.serializer)
234+
self._queues.receive_queue.put_nowait(tick)
235+
196236

197237
class BasicRuntime(Runtime):
198238
"""Default asyncio-based runtime with no durability."""
@@ -251,6 +291,7 @@ def run_workflow(
251291
start_event: StartEvent | None = None,
252292
serialized_state: dict[str, Any] | None = None,
253293
serializer: BaseSerializer | None = None,
294+
adapter_state: dict[str, Any] | None = None,
254295
) -> ExternalRunAdapter:
255296
"""Set up a workflow run. Currently only creates state store.
256297
@@ -276,6 +317,12 @@ def run_workflow(
276317
# might want to lock this better. Unlikely race condition if you spam with the same run_id.
277318
queues = self._get_or_create_queues(run_id, init_state)
278319
queues.state_store = state_store
320+
queues.serializer = active_serializer
321+
322+
# Restore adapter state (pending ticks) if resuming
323+
if adapter_state is not None:
324+
external_adapter = ExternalAsyncioAdapter(queues)
325+
external_adapter.restore_pending_state(adapter_state)
279326

280327
async def run_with_concurrency_limit() -> StopEvent:
281328
# Capture strong reference to queues for the task's lifetime,

packages/llama-index-workflows/src/workflows/plugins/dbos.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ def run_workflow(
144144
start_event: StartEvent | None = None,
145145
serialized_state: dict[str, Any] | None = None,
146146
serializer: BaseSerializer | None = None,
147+
adapter_state: dict[str, Any] | None = None,
147148
) -> ExternalRunAdapter:
148149
"""Set up a workflow run. Currently only creates state store.
149150

packages/llama-index-workflows/src/workflows/runtime/control_loop.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -313,21 +313,17 @@ async def run(
313313

314314
# Resume any in-progress work
315315
self.state, commands = rewind_in_progress(self.state, now)
316-
print(f"DEBUG: rewind_in_progress returned {len(commands)} commands")
317316
for command in commands:
318-
print(f"DEBUG: processing command {type(command).__name__}")
319317
try:
320318
await self.process_command(command)
321319
except Exception:
322320
await self.cleanup_tasks()
323321
raise
324-
print(f"DEBUG: worker_tasks after rewind: {len(self.worker_tasks)}")
325322

326323
# Initialize pull task (single-iteration)
327324
pull_task: asyncio.Task[WorkflowTick | None] | None = asyncio.create_task(
328325
_single_pull(self.adapter)
329326
)
330-
print(f"DEBUG: pull_task created")
331327

332328
# Main event loop
333329
try:
@@ -350,10 +346,6 @@ async def run(
350346
# Drain and process buffered ticks first (from rehydration, queue_tick, etc.)
351347
while self.tick_buffer:
352348
tick = self.tick_buffer.pop(0)
353-
tick_detail = ""
354-
if hasattr(tick, 'event'):
355-
tick_detail = f" ({type(tick.event).__name__})" # type: ignore
356-
print(f"DEBUG: processing tick from buffer: {type(tick).__name__}{tick_detail}")
357349
result = await self._process_tick(tick)
358350
if result is not None:
359351
return result
@@ -368,15 +360,13 @@ async def run(
368360

369361
# Gather all tasks to wait on
370362
all_tasks: set[asyncio.Task[Any]] = {pull_task, *self.worker_tasks}
371-
print(f"DEBUG: waiting on {len(all_tasks)} tasks (workers={len(self.worker_tasks)}, timeout={timeout})")
372363

373364
# Wait for first completion (with timeout for scheduled wakeups)
374365
done, _ = await asyncio.wait(
375366
all_tasks,
376367
timeout=timeout,
377368
return_when=asyncio.FIRST_COMPLETED,
378369
)
379-
print(f"DEBUG: asyncio.wait returned, done={len(done)}")
380370

381371
if not done:
382372
# Timeout - process scheduled ticks
@@ -400,7 +390,6 @@ async def run(
400390
except Exception:
401391
logger.exception("Worker task failed unexpectedly")
402392
continue
403-
print(f"DEBUG: worker completed: {tick_result.step_name}")
404393
result = await self._process_tick(tick_result)
405394
if result is not None:
406395
return result
@@ -418,10 +407,6 @@ async def run(
418407
# Respawn before processing to keep pulling during tick processing
419408
pull_task = asyncio.create_task(_single_pull(self.adapter))
420409
if pull_tick is not None:
421-
tick_detail = ""
422-
if hasattr(pull_tick, 'event'):
423-
tick_detail = f" ({type(pull_tick.event).__name__})" # type: ignore
424-
print(f"DEBUG: pull received: {type(pull_tick).__name__}{tick_detail}")
425410
result = await self._process_tick(pull_tick)
426411
if result is not None:
427412
return result

packages/llama-index-workflows/src/workflows/runtime/types/plugin.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ def run_workflow(
328328
start_event: StartEvent | None = None,
329329
serialized_state: dict[str, Any] | None = None,
330330
serializer: "BaseSerializer | None" = None,
331+
adapter_state: dict[str, Any] | None = None,
331332
) -> ExternalRunAdapter:
332333
"""
333334
Launch a workflow run.
@@ -342,6 +343,7 @@ def run_workflow(
342343
start_event: Optional start event to begin the workflow.
343344
serialized_state: Serialized state store data to restore from.
344345
serializer: Serializer to use for deserializing state.
346+
adapter_state: Opaque adapter state to restore (e.g., pending events).
345347
"""
346348
...
347349

@@ -462,6 +464,32 @@ def replay(self) -> list[WorkflowTick]:
462464
"""
463465
...
464466

467+
def get_pending_state(self) -> dict[str, Any] | None:
468+
"""
469+
Return pending adapter state for serialization.
470+
471+
Called during context serialization to capture any internal state
472+
(like pending events in a receive queue) that should be persisted.
473+
This state will be restored via restore_pending_state() on resume.
474+
475+
Returns:
476+
dict with adapter-specific state, or None if no state to persist.
477+
The format is opaque - each adapter defines its own structure.
478+
"""
479+
return None
480+
481+
def restore_pending_state(self, state: dict[str, Any]) -> None:
482+
"""
483+
Restore pending adapter state on resume.
484+
485+
Called when resuming a workflow to restore any internal state
486+
that was captured by get_pending_state() during serialization.
487+
488+
Args:
489+
state: The adapter-specific state dict from get_pending_state()
490+
"""
491+
pass
492+
465493

466494
def as_snapshottable_adapter(
467495
adapter: ExternalRunAdapter | InternalRunAdapter,

packages/llama-index-workflows/src/workflows/runtime/types/ticks.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@
1717
from __future__ import annotations
1818

1919
from dataclasses import dataclass
20-
from typing import Generic, Union
20+
from typing import TYPE_CHECKING, Generic, Union
2121

2222
from workflows.decorators import R
2323
from workflows.events import Event
2424
from workflows.runtime.types.results import StepFunctionResult
2525

26+
if TYPE_CHECKING:
27+
from workflows.context.serializers import BaseSerializer
28+
2629

2730
@dataclass(frozen=True)
2831
class TickStepResult(Generic[R]):
@@ -68,3 +71,72 @@ class TickTimeout:
6871
WorkflowTick = Union[
6972
TickStepResult[R], TickAddEvent, TickCancelRun, TickPublishEvent, TickTimeout
7073
]
74+
75+
76+
def tick_to_dict(tick: WorkflowTick, serializer: "BaseSerializer") -> dict:
77+
"""Serialize a WorkflowTick to a dict for persistence.
78+
79+
Args:
80+
tick: The tick to serialize
81+
serializer: Serializer for event payloads
82+
83+
Returns:
84+
A dict representation that can be JSON-serialized
85+
"""
86+
if isinstance(tick, TickAddEvent):
87+
return {
88+
"type": "TickAddEvent",
89+
"event": serializer.serialize(tick.event),
90+
"step_name": tick.step_name,
91+
"attempts": tick.attempts,
92+
"first_attempt_at": tick.first_attempt_at,
93+
}
94+
elif isinstance(tick, TickCancelRun):
95+
return {"type": "TickCancelRun"}
96+
elif isinstance(tick, TickPublishEvent):
97+
return {
98+
"type": "TickPublishEvent",
99+
"event": serializer.serialize(tick.event),
100+
}
101+
elif isinstance(tick, TickTimeout):
102+
return {
103+
"type": "TickTimeout",
104+
"timeout": tick.timeout,
105+
}
106+
elif isinstance(tick, TickStepResult):
107+
# TickStepResult shouldn't be in the pending queue, but handle it for completeness
108+
raise ValueError(
109+
"TickStepResult cannot be serialized - it should not be in pending state"
110+
)
111+
else:
112+
raise ValueError(f"Unknown tick type: {type(tick)}")
113+
114+
115+
def tick_from_dict(data: dict, serializer: "BaseSerializer") -> WorkflowTick:
116+
"""Deserialize a WorkflowTick from a dict.
117+
118+
Args:
119+
data: The dict representation from tick_to_dict
120+
serializer: Serializer for event payloads
121+
122+
Returns:
123+
The deserialized WorkflowTick
124+
"""
125+
tick_type = data.get("type")
126+
if tick_type == "TickAddEvent":
127+
return TickAddEvent(
128+
event=serializer.deserialize(data["event"]),
129+
step_name=data.get("step_name"),
130+
attempts=data.get("attempts"),
131+
first_attempt_at=data.get("first_attempt_at"),
132+
)
133+
elif tick_type == "TickCancelRun":
134+
return TickCancelRun()
135+
elif tick_type == "TickPublishEvent":
136+
return TickPublishEvent(
137+
event=serializer.deserialize(data["event"]),
138+
)
139+
elif tick_type == "TickTimeout":
140+
return TickTimeout(timeout=data["timeout"])
141+
else:
142+
raise ValueError(f"Unknown tick type: {tick_type}")

packages/llama-index-workflows/tests/runtime/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def run_workflow(
7070
start_event: StartEvent | None = None,
7171
serialized_state: dict[str, Any] | None = None,
7272
serializer: "BaseSerializer | None" = None,
73+
adapter_state: dict[str, Any] | None = None,
7374
) -> ExternalRunAdapter:
7475
self._current_run_id = run_id
7576
return self.get_external_adapter(run_id)

0 commit comments

Comments
 (0)