Coverage for core / src / sensorkit / data / graph.py: 89%

258 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-02 00:03 +0000

1# SPDX-License-Identifier: Apache-2.0 

2from __future__ import annotations 

3 

4import asyncio 

5import collections 

6import contextlib 

7import io 

8import weakref 

9from abc import ABC, abstractmethod 

10from collections.abc import Buffer 

11from typing import ( 

12 Any, 

13 AsyncGenerator, 

14 Callable, 

15 ClassVar, 

16 Literal, 

17 Self, 

18 Unpack, 

19 cast, 

20 final, 

21 overload, 

22) 

23 

24from loguru import logger 

25from pydantic import BaseModel, Field, model_validator 

26 

27from sensorkit.common.model import ModelRegistry, RegistryBaseModel 

28from sensorkit.data.context import Context 

29from sensorkit.data.streams import ( 

30 BufferReader, 

31 BufferWriter, 

32 StreamReader, 

33 StreamWriter, 

34 create_connected_streams, 

35) 

36 

37 

38class DataGraphError(Exception): ... 

39class DataGraphCycleError(DataGraphError): ... 

40class DataGraphSourceError(DataGraphError): ... 

41 

42 

43type ReceiveKind = Literal["stream", "buffer"] | None 

44 

45 

46class DataFlow: 

47 """An edge in a DataGraph.""" 

48 

49 def __init__(self): 

50 self.send_called = False 

51 self.send_ready = asyncio.Event() 

52 self.receive_ready = asyncio.Event() 

53 self.receive_kind: ReceiveKind = None 

54 self.receive_result: tuple[Context, Buffer | StreamReader] | None = None 

55 

56 @overload 

57 async def send( 

58 self, 

59 context: Context, 

60 arg: StreamReader, 

61 ): ... 

62 

63 @overload 

64 async def send( 

65 self, 

66 context: Context, 

67 arg: Buffer, 

68 ): ... 

69 

70 @overload 

71 async def send( 

72 self, 

73 context: Context, 

74 ) -> StreamWriter: ... 

75 

76 async def send( 

77 self, 

78 context: Context, 

79 arg: Buffer | StreamReader | None = None, 

80 ) -> StreamWriter | None: 

81 """Send data through this edge to the consumer. 

82 

83 Pass a Buffer or StreamReader directly, or omit *arg* to receive a StreamWriter that 

84 the caller can write into. May only be called once per edge instance. 

85 """ 

86 if self.send_called: 

87 raise RuntimeError("send may only be called once") 

88 

89 self.send_called = True 

90 

91 # Wait for the consumer to call the `receive` method. 

92 await self.receive_ready.wait() 

93 

94 # Implement each of the data passing combinations based on the type of the input argument 

95 # and the requested receive kind. Note that a `None` receive kind means that the argument 

96 # type dictates the effective receive kind, enabling "passthrough" semantics. 

97 match (arg, self.receive_kind): 

98 # When a buffer is given, we either pass it straight through to the consumer by 

99 # reference or we return a StreamReader backed by it. In the former case, ownership 

100 # of the buffer is transferred to the consumer. In the latter case, the buffer must 

101 # be immutable. 

102 case (Buffer(), "buffer" | None): 

103 # Buffer to buffer. We do this by reference to avoid a copy. 

104 self._send_ready(context, arg) 

105 case (Buffer(), "stream"): 

106 # Buffer to stream. Return a StreamReader backed by the input buffer. 

107 self._send_ready(context, BufferReader(arg)) 

108 

109 # When a StreamReader is given, we either drain it immediately (buffer case) or pass 

110 # it straight through to the consumer. 

111 case (StreamReader(), "buffer"): 

112 bio = io.BytesIO() 

113 

114 async for chunk in arg: 

115 # FIXME: event loop starvation possible here 

116 bio.write(chunk) 

117 

118 self._send_ready(context, bio.getvalue()) 

119 case (StreamReader(), "stream" | None): 

120 self._send_ready(context, arg) 

121 

122 # When no argument is supplied, we return a StreamWriter that writes data to the 

123 # requested destination. 

124 case (None, "buffer"): 

125 # Stream to buffer. Return a StreamWriter that will fill a buffer. 

126 writer = BufferWriter() 

127 fut = writer.get_future() 

128 fut.add_done_callback(lambda _: self._send_ready(context, fut.result())) 

129 return writer 

130 case (None, "stream" | None): 

131 # Stream to stream. 

132 reader, writer = create_connected_streams() 

133 self._send_ready(context, reader) 

134 return writer 

135 case _: 

136 raise TypeError() 

137 

138 return None 

139 

140 def _send_ready(self, *result: Unpack[tuple[Context, Buffer | StreamReader]]): 

141 # Signal the consumer that the receive result is ready. 

142 self.receive_result = result 

143 self.send_ready.set() 

144 

145 @overload 

146 async def receive( 

147 self, 

148 kind: Literal["stream"], 

149 ) -> tuple[Context, StreamReader]: ... 

150 

151 @overload 

152 async def receive( 

153 self, 

154 kind: Literal["buffer"], 

155 ) -> tuple[Context, Buffer]: ... 

156 

157 @overload 

158 async def receive( 

159 self, 

160 ) -> tuple[Context, Buffer | StreamReader]: ... 

161 

162 async def receive( 

163 self, 

164 kind: ReceiveKind | None = None, 

165 ) -> tuple[Context, StreamReader | Buffer]: 

166 """Wait for the producer to send data and return `(context, data)`. 

167 

168 *kind* controls whether data is delivered as a buffer or stream; `None` defers to 

169 the producer's choice. May only be called once per edge instance. 

170 """ 

171 if self.receive_ready.is_set(): 

172 raise RuntimeError("receive may only be called once") 

173 

174 # Signal that we are ready to receive so the producer can proceed. 

175 self.receive_kind = kind 

176 self.receive_ready.set() 

177 

178 # Wait for the producer to provide a result for us and return it. 

179 await self.send_ready.wait() 

180 return self.receive_result 

181 

182 

183class DataOp(RegistryBaseModel, ABC): 

184 """A node in a DataGraph.""" 

185 op: Literal[None] | str = None 

186 output: list[str] = Field(default_factory=list) 

187 registry: ClassVar[ModelRegistry[DataOp]] = ModelRegistry(discriminator="op") 

188 

189 @classmethod 

190 def model_registry(cls): 

191 return cls.registry 

192 

193 @abstractmethod 

194 async def process(self, incoming: list[DataFlow], outgoing: list[DataFlow]): 

195 """Execute this node, consuming *incoming* edges and producing *outgoing* edges.""" 

196 ... 

197 

198 

199class SourceOp(DataOp, ABC): 

200 """A DataGraph node that triggers graph runs.""" 

201 

202 @abstractmethod 

203 def graph_source(self) -> AsyncGenerator[None, DataFlow]: 

204 """Async generator that triggers each graph run by sending a DataFlow edge.""" 

205 ... 

206 

207 @final 

208 async def process(self, incoming: list[DataFlow], outgoing: list[DataFlow]): 

209 assert len(outgoing) == 1 

210 context, data = await incoming[0].receive() 

211 await outgoing[0].send(context, data) 

212 

213 

214class SinkOp(DataOp, ABC): 

215 """A DataGraph node that consumes graph runs.""" 

216 

217 @abstractmethod 

218 async def graph_sink(self, incoming: DataFlow): 

219 """Consume the final DataFlow edge to complete a graph run.""" 

220 ... 

221 

222 @final 

223 async def process(self, incoming: list[DataFlow], outgoing: list[DataFlow]): 

224 assert len(incoming) == 1 and not outgoing 

225 await self.graph_sink(incoming[0]) 

226 

227 

228def _parse_simple(simple: list[dict[str, Any]]): 

229 """Parse a simple representation of a linear DataGraph.""" 

230 counts = collections.Counter() 

231 nodes = {} 

232 prev_id = None 

233 

234 for node in simple: 

235 if "output" in node: 

236 raise ValueError("Simple DataGraph representations cannot contain 'output' fields") 

237 

238 op = node["op"] 

239 counts[op] += 1 

240 node_id = f"{op}_{counts[op]}" 

241 

242 if prev_id: 

243 nodes[prev_id]["output"] = [node_id] 

244 

245 nodes[node_id] = dict(node) 

246 prev_id = node_id 

247 

248 return nodes 

249 

250 

251def _describe_simple_form(schema: dict[str, Any]): 

252 """Declare the shorthand accepted in place of a node mapping. 

253 

254 Args: 

255 schema: The generated JSON Schema for the model. 

256 """ 

257 schema["properties"]["simple"] = { 

258 "type": "array", 

259 "items": {"type": "object"}, 

260 "description": "Operations to run in order, in place of 'nodes'.", 

261 } 

262 

263 

264class DataGraph(BaseModel, json_schema_extra=_describe_simple_form): 

265 """A graph of operations on data.""" 

266 

267 nodes: dict[str, DataOp] = Field(default_factory=dict) 

268 

269 @model_validator(mode="before") 

270 def _validate(cls, values): 

271 if simple := values.get("simple"): 

272 if "nodes" in values: 

273 raise ValueError("Only one of 'nodes' or 'simple' can be defined") 

274 

275 del values["simple"] 

276 values["nodes"] = dict(_parse_simple(simple)) 

277 

278 return values 

279 

280 def __init__(self, /, **data: Any): 

281 super().__init__(**data) 

282 self._inputs: dict[str, list[DataOp]] = collections.defaultdict(list) 

283 self._sources: set[str] = set() 

284 self._sinks: set[str] = set() 

285 self._runners: set[DataGraphRunner] = set() 

286 

287 for name, node in self.nodes.items(): 

288 self._index_node(name, node) 

289 

290 def add(self, name: str, node: DataOp): 

291 """Add a named node to the graph. Raises DataGraphError if already started or duplicate.""" 

292 if self._runners: 

293 raise DataGraphError("cannot modify graph while executing") 

294 

295 if name in self.nodes: 

296 raise DataGraphError(f"node '{name}' already defined") 

297 

298 self.nodes[name] = node 

299 self._index_node(name, node) 

300 

301 def _index_node(self, name: str, node: DataOp): 

302 if isinstance(node, SourceOp): 

303 self._sources.add(name) 

304 

305 if isinstance(node, SinkOp): 

306 self._sinks.add(name) 

307 

308 for output in node.output: 

309 self._inputs[output].append(node) 

310 

311 def find_source[M: SourceOp](self, kind: type[M] | None = None) -> M: 

312 """Return the source node of the given type, or the sole source if *kind* is omitted.""" 

313 if kind: 

314 for source in self._sources: 

315 source_node = self.nodes[source] 

316 

317 if isinstance(source_node, kind): 

318 return source_node 

319 

320 raise DataGraphSourceError(f"no source of type '{kind.__name__}' defined") 

321 

322 if len(self._sources) != 1: 

323 raise DataGraphError(f"expected exactly one source, got {len(self._sources)}") 

324 

325 return cast(SourceOp, self.nodes[next(iter(self._sources))]) 

326 

327 def app_source(self): 

328 """Return the AppSource node in this graph.""" 

329 from sensorkit.data.local import AppSource 

330 

331 return self.find_source(AppSource) 

332 

333 def find_sink[M: SinkOp](self, kind: type[M] | None = None) -> M: 

334 """Return the sink node of the given type, or the sole sink if *kind* is omitted.""" 

335 if kind: 

336 for sink in self._sinks: 

337 sink_node = self.nodes[sink] 

338 

339 if isinstance(sink_node, kind): 

340 return sink_node 

341 

342 raise DataGraphSourceError(f"no sink of type '{kind.__name__}' defined") 

343 

344 if len(self._sinks) != 1: 

345 raise DataGraphError(f"expected exactly one sink, got {len(self._sinks)}") 

346 

347 return cast(SinkOp, self.nodes[next(iter(self._sinks))]) 

348 

349 def app_sink(self): 

350 """Return the AppSink node in this graph.""" 

351 from sensorkit.data.local import AppSink 

352 

353 return self.find_sink(AppSink) 

354 

355 def _validate_component(self, source: str): 

356 if source not in self._sources: 

357 raise DataGraphSourceError(f"source '{source}' is undefined") 

358 

359 queue = collections.deque([source]) 

360 visited = set() 

361 

362 while queue: 

363 node_name = queue.popleft() 

364 

365 if node_name in visited: 

366 raise DataGraphCycleError(f"cycle detected at node '{node_name}'") 

367 

368 visited.add(node_name) 

369 

370 if node_name not in self.nodes: 

371 raise DataGraphError(f"node '{node_name}' is undefined") 

372 

373 node = self.nodes[node_name] 

374 

375 if node_name != source and isinstance(node, SourceOp): 

376 # FIXME: This may actually not be an error condition. There may be legitimate use 

377 # cases for having multiple sources in a traversal (only one actually acting 

378 # as a source at a time), and the way SourceOp is implemented would 

379 # naturally support no-op passthrough of intermediate sources. 

380 raise DataGraphSourceError(f"multiple sources in traversal at '{source}'") 

381 

382 for next_name in node.output: 

383 queue.append(next_name) 

384 

385 return visited 

386 

387 def start(self, *, task_group: asyncio.TaskGroup = asyncio): 

388 """Validate and start all source components of the graph in the given task group.""" 

389 if not self._sources: 

390 raise DataGraphSourceError("no sources defined") 

391 

392 for source in self._sources: 

393 self._validate_component(source) 

394 runner = DataGraphRunner(self, source, task_group) 

395 self._runners.add(runner) 

396 runner.start(done_callback=self._runners.discard) 

397 

398 async def stop(self): 

399 """Cancel all running DataGraphRunner instances and wait for them to finish.""" 

400 await asyncio.gather( 

401 *(runner.stop() for runner in self._runners.copy()), 

402 return_exceptions=True 

403 ) 

404 

405 

406class DataGraphRunner: 

407 """Invokes runs of one component of a DataGraph.""" 

408 

409 def __init__(self, graph: DataGraph, source: str, task_group: asyncio.TaskGroup): 

410 self._graph = weakref.ref(graph) 

411 self._source = source 

412 self._task_group = task_group 

413 self._exec_task: asyncio.Task | None = None 

414 

415 def start(self, done_callback: Callable[[Self], Any] | None = None): 

416 """Start executing the graph component, optionally invoking *done_callback* on exit.""" 

417 if self._exec_task: 

418 raise RuntimeError("DataGraph runner already started") 

419 

420 # Get a hard reference to the DataGraph. 

421 graph = self._graph() 

422 

423 if not graph: 

424 raise RuntimeError("DataGraph was disposed") 

425 

426 # Ensure that exceptions are not propagated to the main task group, as runs should 

427 # keep the service alive until they complete but errors should not kill the service. 

428 self._exec_task = self._task_group.create_task(self._run_graph(graph)) 

429 logger.info("DataGraph runner started") 

430 

431 def _finalize_graph_run(t: asyncio.Task): 

432 if t.cancelled(): 

433 logger.info("DataGraph runner shut down") 

434 elif e := t.exception(): 

435 logger.error("DataGraph runner exited due to error") 

436 logger.opt(exception=e).debug("DataGraph runner failed") 

437 else: 

438 logger.warning("DataGraph runner exited") 

439 

440 if done_callback: 

441 done_callback(self) 

442 

443 self._exec_task.add_done_callback(_finalize_graph_run) 

444 

445 async def stop(self): 

446 """Cancel the graph runner task and wait for it to finish.""" 

447 self._exec_task.cancel() 

448 

449 with contextlib.suppress(asyncio.CancelledError): 

450 await self._exec_task 

451 

452 async def _run_graph(self, graph: DataGraph): 

453 # Invoke the source and prime the generator it returns. 

454 source = cast(SourceOp, graph.nodes[self._source]) 

455 gen = source.graph_source() 

456 running = True 

457 

458 await gen.asend(None) 

459 

460 # Execute the graph for each iteration of the source op. 

461 while running: 

462 shutdown = False 

463 

464 # Create a new edge to be injected as the incoming edge to the source node. 

465 edge = DataFlow() 

466 

467 try: 

468 # Run the graph in a TaskGroup so that if any node fails, all nodes for this 

469 # run are cancelled. 

470 async with asyncio.TaskGroup() as tg: 

471 self._run_tasks(graph, source, edge, task_group=tg) 

472 

473 try: 

474 # Wait for the SourceOp to trigger and feed the incoming edge. 

475 await gen.asend(edge) 

476 except asyncio.CancelledError: 

477 # FIXME: This check is likely not sufficient to guarantee no deadlock. 

478 if not edge.send_called: 

479 raise 

480 

481 # Wait for tasks to complete before shutting down. We do this by 

482 # suppressing the cancellation and allowing the task group to complete. 

483 # Then we fall through to re-raise via the `shutdown` flag. 

484 asyncio.current_task().uncancel() 

485 shutdown = True 

486 except* StopAsyncIteration: 

487 logger.debug("DataGraph source stopped producing") 

488 running = False 

489 except* Exception: 

490 # We log this as an exception so that the traceback is preserved. 

491 logger.exception("DataGraph run encountered an error") 

492 

493 if shutdown: 

494 raise asyncio.CancelledError() 

495 

496 def _run_tasks( 

497 self, 

498 graph: DataGraph, 

499 source: DataOp, 

500 source_incoming: DataFlow, 

501 *, 

502 task_group: asyncio.TaskGroup, 

503 ): 

504 tasks: list[asyncio.Task] = [] 

505 incoming: dict[str, list[DataFlow]] = collections.defaultdict(list) 

506 outgoing: dict[str, list[DataFlow]] = collections.defaultdict(list) 

507 

508 for name, node in graph.nodes.items(): 

509 for output in node.output: 

510 edge = DataFlow() 

511 incoming[output].append(edge) 

512 outgoing[name].append(edge) 

513 

514 if node is source: 

515 incoming[name].append(source_incoming) 

516 

517 for name, node in graph.nodes.items(): 

518 tasks.append( 

519 task_group.create_task(node.process(incoming[name], outgoing[name])) 

520 ) 

521 

522 return tasks