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/pipeline.py
ADDED
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from threading import RLock
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from calc_flow import _native
|
|
14
|
+
from calc_flow.capabilities import (
|
|
15
|
+
ProviderArrayRules,
|
|
16
|
+
ProviderOptionsSchema,
|
|
17
|
+
RuntimeCapabilities,
|
|
18
|
+
_StatelessProviderLifecycle,
|
|
19
|
+
runtime_capabilities,
|
|
20
|
+
)
|
|
21
|
+
from calc_flow.join_spec import (
|
|
22
|
+
JoinSideWire,
|
|
23
|
+
JoinStateLimits,
|
|
24
|
+
JoinTimeBounds,
|
|
25
|
+
bounds_wire,
|
|
26
|
+
join_wire_spec,
|
|
27
|
+
limits_wire,
|
|
28
|
+
require_distinct_prefixes,
|
|
29
|
+
require_equal_key_counts,
|
|
30
|
+
require_event_time_columns,
|
|
31
|
+
require_join_bounds,
|
|
32
|
+
require_join_limits,
|
|
33
|
+
timedelta_micros,
|
|
34
|
+
)
|
|
35
|
+
from calc_flow.store import _copy_json_value, _run_blocking
|
|
36
|
+
|
|
37
|
+
JSONValue = None | bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"]
|
|
38
|
+
UdfReference = tuple[str, str, str]
|
|
39
|
+
_SYMBOLIC_COMPILE_CACHE_MAX_ENTRIES = 128
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _canonical(value: Mapping[str, Any]) -> str:
|
|
43
|
+
return json.dumps(value, allow_nan=False, separators=(",", ":"), sort_keys=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _udf_documents(references: Sequence[UdfReference]) -> list[dict[str, str]]:
|
|
47
|
+
documents: list[dict[str, str]] = []
|
|
48
|
+
for reference in references:
|
|
49
|
+
if len(reference) != 3:
|
|
50
|
+
raise ValueError("UDF references must be (provider, name, version) tuples")
|
|
51
|
+
provider, name, version = reference
|
|
52
|
+
if not all(isinstance(value, str) for value in reference):
|
|
53
|
+
raise TypeError("UDF reference values must be strings")
|
|
54
|
+
documents.append(
|
|
55
|
+
{
|
|
56
|
+
"kind": "data_fusion_scalar",
|
|
57
|
+
"name": name,
|
|
58
|
+
"provider": provider,
|
|
59
|
+
"version": version,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
return documents
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _node_inputs(node: Mapping[str, Any]) -> tuple[str, ...]:
|
|
66
|
+
configured = node.get("input_ports", [])
|
|
67
|
+
if configured:
|
|
68
|
+
return tuple(port["name"] for port in configured)
|
|
69
|
+
operator = node["operator"]
|
|
70
|
+
if operator["kind"] == "expression":
|
|
71
|
+
return ("input",)
|
|
72
|
+
if operator["kind"] == "sql":
|
|
73
|
+
return tuple(operator["aliases"])
|
|
74
|
+
return ()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _data_sources(project: Mapping[str, Any]) -> list[dict[str, JSONValue]]:
|
|
78
|
+
graph = project["graph"]
|
|
79
|
+
connected = {
|
|
80
|
+
(edge["target_node"], edge.get("target_port", "input"))
|
|
81
|
+
for edge in graph["edges"]
|
|
82
|
+
}
|
|
83
|
+
endpoints = sorted(
|
|
84
|
+
(node["id"], port)
|
|
85
|
+
for node in graph["nodes"]
|
|
86
|
+
for port in _node_inputs(node)
|
|
87
|
+
if (node["id"], port) not in connected
|
|
88
|
+
)
|
|
89
|
+
counts: dict[str, int] = {}
|
|
90
|
+
for _, port in endpoints:
|
|
91
|
+
counts[port] = counts.get(port, 0) + 1
|
|
92
|
+
names = sorted(
|
|
93
|
+
port if counts[port] == 1 else f"{node_id}.{port}"
|
|
94
|
+
for node_id, port in endpoints
|
|
95
|
+
)
|
|
96
|
+
return [
|
|
97
|
+
{"data": [], "format": "inline_json", "id": f"source_{index}", "input": name}
|
|
98
|
+
for index, name in enumerate(names, start=1)
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _updated_project(project_json: str, update: Any) -> str:
|
|
103
|
+
project = json.loads(project_json)
|
|
104
|
+
update(project)
|
|
105
|
+
project["data_sources"] = _data_sources(project)
|
|
106
|
+
return _canonical(project)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _validate_stateless_lifecycle_values(values: Mapping[str, object]) -> None:
|
|
110
|
+
for field_name, value in values.items():
|
|
111
|
+
if type(value) is not bool:
|
|
112
|
+
raise TypeError(
|
|
113
|
+
f"{field_name} must be an exact bool; found {type(value).__name__}"
|
|
114
|
+
)
|
|
115
|
+
if not values["microbatch_invariant"]:
|
|
116
|
+
raise ValueError("stateless stream providers must be micro-batch invariant")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _registered_batch_provider(
|
|
120
|
+
registrations: Sequence[dict[str, Any]],
|
|
121
|
+
provider: str,
|
|
122
|
+
name: str,
|
|
123
|
+
version: str,
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
matches = [
|
|
126
|
+
registration
|
|
127
|
+
for registration in registrations
|
|
128
|
+
if registration["kind"] == "provider"
|
|
129
|
+
and registration["provider"] == provider
|
|
130
|
+
and registration["name"] == name
|
|
131
|
+
and registration["version"] == version
|
|
132
|
+
]
|
|
133
|
+
if len(matches) != 1:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"stateless stream registration requires one existing batch "
|
|
136
|
+
f"provider {provider}:{name}@{version}"
|
|
137
|
+
)
|
|
138
|
+
return matches[0]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True)
|
|
142
|
+
class Runtime:
|
|
143
|
+
_inner: _native.Runtime = field(default_factory=_native.Runtime, repr=False)
|
|
144
|
+
_registration_lock: RLock = field(default_factory=RLock, repr=False, compare=False)
|
|
145
|
+
_session_id: str = field(
|
|
146
|
+
default_factory=lambda: str(uuid4()), repr=False, compare=False
|
|
147
|
+
)
|
|
148
|
+
_registrations: list[dict[str, Any]] = field(
|
|
149
|
+
default_factory=list, repr=False, compare=False
|
|
150
|
+
)
|
|
151
|
+
_symbolic_compile_cache: dict[object, object] = field(
|
|
152
|
+
default_factory=dict, repr=False, compare=False
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _cached_symbolic_compile(
|
|
156
|
+
self, key: object, factory: Callable[[], object], /
|
|
157
|
+
) -> object:
|
|
158
|
+
"""Return one immutable compiled plan for a deterministic cache key."""
|
|
159
|
+
|
|
160
|
+
with self._registration_lock:
|
|
161
|
+
cached = self._symbolic_compile_cache.get(key)
|
|
162
|
+
if cached is not None:
|
|
163
|
+
return cached
|
|
164
|
+
compiled = factory()
|
|
165
|
+
if len(self._symbolic_compile_cache) >= _SYMBOLIC_COMPILE_CACHE_MAX_ENTRIES:
|
|
166
|
+
oldest = next(iter(self._symbolic_compile_cache))
|
|
167
|
+
del self._symbolic_compile_cache[oldest]
|
|
168
|
+
self._symbolic_compile_cache[key] = compiled
|
|
169
|
+
return compiled
|
|
170
|
+
|
|
171
|
+
def _invalidate_symbolic_compile_cache(self) -> None:
|
|
172
|
+
self._symbolic_compile_cache.clear()
|
|
173
|
+
|
|
174
|
+
def register_provider(
|
|
175
|
+
self,
|
|
176
|
+
provider: str,
|
|
177
|
+
name: str,
|
|
178
|
+
version: str,
|
|
179
|
+
callback: Any,
|
|
180
|
+
*,
|
|
181
|
+
options_schema: ProviderOptionsSchema | None = None,
|
|
182
|
+
accepts_context: bool = False,
|
|
183
|
+
) -> None:
|
|
184
|
+
if type(accepts_context) is not bool:
|
|
185
|
+
raise TypeError(
|
|
186
|
+
"accepts_context must be an exact bool; "
|
|
187
|
+
f"found {type(accepts_context).__name__}"
|
|
188
|
+
)
|
|
189
|
+
if options_schema is not None and not isinstance(
|
|
190
|
+
options_schema, ProviderOptionsSchema
|
|
191
|
+
):
|
|
192
|
+
raise TypeError(
|
|
193
|
+
"options_schema must be a ProviderOptionsSchema or None; "
|
|
194
|
+
f"found {type(options_schema).__name__}"
|
|
195
|
+
)
|
|
196
|
+
with self._registration_lock:
|
|
197
|
+
self._inner.register_provider(
|
|
198
|
+
provider,
|
|
199
|
+
name,
|
|
200
|
+
version,
|
|
201
|
+
callback,
|
|
202
|
+
accepts_context=accepts_context,
|
|
203
|
+
)
|
|
204
|
+
registration = {
|
|
205
|
+
"kind": "provider",
|
|
206
|
+
"provider": provider,
|
|
207
|
+
"name": name,
|
|
208
|
+
"version": version,
|
|
209
|
+
"callback": callback,
|
|
210
|
+
"options_schema": options_schema,
|
|
211
|
+
}
|
|
212
|
+
if accepts_context:
|
|
213
|
+
registration["accepts_context"] = True
|
|
214
|
+
self._registrations.append(registration)
|
|
215
|
+
self._invalidate_symbolic_compile_cache()
|
|
216
|
+
|
|
217
|
+
def _register_mapping_provider(
|
|
218
|
+
self,
|
|
219
|
+
provider: str,
|
|
220
|
+
name: str,
|
|
221
|
+
version: str,
|
|
222
|
+
callback: Any,
|
|
223
|
+
*,
|
|
224
|
+
input_ports: Sequence[tuple[str, str]],
|
|
225
|
+
output_ports: Sequence[tuple[str, str]],
|
|
226
|
+
options_schema: ProviderOptionsSchema | None = None,
|
|
227
|
+
accepts_context: bool = False,
|
|
228
|
+
) -> None:
|
|
229
|
+
if type(accepts_context) is not bool:
|
|
230
|
+
raise TypeError(
|
|
231
|
+
"accepts_context must be an exact bool; "
|
|
232
|
+
f"found {type(accepts_context).__name__}"
|
|
233
|
+
)
|
|
234
|
+
if options_schema is not None and not isinstance(
|
|
235
|
+
options_schema, ProviderOptionsSchema
|
|
236
|
+
):
|
|
237
|
+
raise TypeError(
|
|
238
|
+
"options_schema must be a ProviderOptionsSchema or None; "
|
|
239
|
+
f"found {type(options_schema).__name__}"
|
|
240
|
+
)
|
|
241
|
+
copied_inputs = tuple((port, kind) for port, kind in input_ports)
|
|
242
|
+
copied_outputs = tuple((port, kind) for port, kind in output_ports)
|
|
243
|
+
with self._registration_lock:
|
|
244
|
+
self._inner._register_mapping_provider(
|
|
245
|
+
provider,
|
|
246
|
+
name,
|
|
247
|
+
version,
|
|
248
|
+
callback,
|
|
249
|
+
input_ports=copied_inputs,
|
|
250
|
+
output_ports=copied_outputs,
|
|
251
|
+
accepts_context=accepts_context,
|
|
252
|
+
)
|
|
253
|
+
registration = {
|
|
254
|
+
"kind": "provider",
|
|
255
|
+
"provider_mode": "mapping",
|
|
256
|
+
"provider": provider,
|
|
257
|
+
"name": name,
|
|
258
|
+
"version": version,
|
|
259
|
+
"callback": callback,
|
|
260
|
+
"input_ports": copied_inputs,
|
|
261
|
+
"output_ports": copied_outputs,
|
|
262
|
+
"options_schema": options_schema,
|
|
263
|
+
}
|
|
264
|
+
if accepts_context:
|
|
265
|
+
registration["accepts_context"] = True
|
|
266
|
+
self._registrations.append(registration)
|
|
267
|
+
self._invalidate_symbolic_compile_cache()
|
|
268
|
+
|
|
269
|
+
def _register_stateless_stream_provider(
|
|
270
|
+
self,
|
|
271
|
+
provider: str,
|
|
272
|
+
name: str,
|
|
273
|
+
version: str,
|
|
274
|
+
callback: Any,
|
|
275
|
+
*,
|
|
276
|
+
microbatch_invariant: bool,
|
|
277
|
+
deterministic: bool,
|
|
278
|
+
replay_safe: bool,
|
|
279
|
+
supports_static_inputs: bool,
|
|
280
|
+
array_rules: ProviderArrayRules,
|
|
281
|
+
) -> None:
|
|
282
|
+
_validate_stateless_lifecycle_values(
|
|
283
|
+
{
|
|
284
|
+
"microbatch_invariant": microbatch_invariant,
|
|
285
|
+
"deterministic": deterministic,
|
|
286
|
+
"replay_safe": replay_safe,
|
|
287
|
+
"supports_static_inputs": supports_static_inputs,
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
if not isinstance(array_rules, ProviderArrayRules):
|
|
291
|
+
raise TypeError(
|
|
292
|
+
"array_rules must be a ProviderArrayRules; "
|
|
293
|
+
f"found {type(array_rules).__name__}"
|
|
294
|
+
)
|
|
295
|
+
with self._registration_lock:
|
|
296
|
+
registration = _registered_batch_provider(
|
|
297
|
+
self._registrations, provider, name, version
|
|
298
|
+
)
|
|
299
|
+
self._inner._register_stateless_stream_provider(
|
|
300
|
+
provider,
|
|
301
|
+
name,
|
|
302
|
+
version,
|
|
303
|
+
callback,
|
|
304
|
+
microbatch_invariant=microbatch_invariant,
|
|
305
|
+
deterministic=deterministic,
|
|
306
|
+
replay_safe=replay_safe,
|
|
307
|
+
)
|
|
308
|
+
registration["stream_lifecycle"] = _StatelessProviderLifecycle(
|
|
309
|
+
deterministic=deterministic,
|
|
310
|
+
replay_safe=replay_safe,
|
|
311
|
+
supports_static_inputs=supports_static_inputs,
|
|
312
|
+
array_rules=array_rules,
|
|
313
|
+
)
|
|
314
|
+
self._invalidate_symbolic_compile_cache()
|
|
315
|
+
|
|
316
|
+
def _register_stateless_stream_mapping_provider(
|
|
317
|
+
self,
|
|
318
|
+
provider: str,
|
|
319
|
+
name: str,
|
|
320
|
+
version: str,
|
|
321
|
+
callback: Any,
|
|
322
|
+
*,
|
|
323
|
+
microbatch_invariant: bool,
|
|
324
|
+
deterministic: bool,
|
|
325
|
+
replay_safe: bool,
|
|
326
|
+
supports_static_inputs: bool,
|
|
327
|
+
array_rules: ProviderArrayRules,
|
|
328
|
+
) -> None:
|
|
329
|
+
_validate_stateless_lifecycle_values(
|
|
330
|
+
{
|
|
331
|
+
"microbatch_invariant": microbatch_invariant,
|
|
332
|
+
"deterministic": deterministic,
|
|
333
|
+
"replay_safe": replay_safe,
|
|
334
|
+
"supports_static_inputs": supports_static_inputs,
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
if not isinstance(array_rules, ProviderArrayRules):
|
|
338
|
+
raise TypeError(
|
|
339
|
+
"array_rules must be a ProviderArrayRules; "
|
|
340
|
+
f"found {type(array_rules).__name__}"
|
|
341
|
+
)
|
|
342
|
+
with self._registration_lock:
|
|
343
|
+
registration = _registered_batch_provider(
|
|
344
|
+
self._registrations, provider, name, version
|
|
345
|
+
)
|
|
346
|
+
if registration.get("provider_mode") != "mapping":
|
|
347
|
+
raise ValueError(
|
|
348
|
+
"stateless stream mapping registration requires an existing "
|
|
349
|
+
f"mapping provider {provider}:{name}@{version}"
|
|
350
|
+
)
|
|
351
|
+
self._inner._register_stateless_stream_mapping_provider(
|
|
352
|
+
provider,
|
|
353
|
+
name,
|
|
354
|
+
version,
|
|
355
|
+
callback,
|
|
356
|
+
input_ports=registration["input_ports"],
|
|
357
|
+
output_ports=registration["output_ports"],
|
|
358
|
+
microbatch_invariant=microbatch_invariant,
|
|
359
|
+
deterministic=deterministic,
|
|
360
|
+
replay_safe=replay_safe,
|
|
361
|
+
)
|
|
362
|
+
registration["stream_lifecycle"] = _StatelessProviderLifecycle(
|
|
363
|
+
deterministic=deterministic,
|
|
364
|
+
replay_safe=replay_safe,
|
|
365
|
+
supports_static_inputs=supports_static_inputs,
|
|
366
|
+
array_rules=array_rules,
|
|
367
|
+
)
|
|
368
|
+
self._invalidate_symbolic_compile_cache()
|
|
369
|
+
|
|
370
|
+
def register_scalar_udf(
|
|
371
|
+
self,
|
|
372
|
+
*,
|
|
373
|
+
provider: str,
|
|
374
|
+
name: str,
|
|
375
|
+
version: str,
|
|
376
|
+
input_types: Sequence[str],
|
|
377
|
+
return_type: str,
|
|
378
|
+
volatility: str,
|
|
379
|
+
function: Any,
|
|
380
|
+
) -> None:
|
|
381
|
+
from calc_flow.udf import _validate_scalar_udf_registration
|
|
382
|
+
|
|
383
|
+
copied_types = _validate_scalar_udf_registration(input_types, function)
|
|
384
|
+
with self._registration_lock:
|
|
385
|
+
self._inner.register_scalar_udf(
|
|
386
|
+
provider=provider,
|
|
387
|
+
name=name,
|
|
388
|
+
version=version,
|
|
389
|
+
input_types=copied_types,
|
|
390
|
+
return_type=return_type,
|
|
391
|
+
volatility=volatility,
|
|
392
|
+
function=function,
|
|
393
|
+
)
|
|
394
|
+
self._registrations.append(
|
|
395
|
+
{
|
|
396
|
+
"kind": "scalar_udf",
|
|
397
|
+
"provider": provider,
|
|
398
|
+
"name": name,
|
|
399
|
+
"version": version,
|
|
400
|
+
"input_types": tuple(copied_types),
|
|
401
|
+
"return_type": return_type,
|
|
402
|
+
"volatility": volatility,
|
|
403
|
+
"function": function,
|
|
404
|
+
}
|
|
405
|
+
)
|
|
406
|
+
self._invalidate_symbolic_compile_cache()
|
|
407
|
+
|
|
408
|
+
def _copied_registrations(self) -> tuple[dict[str, Any], ...]:
|
|
409
|
+
return tuple(
|
|
410
|
+
{
|
|
411
|
+
**registration,
|
|
412
|
+
**(
|
|
413
|
+
{"input_types": tuple(registration["input_types"])}
|
|
414
|
+
if registration["kind"] == "scalar_udf"
|
|
415
|
+
else (
|
|
416
|
+
{
|
|
417
|
+
"input_ports": tuple(registration["input_ports"]),
|
|
418
|
+
"output_ports": tuple(registration["output_ports"]),
|
|
419
|
+
}
|
|
420
|
+
if registration.get("provider_mode") == "mapping"
|
|
421
|
+
else {}
|
|
422
|
+
)
|
|
423
|
+
),
|
|
424
|
+
}
|
|
425
|
+
for registration in self._registrations
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def _registration_snapshot(self) -> tuple[dict[str, Any], ...]:
|
|
429
|
+
"""Return successful trusted registrations as defensive plain records."""
|
|
430
|
+
with self._registration_lock:
|
|
431
|
+
return self._copied_registrations()
|
|
432
|
+
|
|
433
|
+
def catalog(self) -> list[dict[str, Any]]:
|
|
434
|
+
return self._inner.catalog()
|
|
435
|
+
|
|
436
|
+
def capabilities(self) -> RuntimeCapabilities:
|
|
437
|
+
snapshot, _ = self._capability_registration_snapshot()
|
|
438
|
+
return snapshot
|
|
439
|
+
|
|
440
|
+
def _capability_registration_snapshot(
|
|
441
|
+
self,
|
|
442
|
+
) -> tuple[RuntimeCapabilities, tuple[dict[str, Any], ...]]:
|
|
443
|
+
"""Capture safe metadata and private worker records at one revision."""
|
|
444
|
+
with self._registration_lock:
|
|
445
|
+
registrations = self._copied_registrations()
|
|
446
|
+
snapshot = runtime_capabilities(
|
|
447
|
+
session_id=self._session_id,
|
|
448
|
+
revision=len(registrations),
|
|
449
|
+
package_version=_native.version(),
|
|
450
|
+
registrations=registrations,
|
|
451
|
+
)
|
|
452
|
+
return snapshot, registrations
|
|
453
|
+
|
|
454
|
+
def validation_report(self, project_json: str) -> dict[str, Any]:
|
|
455
|
+
if not isinstance(project_json, str):
|
|
456
|
+
raise TypeError("project_json must be a string")
|
|
457
|
+
return self._inner.validation_report(project_json)
|
|
458
|
+
|
|
459
|
+
def compile_project(self, project_json: str) -> BatchExecutionPlan:
|
|
460
|
+
if not isinstance(project_json, str):
|
|
461
|
+
raise TypeError("project_json must be a string")
|
|
462
|
+
return BatchExecutionPlan(self._inner.compile_project(project_json))
|
|
463
|
+
|
|
464
|
+
def compile_batch_project(self, project_json: str) -> BatchExecutionPlan:
|
|
465
|
+
"""Compile one canonical project-v3 batch document."""
|
|
466
|
+
return self.compile_project(project_json)
|
|
467
|
+
|
|
468
|
+
def compile_stream_project(
|
|
469
|
+
self,
|
|
470
|
+
project_json: str,
|
|
471
|
+
*,
|
|
472
|
+
requirements: StreamRequirements | None = None,
|
|
473
|
+
) -> StreamExecutionPlan:
|
|
474
|
+
"""Compile project JSON into a continuous plan owned by this runtime."""
|
|
475
|
+
if not isinstance(project_json, str):
|
|
476
|
+
raise TypeError("project_json must be a string")
|
|
477
|
+
selected = StreamRequirements() if requirements is None else requirements
|
|
478
|
+
if not isinstance(selected, StreamRequirements):
|
|
479
|
+
raise TypeError(
|
|
480
|
+
"requirements must be a calc_flow.StreamRequirements or None"
|
|
481
|
+
)
|
|
482
|
+
delivery = {
|
|
483
|
+
output: guarantee.value for output, guarantee in selected.delivery.items()
|
|
484
|
+
}
|
|
485
|
+
canonical = _native.validate_project_json(project_json)
|
|
486
|
+
project = json.loads(canonical)
|
|
487
|
+
runtime_options = project["runtime"]["options"]
|
|
488
|
+
state = project["state"]
|
|
489
|
+
return StreamExecutionPlan(
|
|
490
|
+
self._inner.compile_stream_project(canonical, delivery),
|
|
491
|
+
_ProjectStreamSettings(
|
|
492
|
+
state_root=state["root"],
|
|
493
|
+
retained_epochs=state["retention"],
|
|
494
|
+
checkpoint_interval_ms=runtime_options["checkpoint_interval_ms"],
|
|
495
|
+
max_batch_rows=runtime_options["max_batch_rows"],
|
|
496
|
+
max_batch_bytes=runtime_options["max_batch_bytes"],
|
|
497
|
+
),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def _compile_stream_graph_project(
|
|
501
|
+
self,
|
|
502
|
+
project_json: str,
|
|
503
|
+
*,
|
|
504
|
+
requirements: StreamRequirements | None = None,
|
|
505
|
+
) -> StreamExecutionPlan:
|
|
506
|
+
selected = StreamRequirements() if requirements is None else requirements
|
|
507
|
+
delivery = {
|
|
508
|
+
output: guarantee.value for output, guarantee in selected.delivery.items()
|
|
509
|
+
}
|
|
510
|
+
return StreamExecutionPlan(
|
|
511
|
+
self._inner._compile_stream_graph_project(project_json, delivery)
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@dataclass(frozen=True, slots=True)
|
|
516
|
+
class BatchExecutionPlan:
|
|
517
|
+
_inner: _native.ExecutionPlan = field(repr=False)
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def name(self) -> str:
|
|
521
|
+
return self._inner.name
|
|
522
|
+
|
|
523
|
+
@property
|
|
524
|
+
def fingerprint(self) -> str:
|
|
525
|
+
return self._inner.fingerprint
|
|
526
|
+
|
|
527
|
+
def execute(
|
|
528
|
+
self,
|
|
529
|
+
inputs: Mapping[str, _native.Batch],
|
|
530
|
+
*,
|
|
531
|
+
options: _native.ExecutionOptions | None = None,
|
|
532
|
+
) -> _native.RunResult:
|
|
533
|
+
try:
|
|
534
|
+
asyncio.get_running_loop()
|
|
535
|
+
except RuntimeError:
|
|
536
|
+
pass
|
|
537
|
+
else:
|
|
538
|
+
raise RuntimeError(
|
|
539
|
+
"execute() cannot run inside an event loop; use execute_async()"
|
|
540
|
+
)
|
|
541
|
+
if options is not None and type(options) is not _native.ExecutionOptions:
|
|
542
|
+
raise TypeError("options must be a calc_flow.ExecutionOptions or None")
|
|
543
|
+
copied = dict(inputs)
|
|
544
|
+
return self._inner.execute(copied, options=options)
|
|
545
|
+
|
|
546
|
+
def execute_async(
|
|
547
|
+
self,
|
|
548
|
+
inputs: Mapping[str, _native.Batch],
|
|
549
|
+
*,
|
|
550
|
+
options: _native.ExecutionOptions | None = None,
|
|
551
|
+
) -> Awaitable[_native.RunResult]:
|
|
552
|
+
if options is not None and type(options) is not _native.ExecutionOptions:
|
|
553
|
+
raise TypeError("options must be a calc_flow.ExecutionOptions or None")
|
|
554
|
+
copied = dict(inputs)
|
|
555
|
+
|
|
556
|
+
async def execute() -> _native.RunResult:
|
|
557
|
+
native, cancellation = self._inner._execute_async_cancellable(
|
|
558
|
+
copied, options=options
|
|
559
|
+
)
|
|
560
|
+
try:
|
|
561
|
+
return await asyncio.shield(native)
|
|
562
|
+
except asyncio.CancelledError as cancelled:
|
|
563
|
+
native_completed = native.done()
|
|
564
|
+
if native_completed:
|
|
565
|
+
return native.result()
|
|
566
|
+
cancellation.cancel()
|
|
567
|
+
# Cancellation precedence: once the handler passes the
|
|
568
|
+
# terminal-at-entry check, the caller's CancelledError wins
|
|
569
|
+
# over any native outcome observed during the drain. A native
|
|
570
|
+
# failure landing mid-drain is retrieved below (so asyncio
|
|
571
|
+
# never reports it as unretrieved) and then discarded.
|
|
572
|
+
while not native.done():
|
|
573
|
+
try:
|
|
574
|
+
await asyncio.shield(native)
|
|
575
|
+
except asyncio.CancelledError:
|
|
576
|
+
continue
|
|
577
|
+
except Exception:
|
|
578
|
+
break
|
|
579
|
+
if native.done() and not native.cancelled():
|
|
580
|
+
native.exception()
|
|
581
|
+
raise cancelled
|
|
582
|
+
|
|
583
|
+
return execute()
|
|
584
|
+
|
|
585
|
+
async def snapshot_async(self) -> dict[str, Any]:
|
|
586
|
+
state = await self._inner.snapshot_async()
|
|
587
|
+
return _copy_json_value(state, root_mapping=True, label="plan state")
|
|
588
|
+
|
|
589
|
+
def restore_async(self, state: Mapping[str, object]) -> Awaitable[None]:
|
|
590
|
+
copied = _copy_json_value(dict(state), root_mapping=True, label="plan state")
|
|
591
|
+
encoded = json.dumps(copied, separators=(",", ":"), sort_keys=True)
|
|
592
|
+
|
|
593
|
+
async def restore() -> None:
|
|
594
|
+
await self._inner.restore_async(encoded)
|
|
595
|
+
|
|
596
|
+
return restore()
|
|
597
|
+
|
|
598
|
+
async def reset_async(self) -> None:
|
|
599
|
+
await self._inner.reset_async()
|
|
600
|
+
|
|
601
|
+
def snapshot(self) -> dict[str, Any]:
|
|
602
|
+
return _run_blocking(self.snapshot_async, "snapshot_async")
|
|
603
|
+
|
|
604
|
+
def restore(self, state: Mapping[str, object]) -> None:
|
|
605
|
+
return _run_blocking(lambda: self.restore_async(state), "restore_async")
|
|
606
|
+
|
|
607
|
+
def reset(self) -> None:
|
|
608
|
+
return _run_blocking(self.reset_async, "reset_async")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
ExecutionPlan = BatchExecutionPlan
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
class DeliveryGuarantee(StrEnum):
|
|
615
|
+
"""Delivery guarantee requested for one external stream output."""
|
|
616
|
+
|
|
617
|
+
BEST_EFFORT = "best_effort"
|
|
618
|
+
AT_LEAST_ONCE = "at_least_once"
|
|
619
|
+
EXACTLY_ONCE = "exactly_once"
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@dataclass(frozen=True, slots=True)
|
|
623
|
+
class StreamRequirements:
|
|
624
|
+
"""Immutable per-output delivery requirements for stream compilation."""
|
|
625
|
+
|
|
626
|
+
delivery: Mapping[str, DeliveryGuarantee] = field(default_factory=dict)
|
|
627
|
+
|
|
628
|
+
def __post_init__(self) -> None:
|
|
629
|
+
if not isinstance(self.delivery, Mapping):
|
|
630
|
+
raise TypeError(
|
|
631
|
+
"delivery must be a mapping of output names to DeliveryGuarantee values"
|
|
632
|
+
)
|
|
633
|
+
copied = dict(self.delivery)
|
|
634
|
+
for output, guarantee in copied.items():
|
|
635
|
+
if not isinstance(output, str) or not output:
|
|
636
|
+
raise TypeError("delivery output names must be non-empty strings")
|
|
637
|
+
if not isinstance(guarantee, DeliveryGuarantee):
|
|
638
|
+
raise TypeError(
|
|
639
|
+
"delivery guarantees must be calc_flow.DeliveryGuarantee values"
|
|
640
|
+
)
|
|
641
|
+
object.__setattr__(self, "delivery", MappingProxyType(copied))
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@dataclass(frozen=True, slots=True)
|
|
645
|
+
class ArrowFieldSpec:
|
|
646
|
+
"""One exact project-v3 Arrow field."""
|
|
647
|
+
|
|
648
|
+
name: str
|
|
649
|
+
data_type: str
|
|
650
|
+
nullable: bool = True
|
|
651
|
+
|
|
652
|
+
def __post_init__(self) -> None:
|
|
653
|
+
if not isinstance(self.name, str) or not self.name:
|
|
654
|
+
raise TypeError("name must be a non-empty string")
|
|
655
|
+
if not isinstance(self.data_type, str) or not self.data_type:
|
|
656
|
+
raise TypeError("data_type must be a non-empty string")
|
|
657
|
+
if type(self.nullable) is not bool:
|
|
658
|
+
raise TypeError("nullable must be an exact bool")
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _arrow_fields(
|
|
662
|
+
values: Sequence[ArrowFieldSpec], field_name: str
|
|
663
|
+
) -> list[dict[str, object]]:
|
|
664
|
+
if isinstance(values, (str, bytes)) or not isinstance(values, Sequence):
|
|
665
|
+
raise TypeError(f"{field_name} must be a sequence of ArrowFieldSpec values")
|
|
666
|
+
copied = tuple(values)
|
|
667
|
+
for index, value in enumerate(copied):
|
|
668
|
+
if not isinstance(value, ArrowFieldSpec):
|
|
669
|
+
raise TypeError(
|
|
670
|
+
f"{field_name} must contain only calc_flow.ArrowFieldSpec values; "
|
|
671
|
+
f"found {type(value).__name__} at index {index}"
|
|
672
|
+
)
|
|
673
|
+
if not copied:
|
|
674
|
+
raise ValueError(f"{field_name} must contain at least one field")
|
|
675
|
+
return [
|
|
676
|
+
{
|
|
677
|
+
"name": value.name,
|
|
678
|
+
"data_type": value.data_type,
|
|
679
|
+
"nullable": value.nullable,
|
|
680
|
+
}
|
|
681
|
+
for value in copied
|
|
682
|
+
]
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _join_keys(values: Sequence[str], field_name: str) -> list[str]:
|
|
686
|
+
if isinstance(values, (str, bytes)) or not isinstance(values, Sequence):
|
|
687
|
+
raise TypeError(f"{field_name} must be a sequence of column names")
|
|
688
|
+
copied = list(values)
|
|
689
|
+
if not copied or not all(isinstance(value, str) and value for value in copied):
|
|
690
|
+
raise ValueError(f"{field_name} must contain non-empty column names")
|
|
691
|
+
if len(set(copied)) != len(copied):
|
|
692
|
+
raise ValueError(f"{field_name} must contain unique column names")
|
|
693
|
+
return copied
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@dataclass(frozen=True, slots=True)
|
|
697
|
+
class _ProjectStreamSettings:
|
|
698
|
+
state_root: str
|
|
699
|
+
retained_epochs: int
|
|
700
|
+
checkpoint_interval_ms: int
|
|
701
|
+
max_batch_rows: int
|
|
702
|
+
max_batch_bytes: int
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
@dataclass(frozen=True, slots=True)
|
|
706
|
+
class StreamExecutionPlan:
|
|
707
|
+
"""Compiled immutable continuous plan consumed by ``StreamingRunner``."""
|
|
708
|
+
|
|
709
|
+
_inner: _native.StreamExecutionPlan = field(repr=False)
|
|
710
|
+
_project_settings: _ProjectStreamSettings | None = field(default=None, repr=False)
|
|
711
|
+
|
|
712
|
+
@property
|
|
713
|
+
def name(self) -> str:
|
|
714
|
+
return self._inner.name
|
|
715
|
+
|
|
716
|
+
@property
|
|
717
|
+
def fingerprint(self) -> str:
|
|
718
|
+
return self._inner.fingerprint
|
|
719
|
+
|
|
720
|
+
@property
|
|
721
|
+
def requirements(self) -> StreamRequirements:
|
|
722
|
+
return StreamRequirements(
|
|
723
|
+
{
|
|
724
|
+
output: DeliveryGuarantee(value)
|
|
725
|
+
for output, value in self._inner.requirements.items()
|
|
726
|
+
}
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
@property
|
|
730
|
+
def source_binding_ids(self) -> tuple[str, ...]:
|
|
731
|
+
return self._inner.source_binding_ids
|
|
732
|
+
|
|
733
|
+
@property
|
|
734
|
+
def static_input_ids(self) -> tuple[str, ...]:
|
|
735
|
+
return self._inner.static_input_ids
|
|
736
|
+
|
|
737
|
+
@property
|
|
738
|
+
def sink_binding_ids(self) -> tuple[str, ...]:
|
|
739
|
+
return self._inner.sink_binding_ids
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
743
|
+
class PipelineBuilder:
|
|
744
|
+
_project_json: str = field(repr=False)
|
|
745
|
+
|
|
746
|
+
def __init__(self, name: str) -> None:
|
|
747
|
+
if not isinstance(name, str):
|
|
748
|
+
raise TypeError("pipeline name must be a string")
|
|
749
|
+
project = {
|
|
750
|
+
"data_sources": [],
|
|
751
|
+
"format_version": 3,
|
|
752
|
+
"id": name,
|
|
753
|
+
"name": name,
|
|
754
|
+
"runtime": {"mode": "batch", "options": {}},
|
|
755
|
+
"graph": {"edges": [], "name": name, "nodes": []},
|
|
756
|
+
}
|
|
757
|
+
object.__setattr__(self, "_project_json", _canonical(project))
|
|
758
|
+
|
|
759
|
+
@classmethod
|
|
760
|
+
def _from_json(cls, project_json: str) -> PipelineBuilder:
|
|
761
|
+
builder = object.__new__(cls)
|
|
762
|
+
object.__setattr__(builder, "_project_json", project_json)
|
|
763
|
+
return builder
|
|
764
|
+
|
|
765
|
+
@property
|
|
766
|
+
def project(self) -> dict[str, Any]:
|
|
767
|
+
return json.loads(self._project_json)
|
|
768
|
+
|
|
769
|
+
def with_datafusion_config(
|
|
770
|
+
self,
|
|
771
|
+
*,
|
|
772
|
+
batch_size: int = 8_192,
|
|
773
|
+
target_partitions: int = 1,
|
|
774
|
+
parallelism_mode: Literal["fixed", "auto"] = "fixed",
|
|
775
|
+
max_partitions: int = 32,
|
|
776
|
+
min_rows_per_partition: int = 65_536,
|
|
777
|
+
small_rows_threshold: int = 10_001,
|
|
778
|
+
enable_rolling_rewrite: bool = True,
|
|
779
|
+
collect_diagnostics: bool = True,
|
|
780
|
+
) -> PipelineBuilder:
|
|
781
|
+
"""Return a builder with an immutable run-scoped DataFusion policy.
|
|
782
|
+
|
|
783
|
+
``auto`` is opt-in. It uses trusted
|
|
784
|
+
``calc_flow.datafusion.active_entities`` batch metadata and safely
|
|
785
|
+
falls back to one partition when that statistic is absent or invalid.
|
|
786
|
+
"""
|
|
787
|
+
integers = {
|
|
788
|
+
"batch_size": batch_size,
|
|
789
|
+
"target_partitions": target_partitions,
|
|
790
|
+
"max_partitions": max_partitions,
|
|
791
|
+
"min_rows_per_partition": min_rows_per_partition,
|
|
792
|
+
"small_rows_threshold": small_rows_threshold,
|
|
793
|
+
}
|
|
794
|
+
for field_name, value in integers.items():
|
|
795
|
+
if type(value) is not int:
|
|
796
|
+
raise TypeError(f"{field_name} must be a positive integer")
|
|
797
|
+
if value <= 0:
|
|
798
|
+
raise ValueError(f"{field_name} must be a positive integer")
|
|
799
|
+
if type(parallelism_mode) is not str:
|
|
800
|
+
raise TypeError("parallelism_mode must be fixed or auto")
|
|
801
|
+
if parallelism_mode not in {"fixed", "auto"}:
|
|
802
|
+
raise ValueError("parallelism_mode must be fixed or auto")
|
|
803
|
+
for field_name, value in {
|
|
804
|
+
"enable_rolling_rewrite": enable_rolling_rewrite,
|
|
805
|
+
"collect_diagnostics": collect_diagnostics,
|
|
806
|
+
}.items():
|
|
807
|
+
if type(value) is not bool:
|
|
808
|
+
raise TypeError(f"{field_name} must be an exact bool")
|
|
809
|
+
|
|
810
|
+
def update(project: dict[str, Any]) -> None:
|
|
811
|
+
project["graph"]["datafusion"] = {
|
|
812
|
+
**integers,
|
|
813
|
+
"parallelism_mode": parallelism_mode,
|
|
814
|
+
"enable_rolling_rewrite": enable_rolling_rewrite,
|
|
815
|
+
"collect_diagnostics": collect_diagnostics,
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return self._from_json(_updated_project(self._project_json, update))
|
|
819
|
+
|
|
820
|
+
def expression(
|
|
821
|
+
self,
|
|
822
|
+
name: str,
|
|
823
|
+
expression: str,
|
|
824
|
+
*,
|
|
825
|
+
select: Sequence[str] = (),
|
|
826
|
+
filter: str | None = None,
|
|
827
|
+
udfs: Sequence[UdfReference] = (),
|
|
828
|
+
) -> PipelineBuilder:
|
|
829
|
+
def add(project: dict[str, Any]) -> None:
|
|
830
|
+
project["graph"]["nodes"].append(
|
|
831
|
+
{
|
|
832
|
+
"id": name,
|
|
833
|
+
"operator": {
|
|
834
|
+
"expression": expression,
|
|
835
|
+
"filter": filter,
|
|
836
|
+
"kind": "expression",
|
|
837
|
+
"select": list(select),
|
|
838
|
+
"udfs": _udf_documents(tuple(udfs)),
|
|
839
|
+
},
|
|
840
|
+
}
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
return self._from_json(_updated_project(self._project_json, add))
|
|
844
|
+
|
|
845
|
+
def sql(
|
|
846
|
+
self,
|
|
847
|
+
name: str,
|
|
848
|
+
query: str,
|
|
849
|
+
*,
|
|
850
|
+
aliases: Sequence[str] = ("input",),
|
|
851
|
+
udfs: Sequence[UdfReference] = (),
|
|
852
|
+
) -> PipelineBuilder:
|
|
853
|
+
def add(project: dict[str, Any]) -> None:
|
|
854
|
+
project["graph"]["nodes"].append(
|
|
855
|
+
{
|
|
856
|
+
"id": name,
|
|
857
|
+
"operator": {
|
|
858
|
+
"aliases": list(aliases),
|
|
859
|
+
"kind": "sql",
|
|
860
|
+
"query": query,
|
|
861
|
+
"udfs": _udf_documents(tuple(udfs)),
|
|
862
|
+
},
|
|
863
|
+
}
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
return self._from_json(_updated_project(self._project_json, add))
|
|
867
|
+
|
|
868
|
+
def external(
|
|
869
|
+
self,
|
|
870
|
+
node_id: str,
|
|
871
|
+
provider: str,
|
|
872
|
+
name: str,
|
|
873
|
+
version: str,
|
|
874
|
+
options: Mapping[str, object],
|
|
875
|
+
) -> PipelineBuilder:
|
|
876
|
+
copied_options = dict(options)
|
|
877
|
+
|
|
878
|
+
def add(project: dict[str, Any]) -> None:
|
|
879
|
+
project["graph"]["nodes"].append(
|
|
880
|
+
{
|
|
881
|
+
"id": node_id,
|
|
882
|
+
"input_ports": [
|
|
883
|
+
{
|
|
884
|
+
"kind": "array",
|
|
885
|
+
"name": "input",
|
|
886
|
+
"required": True,
|
|
887
|
+
"schema": [],
|
|
888
|
+
}
|
|
889
|
+
],
|
|
890
|
+
"operator": {
|
|
891
|
+
"kind": "external",
|
|
892
|
+
"name": name,
|
|
893
|
+
"options": copied_options,
|
|
894
|
+
"provider": provider,
|
|
895
|
+
"version": version,
|
|
896
|
+
},
|
|
897
|
+
"output_ports": [
|
|
898
|
+
{
|
|
899
|
+
"kind": "array",
|
|
900
|
+
"name": "output",
|
|
901
|
+
"required": True,
|
|
902
|
+
"schema": [],
|
|
903
|
+
}
|
|
904
|
+
],
|
|
905
|
+
}
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
return self._from_json(_updated_project(self._project_json, add))
|
|
909
|
+
|
|
910
|
+
def table_matmul(
|
|
911
|
+
self,
|
|
912
|
+
node_id: str,
|
|
913
|
+
*,
|
|
914
|
+
backend: Literal["numpy", "jax"],
|
|
915
|
+
columns: Sequence[str],
|
|
916
|
+
) -> PipelineBuilder:
|
|
917
|
+
if backend not in {"numpy", "jax"}:
|
|
918
|
+
raise ValueError("backend must be 'numpy' or 'jax'")
|
|
919
|
+
if isinstance(columns, (str, bytes)) or not isinstance(columns, Sequence):
|
|
920
|
+
raise TypeError("columns must be a sequence of column names")
|
|
921
|
+
copied_columns = list(columns)
|
|
922
|
+
if not copied_columns:
|
|
923
|
+
raise ValueError("columns must contain at least one column name")
|
|
924
|
+
if not all(isinstance(column, str) and column for column in copied_columns):
|
|
925
|
+
raise TypeError("columns must contain non-empty strings")
|
|
926
|
+
if len(set(copied_columns)) != len(copied_columns):
|
|
927
|
+
raise ValueError("columns must be unique")
|
|
928
|
+
|
|
929
|
+
def add(project: dict[str, Any]) -> None:
|
|
930
|
+
project["graph"]["nodes"].append(
|
|
931
|
+
{
|
|
932
|
+
"id": node_id,
|
|
933
|
+
"input_ports": [
|
|
934
|
+
{
|
|
935
|
+
"kind": "table",
|
|
936
|
+
"name": "table",
|
|
937
|
+
"required": True,
|
|
938
|
+
"schema": [],
|
|
939
|
+
},
|
|
940
|
+
{
|
|
941
|
+
"kind": "array",
|
|
942
|
+
"name": "weights",
|
|
943
|
+
"required": True,
|
|
944
|
+
"schema": [],
|
|
945
|
+
},
|
|
946
|
+
],
|
|
947
|
+
"operator": {
|
|
948
|
+
"kind": "external",
|
|
949
|
+
"name": "table_matmul",
|
|
950
|
+
"options": {"columns": copied_columns},
|
|
951
|
+
"provider": backend,
|
|
952
|
+
"version": "1",
|
|
953
|
+
},
|
|
954
|
+
"output_ports": [
|
|
955
|
+
{
|
|
956
|
+
"kind": "array",
|
|
957
|
+
"name": "output",
|
|
958
|
+
"required": True,
|
|
959
|
+
"schema": [],
|
|
960
|
+
}
|
|
961
|
+
],
|
|
962
|
+
}
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
return self._from_json(_updated_project(self._project_json, add))
|
|
966
|
+
|
|
967
|
+
def stream_join(
|
|
968
|
+
self,
|
|
969
|
+
name: str,
|
|
970
|
+
*,
|
|
971
|
+
left_schema: Sequence[ArrowFieldSpec],
|
|
972
|
+
right_schema: Sequence[ArrowFieldSpec],
|
|
973
|
+
left_keys: Sequence[str],
|
|
974
|
+
right_keys: Sequence[str],
|
|
975
|
+
left_event_time: str,
|
|
976
|
+
right_event_time: str,
|
|
977
|
+
bounds: JoinTimeBounds,
|
|
978
|
+
limits: JoinStateLimits,
|
|
979
|
+
left_prefix: str = "left",
|
|
980
|
+
right_prefix: str = "right",
|
|
981
|
+
) -> PipelineBuilder:
|
|
982
|
+
"""Return a new builder containing one bounded inner stream Join."""
|
|
983
|
+
if not isinstance(name, str) or not name:
|
|
984
|
+
raise TypeError("name must be a non-empty string")
|
|
985
|
+
copied_left_schema = _arrow_fields(left_schema, "left_schema")
|
|
986
|
+
copied_right_schema = _arrow_fields(right_schema, "right_schema")
|
|
987
|
+
copied_left_keys = _join_keys(left_keys, "left_keys")
|
|
988
|
+
copied_right_keys = _join_keys(right_keys, "right_keys")
|
|
989
|
+
require_equal_key_counts(copied_left_keys, copied_right_keys)
|
|
990
|
+
require_event_time_columns(left_event_time, right_event_time)
|
|
991
|
+
require_join_bounds(bounds)
|
|
992
|
+
require_join_limits(limits)
|
|
993
|
+
require_distinct_prefixes(left_prefix, right_prefix)
|
|
994
|
+
|
|
995
|
+
def add(project: dict[str, Any]) -> None:
|
|
996
|
+
project["graph"]["nodes"].append(
|
|
997
|
+
{
|
|
998
|
+
"id": name,
|
|
999
|
+
"input_ports": [
|
|
1000
|
+
{
|
|
1001
|
+
"kind": "table",
|
|
1002
|
+
"name": "left",
|
|
1003
|
+
"required": True,
|
|
1004
|
+
"schema": copied_left_schema,
|
|
1005
|
+
},
|
|
1006
|
+
{
|
|
1007
|
+
"kind": "table",
|
|
1008
|
+
"name": "right",
|
|
1009
|
+
"required": True,
|
|
1010
|
+
"schema": copied_right_schema,
|
|
1011
|
+
},
|
|
1012
|
+
],
|
|
1013
|
+
"operator": {
|
|
1014
|
+
"kind": "stream_join",
|
|
1015
|
+
"spec": join_wire_spec(
|
|
1016
|
+
JoinSideWire(
|
|
1017
|
+
keys=tuple(copied_left_keys),
|
|
1018
|
+
event_time=left_event_time,
|
|
1019
|
+
prefix=left_prefix,
|
|
1020
|
+
),
|
|
1021
|
+
JoinSideWire(
|
|
1022
|
+
keys=tuple(copied_right_keys),
|
|
1023
|
+
event_time=right_event_time,
|
|
1024
|
+
prefix=right_prefix,
|
|
1025
|
+
),
|
|
1026
|
+
bounds_wire(
|
|
1027
|
+
timedelta_micros(bounds.before, "before"),
|
|
1028
|
+
timedelta_micros(bounds.after, "after"),
|
|
1029
|
+
),
|
|
1030
|
+
limits_wire(
|
|
1031
|
+
limits.max_state_rows_per_side,
|
|
1032
|
+
limits.max_state_bytes_per_side,
|
|
1033
|
+
limits.max_matches_per_input_batch,
|
|
1034
|
+
),
|
|
1035
|
+
),
|
|
1036
|
+
},
|
|
1037
|
+
"output_ports": [],
|
|
1038
|
+
}
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
return self._from_json(_updated_project(self._project_json, add))
|
|
1042
|
+
|
|
1043
|
+
def connect(
|
|
1044
|
+
self,
|
|
1045
|
+
source_node: str,
|
|
1046
|
+
target_node: str,
|
|
1047
|
+
*,
|
|
1048
|
+
source_port: str = "output",
|
|
1049
|
+
target_port: str = "input",
|
|
1050
|
+
) -> PipelineBuilder:
|
|
1051
|
+
def add(project: dict[str, Any]) -> None:
|
|
1052
|
+
project["graph"]["edges"].append(
|
|
1053
|
+
{
|
|
1054
|
+
"source_node": source_node,
|
|
1055
|
+
"source_port": source_port,
|
|
1056
|
+
"target_node": target_node,
|
|
1057
|
+
"target_port": target_port,
|
|
1058
|
+
}
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
return self._from_json(_updated_project(self._project_json, add))
|
|
1062
|
+
|
|
1063
|
+
def compile_batch(self, runtime: Runtime | None = None) -> BatchExecutionPlan:
|
|
1064
|
+
from calc_flow.array import _validate_provider_options
|
|
1065
|
+
|
|
1066
|
+
for node in self.project["graph"]["nodes"]:
|
|
1067
|
+
operator = node["operator"]
|
|
1068
|
+
if operator["kind"] == "external":
|
|
1069
|
+
_validate_provider_options(
|
|
1070
|
+
operator["provider"],
|
|
1071
|
+
operator["name"],
|
|
1072
|
+
operator["version"],
|
|
1073
|
+
operator["options"],
|
|
1074
|
+
)
|
|
1075
|
+
selected = Runtime() if runtime is None else runtime
|
|
1076
|
+
if not isinstance(selected, Runtime):
|
|
1077
|
+
raise TypeError("runtime must be a calc_flow.Runtime")
|
|
1078
|
+
return selected.compile_batch_project(self._project_json)
|
|
1079
|
+
|
|
1080
|
+
def compile_stream(
|
|
1081
|
+
self,
|
|
1082
|
+
*,
|
|
1083
|
+
requirements: StreamRequirements | None = None,
|
|
1084
|
+
runtime: Runtime | None = None,
|
|
1085
|
+
) -> StreamExecutionPlan:
|
|
1086
|
+
"""Compile a continuous plan with explicit delivery requirements."""
|
|
1087
|
+
selected = Runtime() if runtime is None else runtime
|
|
1088
|
+
if not isinstance(selected, Runtime):
|
|
1089
|
+
raise TypeError("runtime must be a calc_flow.Runtime")
|
|
1090
|
+
project = self.project
|
|
1091
|
+
project["runtime"] = {"mode": "stream", "options": {}}
|
|
1092
|
+
project["data_sources"] = []
|
|
1093
|
+
return selected._compile_stream_graph_project(
|
|
1094
|
+
_canonical(project), requirements=requirements
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def compile_stream_project(
|
|
1099
|
+
project: Mapping[str, object],
|
|
1100
|
+
*,
|
|
1101
|
+
requirements: StreamRequirements | None = None,
|
|
1102
|
+
runtime: Runtime | None = None,
|
|
1103
|
+
) -> StreamExecutionPlan:
|
|
1104
|
+
"""Defensively compile one connector-backed project-v3 document."""
|
|
1105
|
+
from calc_flow.config import ProjectDocument
|
|
1106
|
+
|
|
1107
|
+
if not isinstance(project, Mapping):
|
|
1108
|
+
raise TypeError("project must be a mapping")
|
|
1109
|
+
document = ProjectDocument.model_validate(dict(project))
|
|
1110
|
+
selected = Runtime() if runtime is None else runtime
|
|
1111
|
+
if not isinstance(selected, Runtime):
|
|
1112
|
+
raise TypeError("runtime must be a calc_flow.Runtime")
|
|
1113
|
+
return selected.compile_stream_project(
|
|
1114
|
+
document.canonical_json(), requirements=requirements
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def project_json_schema() -> str:
|
|
1119
|
+
return _native.project_json_schema()
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def validate_project_json(project_json: str) -> str:
|
|
1123
|
+
return _native.validate_project_json(project_json)
|