Skip to content

Commit 66fb9d0

Browse files
sjrldavidsbatista
andauthored
fix!: resume a pipeline snapshot taken on a later loop visit (#12162)
Co-authored-by: David S. Batista <dsbatista@gmail.com>
1 parent 4ef25c4 commit 66fb9d0

13 files changed

Lines changed: 547 additions & 178 deletions

File tree

haystack/core/component/types.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ def is_variadic(self) -> bool:
7373
@property
7474
def is_mandatory(self) -> bool:
7575
"""Check if the input is mandatory."""
76-
return self.default_value == _empty
76+
# Identity, so a default with a custom `__eq__`, such as a DataFrame, is never compared to the sentinel.
77+
return self.default_value is _empty
7778

7879
def __post_init__(self) -> None:
7980
try:

haystack/core/pipeline/base.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
PipelineValidationError,
2828
)
2929
from haystack.core.pipeline.component_checks import (
30-
_NO_OUTPUT_PRODUCED,
30+
_NoOutputProduced,
3131
all_predecessors_executed,
3232
are_all_sockets_ready,
3333
can_component_run,
@@ -1223,7 +1223,9 @@ def _consume_component_inputs(
12231223
greedy_inputs_to_remove = set()
12241224
for socket_name, socket in component["input_sockets"].items():
12251225
socket_inputs = component_inputs.get(socket_name, [])
1226-
socket_inputs_values = [sock["value"] for sock in socket_inputs if sock["value"] is not _NO_OUTPUT_PRODUCED]
1226+
socket_inputs_values = [
1227+
sock["value"] for sock in socket_inputs if not isinstance(sock["value"], _NoOutputProduced)
1228+
]
12271229

12281230
# if we are resuming a component, the inputs are already consumed, so we just return the first input
12291231
if is_resume:
@@ -1546,12 +1548,12 @@ def _write_component_outputs(
15461548
:param include_outputs_from: Set of component names that should always return an output from the pipeline.
15471549
"""
15481550
for receiver_name, sender_socket, receiver_socket, conversion_strategy in receivers:
1549-
# We either get the value that was produced by the actor or we use the _NO_OUTPUT_PRODUCED class to indicate
1551+
# We either get the value that was produced by the actor or we use a _NoOutputProduced marker to indicate
15501552
# that the sender did not produce an output for this socket.
15511553
# This allows us to track if a predecessor already ran but did not produce an output.
1552-
value = component_outputs.get(sender_socket.name, _NO_OUTPUT_PRODUCED)
1554+
value = component_outputs.get(sender_socket.name, _NoOutputProduced())
15531555

1554-
if value is not _NO_OUTPUT_PRODUCED and conversion_strategy:
1556+
if not isinstance(value, _NoOutputProduced) and conversion_strategy:
15551557
try:
15561558
value = _convert_value(value=value, conversion_strategy=conversion_strategy)
15571559
except Exception as e:
@@ -1588,7 +1590,8 @@ def _write_component_outputs(
15881590
)
15891591
else:
15901592
# If the receiver socket is not lazy variadic, it is greedy variadic or non-variadic.
1591-
# We overwrite with the new input if it's not _NO_OUTPUT_PRODUCED or if the current value is None.
1593+
# We overwrite with the new input if it's not a _NoOutputProduced marker, or if the current value
1594+
# is None.
15921595
_write_to_standard_socket(
15931596
inputs=inputs,
15941597
receiver_name=receiver_name,
@@ -1861,5 +1864,5 @@ def _write_to_standard_socket(
18611864
current_value = inputs[receiver_name].get(receiver_socket_name)
18621865

18631866
# Only overwrite if there's no existing value, or we have a new value to provide
1864-
if current_value is None or value is not _NO_OUTPUT_PRODUCED:
1867+
if current_value is None or not isinstance(value, _NoOutputProduced):
18651868
inputs[receiver_name][receiver_socket_name] = [{"sender": component_name, "value": value}]

haystack/core/pipeline/breakpoint.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
from haystack import logging
1515
from haystack.core.errors import PipelineInvalidPipelineSnapshotError
16-
from haystack.dataclasses.breakpoints import Breakpoint, PipelineSnapshot, PipelineState
16+
from haystack.dataclasses.breakpoints import INTERNAL_INPUTS_FORMAT, Breakpoint, PipelineSnapshot, PipelineState
17+
from haystack.utils import _deserialize_value_with_schema
1718
from haystack.utils.base_serialization import _serialize_with_field_fallback
1819

1920
logger = logging.getLogger(__name__)
@@ -211,6 +212,48 @@ def _save_pipeline_snapshot(
211212
return str(full_path)
212213

213214

215+
def _serialize_internal_inputs(inputs: dict[str, Any]) -> dict[str, Any]:
216+
"""
217+
Serialize the pipeline's internal inputs state, keeping the `sender` of every input intact.
218+
219+
The `sender` tells a resumed run which inputs came from a predecessor and which from outside the pipeline, and
220+
therefore whether the paused component can be triggered again.
221+
222+
The inputs a socket received are stored keyed by position rather than as a list, because a list gets a single
223+
schema derived from its first item. A socket with several senders can hold values of different types, for
224+
example a value from one sender and a `_NoOutputProduced` marker from another, and those need one schema
225+
each to survive the round trip.
226+
227+
:param inputs: The pipeline's internal inputs state.
228+
:returns: A dict of the form `{"serialization_schema": ..., "serialized_data": ...}`.
229+
"""
230+
positional_inputs = {
231+
component_name: {
232+
socket_name: {str(position): entry for position, entry in enumerate(socket_inputs)}
233+
for socket_name, socket_inputs in socket_dict.items()
234+
}
235+
for component_name, socket_dict in inputs.items()
236+
}
237+
238+
return _serialize_with_field_fallback(positional_inputs, description="the inputs of the current pipeline state")
239+
240+
241+
def _deserialize_internal_inputs(serialized_inputs: dict[str, Any]) -> dict[str, Any]:
242+
"""
243+
Restore the pipeline's internal inputs state from a snapshot written by `_serialize_internal_inputs`.
244+
245+
:param serialized_inputs: The `inputs` of a `PipelineState` whose `inputs_format` is `INTERNAL_INPUTS_FORMAT`.
246+
:returns: The pipeline's internal inputs state.
247+
"""
248+
return {
249+
component_name: {
250+
socket_name: [socket_inputs[position] for position in sorted(socket_inputs, key=int)]
251+
for socket_name, socket_inputs in socket_dict.items()
252+
}
253+
for component_name, socket_dict in _deserialize_value_with_schema(serialized_inputs).items()
254+
}
255+
256+
214257
def _create_pipeline_snapshot(
215258
*,
216259
inputs: dict[str, Any],
@@ -226,7 +269,8 @@ def _create_pipeline_snapshot(
226269
Create a snapshot of the pipeline at the point where the breakpoint was triggered.
227270
228271
:param inputs: The current pipeline snapshot inputs.
229-
:param component_inputs: The inputs to the component that triggered the breakpoint.
272+
:param component_inputs: The inputs of the component that triggered the breakpoint, as they were before the
273+
component consumed them.
230274
:param break_point: The breakpoint that triggered the snapshot.
231275
:param component_visits: The visit count of the component that triggered the breakpoint.
232276
:param original_input_data: The original input data.
@@ -239,11 +283,8 @@ def _create_pipeline_snapshot(
239283
component_name = break_point.component_name
240284

241285
transformed_original_input_data = _transform_json_structure(original_input_data)
242-
transformed_inputs = _transform_json_structure({**inputs, component_name: component_inputs})
243286

244-
serialized_inputs = _serialize_with_field_fallback(
245-
transformed_inputs, description="the inputs of the current pipeline state"
246-
)
287+
serialized_inputs = _serialize_internal_inputs({**inputs, component_name: component_inputs})
247288
serialized_original_input_data = _serialize_with_field_fallback(
248289
transformed_original_input_data, description="original input data for `pipeline.run`"
249290
)
@@ -253,7 +294,10 @@ def _create_pipeline_snapshot(
253294

254295
return PipelineSnapshot(
255296
pipeline_state=PipelineState(
256-
inputs=serialized_inputs, component_visits=component_visits, pipeline_outputs=serialized_pipeline_outputs
297+
inputs=serialized_inputs,
298+
component_visits=component_visits,
299+
pipeline_outputs=serialized_pipeline_outputs,
300+
inputs_format=INTERNAL_INPUTS_FORMAT,
257301
),
258302
timestamp=datetime.now(),
259303
break_point=break_point,

haystack/core/pipeline/component_checks.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,38 @@
44

55
from typing import Any
66

7-
from haystack.core.component.types import InputSocket, _empty
7+
from haystack.core.component.types import InputSocket
88

9-
_NO_OUTPUT_PRODUCED = _empty
9+
10+
class _NoOutputProduced:
11+
"""
12+
Marker a socket input holds when its sender ran without producing a value for that socket.
13+
14+
Test for it with `isinstance(value, _NoOutputProduced)`, never with `==`: comparing runs `__eq__` on the value
15+
being checked, which for a value such as a DataFrame does not return a bool. It carries no state, so instances
16+
are interchangeable and every one of them means the same thing.
17+
18+
It is serializable because it reaches a pipeline snapshot as part of the pipeline's inputs.
19+
"""
20+
21+
def __eq__(self, other: object) -> bool:
22+
return isinstance(other, _NoOutputProduced)
23+
24+
def __hash__(self) -> int:
25+
return hash(_NoOutputProduced)
26+
27+
def to_dict(self) -> dict[str, Any]:
28+
"""
29+
Serialize the marker. It carries no state, so the payload is empty.
30+
"""
31+
return {}
32+
33+
@classmethod
34+
def from_dict(cls, data: dict[str, Any]) -> "_NoOutputProduced": # noqa: ARG003
35+
"""
36+
Deserialize the marker.
37+
"""
38+
return cls()
1039

1140

1241
def can_component_run(component: dict, inputs: dict) -> bool:
@@ -103,7 +132,7 @@ def any_socket_value_from_predecessor_received(socket_inputs: list[dict[str, Any
103132
:param socket_inputs: Inputs for the component's socket.
104133
"""
105134
# When sender is None, the input was provided from outside the pipeline.
106-
return any(inp["value"] is not _NO_OUTPUT_PRODUCED and inp["sender"] is not None for inp in socket_inputs)
135+
return any(not isinstance(inp["value"], _NoOutputProduced) and inp["sender"] is not None for inp in socket_inputs)
107136

108137

109138
def has_user_input(inputs: dict) -> bool:
@@ -142,7 +171,7 @@ def any_socket_input_received(socket_inputs: list[dict]) -> bool:
142171
143172
:param socket_inputs: Inputs for the socket.
144173
"""
145-
return any(inp["value"] is not _NO_OUTPUT_PRODUCED for inp in socket_inputs)
174+
return any(not isinstance(inp["value"], _NoOutputProduced) for inp in socket_inputs)
146175

147176

148177
def has_lazy_variadic_socket_received_all_inputs(socket: InputSocket, socket_inputs: list[dict]) -> bool:
@@ -156,7 +185,7 @@ def has_lazy_variadic_socket_received_all_inputs(socket: InputSocket, socket_inp
156185
actual_senders = {
157186
sock["sender"]
158187
for sock in socket_inputs
159-
if sock["value"] is not _NO_OUTPUT_PRODUCED and sock["sender"] is not None
188+
if not isinstance(sock["value"], _NoOutputProduced) and sock["sender"] is not None
160189
}
161190
return expected_senders == actual_senders
162191

@@ -176,7 +205,7 @@ def has_socket_received_all_inputs(socket: InputSocket, socket_inputs: list[dict
176205
if (
177206
socket.is_variadic
178207
and socket.is_greedy
179-
and any(sock["value"] is not _NO_OUTPUT_PRODUCED for sock in socket_inputs)
208+
and any(not isinstance(sock["value"], _NoOutputProduced) for sock in socket_inputs)
180209
):
181210
return True
182211

@@ -185,7 +214,7 @@ def has_socket_received_all_inputs(socket: InputSocket, socket_inputs: list[dict
185214
return True
186215

187216
# The socket is not variadic and the only expected input is complete.
188-
return not socket.is_variadic and socket_inputs[0]["value"] is not _NO_OUTPUT_PRODUCED
217+
return not socket.is_variadic and not isinstance(socket_inputs[0]["value"], _NoOutputProduced)
189218

190219

191220
def all_predecessors_executed(component: dict, inputs: dict) -> bool:

haystack/core/pipeline/pipeline.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@
2121
from haystack.core.pipeline.breakpoint import (
2222
SnapshotCallback,
2323
_create_pipeline_snapshot,
24+
_deserialize_internal_inputs,
2425
_save_pipeline_snapshot,
2526
_validate_break_point_against_pipeline,
2627
_validate_pipeline_snapshot_against_pipeline,
2728
)
2829
from haystack.core.pipeline.utils import _deepcopy_with_exceptions
2930
from haystack.dataclasses import AsyncStreamingCallbackT, StreamingCallbackT, StreamingChunk, select_streaming_callback
30-
from haystack.dataclasses.breakpoints import Breakpoint, PipelineSnapshot
31+
from haystack.dataclasses.breakpoints import INTERNAL_INPUTS_FORMAT, Breakpoint, PipelineSnapshot
3132
from haystack.dataclasses.streaming_chunk import _invoke_streaming_callback
3233
from haystack.telemetry import pipeline_running
3334
from haystack.utils import _deserialize_value_with_schema
@@ -347,6 +348,9 @@ def run( # noqa: PLR0915, PLR0912, C901
347348
include_outputs_from = set()
348349

349350
pipeline_outputs: dict[str, Any] = {}
351+
# Set when resuming from a snapshot that predates `INTERNAL_INPUTS_FORMAT` and therefore lost the sender of
352+
# each input. Cleared as soon as the paused component has run.
353+
legacy_resume_component: str | None = None
350354

351355
if not pipeline_snapshot:
352356
# normalize `data`
@@ -362,14 +366,26 @@ def run( # noqa: PLR0915, PLR0912, C901
362366
# We track component visits to decide if a component can run.
363367
component_visits = dict.fromkeys(ordered_component_names, 0)
364368

369+
inputs = self._convert_to_internal_format(pipeline_inputs=data)
370+
365371
else:
366372
# Validate the pipeline snapshot against the current pipeline graph
367373
_validate_pipeline_snapshot_against_pipeline(pipeline_snapshot, self.graph)
368374

369375
# Handle resuming the pipeline from a snapshot
370376
component_visits = pipeline_snapshot.pipeline_state.component_visits
371377
ordered_component_names = pipeline_snapshot.ordered_component_names
372-
data = _deserialize_value_with_schema(pipeline_snapshot.pipeline_state.inputs)
378+
data = _deserialize_value_with_schema(pipeline_snapshot.original_input_data)
379+
380+
if pipeline_snapshot.pipeline_state.inputs_format == INTERNAL_INPUTS_FORMAT:
381+
inputs = _deserialize_internal_inputs(pipeline_snapshot.pipeline_state.inputs)
382+
else:
383+
# A legacy snapshot lost the sender of each input, so the only thing we can do is treat them as if
384+
# they came from outside the pipeline. The paused component then has to be let through once.
385+
inputs = self._convert_to_internal_format(
386+
pipeline_inputs=_deserialize_value_with_schema(pipeline_snapshot.pipeline_state.inputs)
387+
)
388+
legacy_resume_component = pipeline_snapshot.break_point.component_name
373389

374390
# include_outputs_from from the snapshot when resuming
375391
include_outputs_from = pipeline_snapshot.include_outputs_from
@@ -391,7 +407,6 @@ def run( # noqa: PLR0915, PLR0912, C901
391407
"haystack.pipeline.execution_mode": "sync",
392408
},
393409
) as span:
394-
inputs = self._convert_to_internal_format(pipeline_inputs=data)
395410
priority_queue = self._fill_queue(ordered_component_names, inputs, component_visits)
396411

397412
# check if pipeline is blocked before execution
@@ -431,10 +446,15 @@ def run( # noqa: PLR0915, PLR0912, C901
431446
component_name, component_visits[component_name]
432447
)
433448

434-
if pipeline_snapshot:
435-
is_resume = pipeline_snapshot.break_point.component_name == component_name
436-
else:
437-
is_resume = False
449+
is_resume = legacy_resume_component == component_name
450+
if is_resume:
451+
# Only the first execution of the paused component needs the legacy handling. Later visits of the
452+
# same component inside a loop receive regular inputs and must consume them normally.
453+
legacy_resume_component = None
454+
455+
# A snapshot has to store the component's inputs as they were before the component consumed them, so
456+
# that resuming re-triggers the component the same way this run did.
457+
component_inputs_before_consume = inputs.get(component_name, {})
438458
component_inputs = self._consume_component_inputs(
439459
component_name=component_name, component=component, inputs=inputs, is_resume=is_resume
440460
)
@@ -444,13 +464,6 @@ def run( # noqa: PLR0915, PLR0912, C901
444464
# initialization
445465
component_inputs = self._add_missing_input_defaults(component_inputs, component["input_sockets"])
446466

447-
# Scenario 1: Pipeline snapshot is provided to resume the pipeline at a specific component
448-
# Deserialize the component_inputs if they are passed in the pipeline_snapshot.
449-
# this check will prevent other component_inputs generated at runtime from being deserialized
450-
if pipeline_snapshot and component_name in pipeline_snapshot.pipeline_state.inputs.keys():
451-
for key, value in component_inputs.items():
452-
component_inputs[key] = _deserialize_value_with_schema(value)
453-
454467
try:
455468
component_outputs = self._run_component(
456469
component_name=component_name,
@@ -474,7 +487,7 @@ def run( # noqa: PLR0915, PLR0912, C901
474487
# Create a snapshot of the state of the pipeline before the error occurred.
475488
pipeline_snapshot = _create_pipeline_snapshot(
476489
inputs=_deepcopy_with_exceptions(inputs),
477-
component_inputs=_deepcopy_with_exceptions(component_inputs),
490+
component_inputs=_deepcopy_with_exceptions(component_inputs_before_consume),
478491
break_point=saved_break_point,
479492
component_visits=component_visits,
480493
original_input_data=data,

haystack/dataclasses/breakpoints.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
from haystack.utils.dataclasses import _warn_on_inplace_mutation
1010

11+
# Value of `PipelineState.inputs_format` used by every snapshot Haystack creates now. See that field for the shape
12+
# each format implies.
13+
INTERNAL_INPUTS_FORMAT = "internal"
14+
1115

1216
@dataclass(frozen=True)
1317
class Breakpoint:
@@ -53,11 +57,17 @@ class PipelineState:
5357
:param component_visits: A dictionary mapping component names to their visit counts.
5458
:param inputs: The inputs processed by the pipeline at the time of the snapshot.
5559
:param pipeline_outputs: Dictionary containing the final outputs of the pipeline up to the breakpoint.
60+
:param inputs_format: Which format `inputs` are stored in. `"internal"` means each input records the component
61+
that sent it, keyed by the position it arrived in, as in
62+
`{component: {socket: {"0": {"sender": ..., "value": ...}}}}`. `None` marks snapshots taken before Haystack
63+
recorded the sender, which hold one flattened value per socket, `{component: {socket: value}}`, and can only
64+
be resumed on a component's first visit.
5665
"""
5766

5867
inputs: dict[str, Any]
5968
component_visits: dict[str, int]
6069
pipeline_outputs: dict[str, Any]
70+
inputs_format: str | None = None
6171

6272
def to_dict(self) -> dict[str, Any]:
6373
"""

0 commit comments

Comments
 (0)