calc-flow-python 4.0.0__cp313-abi3-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
calc_flow/runtime.py ADDED
@@ -0,0 +1,979 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import concurrent.futures
5
+ import inspect
6
+ import os
7
+ import threading
8
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime, timedelta
11
+ from enum import StrEnum
12
+ from types import MappingProxyType
13
+ from typing import TYPE_CHECKING, Any, Literal, NoReturn, Protocol, TypedDict
14
+
15
+ from calc_flow import _native
16
+ from calc_flow.store import _copy_json_value
17
+
18
+ if TYPE_CHECKING:
19
+ from calc_flow.pipeline import StreamExecutionPlan
20
+
21
+ type JSONValue = (
22
+ None | bool | int | float | str | list[JSONValue] | dict[str, JSONValue]
23
+ )
24
+ type StreamingFailureReasonCode = Literal[
25
+ "join_state_limit_exceeded",
26
+ "join_match_limit_exceeded",
27
+ "join_counter_overflow",
28
+ "join_time_conversion_failed",
29
+ ]
30
+
31
+
32
+ async def _raise_after_cancellation_cleanup(
33
+ cleanup: Awaitable[object], cancellation: asyncio.CancelledError
34
+ ) -> NoReturn:
35
+ async def run_cleanup() -> None:
36
+ await cleanup
37
+
38
+ cleanup_task = asyncio.create_task(run_cleanup())
39
+ while not cleanup_task.done():
40
+ try:
41
+ await asyncio.shield(cleanup_task)
42
+ except asyncio.CancelledError:
43
+ continue
44
+ try:
45
+ cleanup_task.result()
46
+ except BaseException as cleanup_error:
47
+ raise cleanup_error from cancellation
48
+ raise cancellation
49
+
50
+
51
+ async def _finish_cleanup(cleanup: Awaitable[object]) -> None:
52
+ """Finish already-linearized terminal cleanup despite observer cancellation."""
53
+
54
+ owner = asyncio.current_task()
55
+
56
+ async def run_cleanup() -> None:
57
+ await cleanup
58
+
59
+ cleanup_task = asyncio.create_task(run_cleanup())
60
+ while not cleanup_task.done():
61
+ try:
62
+ await asyncio.shield(cleanup_task)
63
+ except asyncio.CancelledError:
64
+ if owner is not None:
65
+ while owner.cancelling():
66
+ owner.uncancel()
67
+ continue
68
+ cleanup_task.result()
69
+
70
+
71
+ class ReplayPositioning(StrEnum):
72
+ """Replay protocol implemented by a stream source."""
73
+
74
+ EXACT_PAUSE_REPORT_AND_SEEK = "exact_pause_report_and_seek"
75
+ UNSUPPORTED = "unsupported"
76
+
77
+
78
+ class NativeWatermarkCapability(StrEnum):
79
+ """Native watermark behavior declared by a stream source."""
80
+
81
+ NEVER_EMITS = "never_emits"
82
+ EMITS_NATIVE = "emits_native"
83
+ RUNTIME_TOGGLEABLE = "runtime_toggleable"
84
+ UNKNOWN = "unknown"
85
+
86
+
87
+ class SourceDeliveryCapability(StrEnum):
88
+ """Whether admitted source events can be lost before observation."""
89
+
90
+ LOSSLESS = "lossless"
91
+ LOSSY = "lossy"
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class SourceProvidedWatermarks:
96
+ pass
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class BoundedOutOfOrderness:
101
+ event_time_column: str
102
+ max_out_of_orderness: timedelta
103
+ emit_interval: timedelta
104
+ idle_timeout: timedelta | None = None
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class DisabledWatermarks:
109
+ idle_timeout: timedelta | None = None
110
+
111
+
112
+ type WatermarkPolicy = (
113
+ SourceProvidedWatermarks | BoundedOutOfOrderness | DisabledWatermarks
114
+ )
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class OrdinaryDelivery:
119
+ pass
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class EpochIdempotentDelivery:
124
+ mechanism: str
125
+ retention: Literal["bounded", "unbounded"]
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class TransactionalDelivery:
130
+ pass
131
+
132
+
133
+ type SinkDelivery = OrdinaryDelivery | EpochIdempotentDelivery | TransactionalDelivery
134
+
135
+
136
+ def _frozen_json_mapping(value: Mapping[str, object], label: str) -> Mapping[str, Any]:
137
+ copied = _copy_json_value(dict(value), root_mapping=True, label=label)
138
+ return MappingProxyType(copied)
139
+
140
+
141
+ @dataclass(frozen=True, slots=True)
142
+ class Cursor:
143
+ """Owned or unbound immutable source replay position."""
144
+
145
+ order: bytes
146
+ payload: Mapping[str, Any]
147
+ source_id: str | None = None
148
+
149
+ def __post_init__(self) -> None:
150
+ if type(self.order) is not bytes:
151
+ raise TypeError("cursor order must be bytes")
152
+ if not self.order:
153
+ raise ValueError("cursor order must not be empty")
154
+ if self.source_id is not None and (
155
+ not isinstance(self.source_id, str) or not self.source_id
156
+ ):
157
+ raise TypeError("cursor source_id must be a non-empty string or None")
158
+ object.__setattr__(
159
+ self, "payload", _frozen_json_mapping(self.payload, "cursor payload")
160
+ )
161
+
162
+
163
+ @dataclass(frozen=True, slots=True)
164
+ class SourceCapabilities:
165
+ """Source capability descriptor sampled once before connector open."""
166
+
167
+ replay_positioning: ReplayPositioning
168
+ delivery: SourceDeliveryCapability
169
+ max_batch_rows: int
170
+ max_batch_bytes: int
171
+ schema: object | None = None
172
+ native_watermarks: NativeWatermarkCapability = (
173
+ NativeWatermarkCapability.EMITS_NATIVE
174
+ )
175
+
176
+ def __post_init__(self) -> None:
177
+ if not isinstance(self.replay_positioning, ReplayPositioning):
178
+ raise TypeError("replay_positioning must be a ReplayPositioning value")
179
+ if not isinstance(self.delivery, SourceDeliveryCapability):
180
+ raise TypeError("delivery must be a SourceDeliveryCapability value")
181
+ for name in ("max_batch_rows", "max_batch_bytes"):
182
+ value = getattr(self, name)
183
+ if type(value) is not int or value <= 0:
184
+ raise ValueError(f"{name} must be a positive integer")
185
+ if not isinstance(self.native_watermarks, NativeWatermarkCapability):
186
+ raise TypeError(
187
+ "native_watermarks must be a NativeWatermarkCapability value"
188
+ )
189
+
190
+
191
+ @dataclass(frozen=True, slots=True)
192
+ class Data:
193
+ """One source batch paired with its replay cursor."""
194
+
195
+ batch: _native.Batch
196
+ cursor: Cursor
197
+
198
+ def __post_init__(self) -> None:
199
+ if not isinstance(self.batch, _native.Batch):
200
+ raise TypeError("data batch must be a calc_flow.Batch")
201
+ if not isinstance(self.cursor, Cursor):
202
+ raise TypeError("data cursor must be a calc_flow.Cursor")
203
+
204
+
205
+ @dataclass(frozen=True, slots=True)
206
+ class Watermark:
207
+ """Timezone-aware source-native event-time watermark."""
208
+
209
+ at: datetime
210
+
211
+ def __post_init__(self) -> None:
212
+ if not isinstance(self.at, datetime) or self.at.utcoffset() is None:
213
+ raise ValueError("watermark datetime must be timezone-aware")
214
+ object.__setattr__(self, "at", self.at.astimezone(UTC))
215
+
216
+
217
+ @dataclass(frozen=True, slots=True)
218
+ class Idle:
219
+ pass
220
+
221
+
222
+ type SourceEvent = Data | Watermark | Idle
223
+
224
+
225
+ @dataclass(frozen=True, slots=True)
226
+ class SinkRecovery:
227
+ epoch: int
228
+ terminal: bool
229
+ delivery: SinkDelivery
230
+ pre_commit: Mapping[str, Any]
231
+
232
+ def __post_init__(self) -> None:
233
+ object.__setattr__(
234
+ self,
235
+ "pre_commit",
236
+ _frozen_json_mapping(self.pre_commit, "sink recovery pre_commit"),
237
+ )
238
+
239
+
240
+ def _require_async_methods(
241
+ connector: object, kind: str, methods: Sequence[str]
242
+ ) -> None:
243
+ for method in methods:
244
+ callback = getattr(connector, method, None)
245
+ if callback is None or not inspect.iscoroutinefunction(callback):
246
+ raise TypeError(f"{kind}.{method} must be declared with async def")
247
+
248
+
249
+ def _duration_micros(value: timedelta, name: str) -> int:
250
+ if not isinstance(value, timedelta):
251
+ raise TypeError(f"{name} must be a datetime.timedelta")
252
+ micros = (
253
+ value.days * 86_400_000_000 + value.seconds * 1_000_000 + value.microseconds
254
+ )
255
+ if micros < 0:
256
+ raise ValueError(f"{name} must not be negative")
257
+ return micros
258
+
259
+
260
+ def _datetime_micros(value: datetime) -> int:
261
+ normalized = value.astimezone(UTC)
262
+ delta = normalized - datetime(1970, 1, 1, tzinfo=UTC)
263
+ return delta.days * 86_400_000_000 + delta.seconds * 1_000_000 + delta.microseconds
264
+
265
+
266
+ class StreamSource(Protocol):
267
+ """Async-only source connector consumed by a continuous runner."""
268
+
269
+ def capabilities(self) -> SourceCapabilities: ...
270
+ async def open(self, cursor: Cursor | None) -> None: ...
271
+ async def next(self) -> Data | Watermark | Idle | None: ...
272
+ async def close(self) -> None: ...
273
+
274
+
275
+ class StreamSink(Protocol):
276
+ """Async-only ordinary at-least-once sink connector."""
277
+
278
+ async def open(self) -> None: ...
279
+ async def write(self, batch: _native.Batch) -> None: ...
280
+ async def close(self) -> None: ...
281
+
282
+
283
+ class TransactionalStreamSink(Protocol):
284
+ """Async-only transactional or epoch-idempotent sink connector."""
285
+
286
+ async def open(self) -> None: ...
287
+ async def begin_epoch(self, epoch: int) -> None: ...
288
+ async def write(self, batch: _native.Batch) -> None: ...
289
+ async def pre_commit(self, epoch: int) -> Mapping[str, JSONValue]: ...
290
+ async def commit(self, epoch: int, pre_commit: Mapping[str, JSONValue]) -> None: ...
291
+ async def abort(
292
+ self, epoch: int, pre_commit: Mapping[str, JSONValue] | None
293
+ ) -> None: ...
294
+ async def recover(self, recovery: SinkRecovery) -> None: ...
295
+ async def close(self) -> None: ...
296
+
297
+
298
+ @dataclass(frozen=True, slots=True)
299
+ class SourceBinding:
300
+ """Own a source connector and its immutable watermark policy."""
301
+
302
+ source: StreamSource = field(repr=False)
303
+ watermark_policy: WatermarkPolicy = field(default_factory=SourceProvidedWatermarks)
304
+
305
+ def __init__(
306
+ self, source: StreamSource, *, watermark_policy: WatermarkPolicy | None = None
307
+ ) -> None:
308
+ if not callable(getattr(source, "capabilities", None)):
309
+ raise TypeError("source.capabilities must be callable")
310
+ _require_async_methods(source, "source", ("open", "next", "close"))
311
+ selected = (
312
+ SourceProvidedWatermarks() if watermark_policy is None else watermark_policy
313
+ )
314
+ if not isinstance(
315
+ selected,
316
+ (SourceProvidedWatermarks, BoundedOutOfOrderness, DisabledWatermarks),
317
+ ):
318
+ raise TypeError("watermark_policy is not a supported watermark policy")
319
+ object.__setattr__(self, "source", source)
320
+ object.__setattr__(self, "watermark_policy", selected)
321
+
322
+ def _native_capabilities(self) -> dict[str, object]:
323
+ value = self.source.capabilities()
324
+ if not isinstance(value, SourceCapabilities):
325
+ raise TypeError("source.capabilities() must return SourceCapabilities")
326
+ return {
327
+ "replay_positioning": value.replay_positioning.value,
328
+ "delivery": value.delivery.value,
329
+ "max_batch_rows": value.max_batch_rows,
330
+ "max_batch_bytes": value.max_batch_bytes,
331
+ "schema": value.schema,
332
+ "native_watermarks": value.native_watermarks.value,
333
+ }
334
+
335
+ def _native_policy(self) -> dict[str, object]:
336
+ policy = self.watermark_policy
337
+ if isinstance(policy, SourceProvidedWatermarks):
338
+ return {"kind": "source_provided"}
339
+ if isinstance(policy, BoundedOutOfOrderness):
340
+ if not policy.event_time_column:
341
+ raise ValueError("event_time_column must not be empty")
342
+ return {
343
+ "kind": "bounded_out_of_orderness",
344
+ "event_time_column": policy.event_time_column,
345
+ "max_out_of_orderness_micros": _duration_micros(
346
+ policy.max_out_of_orderness, "max_out_of_orderness"
347
+ ),
348
+ "emit_interval_micros": _duration_micros(
349
+ policy.emit_interval, "emit_interval"
350
+ ),
351
+ "idle_timeout_micros": (
352
+ None
353
+ if policy.idle_timeout is None
354
+ else _duration_micros(policy.idle_timeout, "idle_timeout")
355
+ ),
356
+ }
357
+ return {
358
+ "kind": "disabled",
359
+ "idle_timeout_micros": (
360
+ None
361
+ if policy.idle_timeout is None
362
+ else _duration_micros(policy.idle_timeout, "idle_timeout")
363
+ ),
364
+ }
365
+
366
+ async def _native_open(
367
+ self,
368
+ source_id: str | None,
369
+ order: bytes | None,
370
+ payload: Mapping[str, object] | None,
371
+ ) -> None:
372
+ cursor = (
373
+ None
374
+ if order is None
375
+ else Cursor(order, {} if payload is None else payload, source_id)
376
+ )
377
+ await self.source.open(cursor)
378
+
379
+ async def _native_next(self) -> tuple[object, ...] | None:
380
+ value = await self.source.next()
381
+ if value is None:
382
+ return None
383
+ if isinstance(value, Data):
384
+ return (
385
+ "data",
386
+ value.batch,
387
+ value.cursor.source_id,
388
+ value.cursor.order,
389
+ dict(value.cursor.payload),
390
+ )
391
+ if isinstance(value, Watermark):
392
+ return ("watermark", _datetime_micros(value.at))
393
+ if isinstance(value, Idle):
394
+ return ("idle",)
395
+ raise TypeError("source.next() must return Data, Watermark, Idle, or None")
396
+
397
+ async def _native_close(self) -> None:
398
+ await self.source.close()
399
+
400
+
401
+ @dataclass(frozen=True, slots=True)
402
+ class SinkBinding:
403
+ """Own a named sink connector and frozen delivery evidence."""
404
+
405
+ sink_id: str
406
+ sink: object = field(repr=False)
407
+ delivery: SinkDelivery
408
+
409
+ @classmethod
410
+ def ordinary(cls, sink_id: str, sink: StreamSink) -> SinkBinding:
411
+ _require_async_methods(sink, "sink", ("open", "write", "close"))
412
+ return cls(sink_id, sink, OrdinaryDelivery())
413
+
414
+ @classmethod
415
+ def transactional(cls, sink_id: str, sink: TransactionalStreamSink) -> SinkBinding:
416
+ _require_async_methods(
417
+ sink,
418
+ "sink",
419
+ (
420
+ "open",
421
+ "begin_epoch",
422
+ "write",
423
+ "pre_commit",
424
+ "commit",
425
+ "abort",
426
+ "recover",
427
+ "close",
428
+ ),
429
+ )
430
+ return cls(sink_id, sink, TransactionalDelivery())
431
+
432
+ @classmethod
433
+ def epoch_idempotent(
434
+ cls,
435
+ sink_id: str,
436
+ sink: TransactionalStreamSink,
437
+ *,
438
+ mechanism: str,
439
+ retention: Literal["bounded", "unbounded"],
440
+ ) -> SinkBinding:
441
+ binding = cls.transactional(sink_id, sink)
442
+ if not mechanism:
443
+ raise ValueError("mechanism must not be empty")
444
+ if retention not in ("bounded", "unbounded"):
445
+ raise ValueError("retention must be 'bounded' or 'unbounded'")
446
+ return cls(
447
+ binding.sink_id,
448
+ binding.sink,
449
+ EpochIdempotentDelivery(mechanism, retention),
450
+ )
451
+
452
+ def __post_init__(self) -> None:
453
+ if not isinstance(self.sink_id, str) or not self.sink_id:
454
+ raise TypeError("sink_id must be a non-empty string")
455
+
456
+ def _native_descriptor(self) -> dict[str, object]:
457
+ if isinstance(self.delivery, OrdinaryDelivery):
458
+ return {"kind": "ordinary"}
459
+ if isinstance(self.delivery, TransactionalDelivery):
460
+ return {"kind": "transactional"}
461
+ return {
462
+ "kind": "epoch_idempotent",
463
+ "mechanism": self.delivery.mechanism,
464
+ "retention": self.delivery.retention,
465
+ }
466
+
467
+ async def _native_open(self) -> None:
468
+ await self.sink.open()
469
+
470
+ async def _native_write(self, batch: _native.Batch) -> None:
471
+ await self.sink.write(batch)
472
+
473
+ async def _native_begin_epoch(self, epoch: int) -> None:
474
+ await self.sink.begin_epoch(epoch)
475
+
476
+ async def _native_pre_commit(self, epoch: int) -> dict[str, Any]:
477
+ value = await self.sink.pre_commit(epoch)
478
+ return dict(_frozen_json_mapping(value, "sink pre_commit"))
479
+
480
+ async def _native_commit(
481
+ self, epoch: int, pre_commit: Mapping[str, object]
482
+ ) -> None:
483
+ await self.sink.commit(
484
+ epoch, _frozen_json_mapping(pre_commit, "sink pre_commit")
485
+ )
486
+
487
+ async def _native_abort(
488
+ self, epoch: int, pre_commit: Mapping[str, object] | None
489
+ ) -> None:
490
+ copied = (
491
+ None
492
+ if pre_commit is None
493
+ else _frozen_json_mapping(pre_commit, "sink pre_commit")
494
+ )
495
+ await self.sink.abort(epoch, copied)
496
+
497
+ async def _native_recover(
498
+ self,
499
+ epoch: int,
500
+ terminal: bool,
501
+ delivery: Mapping[str, object],
502
+ pre_commit: Mapping[str, object],
503
+ ) -> None:
504
+ kind = delivery["kind"]
505
+ if kind == "ordinary":
506
+ selected: SinkDelivery = OrdinaryDelivery()
507
+ elif kind == "transactional":
508
+ selected = TransactionalDelivery()
509
+ else:
510
+ selected = EpochIdempotentDelivery(
511
+ str(delivery["mechanism"]),
512
+ delivery["retention"], # type: ignore[arg-type]
513
+ )
514
+ await self.sink.recover(SinkRecovery(epoch, terminal, selected, pre_commit))
515
+
516
+ async def _native_close(self) -> None:
517
+ await self.sink.close()
518
+
519
+
520
+ @dataclass(frozen=True, slots=True)
521
+ class EdgeBudget:
522
+ """Row and byte bounds applied independently to every stream edge."""
523
+
524
+ max_rows: int = 10_000
525
+ max_bytes: int = 64 << 20
526
+
527
+ def __post_init__(self) -> None:
528
+ for name in ("max_rows", "max_bytes"):
529
+ value = getattr(self, name)
530
+ if type(value) is not int or value <= 0:
531
+ raise ValueError(f"{name} must be a positive integer")
532
+
533
+
534
+ @dataclass(frozen=True, slots=True)
535
+ class StreamRuntimeConfig:
536
+ """Immutable runtime tuning excluded from the plan fingerprint."""
537
+
538
+ checkpoint_interval: timedelta = timedelta(seconds=60)
539
+ checkpoint_timeout: timedelta = timedelta(minutes=10)
540
+ edge_budget: EdgeBudget = EdgeBudget()
541
+ retained_epochs: int = 2
542
+
543
+ def _native(self) -> dict[str, int]:
544
+ if not isinstance(self.edge_budget, EdgeBudget):
545
+ raise TypeError("edge_budget must be a calc_flow.EdgeBudget")
546
+ if type(self.retained_epochs) is not int or self.retained_epochs <= 0:
547
+ raise ValueError("retained_epochs must be a positive integer")
548
+ return {
549
+ "checkpoint_interval_micros": _duration_micros(
550
+ self.checkpoint_interval, "checkpoint_interval"
551
+ ),
552
+ "checkpoint_timeout_micros": _duration_micros(
553
+ self.checkpoint_timeout, "checkpoint_timeout"
554
+ ),
555
+ "edge_max_rows": self.edge_budget.max_rows,
556
+ "edge_max_bytes": self.edge_budget.max_bytes,
557
+ "retained_epochs": self.retained_epochs,
558
+ }
559
+
560
+
561
+ class ManagedCheckpointRuntime:
562
+ """Capture one local root for managed state and manifest storage."""
563
+
564
+ __slots__ = ("_inner",)
565
+
566
+ def __init__(self, directory: os.PathLike[str] | str, /) -> None:
567
+ path = os.fspath(directory)
568
+ if not isinstance(path, str):
569
+ raise TypeError("checkpoint directory must resolve to a string path")
570
+ self._inner = _native._ManagedCheckpointRuntime(path)
571
+
572
+
573
+ @dataclass(frozen=True, slots=True)
574
+ class StreamingError:
575
+ """Payload-safe structured terminal error projection."""
576
+
577
+ category: str
578
+ reason_code: StreamingFailureReasonCode | None
579
+ message: str
580
+ job_id: int | None
581
+ epoch: int | None
582
+ checkpoint_phase: str | None
583
+ component_kind: str | None
584
+ component_id: str | None
585
+ diagnostic_id: int | None
586
+ position: int
587
+
588
+
589
+ @dataclass(frozen=True, slots=True)
590
+ class JobOutcome:
591
+ """Immutable terminal outcome returned by job lifecycle methods."""
592
+
593
+ state: str
594
+ cause: str
595
+ completed_epoch: int | None
596
+ errors: tuple[StreamingError, ...]
597
+
598
+
599
+ class OutputDeliveryStatus(TypedDict):
600
+ requested: Literal["best_effort", "at_least_once", "exactly_once"]
601
+ effective: Literal["best_effort", "at_least_once", "exactly_once"]
602
+
603
+
604
+ class JobStatus(TypedDict):
605
+ job_id: int
606
+ state: Literal[
607
+ "running",
608
+ "draining",
609
+ "completed",
610
+ "cancelled",
611
+ "failed",
612
+ "recovery_required",
613
+ ]
614
+ terminal_cause: str | None
615
+ delivery: dict[str, OutputDeliveryStatus]
616
+ task_count: int
617
+ task_errors: int
618
+ metrics_overflowed: bool
619
+ watermark_micros: int | None
620
+ edges: dict[str, dict[str, object]]
621
+ sources: dict[str, dict[str, object]]
622
+ operators: dict[str, dict[str, object]]
623
+ sinks: dict[str, dict[str, object]]
624
+ stream_joins: dict[str, StreamJoinStatus]
625
+ checkpoint: dict[str, object]
626
+
627
+
628
+ class StreamJoinSideStatus(TypedDict):
629
+ retained_rows: int
630
+ retained_bytes: int
631
+ evicted_rows: int
632
+ late_rows: int
633
+ late_affected_batches: int
634
+ max_lateness_micros: int | None
635
+ null_event_time_rows: int
636
+ null_key_rows: int
637
+
638
+
639
+ class StreamJoinStatus(TypedDict):
640
+ left: StreamJoinSideStatus
641
+ right: StreamJoinSideStatus
642
+ emitted_match_rows: int
643
+ state_limit_failures: int
644
+ match_limit_failures: int
645
+
646
+
647
+ def _outcome(value: Mapping[str, object]) -> JobOutcome:
648
+ errors = tuple(StreamingError(**error) for error in value["errors"]) # type: ignore[arg-type]
649
+ return JobOutcome(
650
+ state=str(value["state"]),
651
+ cause=str(value["cause"]),
652
+ completed_epoch=value["completed_epoch"], # type: ignore[arg-type]
653
+ errors=errors,
654
+ )
655
+
656
+
657
+ def _reject_active_loop(method: str) -> None:
658
+ try:
659
+ asyncio.get_running_loop()
660
+ except RuntimeError:
661
+ return
662
+ raise RuntimeError(
663
+ f"{method}() cannot run inside an event loop; use {method}_async()"
664
+ )
665
+
666
+
667
+ class _BlockingEventLoop:
668
+ __slots__ = ("_closed", "_loop", "_ready", "_thread")
669
+
670
+ def __init__(self) -> None:
671
+ self._loop = asyncio.new_event_loop()
672
+ self._closed = threading.Event()
673
+ self._ready = threading.Event()
674
+ self._thread = threading.Thread(
675
+ target=self._run,
676
+ name="calc-flow-continuous",
677
+ daemon=True,
678
+ )
679
+ self._thread.start()
680
+ self._ready.wait()
681
+
682
+ def _run(self) -> None:
683
+ asyncio.set_event_loop(self._loop)
684
+ self._ready.set()
685
+ try:
686
+ self._loop.run_forever()
687
+ finally:
688
+ self._loop.close()
689
+ self._closed.set()
690
+
691
+ async def _invoke[T](self, factory: Callable[[], Awaitable[T]]) -> T:
692
+ return await factory()
693
+
694
+ def _submit[T](
695
+ self, factory: Callable[[], Awaitable[T]]
696
+ ) -> concurrent.futures.Future[T]:
697
+ if self._closed.is_set():
698
+ raise RuntimeError("the calc-flow continuous event loop is closed")
699
+ return asyncio.run_coroutine_threadsafe(self._invoke(factory), self._loop)
700
+
701
+ def run[T](self, factory: Callable[[], Awaitable[T]]) -> T:
702
+ return self._submit(factory).result()
703
+
704
+ async def run_async[T](self, factory: Callable[[], Awaitable[T]]) -> T:
705
+ if self.owns_current_thread():
706
+ return await factory()
707
+ return await asyncio.wrap_future(self._submit(factory))
708
+
709
+ def close_after(
710
+ self,
711
+ factory: Callable[[], Awaitable[object]],
712
+ release: Callable[[], object],
713
+ ) -> None:
714
+ async def invoke() -> object:
715
+ try:
716
+ await factory()
717
+ finally:
718
+ release()
719
+
720
+ try:
721
+ future = self._submit(invoke)
722
+ except RuntimeError:
723
+ release()
724
+ return
725
+ future.add_done_callback(lambda _future: self._request_stop())
726
+
727
+ def owns_current_thread(self) -> bool:
728
+ return threading.current_thread() is self._thread
729
+
730
+ def _request_stop(self) -> None:
731
+ if self._closed.is_set():
732
+ return
733
+ try:
734
+ if self.owns_current_thread():
735
+ self._loop.stop()
736
+ else:
737
+ self._loop.call_soon_threadsafe(self._loop.stop)
738
+ except RuntimeError:
739
+ if not self._closed.is_set():
740
+ raise
741
+
742
+ def close(self) -> None:
743
+ self._request_stop()
744
+ if not self.owns_current_thread():
745
+ self._thread.join()
746
+
747
+ async def close_async(self) -> None:
748
+ if self.owns_current_thread():
749
+ self._request_stop()
750
+ return
751
+ await asyncio.to_thread(self.close)
752
+
753
+
754
+ class StreamingJob:
755
+ """Sole lifecycle owner returned after a streaming runner starts."""
756
+
757
+ __slots__ = ("_blocking_loop", "_inner", "__weakref__")
758
+
759
+ def __init__(
760
+ self,
761
+ inner: _native._StreamingJob,
762
+ blocking_loop: _BlockingEventLoop | None = None,
763
+ ) -> None:
764
+ self._inner = inner
765
+ self._blocking_loop = blocking_loop
766
+
767
+ def _run_blocking[T](
768
+ self,
769
+ factory: Callable[[], Awaitable[T]],
770
+ method: str,
771
+ *,
772
+ terminal: bool = False,
773
+ ) -> T:
774
+ _reject_active_loop(method)
775
+ loop = self._blocking_loop
776
+ if loop is None:
777
+ return asyncio.run(factory())
778
+ try:
779
+ return loop.run(factory)
780
+ finally:
781
+ if terminal:
782
+ loop.close()
783
+ self._blocking_loop = None
784
+
785
+ async def _run_terminal_async(
786
+ self, factory: Callable[[], Awaitable[Mapping[str, object]]]
787
+ ) -> JobOutcome:
788
+ loop = self._blocking_loop
789
+ if loop is None or loop.owns_current_thread():
790
+ value = await factory()
791
+ else:
792
+ try:
793
+ value = await loop.run_async(factory)
794
+ except asyncio.CancelledError:
795
+ loop.close_after(self._inner.wait_async, self._inner._release_roots)
796
+ raise
797
+ self._inner._release_roots()
798
+ if loop is not None and not loop.owns_current_thread():
799
+ self._blocking_loop = None
800
+ await _finish_cleanup(loop.close_async())
801
+ return _outcome(value)
802
+
803
+ @property
804
+ def id(self) -> int:
805
+ return self._inner.id
806
+
807
+ def status(self) -> JobStatus:
808
+ """Return a fresh CPU-local status snapshot; safe inside an event loop."""
809
+ return self._inner.status()
810
+
811
+ async def trigger_checkpoint_async(self) -> int:
812
+ """Request and await one durable checkpoint epoch."""
813
+ return await self._inner.trigger_checkpoint_async()
814
+
815
+ def trigger_checkpoint(self) -> int:
816
+ return self._run_blocking(self.trigger_checkpoint_async, "trigger_checkpoint")
817
+
818
+ async def shutdown_async(self) -> JobOutcome:
819
+ """Drain the job, publish terminal progress, and await cleanup."""
820
+ return await self._run_terminal_async(self._inner.shutdown_async)
821
+
822
+ def shutdown(self) -> JobOutcome:
823
+ return self._run_blocking(self.shutdown_async, "shutdown", terminal=True)
824
+
825
+ async def cancel_async(self) -> JobOutcome:
826
+ """Cancel the job and await bounded connector cleanup."""
827
+ return await self._run_terminal_async(self._inner.cancel_async)
828
+
829
+ def cancel(self) -> JobOutcome:
830
+ return self._run_blocking(self.cancel_async, "cancel", terminal=True)
831
+
832
+ async def wait_async(self) -> JobOutcome:
833
+ """Observe terminal completion without changing job state."""
834
+ return await self._run_terminal_async(self._inner.wait_async)
835
+
836
+ def wait(self) -> JobOutcome:
837
+ return self._run_blocking(self.wait_async, "wait", terminal=True)
838
+
839
+ def __del__(self) -> None:
840
+ loop = self._blocking_loop
841
+ if loop is None:
842
+ return
843
+ self._blocking_loop = None
844
+ try:
845
+ loop.close_after(self._inner.cancel_async, self._inner._release_roots)
846
+ except BaseException:
847
+ loop.close()
848
+
849
+
850
+ def _runner_sources(
851
+ sources: Mapping[str, SourceBinding],
852
+ ) -> dict[str, SourceBinding]:
853
+ if not isinstance(sources, Mapping):
854
+ raise TypeError("sources must be a mapping of source bindings")
855
+ copied = dict(sources)
856
+ if not all(
857
+ isinstance(name, str) and isinstance(binding, SourceBinding)
858
+ for name, binding in copied.items()
859
+ ):
860
+ raise TypeError("sources must map strings to SourceBinding values")
861
+ return copied
862
+
863
+
864
+ def _runner_sinks(
865
+ sinks: Mapping[str, Sequence[SinkBinding]],
866
+ ) -> dict[str, tuple[SinkBinding, ...]]:
867
+ if not isinstance(sinks, Mapping):
868
+ raise TypeError("sinks must be a mapping of sink bindings")
869
+ copied = {output: tuple(bindings) for output, bindings in sinks.items()}
870
+ if not all(
871
+ isinstance(output, str)
872
+ and all(isinstance(binding, SinkBinding) for binding in bindings)
873
+ for output, bindings in copied.items()
874
+ ):
875
+ raise TypeError("sinks must map strings to SinkBinding sequences")
876
+ return copied
877
+
878
+
879
+ def _runner_static_inputs(
880
+ static_inputs: Mapping[str, _native.Batch] | None,
881
+ ) -> dict[str, _native.Batch]:
882
+ if static_inputs is None:
883
+ return {}
884
+ if not isinstance(static_inputs, Mapping):
885
+ raise TypeError("static_inputs must be a mapping of calc_flow.Batch values")
886
+ copied = dict(static_inputs)
887
+ if not all(
888
+ isinstance(name, str) and isinstance(batch, _native.Batch)
889
+ for name, batch in copied.items()
890
+ ):
891
+ raise TypeError("static_inputs must be a mapping of calc_flow.Batch values")
892
+ return copied
893
+
894
+
895
+ def _runner_config(config: StreamRuntimeConfig | None) -> StreamRuntimeConfig:
896
+ selected = StreamRuntimeConfig() if config is None else config
897
+ if not isinstance(selected, StreamRuntimeConfig):
898
+ raise TypeError("config must be a calc_flow.StreamRuntimeConfig or None")
899
+ return selected
900
+
901
+
902
+ class StreamingRunner:
903
+ """One-shot source-driven continuous runner owning all bindings."""
904
+
905
+ __slots__ = ("_inner", "__weakref__")
906
+
907
+ def __init__(
908
+ self,
909
+ plan: StreamExecutionPlan,
910
+ sources: Mapping[str, SourceBinding] | None = None,
911
+ sinks: Mapping[str, Sequence[SinkBinding]] | None = None,
912
+ checkpoints: ManagedCheckpointRuntime | None = None,
913
+ *,
914
+ config: StreamRuntimeConfig | None = None,
915
+ static_inputs: Mapping[str, _native.Batch] | None = None,
916
+ ) -> None:
917
+ from calc_flow.pipeline import StreamExecutionPlan
918
+
919
+ if not isinstance(plan, StreamExecutionPlan):
920
+ raise TypeError("plan must be a calc_flow.StreamExecutionPlan")
921
+ settings = plan._project_settings
922
+ if settings is not None:
923
+ if any(
924
+ value is not None for value in (sources, sinks, checkpoints, config)
925
+ ):
926
+ raise TypeError(
927
+ "connector-backed project plans own sources, sinks, checkpoints, "
928
+ "and runtime config"
929
+ )
930
+ sources = {}
931
+ sinks = {}
932
+ checkpoints = ManagedCheckpointRuntime(settings.state_root)
933
+ config = StreamRuntimeConfig(
934
+ checkpoint_interval=timedelta(
935
+ milliseconds=settings.checkpoint_interval_ms
936
+ ),
937
+ edge_budget=EdgeBudget(
938
+ settings.max_batch_rows, settings.max_batch_bytes
939
+ ),
940
+ retained_epochs=settings.retained_epochs,
941
+ )
942
+ if not isinstance(sources, Mapping):
943
+ raise TypeError("sources must be a mapping of source bindings")
944
+ if not isinstance(sinks, Mapping):
945
+ raise TypeError("sinks must be a mapping of sink bindings")
946
+ if not isinstance(checkpoints, ManagedCheckpointRuntime):
947
+ raise TypeError("checkpoints must be a calc_flow.ManagedCheckpointRuntime")
948
+ self._inner = _native._StreamingRunner(
949
+ plan._inner,
950
+ _runner_sources(sources),
951
+ _runner_sinks(sinks),
952
+ checkpoints._inner,
953
+ _runner_config(config)._native(),
954
+ _runner_static_inputs(static_inputs),
955
+ )
956
+
957
+ async def start_async(self) -> StreamingJob:
958
+ """Consume this runner and asynchronously launch one owning job."""
959
+ try:
960
+ try:
961
+ return StreamingJob(await self._inner.start_async())
962
+ except asyncio.CancelledError as cancellation:
963
+ await _raise_after_cancellation_cleanup(
964
+ self._inner._wait_start_cleanup_async(), cancellation
965
+ )
966
+ finally:
967
+ self._inner._release_roots()
968
+
969
+ def start(self) -> StreamingJob:
970
+ """Start outside an event loop using the guarded blocking facade."""
971
+ _reject_active_loop("start")
972
+ loop = _BlockingEventLoop()
973
+ try:
974
+ job = loop.run(self.start_async)
975
+ except BaseException:
976
+ loop.close()
977
+ raise
978
+ job._blocking_loop = loop
979
+ return job