Files
wehub-resource-sync c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

346 lines
15 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
import os
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any
from networkx import MultiDiGraph
from haystack import logging
from haystack.core.errors import PipelineInvalidPipelineSnapshotError
from haystack.core.pipeline.utils import _deepcopy_with_exceptions
from haystack.dataclasses.breakpoints import Breakpoint, PipelineSnapshot, PipelineState
from haystack.utils.base_serialization import _serialize_value_with_schema
logger = logging.getLogger(__name__)
# Environment variable to control pipeline snapshot file saving (enabled by default)
HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED = "HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED"
# Type alias for snapshot callback function
# The callback receives a PipelineSnapshot and optionally returns a file path string
SnapshotCallback = Callable[[PipelineSnapshot], str | None]
def _is_snapshot_save_enabled() -> bool:
"""
Check if pipeline snapshot file saving is enabled via environment variable.
The environment variable HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED controls whether
pipeline snapshots are saved to files. By default (when the variable is not set),
saving is disabled. Only "true" and "1" (case-insensitive) enable saving; any other value disables it.
:returns: True if snapshot saving is enabled, False otherwise.
"""
value = os.environ.get(HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED, "false").lower()
return value in ("true", "1")
def _validate_break_point_against_pipeline(break_point: Breakpoint, graph: MultiDiGraph) -> None:
"""
Validates the breakpoints passed to the pipeline.
Makes sure the breakpoint contains a valid components registered in the pipeline.
:param break_point: a breakpoint to validate
"""
if break_point.component_name not in graph.nodes:
raise ValueError(f"break_point {break_point} is not a registered component in the pipeline")
def _validate_pipeline_snapshot_against_pipeline(pipeline_snapshot: PipelineSnapshot, graph: MultiDiGraph) -> None:
"""
Validates that the pipeline_snapshot contains valid configuration for the current pipeline.
Raises a PipelineInvalidPipelineSnapshotError if any component in pipeline_snapshot is not part of the
target pipeline.
:param pipeline_snapshot: The saved state to validate.
"""
pipeline_state = pipeline_snapshot.pipeline_state
valid_components = set(graph.nodes.keys())
# Check if the ordered_component_names are valid components in the pipeline
invalid_ordered_components = set(pipeline_snapshot.ordered_component_names) - valid_components
if invalid_ordered_components:
raise PipelineInvalidPipelineSnapshotError(
f"Invalid pipeline snapshot: components {invalid_ordered_components} in 'ordered_component_names' "
f"are not part of the current pipeline."
)
# Check if the original_input_data is valid components in the pipeline
serialized_input_data = pipeline_snapshot.original_input_data["serialized_data"]
invalid_input_data = set(serialized_input_data.keys()) - valid_components
if invalid_input_data:
raise PipelineInvalidPipelineSnapshotError(
f"Invalid pipeline snapshot: components {invalid_input_data} in 'input_data' "
f"are not part of the current pipeline."
)
# Validate 'component_visits'
invalid_component_visits = set(pipeline_state.component_visits.keys()) - valid_components
if invalid_component_visits:
raise PipelineInvalidPipelineSnapshotError(
f"Invalid pipeline snapshot: components {invalid_component_visits} in 'component_visits' "
f"are not part of the current pipeline."
)
component_name = pipeline_snapshot.break_point.component_name
visit_count = pipeline_snapshot.pipeline_state.component_visits[component_name]
logger.info(
"Resuming pipeline from {component} with visit count {visits}", component=component_name, visits=visit_count
)
def load_pipeline_snapshot(file_path: str | Path) -> PipelineSnapshot:
"""
Load a saved pipeline snapshot.
:param file_path: Path to the pipeline_snapshot file.
:returns:
Dict containing the loaded pipeline_snapshot.
"""
file_path = Path(file_path)
try:
with open(file_path, encoding="utf-8") as f:
pipeline_snapshot_dict = json.load(f)
except FileNotFoundError as e:
raise FileNotFoundError(f"File not found: {file_path}") from e
except json.JSONDecodeError as e:
raise json.JSONDecodeError(f"Invalid JSON file {file_path}: {str(e)}", e.doc, e.pos) from e
except OSError as e:
raise OSError(f"Error reading {file_path}: {str(e)}") from e
try:
pipeline_snapshot = PipelineSnapshot.from_dict(pipeline_snapshot_dict)
except ValueError as e:
raise ValueError(f"Invalid pipeline snapshot from {file_path}: {str(e)}") from e
logger.info("Successfully loaded the pipeline snapshot from: {file_path}", file_path=file_path)
return pipeline_snapshot
def _save_pipeline_snapshot(
pipeline_snapshot: PipelineSnapshot,
raise_on_failure: bool = True,
snapshot_callback: SnapshotCallback | None = None,
) -> str | None:
"""
Save the pipeline snapshot dictionary to a JSON file, or invoke a custom callback.
If a `snapshot_callback` is provided, it will be called with the pipeline snapshot instead of saving to a file.
This allows users to customize how snapshots are handled (e.g., saving to a database, sending to a remote service).
When no callback is provided, the default behavior saves to a JSON file:
- The filename is generated based on the component name, visit count, and timestamp.
- The component name is taken from the break point's `component_name`.
- The visit count is taken from the pipeline state's `component_visits` for the component name.
- The timestamp is taken from the pipeline snapshot's `timestamp` or the current time if not available.
- The file path is taken from the break point's `snapshot_file_path`.
- If the `snapshot_file_path` is None, the function will return without saving.
The default file saving behavior is disabled. To enable it, set the environment variable
`HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED` to "true" or "1". When disabled,
the function will return None without saving to a file (custom callbacks are still invoked).
:param pipeline_snapshot: The pipeline snapshot to save.
:param raise_on_failure: If True, raises an exception if saving fails. If False, logs the error and returns.
:param snapshot_callback: Optional callback function that receives the PipelineSnapshot.
If provided, the callback is invoked instead of the default file-saving behavior.
The callback should return an optional string (e.g., a file path or identifier) or None.
:returns:
The full path to the saved JSON file (or the value returned by the callback), or None if
`snapshot_file_path` is None, no callback is provided, or snapshot saving is disabled.
:raises:
Exception: If saving the JSON snapshot fails (when raise_on_failure is True).
"""
# If a callback is provided, use it instead of the default file-saving behavior
if snapshot_callback is not None:
try:
result = snapshot_callback(pipeline_snapshot)
logger.info("Pipeline snapshot handled by custom callback.")
return result
except Exception as error:
logger.exception("Failed to handle pipeline snapshot with custom callback. Error: {error}", error=error)
if raise_on_failure:
raise
return None
# Check if snapshot saving is enabled via environment variable (enabled by default)
if not _is_snapshot_save_enabled():
logger.debug("Pipeline snapshot file saving is disabled via HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED env var.")
return None
break_point = pipeline_snapshot.break_point
snapshot_file_path = break_point.snapshot_file_path
if snapshot_file_path is None:
return None
dt = pipeline_snapshot.timestamp or datetime.now()
snapshot_dir = Path(snapshot_file_path)
component_name = break_point.component_name
visit_nr = pipeline_snapshot.pipeline_state.component_visits.get(component_name, 0)
timestamp = dt.strftime("%Y_%m_%d_%H_%M_%S")
file_name = f"{component_name}_{visit_nr}_{timestamp}.json"
full_path = snapshot_dir / file_name
try:
snapshot_dir.mkdir(parents=True, exist_ok=True)
with open(full_path, "w") as f_out:
json.dump(pipeline_snapshot.to_dict(), f_out, indent=2)
logger.info(
"Pipeline snapshot saved to '{full_path}'. You can use this file to debug or resume the pipeline.",
full_path=full_path,
)
except Exception as error:
logger.exception("Failed to save pipeline snapshot to '{full_path}'. Error: {e}", full_path=full_path, e=error)
if raise_on_failure:
raise
return str(full_path)
def _create_pipeline_snapshot(
*,
inputs: dict[str, Any],
component_inputs: dict[str, Any],
break_point: Breakpoint,
component_visits: dict[str, int],
original_input_data: dict[str, Any],
ordered_component_names: list[str],
include_outputs_from: set[str],
pipeline_outputs: dict[str, Any],
) -> PipelineSnapshot:
"""
Create a snapshot of the pipeline at the point where the breakpoint was triggered.
:param inputs: The current pipeline snapshot inputs.
:param component_inputs: The inputs to the component that triggered the breakpoint.
:param break_point: The breakpoint that triggered the snapshot.
:param component_visits: The visit count of the component that triggered the breakpoint.
:param original_input_data: The original input data.
:param ordered_component_names: The ordered component names.
:param include_outputs_from: Set of component names whose outputs should be included in the pipeline results.
:param pipeline_outputs: The current outputs of the pipeline.
:returns:
A PipelineSnapshot containing the state of the pipeline at the point of the breakpoint.
"""
component_name = break_point.component_name
transformed_original_input_data = _transform_json_structure(original_input_data)
transformed_inputs = _transform_json_structure({**inputs, component_name: component_inputs})
serialized_inputs = _serialize_with_field_fallback(
transformed_inputs, description="the inputs of the current pipeline state"
)
serialized_original_input_data = _serialize_with_field_fallback(
transformed_original_input_data, description="original input data for `pipeline.run`"
)
serialized_pipeline_outputs = _serialize_with_field_fallback(
pipeline_outputs, description="outputs of the current pipeline state"
)
return PipelineSnapshot(
pipeline_state=PipelineState(
inputs=serialized_inputs, component_visits=component_visits, pipeline_outputs=serialized_pipeline_outputs
),
timestamp=datetime.now(),
break_point=break_point,
original_input_data=serialized_original_input_data,
ordered_component_names=ordered_component_names,
include_outputs_from=include_outputs_from,
)
def _transform_json_structure(data: dict[str, Any] | list[Any] | Any) -> Any:
"""
Transforms a JSON structure by removing the 'sender' key and moving the 'value' to the top level.
For example:
"key": [{"sender": null, "value": "some value"}] -> "key": "some value"
:param data: The JSON structure to transform.
:returns: The transformed structure.
"""
if isinstance(data, dict):
# If this dict has both 'sender' and 'value', return just the value
if "value" in data and "sender" in data:
return data["value"]
# Otherwise, recursively process each key-value pair
return {k: _transform_json_structure(v) for k, v in data.items()}
if isinstance(data, list):
# First, transform each item in the list.
transformed = [_transform_json_structure(item) for item in data]
# If the original list has exactly one element and that element was a dict
# with 'sender' and 'value', then unwrap the list.
if len(data) == 1 and isinstance(data[0], dict) and "value" in data[0] and "sender" in data[0]:
return transformed[0]
return transformed
# For other data types, just return the value as is.
return data
def _serialize_with_field_fallback(payload: Any, *, description: str) -> dict[str, Any]:
"""
Serialize a payload and, on failure, retry field-by-field to preserve resumable fields.
If the whole payload serializes, the result is returned as-is. Otherwise, and if the payload is a
mapping, each top-level field is serialized individually and only the failing fields are omitted.
When the payload is not a mapping, or when every field fails to serialize, the helper returns a
structurally valid empty-object payload so that the downstream ``_deserialize_value_with_schema``
can still load it back instead of raising ``DeserializationError`` on a bare ``{}``.
:param payload: The value to serialize.
:param description: Short human-readable label used in warning messages, for example
``"the agent's chat_generator inputs"`` or ``"the inputs of the current pipeline state"``.
:returns: A dict of the form ``{"serialization_schema": ..., "serialized_data": ...}``.
"""
try:
return _serialize_value_with_schema(_deepcopy_with_exceptions(payload))
except Exception as error:
logger.warning(
"Failed to serialize {description}. "
"Haystack will omit only the non-serializable fields when possible. Error: {e}",
description=description,
e=error,
)
serialized_properties: dict[str, Any] = {}
serialized_data: dict[str, Any] = {}
if isinstance(payload, dict):
for field_name, value in payload.items():
try:
serialized_value = _serialize_value_with_schema(_deepcopy_with_exceptions(value))
except Exception as field_error:
logger.warning(
"Failed to serialize the '{field_name}' field of {description}. "
"The field will be omitted from the snapshot. Error: {e}",
field_name=field_name,
description=description,
e=field_error,
)
continue
serialized_properties[field_name] = serialized_value["serialization_schema"]
serialized_data[field_name] = serialized_value["serialized_data"]
return {
"serialization_schema": {"type": "object", "properties": serialized_properties},
"serialized_data": serialized_data,
}