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

110 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 09:42 +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 (disabled 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 # A snapshot must describe the complete pipeline it resumes. If the current pipeline contains components that 

77 # were not present when the snapshot was created, they would never be added to the resume queue and could be 

78 # silently skipped. 

79 missing_ordered_components = valid_components - set(pipeline_snapshot.ordered_component_names) 

80 if missing_ordered_components: 

81 raise PipelineInvalidPipelineSnapshotError( 

82 f"Invalid pipeline snapshot: components {missing_ordered_components} in the current pipeline are not " 

83 f"present in 'ordered_component_names'." 

84 ) 

85 

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

87 serialized_input_data = pipeline_snapshot.original_input_data["serialized_data"] 

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

89 if invalid_input_data: 

90 raise PipelineInvalidPipelineSnapshotError( 

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

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

93 ) 

94 

95 # Validate 'component_visits' 

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

97 if invalid_component_visits: 

98 raise PipelineInvalidPipelineSnapshotError( 

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

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

101 ) 

102 

103 component_name = pipeline_snapshot.break_point.component_name 

104 visit_count = pipeline_snapshot.pipeline_state.component_visits[component_name] 

105 

106 logger.info( 

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

108 ) 

109 

110 

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

112 """ 

113 Load a saved pipeline snapshot. 

114 

115 :param file_path: Path to the pipeline_snapshot file. 

116 :returns: 

117 Dict containing the loaded pipeline_snapshot. 

118 """ 

119 

120 file_path = Path(file_path) 

121 

122 try: 

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

124 pipeline_snapshot_dict = json.load(f) 

125 except FileNotFoundError as e: 

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

127 except json.JSONDecodeError as e: 

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

129 except OSError as e: 

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

131 

132 try: 

133 pipeline_snapshot = PipelineSnapshot.from_dict(pipeline_snapshot_dict) 

134 except ValueError as e: 

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

136 

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

138 return pipeline_snapshot 

139 

140 

141def _save_pipeline_snapshot( 

142 pipeline_snapshot: PipelineSnapshot, 

143 raise_on_failure: bool = True, 

144 snapshot_callback: SnapshotCallback | None = None, 

145) -> str | None: 

146 """ 

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

148 

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

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

151 

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

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

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

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

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

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

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

159 

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

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

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

163 

164 :param pipeline_snapshot: The pipeline snapshot to save. 

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

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

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

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

169 

170 :returns: 

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

172 `snapshot_file_path` is None, no callback is provided, snapshot saving is disabled, or saving fails and 

173 `raise_on_failure` is False. 

174 :raises: 

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

176 """ 

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

178 if snapshot_callback is not None: 

179 try: 

180 result = snapshot_callback(pipeline_snapshot) 

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

182 return result 

183 except Exception as error: 

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

185 if raise_on_failure: 

186 raise 

187 return None 

188 

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

190 if not _is_snapshot_save_enabled(): 

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

192 return None 

193 

194 break_point = pipeline_snapshot.break_point 

195 snapshot_file_path = break_point.snapshot_file_path 

196 

197 if snapshot_file_path is None: 

198 return None 

199 

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

201 snapshot_dir = Path(snapshot_file_path) 

202 

203 component_name = break_point.component_name 

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

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

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

207 full_path = snapshot_dir / file_name 

208 

209 try: 

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

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

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

213 logger.info( 

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

215 full_path=full_path, 

216 ) 

217 except Exception as error: 

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

219 if raise_on_failure: 

220 raise 

221 return None 

222 

223 return str(full_path) 

224 

225 

226def _create_pipeline_snapshot( 

227 *, 

228 inputs: dict[str, Any], 

229 component_inputs: dict[str, Any], 

230 break_point: Breakpoint, 

231 component_visits: dict[str, int], 

232 original_input_data: dict[str, Any], 

233 ordered_component_names: list[str], 

234 include_outputs_from: set[str], 

235 pipeline_outputs: dict[str, Any], 

236) -> PipelineSnapshot: 

237 """ 

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

239 

240 :param inputs: The current pipeline snapshot inputs. 

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

242 component consumed them. 

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

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

245 :param original_input_data: The original input data. 

246 :param ordered_component_names: The ordered component names. 

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

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

249 :returns: 

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

251 """ 

252 component_name = break_point.component_name 

253 

254 transformed_original_input_data = _transform_json_structure(original_input_data) 

255 

256 serialized_inputs = _serialize_with_field_fallback( 

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

258 ) 

259 serialized_original_input_data = _serialize_with_field_fallback( 

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

261 ) 

262 serialized_pipeline_outputs = _serialize_with_field_fallback( 

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

264 ) 

265 

266 return PipelineSnapshot( 

267 pipeline_state=PipelineState( 

268 inputs=serialized_inputs, 

269 component_visits=component_visits, 

270 pipeline_outputs=serialized_pipeline_outputs, 

271 inputs_format=INTERNAL_INPUTS_FORMAT, 

272 ), 

273 timestamp=datetime.now(), 

274 break_point=break_point, 

275 original_input_data=serialized_original_input_data, 

276 ordered_component_names=ordered_component_names, 

277 include_outputs_from=include_outputs_from, 

278 ) 

279 

280 

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

282 """ 

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

284 

285 For example: 

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

287 

288 :param data: The JSON structure to transform. 

289 :returns: The transformed structure. 

290 """ 

291 if isinstance(data, dict): 

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

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

294 return data["value"] 

295 # Otherwise, recursively process each key-value pair 

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

297 

298 if isinstance(data, list): 

299 # First, transform each item in the list. 

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

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

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

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

304 return transformed[0] 

305 return transformed 

306 

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

308 return data