Coverage for haystack/core/pipeline/breakpoint.py: 90%

106 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import json 

6import os 

7from collections.abc import Callable 

8from datetime import datetime 

9from pathlib import Path 

10from typing import Any 

11 

12from networkx import MultiDiGraph 

13 

14from haystack import logging 

15from haystack.core.errors import PipelineInvalidPipelineSnapshotError 

16from haystack.dataclasses.breakpoints import INTERNAL_INPUTS_FORMAT, Breakpoint, PipelineSnapshot, PipelineState 

17from haystack.utils.base_serialization import _serialize_with_field_fallback 

18 

19logger = logging.getLogger(__name__) 

20 

21# Environment variable to control pipeline snapshot file saving (enabled by default) 

22HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED = "HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED" 

23 

24# Type alias for snapshot callback function 

25# The callback receives a PipelineSnapshot and optionally returns a file path string 

26SnapshotCallback = Callable[[PipelineSnapshot], str | None] 

27 

28 

29def _is_snapshot_save_enabled() -> bool: 

30 """ 

31 Check if pipeline snapshot file saving is enabled via environment variable. 

32 

33 The environment variable HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED controls whether 

34 pipeline snapshots are saved to files. By default (when the variable is not set), 

35 saving is disabled. Only "true" and "1" (case-insensitive) enable saving; any other value disables it. 

36 

37 :returns: True if snapshot saving is enabled, False otherwise. 

38 """ 

39 value = os.environ.get(HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED, "false").lower() 

40 return value in ("true", "1") 

41 

42 

43def _validate_break_point_against_pipeline(break_point: Breakpoint, graph: MultiDiGraph) -> None: 

44 """ 

45 Validates the breakpoints passed to the pipeline. 

46 

47 Makes sure the breakpoint contains a valid components registered in the pipeline. 

48 

49 :param break_point: a breakpoint to validate 

50 """ 

51 if break_point.component_name not in graph.nodes: 

52 raise ValueError(f"break_point {break_point} is not a registered component in the pipeline") 

53 

54 

55def _validate_pipeline_snapshot_against_pipeline(pipeline_snapshot: PipelineSnapshot, graph: MultiDiGraph) -> None: 

56 """ 

57 Validates that the pipeline_snapshot contains valid configuration for the current pipeline. 

58 

59 Raises a PipelineInvalidPipelineSnapshotError if any component in pipeline_snapshot is not part of the 

60 target pipeline. 

61 

62 :param pipeline_snapshot: The saved state to validate. 

63 """ 

64 

65 pipeline_state = pipeline_snapshot.pipeline_state 

66 valid_components = set(graph.nodes.keys()) 

67 

68 # Check if the ordered_component_names are valid components in the pipeline 

69 invalid_ordered_components = set(pipeline_snapshot.ordered_component_names) - valid_components 

70 if invalid_ordered_components: 

71 raise PipelineInvalidPipelineSnapshotError( 

72 f"Invalid pipeline snapshot: components {invalid_ordered_components} in 'ordered_component_names' " 

73 f"are not part of the current pipeline." 

74 ) 

75 

76 # Check if the original_input_data is valid components in the pipeline 

77 serialized_input_data = pipeline_snapshot.original_input_data["serialized_data"] 

78 invalid_input_data = set(serialized_input_data.keys()) - valid_components 

79 if invalid_input_data: 

80 raise PipelineInvalidPipelineSnapshotError( 

81 f"Invalid pipeline snapshot: components {invalid_input_data} in 'input_data' " 

82 f"are not part of the current pipeline." 

83 ) 

84 

85 # Validate 'component_visits' 

86 invalid_component_visits = set(pipeline_state.component_visits.keys()) - valid_components 

87 if invalid_component_visits: 

88 raise PipelineInvalidPipelineSnapshotError( 

89 f"Invalid pipeline snapshot: components {invalid_component_visits} in 'component_visits' " 

90 f"are not part of the current pipeline." 

91 ) 

92 

93 component_name = pipeline_snapshot.break_point.component_name 

94 visit_count = pipeline_snapshot.pipeline_state.component_visits[component_name] 

95 

96 logger.info( 

97 "Resuming pipeline from {component} with visit count {visits}", component=component_name, visits=visit_count 

98 ) 

99 

100 

101def load_pipeline_snapshot(file_path: str | Path) -> PipelineSnapshot: 

102 """ 

103 Load a saved pipeline snapshot. 

104 

105 :param file_path: Path to the pipeline_snapshot file. 

106 :returns: 

107 Dict containing the loaded pipeline_snapshot. 

108 """ 

109 

110 file_path = Path(file_path) 

111 

112 try: 

113 with open(file_path, encoding="utf-8") as f: 

114 pipeline_snapshot_dict = json.load(f) 

115 except FileNotFoundError as e: 

116 raise FileNotFoundError(f"File not found: {file_path}") from e 

117 except json.JSONDecodeError as e: 

118 raise json.JSONDecodeError(f"Invalid JSON file {file_path}: {str(e)}", e.doc, e.pos) from e 

119 except OSError as e: 

120 raise OSError(f"Error reading {file_path}: {str(e)}") from e 

121 

122 try: 

123 pipeline_snapshot = PipelineSnapshot.from_dict(pipeline_snapshot_dict) 

124 except ValueError as e: 

125 raise ValueError(f"Invalid pipeline snapshot from {file_path}: {str(e)}") from e 

126 

127 logger.info("Successfully loaded the pipeline snapshot from: {file_path}", file_path=file_path) 

128 return pipeline_snapshot 

129 

130 

131def _save_pipeline_snapshot( 

132 pipeline_snapshot: PipelineSnapshot, 

133 raise_on_failure: bool = True, 

134 snapshot_callback: SnapshotCallback | None = None, 

135) -> str | None: 

136 """ 

137 Save the pipeline snapshot dictionary to a JSON file, or invoke a custom callback. 

138 

139 If a `snapshot_callback` is provided, it will be called with the pipeline snapshot instead of saving to a file. 

140 This allows users to customize how snapshots are handled (e.g., saving to a database, sending to a remote service). 

141 

142 When no callback is provided, the default behavior saves to a JSON file: 

143 - The filename is generated based on the component name, visit count, and timestamp. 

144 - The component name is taken from the break point's `component_name`. 

145 - The visit count is taken from the pipeline state's `component_visits` for the component name. 

146 - The timestamp is taken from the pipeline snapshot's `timestamp` or the current time if not available. 

147 - The file path is taken from the break point's `snapshot_file_path`. 

148 - If the `snapshot_file_path` is None, the function will return without saving. 

149 

150 The default file saving behavior is disabled. To enable it, set the environment variable 

151 `HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED` to "true" or "1". When disabled, 

152 the function will return None without saving to a file (custom callbacks are still invoked). 

153 

154 :param pipeline_snapshot: The pipeline snapshot to save. 

155 :param raise_on_failure: If True, raises an exception if saving fails. If False, logs the error and returns. 

156 :param snapshot_callback: Optional callback function that receives the PipelineSnapshot. 

157 If provided, the callback is invoked instead of the default file-saving behavior. 

158 The callback should return an optional string (e.g., a file path or identifier) or None. 

159 

160 :returns: 

161 The full path to the saved JSON file (or the value returned by the callback), or None if 

162 `snapshot_file_path` is None, no callback is provided, or snapshot saving is disabled. 

163 :raises: 

164 Exception: If saving the JSON snapshot fails (when raise_on_failure is True). 

165 """ 

166 # If a callback is provided, use it instead of the default file-saving behavior 

167 if snapshot_callback is not None: 

168 try: 

169 result = snapshot_callback(pipeline_snapshot) 

170 logger.info("Pipeline snapshot handled by custom callback.") 

171 return result 

172 except Exception as error: 

173 logger.exception("Failed to handle pipeline snapshot with custom callback. Error: {error}", error=error) 

174 if raise_on_failure: 

175 raise 

176 return None 

177 

178 # Check if snapshot saving is enabled via environment variable (enabled by default) 

179 if not _is_snapshot_save_enabled(): 

180 logger.debug("Pipeline snapshot file saving is disabled via HAYSTACK_PIPELINE_SNAPSHOT_SAVE_ENABLED env var.") 

181 return None 

182 

183 break_point = pipeline_snapshot.break_point 

184 snapshot_file_path = break_point.snapshot_file_path 

185 

186 if snapshot_file_path is None: 

187 return None 

188 

189 dt = pipeline_snapshot.timestamp or datetime.now() 

190 snapshot_dir = Path(snapshot_file_path) 

191 

192 component_name = break_point.component_name 

193 visit_nr = pipeline_snapshot.pipeline_state.component_visits.get(component_name, 0) 

194 timestamp = dt.strftime("%Y_%m_%d_%H_%M_%S") 

195 file_name = f"{component_name}_{visit_nr}_{timestamp}.json" 

196 full_path = snapshot_dir / file_name 

197 

198 try: 

199 snapshot_dir.mkdir(parents=True, exist_ok=True) 

200 with open(full_path, "w") as f_out: 

201 json.dump(pipeline_snapshot.to_dict(), f_out, indent=2) 

202 logger.info( 

203 "Pipeline snapshot saved to '{full_path}'. You can use this file to debug or resume the pipeline.", 

204 full_path=full_path, 

205 ) 

206 except Exception as error: 

207 logger.exception("Failed to save pipeline snapshot to '{full_path}'. Error: {e}", full_path=full_path, e=error) 

208 if raise_on_failure: 

209 raise 

210 

211 return str(full_path) 

212 

213 

214def _create_pipeline_snapshot( 

215 *, 

216 inputs: dict[str, Any], 

217 component_inputs: dict[str, Any], 

218 break_point: Breakpoint, 

219 component_visits: dict[str, int], 

220 original_input_data: dict[str, Any], 

221 ordered_component_names: list[str], 

222 include_outputs_from: set[str], 

223 pipeline_outputs: dict[str, Any], 

224) -> PipelineSnapshot: 

225 """ 

226 Create a snapshot of the pipeline at the point where the breakpoint was triggered. 

227 

228 :param inputs: The current pipeline snapshot inputs. 

229 :param component_inputs: The inputs of the component that triggered the breakpoint, as they were before the 

230 component consumed them. 

231 :param break_point: The breakpoint that triggered the snapshot. 

232 :param component_visits: The visit count of the component that triggered the breakpoint. 

233 :param original_input_data: The original input data. 

234 :param ordered_component_names: The ordered component names. 

235 :param include_outputs_from: Set of component names whose outputs should be included in the pipeline results. 

236 :param pipeline_outputs: The current outputs of the pipeline. 

237 :returns: 

238 A PipelineSnapshot containing the state of the pipeline at the point of the breakpoint. 

239 """ 

240 component_name = break_point.component_name 

241 

242 transformed_original_input_data = _transform_json_structure(original_input_data) 

243 

244 serialized_inputs = _serialize_with_field_fallback( 

245 {**inputs, component_name: component_inputs}, description="the inputs of the current pipeline state" 

246 ) 

247 serialized_original_input_data = _serialize_with_field_fallback( 

248 transformed_original_input_data, description="original input data for `pipeline.run`" 

249 ) 

250 serialized_pipeline_outputs = _serialize_with_field_fallback( 

251 pipeline_outputs, description="outputs of the current pipeline state" 

252 ) 

253 

254 return PipelineSnapshot( 

255 pipeline_state=PipelineState( 

256 inputs=serialized_inputs, 

257 component_visits=component_visits, 

258 pipeline_outputs=serialized_pipeline_outputs, 

259 inputs_format=INTERNAL_INPUTS_FORMAT, 

260 ), 

261 timestamp=datetime.now(), 

262 break_point=break_point, 

263 original_input_data=serialized_original_input_data, 

264 ordered_component_names=ordered_component_names, 

265 include_outputs_from=include_outputs_from, 

266 ) 

267 

268 

269def _transform_json_structure(data: dict[str, Any] | list[Any] | Any) -> Any: 

270 """ 

271 Transforms a JSON structure by removing the 'sender' key and moving the 'value' to the top level. 

272 

273 For example: 

274 "key": [{"sender": null, "value": "some value"}] -> "key": "some value" 

275 

276 :param data: The JSON structure to transform. 

277 :returns: The transformed structure. 

278 """ 

279 if isinstance(data, dict): 

280 # If this dict has both 'sender' and 'value', return just the value 

281 if "value" in data and "sender" in data: 

282 return data["value"] 

283 # Otherwise, recursively process each key-value pair 

284 return {k: _transform_json_structure(v) for k, v in data.items()} 

285 

286 if isinstance(data, list): 

287 # First, transform each item in the list. 

288 transformed = [_transform_json_structure(item) for item in data] 

289 # If the original list has exactly one element and that element was a dict 

290 # with 'sender' and 'value', then unwrap the list. 

291 if len(data) == 1 and isinstance(data[0], dict) and "value" in data[0] and "sender" in data[0]: 

292 return transformed[0] 

293 return transformed 

294 

295 # For other data types, just return the value as is. 

296 return data