weaverstack 0.1.1__py3-none-any.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.
- weaver/__init__.py +59 -0
- weaver/build_bundle/__init__.py +109 -0
- weaver/build_bundle/aliases.py +325 -0
- weaver/build_bundle/bundle.py +359 -0
- weaver/build_bundle/catalogue_actions.py +275 -0
- weaver/build_bundle/changes.py +186 -0
- weaver/build_bundle/endpoints.py +83 -0
- weaver/build_bundle/executors/__init__.py +69 -0
- weaver/build_bundle/executors/alias.py +202 -0
- weaver/build_bundle/executors/base.py +132 -0
- weaver/build_bundle/executors/folder.py +71 -0
- weaver/build_bundle/executors/load_file.py +205 -0
- weaver/build_bundle/executors/spark_case.py +26 -0
- weaver/build_bundle/executors/spark_schema.py +60 -0
- weaver/build_bundle/executors/spark_sql.py +59 -0
- weaver/build_bundle/executors/spark_sql_batch.py +57 -0
- weaver/build_bundle/executors/spark_table.py +213 -0
- weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
- weaver/build_bundle/executors/tsql.py +81 -0
- weaver/build_bundle/incremental.py +288 -0
- weaver/build_bundle/installer.py +384 -0
- weaver/build_bundle/models.py +288 -0
- weaver/build_bundle/payloads.py +34 -0
- weaver/build_bundle/physical.py +625 -0
- weaver/build_bundle/planner.py +389 -0
- weaver/build_bundle/prune.py +620 -0
- weaver/build_bundle/report.py +108 -0
- weaver/build_bundle/stages.py +196 -0
- weaver/build_bundle/targets.py +272 -0
- weaver/build_bundle/workflow.py +585 -0
- weaver/catalogue/__init__.py +73 -0
- weaver/catalogue/builtin.py +238 -0
- weaver/catalogue/claims.py +121 -0
- weaver/catalogue/projection.py +437 -0
- weaver/catalogue/reader.py +152 -0
- weaver/catalogue/reconcile.py +231 -0
- weaver/catalogue/render.py +410 -0
- weaver/catalogue/state.py +660 -0
- weaver/catalogue/tables.py +648 -0
- weaver/config.py +178 -0
- weaver/declaration/__init__.py +171 -0
- weaver/declaration/columns.py +223 -0
- weaver/declaration/ddl.py +266 -0
- weaver/declaration/dependencies.py +544 -0
- weaver/declaration/graph.py +240 -0
- weaver/declaration/item_dependencies.py +292 -0
- weaver/declaration/load.py +191 -0
- weaver/declaration/metadata.py +1405 -0
- weaver/declaration/model.py +448 -0
- weaver/declaration/references.py +294 -0
- weaver/declaration/repository.py +959 -0
- weaver/declaration/schemas.py +135 -0
- weaver/declaration/source.py +674 -0
- weaver/declaration/spark_load.py +759 -0
- weaver/declaration/sql_shaping.py +591 -0
- weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
- weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
- weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
- weaver/declaration/templates/load/column_metadata.sql +40 -0
- weaver/declaration/templates/load/full_replace_body.sql +21 -0
- weaver/declaration/templates/load/install_load_procedure.sql +27 -0
- weaver/declaration/templates/load/load_procedure.sql +48 -0
- weaver/declaration/templates/load/primary_key_body.sql +113 -0
- weaver/declaration/tsql_ddl.py +468 -0
- weaver/declaration/tsql_load.py +417 -0
- weaver/declaration/warehouse_type_mapping.yml +93 -0
- weaver/diagnostics.py +247 -0
- weaver/errors.py +61 -0
- weaver/etl.py +469 -0
- weaver/fabric/__init__.py +107 -0
- weaver/fabric/auth.py +137 -0
- weaver/fabric/capacity.py +143 -0
- weaver/fabric/client.py +147 -0
- weaver/fabric/environment.py +460 -0
- weaver/fabric/livy.py +478 -0
- weaver/fabric/notebooks.py +201 -0
- weaver/fabric/onelake.py +263 -0
- weaver/fabric/resolution.py +344 -0
- weaver/fabric/resources.py +245 -0
- weaver/fabric/session.py +148 -0
- weaver/fabric/shortcuts.py +120 -0
- weaver/fabric/sql.py +118 -0
- weaver/fabric/store.py +198 -0
- weaver/initialise.py +209 -0
- weaver/lakehouse.py +386 -0
- weaver/load.py +474 -0
- weaver/load_execution.py +483 -0
- weaver/load_plan.py +912 -0
- weaver/load_report.py +330 -0
- weaver/load_resolution.py +386 -0
- weaver/locations.py +164 -0
- weaver/objects.py +392 -0
- weaver/operations.py +757 -0
- weaver/physical_wipe.py +369 -0
- weaver/push.py +76 -0
- weaver/resolution.py +292 -0
- weaver/runtime/__init__.py +30 -0
- weaver/runtime/folder_load.py +402 -0
- weaver/runtime/load_contract.py +245 -0
- weaver/runtime/load_result.py +104 -0
- weaver/runtime/spark_load.py +152 -0
- weaver/runtime/table_load.py +497 -0
- weaver/spark/__init__.py +49 -0
- weaver/spark/catalogue.py +245 -0
- weaver/spark/destination.py +195 -0
- weaver/spark/session.py +84 -0
- weaver/spark/tokens.py +138 -0
- weaver/sql/__init__.py +40 -0
- weaver/sql/authentication.py +38 -0
- weaver/sql/connection.py +90 -0
- weaver/sql/errors.py +25 -0
- weaver/sql/execution.py +123 -0
- weaver/sql/pool.py +174 -0
- weaver/sql/wipe.py +156 -0
- weaver/store.py +209 -0
- weaver/targets.py +257 -0
- weaver/task_logging.py +215 -0
- weaver/unbind.py +74 -0
- weaver/workspaces.py +175 -0
- weaver_cli/__init__.py +12 -0
- weaver_cli/__main__.py +7 -0
- weaver_cli/main.py +626 -0
- weaverstack-0.1.1.dist-info/METADATA +113 -0
- weaverstack-0.1.1.dist-info/RECORD +127 -0
- weaverstack-0.1.1.dist-info/WHEEL +4 -0
- weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
- weaverstack-0.1.1.dist-info/licenses/LICENSE +201 -0
weaver/load_execution.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""Running a resolved load plan, one node at a time, in topological order.
|
|
2
|
+
|
|
3
|
+
Sequential by design for this phase, and the design does not prevent parallelism
|
|
4
|
+
later: the executor asks the graph what is ready and runs it, so the only thing
|
|
5
|
+
that would change is how many it runs at once. No concurrency abstraction is
|
|
6
|
+
introduced now, because an unused one is a guess about the shape of a problem
|
|
7
|
+
nobody has met yet.
|
|
8
|
+
|
|
9
|
+
**The dispatcher dispatches.** Merge semantics, identity handling, rejection
|
|
10
|
+
policy, folder replacement and source comparison all live in the primitives, each
|
|
11
|
+
of which is runnable on its own and tested on its own. What happens here is the
|
|
12
|
+
translation between one resolved node and one normalised result — and the two
|
|
13
|
+
places that translation can go wrong are the two this module is about: reading a
|
|
14
|
+
transport's answer, and surviving a transport that does not answer at all.
|
|
15
|
+
|
|
16
|
+
**Two levels of fault tolerance, and they are not the same thing.** The requested
|
|
17
|
+
value is passed *into* the primitive, which is what governs its own rejection
|
|
18
|
+
behaviour. Around that, the whole dispatch boundary is wrapped, because the
|
|
19
|
+
failures orchestration must survive are the ones no primitive normalises: a
|
|
20
|
+
module that will not import, a Warehouse that will not connect, a result that is
|
|
21
|
+
not a result. Both feed the same message stream.
|
|
22
|
+
|
|
23
|
+
**Failure propagation is by graph, never by position.** A node whose upstream
|
|
24
|
+
failed is ``blocked`` whatever ``fault_tolerant`` says — tolerance decides
|
|
25
|
+
whether *independent* branches continue, and never whether a node may run on a
|
|
26
|
+
dependency that did not. Nothing here can execute a node whose upstream failed,
|
|
27
|
+
because the check is against the recorded status of its upstream nodes rather
|
|
28
|
+
than against a flag.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from typing import Callable
|
|
35
|
+
|
|
36
|
+
from .errors import LoadError
|
|
37
|
+
from .etl import LOAD_ROOT, load_procedure_name
|
|
38
|
+
from .load_plan import (
|
|
39
|
+
ENDPOINT_REFRESH,
|
|
40
|
+
PYTHON_FOLDER,
|
|
41
|
+
PYTHON_TABLE,
|
|
42
|
+
SPARK_SQL_FILE,
|
|
43
|
+
WAREHOUSE_PROCEDURE,
|
|
44
|
+
)
|
|
45
|
+
from .load_report import (
|
|
46
|
+
BLOCKED,
|
|
47
|
+
DEPENDENCY_BLOCKED,
|
|
48
|
+
DISPATCH_EXCEPTION,
|
|
49
|
+
ENDPOINT_REFRESH_FAILURE,
|
|
50
|
+
FAILED,
|
|
51
|
+
PENDING,
|
|
52
|
+
PRIMITIVE_FAILURE,
|
|
53
|
+
PRIMITIVE_REJECTS,
|
|
54
|
+
RESULT_CONTRACT_INVALID,
|
|
55
|
+
SKIPPED,
|
|
56
|
+
SUCCEEDED,
|
|
57
|
+
SUCCEEDED_WITH_REJECTS,
|
|
58
|
+
LoadMessage,
|
|
59
|
+
LoadNodeReport,
|
|
60
|
+
LoadResult,
|
|
61
|
+
error,
|
|
62
|
+
info,
|
|
63
|
+
warning,
|
|
64
|
+
)
|
|
65
|
+
from .load_resolution import LoadEnvironment, ResolvedLoadNode, ResolvedLoadPlan
|
|
66
|
+
from .locations import Location
|
|
67
|
+
from .targets import ItemRef
|
|
68
|
+
|
|
69
|
+
#: Statuses an upstream node may have and still let its dependants run. Rejects
|
|
70
|
+
#: are reported failure of the *rows*, not of the step: the valid work completed
|
|
71
|
+
#: and what it wrote is there to be read.
|
|
72
|
+
_CLEARS_DOWNSTREAM = frozenset({SUCCEEDED, SUCCEEDED_WITH_REJECTS, SKIPPED})
|
|
73
|
+
|
|
74
|
+
#: Statuses that propagate. Deliberately narrower than "not cleared": a node that
|
|
75
|
+
#: never started because fail-fast stopped the run has not *failed*, so its
|
|
76
|
+
#: dependants are unstarted too. Marking them blocked would report a failure that
|
|
77
|
+
#: nothing observed and hide which node actually broke.
|
|
78
|
+
_PROPAGATES = frozenset({FAILED, BLOCKED})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def execute_load_plan(
|
|
82
|
+
plan: ResolvedLoadPlan,
|
|
83
|
+
*,
|
|
84
|
+
fault_tolerant: bool = False,
|
|
85
|
+
environment: LoadEnvironment,
|
|
86
|
+
dispatch: Callable[..., LoadResult] | None = None,
|
|
87
|
+
on_step: Callable[[LoadNodeReport], None] | None = None,
|
|
88
|
+
) -> tuple[LoadNodeReport, ...]:
|
|
89
|
+
"""Execute a resolved plan sequentially and report every planned node.
|
|
90
|
+
|
|
91
|
+
``dispatch`` is injectable so orchestration can be proven over prepared nodes
|
|
92
|
+
and fake results — these are orchestration claims, not primitive ones, and a
|
|
93
|
+
test that had to stand up four engines to assert an ordering would be
|
|
94
|
+
asserting the engines.
|
|
95
|
+
|
|
96
|
+
``on_step`` receives each executed step's report as it completes, which is
|
|
97
|
+
how task evidence reaches storage without this module knowing what a file is.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
dispatch = dispatch or dispatch_load_node
|
|
101
|
+
statuses: dict[str, str] = {node.node_id: PENDING for node in plan.nodes}
|
|
102
|
+
reports: dict[str, LoadNodeReport] = {}
|
|
103
|
+
stopped = False
|
|
104
|
+
|
|
105
|
+
for resolved in plan.order:
|
|
106
|
+
node = resolved.node
|
|
107
|
+
blocking = _blocking(plan, node.node_id, statuses)
|
|
108
|
+
if blocking:
|
|
109
|
+
reports[node.node_id] = _blocked_report(resolved, blocking)
|
|
110
|
+
statuses[node.node_id] = BLOCKED
|
|
111
|
+
continue
|
|
112
|
+
if stopped:
|
|
113
|
+
# Fail-fast: nothing new is scheduled. This node's own dependencies
|
|
114
|
+
# were fine, so it is not blocked — it simply never started, and
|
|
115
|
+
# saying so is more useful than inventing a failure for it.
|
|
116
|
+
reports[node.node_id] = _pending_report(resolved)
|
|
117
|
+
continue
|
|
118
|
+
if not resolved.valid:
|
|
119
|
+
reports[node.node_id] = _invalid_report(resolved)
|
|
120
|
+
statuses[node.node_id] = FAILED
|
|
121
|
+
if not fault_tolerant:
|
|
122
|
+
stopped = True
|
|
123
|
+
continue
|
|
124
|
+
if resolved.unsupported:
|
|
125
|
+
reports[node.node_id] = _skipped_report(resolved)
|
|
126
|
+
statuses[node.node_id] = SKIPPED
|
|
127
|
+
if on_step is not None:
|
|
128
|
+
on_step(reports[node.node_id])
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
started = _now()
|
|
132
|
+
result, messages = _guarded(
|
|
133
|
+
dispatch,
|
|
134
|
+
resolved,
|
|
135
|
+
fault_tolerant=fault_tolerant,
|
|
136
|
+
environment=environment,
|
|
137
|
+
)
|
|
138
|
+
status = _status_for(result)
|
|
139
|
+
report = LoadNodeReport(
|
|
140
|
+
node_id=node.node_id,
|
|
141
|
+
logical_id=str(node.logical_id) if node.logical_id else None,
|
|
142
|
+
physical_target=str(node.physical_target),
|
|
143
|
+
primitive_kind=node.primitive_kind,
|
|
144
|
+
dispatch_location=resolved.dispatch_location,
|
|
145
|
+
status=status,
|
|
146
|
+
executed=True,
|
|
147
|
+
messages=messages,
|
|
148
|
+
result=result,
|
|
149
|
+
started_at=started,
|
|
150
|
+
finished_at=_now(),
|
|
151
|
+
)
|
|
152
|
+
reports[node.node_id] = report
|
|
153
|
+
statuses[node.node_id] = status
|
|
154
|
+
if on_step is not None:
|
|
155
|
+
on_step(report)
|
|
156
|
+
if status == FAILED and not fault_tolerant:
|
|
157
|
+
stopped = True
|
|
158
|
+
|
|
159
|
+
return tuple(reports[node.node.node_id] for node in plan.order)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _blocking(plan: ResolvedLoadPlan, node_id: str, statuses) -> tuple[str, ...]:
|
|
163
|
+
return tuple(
|
|
164
|
+
sorted(
|
|
165
|
+
upstream
|
|
166
|
+
for upstream in plan.dag.upstream(node_id)
|
|
167
|
+
if statuses.get(upstream) in _PROPAGATES
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _status_for(result: LoadResult) -> str:
|
|
173
|
+
if result.succeeded:
|
|
174
|
+
return SUCCEEDED
|
|
175
|
+
# A primitive that refused rows and was asked to tolerate them wrote the
|
|
176
|
+
# valid ones and reported the refusal. That is not a failed step; a step that
|
|
177
|
+
# failed without refusing anything is.
|
|
178
|
+
return SUCCEEDED_WITH_REJECTS if result.rows_rejected else FAILED
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _guarded(
|
|
182
|
+
dispatch, resolved: ResolvedLoadNode, *, fault_tolerant: bool, environment
|
|
183
|
+
) -> tuple[LoadResult, tuple[LoadMessage, ...]]:
|
|
184
|
+
"""Dispatch one node, converting anything it throws into a failed result.
|
|
185
|
+
|
|
186
|
+
The orchestrator's own fault tolerance, and it is unconditional: an
|
|
187
|
+
unexpected exception becomes data whatever ``fault_tolerant`` says, because
|
|
188
|
+
the run has to record what happened before it decides what to do about it.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
node = resolved.node
|
|
192
|
+
try:
|
|
193
|
+
result = dispatch(
|
|
194
|
+
resolved, fault_tolerant=fault_tolerant, environment=environment
|
|
195
|
+
)
|
|
196
|
+
except LoadError as exc:
|
|
197
|
+
carried = getattr(exc, "result", None)
|
|
198
|
+
result = (
|
|
199
|
+
carried
|
|
200
|
+
if isinstance(carried, LoadResult)
|
|
201
|
+
else LoadResult.failure(str(exc))
|
|
202
|
+
)
|
|
203
|
+
return result, (
|
|
204
|
+
error(
|
|
205
|
+
_failure_code(node.primitive_kind),
|
|
206
|
+
f"{node.node_id} failed: {exc}",
|
|
207
|
+
source=node.primitive_kind,
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
except Exception as exc: # noqa: BLE001 - the boundary exists to catch these
|
|
211
|
+
return LoadResult.failure(f"{type(exc).__name__}: {exc}"), (
|
|
212
|
+
error(
|
|
213
|
+
DISPATCH_EXCEPTION,
|
|
214
|
+
f"{node.node_id} raised {type(exc).__name__}: {exc}",
|
|
215
|
+
source="load_execution",
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
if not isinstance(result, LoadResult):
|
|
219
|
+
return LoadResult.failure(
|
|
220
|
+
f"the primitive returned {type(result).__name__}, not a load result"
|
|
221
|
+
), (
|
|
222
|
+
error(
|
|
223
|
+
RESULT_CONTRACT_INVALID,
|
|
224
|
+
f"{node.node_id} returned {type(result).__name__} rather than a "
|
|
225
|
+
"load result",
|
|
226
|
+
source=node.primitive_kind,
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
return result, _result_messages(resolved, result)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _failure_code(primitive_kind: str) -> str:
|
|
233
|
+
return (
|
|
234
|
+
ENDPOINT_REFRESH_FAILURE
|
|
235
|
+
if primitive_kind == ENDPOINT_REFRESH
|
|
236
|
+
else PRIMITIVE_FAILURE
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _result_messages(
|
|
241
|
+
resolved: ResolvedLoadNode, result: LoadResult
|
|
242
|
+
) -> tuple[LoadMessage, ...]:
|
|
243
|
+
node = resolved.node
|
|
244
|
+
if result.succeeded:
|
|
245
|
+
return ()
|
|
246
|
+
if result.rows_rejected:
|
|
247
|
+
return (
|
|
248
|
+
warning(
|
|
249
|
+
PRIMITIVE_REJECTS,
|
|
250
|
+
f"{node.node_id} rejected {result.rows_rejected} row(s): "
|
|
251
|
+
f"{result.error_message}",
|
|
252
|
+
source=node.primitive_kind,
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
return (
|
|
256
|
+
error(
|
|
257
|
+
_failure_code(node.primitive_kind),
|
|
258
|
+
f"{node.node_id} reported failure: {result.error_message}",
|
|
259
|
+
source=node.primitive_kind,
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _blocked_report(resolved: ResolvedLoadNode, blocking) -> LoadNodeReport:
|
|
265
|
+
return _report(
|
|
266
|
+
resolved,
|
|
267
|
+
BLOCKED,
|
|
268
|
+
(
|
|
269
|
+
error(
|
|
270
|
+
DEPENDENCY_BLOCKED,
|
|
271
|
+
f"{resolved.node_id} did not run: "
|
|
272
|
+
+ ", ".join(blocking)
|
|
273
|
+
+ " did not complete successfully",
|
|
274
|
+
source="load_execution",
|
|
275
|
+
),
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _invalid_report(resolved: ResolvedLoadNode) -> LoadNodeReport:
|
|
281
|
+
return _report(resolved, FAILED, resolved.validation_messages)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _skipped_report(resolved: ResolvedLoadNode) -> LoadNodeReport:
|
|
285
|
+
return _report(resolved, SKIPPED, resolved.validation_messages)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _pending_report(resolved: ResolvedLoadNode) -> LoadNodeReport:
|
|
289
|
+
return _report(
|
|
290
|
+
resolved,
|
|
291
|
+
PENDING,
|
|
292
|
+
(
|
|
293
|
+
info(
|
|
294
|
+
DEPENDENCY_BLOCKED,
|
|
295
|
+
f"{resolved.node_id} was not scheduled: an earlier node failed "
|
|
296
|
+
"and this run is not fault tolerant",
|
|
297
|
+
source="load_execution",
|
|
298
|
+
),
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _report(resolved: ResolvedLoadNode, status: str, messages) -> LoadNodeReport:
|
|
304
|
+
node = resolved.node
|
|
305
|
+
return LoadNodeReport(
|
|
306
|
+
node_id=node.node_id,
|
|
307
|
+
logical_id=str(node.logical_id) if node.logical_id else None,
|
|
308
|
+
physical_target=str(node.physical_target),
|
|
309
|
+
primitive_kind=node.primitive_kind,
|
|
310
|
+
dispatch_location=resolved.dispatch_location,
|
|
311
|
+
status=status,
|
|
312
|
+
executed=False,
|
|
313
|
+
messages=tuple(messages),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _now() -> str:
|
|
318
|
+
return datetime.now(timezone.utc).isoformat()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --- the dispatch boundary ----------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def dispatch_load_node(
|
|
325
|
+
resolved: ResolvedLoadNode,
|
|
326
|
+
*,
|
|
327
|
+
fault_tolerant: bool = False,
|
|
328
|
+
environment: LoadEnvironment,
|
|
329
|
+
) -> LoadResult:
|
|
330
|
+
"""Run one resolved node's installed primitive and return what it reported."""
|
|
331
|
+
|
|
332
|
+
kind = resolved.node.primitive_kind
|
|
333
|
+
if kind == WAREHOUSE_PROCEDURE:
|
|
334
|
+
return _dispatch_warehouse_procedure(resolved, fault_tolerant, environment)
|
|
335
|
+
if kind == SPARK_SQL_FILE:
|
|
336
|
+
return _dispatch_spark_sql_file(resolved, fault_tolerant, environment)
|
|
337
|
+
if kind in (PYTHON_TABLE, PYTHON_FOLDER):
|
|
338
|
+
return _dispatch_python(resolved, fault_tolerant, environment)
|
|
339
|
+
if kind == ENDPOINT_REFRESH:
|
|
340
|
+
return _dispatch_endpoint_refresh(resolved, environment)
|
|
341
|
+
raise LoadError(f"{resolved.node_id} names unknown primitive kind {kind!r}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _dispatch_warehouse_procedure(
|
|
345
|
+
resolved, fault_tolerant: bool, environment: LoadEnvironment
|
|
346
|
+
) -> LoadResult:
|
|
347
|
+
target = resolved.node.physical_target
|
|
348
|
+
sql = environment.sql_for(target)
|
|
349
|
+
if sql is None:
|
|
350
|
+
raise LoadError(
|
|
351
|
+
f"{resolved.node_id} needs a SQL capability for {target}, and this "
|
|
352
|
+
"run has none"
|
|
353
|
+
)
|
|
354
|
+
procedure = load_procedure_name(resolved.node.logical_id.object_id)
|
|
355
|
+
rows = sql.query(
|
|
356
|
+
f"exec {procedure} @fault_tolerant = {1 if fault_tolerant else 0}"
|
|
357
|
+
)
|
|
358
|
+
if not rows:
|
|
359
|
+
raise LoadError(
|
|
360
|
+
f"{procedure} returned no row — a load procedure projects its result"
|
|
361
|
+
)
|
|
362
|
+
return LoadResult.from_row(rows[0])
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _dispatch_spark_sql_file(
|
|
366
|
+
resolved, fault_tolerant: bool, environment: LoadEnvironment
|
|
367
|
+
) -> LoadResult:
|
|
368
|
+
from .runtime.spark_load import run_load_program
|
|
369
|
+
|
|
370
|
+
if environment.store is None or environment.spark is None:
|
|
371
|
+
raise LoadError(
|
|
372
|
+
f"{resolved.node_id} needs a store and a Spark session, and this run "
|
|
373
|
+
"has neither"
|
|
374
|
+
)
|
|
375
|
+
program = environment.store.read(Location(resolved.dispatch_location))
|
|
376
|
+
return run_load_program(
|
|
377
|
+
environment.spark,
|
|
378
|
+
program.decode("utf-8"),
|
|
379
|
+
fault_tolerant=fault_tolerant,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _dispatch_python(
|
|
384
|
+
resolved, fault_tolerant: bool, environment: LoadEnvironment
|
|
385
|
+
) -> LoadResult:
|
|
386
|
+
"""Import the deployed module, construct its object, and load it.
|
|
387
|
+
|
|
388
|
+
The destination is resolved *here* and handed in, never inferred: an authored
|
|
389
|
+
object with no Lakehouse falls back to the session's attachment, which in an
|
|
390
|
+
orchestrated run is the Weaver control plane. Orchestration runs detached
|
|
391
|
+
from every destination it writes to, so it must always say which one it
|
|
392
|
+
means.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
from .lakehouse import lakehouse_for
|
|
396
|
+
|
|
397
|
+
if environment.spark is None:
|
|
398
|
+
raise LoadError(f"{resolved.node_id} needs a Spark session, and this run has none")
|
|
399
|
+
lakehouse = lakehouse_for(environment.resolver, ItemRef(resolved.node.physical_target.name))
|
|
400
|
+
runtime_root = _join(lakehouse.files_root(), *LOAD_ROOT.split("/"))
|
|
401
|
+
relative = (
|
|
402
|
+
f"{resolved.node.primitive_object.schema}/"
|
|
403
|
+
f"{resolved.node.primitive_object.object}"
|
|
404
|
+
)
|
|
405
|
+
within = relative[len(LOAD_ROOT) + 1 :] if relative.startswith(LOAD_ROOT) else relative
|
|
406
|
+
module = _import_deployed(
|
|
407
|
+
runtime_root, within, expected=resolved.expected_class, node_id=resolved.node_id
|
|
408
|
+
)
|
|
409
|
+
cls = getattr(module, resolved.expected_class)
|
|
410
|
+
return cls(environment.spark, lakehouse=lakehouse).load(
|
|
411
|
+
fault_tolerant=fault_tolerant
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _import_deployed(runtime_root: str, relative: str, *, expected: str, node_id: str):
|
|
416
|
+
"""One deployed module, imported from the runtime tree it was deployed into.
|
|
417
|
+
|
|
418
|
+
Loaded from its exact file, so the module a node dispatches is unambiguously
|
|
419
|
+
the one at the location the node resolved — but *named* by its position in
|
|
420
|
+
the tree, ``Files.Sales__Seed`` rather than ``Sales__Seed``, because that is
|
|
421
|
+
what other deployed modules import it as. Naming it otherwise would leave two
|
|
422
|
+
module objects for one file, one of them the object nobody imports.
|
|
423
|
+
|
|
424
|
+
The tree's root goes on ``sys.path`` first, so a module's own imports resolve
|
|
425
|
+
exactly as they did when it was authored: ``from lib.dates import parse``
|
|
426
|
+
finds ``lib`` where it was written, and ``from Files.Sales__Seed import …``
|
|
427
|
+
finds the folder module through the ordinary machinery.
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
import importlib.util
|
|
431
|
+
import sys
|
|
432
|
+
|
|
433
|
+
path = _join(runtime_root, *relative.split("/"))
|
|
434
|
+
# Ahead of whatever is already there: a process may hold more than one
|
|
435
|
+
# estate's runtime tree, and the one being dispatched is the one that wins.
|
|
436
|
+
if runtime_root in sys.path:
|
|
437
|
+
sys.path.remove(runtime_root)
|
|
438
|
+
sys.path.insert(0, runtime_root)
|
|
439
|
+
importlib.invalidate_caches()
|
|
440
|
+
name = relative[: -len(".py")].replace("/", ".")
|
|
441
|
+
specification = importlib.util.spec_from_file_location(name, path)
|
|
442
|
+
if specification is None or specification.loader is None:
|
|
443
|
+
raise LoadError(f"{node_id}: no deployed module at {path}")
|
|
444
|
+
module = importlib.util.module_from_spec(specification)
|
|
445
|
+
# Registered before execution so a module that imports itself by name — and a
|
|
446
|
+
# dataclass or pickle that later looks it up — finds the one being run.
|
|
447
|
+
sys.modules[name] = module
|
|
448
|
+
try:
|
|
449
|
+
specification.loader.exec_module(module)
|
|
450
|
+
except FileNotFoundError as exc:
|
|
451
|
+
raise LoadError(f"{node_id}: no deployed module at {path}") from exc
|
|
452
|
+
except Exception as exc: # noqa: BLE001 - authored code, any failure is data
|
|
453
|
+
raise LoadError(
|
|
454
|
+
f"{node_id}: importing {path} raised {type(exc).__name__}: {exc}"
|
|
455
|
+
) from exc
|
|
456
|
+
if not hasattr(module, expected):
|
|
457
|
+
raise LoadError(
|
|
458
|
+
f"{node_id}: {path} defines no class {expected!r} — a deployed object "
|
|
459
|
+
"module names its class for its file"
|
|
460
|
+
)
|
|
461
|
+
return module
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _dispatch_endpoint_refresh(resolved, environment: LoadEnvironment) -> LoadResult:
|
|
465
|
+
"""Refresh one Lakehouse's SQL analytics endpoint. No rows, so no counts."""
|
|
466
|
+
|
|
467
|
+
refresh = getattr(environment.resolver, "refresh_sql_endpoint", None)
|
|
468
|
+
if refresh is None:
|
|
469
|
+
raise LoadError(
|
|
470
|
+
f"{resolved.node_id}: this environment cannot refresh a SQL endpoint"
|
|
471
|
+
)
|
|
472
|
+
refresh(ItemRef(resolved.node.physical_target.name))
|
|
473
|
+
return LoadResult(succeeded=True)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _join(root: str, *parts: str) -> str:
|
|
477
|
+
return "/".join([root.rstrip("/"), *parts])
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
__all__ = [
|
|
481
|
+
"dispatch_load_node",
|
|
482
|
+
"execute_load_plan",
|
|
483
|
+
]
|