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/__init__.py +169 -0
- calc_flow/_native.pyd +0 -0
- calc_flow/_native.pyi +366 -0
- calc_flow/array.py +1324 -0
- calc_flow/capabilities.py +775 -0
- calc_flow/config.py +219 -0
- calc_flow/errors.py +25 -0
- calc_flow/join_spec.py +156 -0
- calc_flow/pipeline.py +1123 -0
- calc_flow/py.typed +0 -0
- calc_flow/runtime.py +979 -0
- calc_flow/store.py +138 -0
- calc_flow/symbolic/__init__.py +65 -0
- calc_flow/symbolic/_generated_rolling_kernels.py +23 -0
- calc_flow/symbolic/analyzer.py +2280 -0
- calc_flow/symbolic/domains.py +77 -0
- calc_flow/symbolic/errors.py +58 -0
- calc_flow/symbolic/expr.py +662 -0
- calc_flow/symbolic/lower/__init__.py +31 -0
- calc_flow/symbolic/lower/planners.py +1270 -0
- calc_flow/symbolic/lower/program.py +840 -0
- calc_flow/symbolic/lower/segments.py +836 -0
- calc_flow/symbolic/lower/strategies.py +1472 -0
- calc_flow/symbolic/nodes.py +603 -0
- calc_flow/symbolic/ops.py +1155 -0
- calc_flow/symbolic/optimizer.py +600 -0
- calc_flow/symbolic/program.py +377 -0
- calc_flow/symbolic/types.py +110 -0
- calc_flow/symbolic/windows.py +153 -0
- calc_flow/udf.py +19 -0
- calc_flow_python-4.0.0.dist-info/METADATA +376 -0
- calc_flow_python-4.0.0.dist-info/RECORD +35 -0
- calc_flow_python-4.0.0.dist-info/WHEEL +4 -0
- calc_flow_python-4.0.0.dist-info/licenses/LICENSE +202 -0
- calc_flow_python-4.0.0.dist-info/sboms/calc-flow-python.cyclonedx.json +10081 -0
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
|