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_plan.py
ADDED
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
"""The physical load graph, derived from the installed catalogue alone.
|
|
2
|
+
|
|
3
|
+
This is where load orchestration decides *what runs and in what order*, and it is
|
|
4
|
+
pure Python: a catalogue in, a graph out. No session, no SQL connection, no
|
|
5
|
+
target and no repository. That is not an economy — it is the claim. The
|
|
6
|
+
repository is the *source*; once an estate is installed, the catalogue is the
|
|
7
|
+
authority on what exists, and an orchestrator that reopened the source would be
|
|
8
|
+
loading what somebody meant to install rather than what is there.
|
|
9
|
+
|
|
10
|
+
The direction of travel is the interesting part. A build establishes
|
|
11
|
+
|
|
12
|
+
.. code-block:: text
|
|
13
|
+
|
|
14
|
+
logical identity → physical target and physical object
|
|
15
|
+
|
|
16
|
+
and load orchestration needs the reverse, because a caller names physical
|
|
17
|
+
targets. So the first thing that happens is that the Installation and Registry
|
|
18
|
+
rows are turned inside out into :class:`InstalledEstate`.
|
|
19
|
+
|
|
20
|
+
Two logical objects resolving to one physical object would make "load
|
|
21
|
+
Warehouse/Reporting" a request with two possible meanings, and no such request is
|
|
22
|
+
carried out. But the *reading* records that finding rather than raising on it, and
|
|
23
|
+
:func:`load_dag` refuses when it touches what was asked for — because an estate
|
|
24
|
+
accumulates a Registry row for every item ever bound to a target, and a stale
|
|
25
|
+
duplicate in one Warehouse must not stop a load of an unrelated Lakehouse.
|
|
26
|
+
|
|
27
|
+
**The graph is physical, and the alias is why that matters.** A logical
|
|
28
|
+
dependency that crosses items is not an edge between two objects; it is a
|
|
29
|
+
publication path, and the path has a barrier in it:
|
|
30
|
+
|
|
31
|
+
.. code-block:: text
|
|
32
|
+
|
|
33
|
+
Lakehouse/Raw/Sales.Order
|
|
34
|
+
→ published through a Warehouse-facing alias
|
|
35
|
+
→ Warehouse/Reporting/Sales.Order
|
|
36
|
+
→ consumed by Warehouse/Reporting/Sales.Summary
|
|
37
|
+
|
|
38
|
+
becomes
|
|
39
|
+
|
|
40
|
+
.. code-block:: text
|
|
41
|
+
|
|
42
|
+
load Raw Sales.Order → refresh Raw SQL endpoint → load Reporting Sales.Summary
|
|
43
|
+
|
|
44
|
+
The refresh is a node, not something dispatch does quietly on the way past. A
|
|
45
|
+
Lakehouse presents its Delta tables to SQL through an endpoint whose metadata
|
|
46
|
+
lags the write, so a consumer reading across that boundary before the refresh
|
|
47
|
+
reads the previous shape — and a barrier that lives inside dispatch cannot be
|
|
48
|
+
seen in a plan, cannot be ordered against anything, and cannot be asserted.
|
|
49
|
+
|
|
50
|
+
**Views are conduits, not nodes.** A view owns no load work, so it is never
|
|
51
|
+
dispatched; but a table depending on a view depends on whatever the view reads,
|
|
52
|
+
so the traversal passes *through* it to the loadable ancestors behind it. An
|
|
53
|
+
object with no installed load primitive is treated the same way.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
from dataclasses import dataclass, field, replace
|
|
59
|
+
from types import MappingProxyType
|
|
60
|
+
from typing import Mapping, Sequence
|
|
61
|
+
|
|
62
|
+
from .catalogue.state import Catalogue
|
|
63
|
+
from .catalogue.tables import ALIAS, DEPENDENCY, INSTALLATION
|
|
64
|
+
from .declaration.metadata import ObjectId
|
|
65
|
+
from .declaration.model import (
|
|
66
|
+
FILE_SHAPE,
|
|
67
|
+
LAKEHOUSE,
|
|
68
|
+
WAREHOUSE,
|
|
69
|
+
WeaverDocumentId,
|
|
70
|
+
WeaverItemId,
|
|
71
|
+
)
|
|
72
|
+
from .errors import LoadError
|
|
73
|
+
from .etl import LOAD_ROOT, load_procedure_id
|
|
74
|
+
from .load_report import DEPENDENCY_EXTERNAL, LoadMessage, info
|
|
75
|
+
from .targets import LAKEHOUSE_KIND, WAREHOUSE_KIND
|
|
76
|
+
|
|
77
|
+
# --- the primitive kinds ------------------------------------------------------
|
|
78
|
+
#
|
|
79
|
+
# What an installed load *is*, in the vocabulary dispatch branches on. Five
|
|
80
|
+
# values, four of them a real installed artefact and one a barrier the planner
|
|
81
|
+
# inserts. They are strings rather than a class hierarchy because they cross into
|
|
82
|
+
# a plan file and a task log, where a reader needs to see the word.
|
|
83
|
+
|
|
84
|
+
WAREHOUSE_PROCEDURE = "warehouse_procedure"
|
|
85
|
+
SPARK_SQL_FILE = "spark_sql_file"
|
|
86
|
+
PYTHON_TABLE = "python_table"
|
|
87
|
+
PYTHON_FOLDER = "python_folder"
|
|
88
|
+
ENDPOINT_REFRESH = "endpoint_refresh"
|
|
89
|
+
|
|
90
|
+
PRIMITIVE_KINDS = (
|
|
91
|
+
WAREHOUSE_PROCEDURE,
|
|
92
|
+
SPARK_SQL_FILE,
|
|
93
|
+
PYTHON_TABLE,
|
|
94
|
+
PYTHON_FOLDER,
|
|
95
|
+
ENDPOINT_REFRESH,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
#: What the catalogue calls each physical target kind. The same two words the
|
|
99
|
+
#: build's :mod:`weaver.build_bundle.targets` uses, because a load plan and a
|
|
100
|
+
#: build bundle describe the same estate.
|
|
101
|
+
LAKEHOUSE_TARGET = "lakehouse"
|
|
102
|
+
WAREHOUSE_TARGET = "warehouse"
|
|
103
|
+
|
|
104
|
+
_TARGET_KIND_FOR_ITEM = {LAKEHOUSE: LAKEHOUSE_TARGET, WAREHOUSE: WAREHOUSE_TARGET}
|
|
105
|
+
_GRAMMAR_KIND = {LAKEHOUSE_TARGET: LAKEHOUSE_KIND, WAREHOUSE_TARGET: WAREHOUSE_KIND}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class PhysicalTargetRef:
|
|
110
|
+
"""One physical item, as the public grammar names it."""
|
|
111
|
+
|
|
112
|
+
kind: str
|
|
113
|
+
name: str
|
|
114
|
+
|
|
115
|
+
def __str__(self) -> str:
|
|
116
|
+
return f"{_GRAMMAR_KIND[self.kind]}/{self.name}"
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def is_lakehouse(self) -> bool:
|
|
120
|
+
return self.kind == LAKEHOUSE_TARGET
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class PhysicalObjectRef:
|
|
125
|
+
"""One installed object, addressed as its physical target holds it.
|
|
126
|
+
|
|
127
|
+
``schema`` is the catalogue's ``schema_name`` unchanged — which for a folder
|
|
128
|
+
carries its ``Files/`` prefix and for a deployed file is the path beneath
|
|
129
|
+
``Files``. Keeping the stored spelling means a reference can be handed
|
|
130
|
+
straight to :meth:`weaver.build_bundle.prune.TargetInventory.has_object`
|
|
131
|
+
without a translation that could disagree with the one the catalogue used.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
target_id: str
|
|
135
|
+
target_kind: str
|
|
136
|
+
schema: str
|
|
137
|
+
object: str
|
|
138
|
+
object_type: str
|
|
139
|
+
shape: str | None = None
|
|
140
|
+
|
|
141
|
+
def __str__(self) -> str:
|
|
142
|
+
return f"{self.schema}.{self.object}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class InstalledObject:
|
|
147
|
+
"""One certified Registry row, as load planning reads it."""
|
|
148
|
+
|
|
149
|
+
identity: WeaverDocumentId
|
|
150
|
+
object_type: str
|
|
151
|
+
target: PhysicalTargetRef
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def physical(self) -> PhysicalObjectRef:
|
|
155
|
+
from .catalogue.claims import catalogue_schema
|
|
156
|
+
|
|
157
|
+
return PhysicalObjectRef(
|
|
158
|
+
target_id=self.target.name,
|
|
159
|
+
target_kind=self.target.kind,
|
|
160
|
+
schema=catalogue_schema(self.identity),
|
|
161
|
+
object=self.identity.object_id.object,
|
|
162
|
+
object_type=self.object_type,
|
|
163
|
+
shape=self.identity.shape,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class InstalledAlias:
|
|
169
|
+
"""One name a consuming item presents for another item's document."""
|
|
170
|
+
|
|
171
|
+
destination: WeaverDocumentId
|
|
172
|
+
source: WeaverDocumentId
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class InstalledDependency:
|
|
177
|
+
"""One dependency edge, with the reference exactly as its author wrote it."""
|
|
178
|
+
|
|
179
|
+
consumer: WeaverDocumentId
|
|
180
|
+
reference: str
|
|
181
|
+
is_within_item: bool
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class InstalledEstate:
|
|
186
|
+
"""The installed catalogue, reversed into what load planning asks of it.
|
|
187
|
+
|
|
188
|
+
Transport-neutral by construction: it is built from a :class:`Catalogue`,
|
|
189
|
+
which a test can hand-write and production reads over Spark. Everything below
|
|
190
|
+
this class is arithmetic on these five mappings.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
installations: Mapping[WeaverItemId, PhysicalTargetRef]
|
|
194
|
+
objects: Mapping[WeaverDocumentId, InstalledObject]
|
|
195
|
+
primitives: Mapping[WeaverDocumentId, InstalledObject]
|
|
196
|
+
dependencies: tuple[InstalledDependency, ...]
|
|
197
|
+
aliases: tuple[InstalledAlias, ...]
|
|
198
|
+
#: Physical addresses two logical objects both claim, by the target they are
|
|
199
|
+
#: in. Recorded rather than raised — see :meth:`from_catalogue`.
|
|
200
|
+
ambiguous: Mapping[PhysicalTargetRef, tuple[str, ...]] = field(
|
|
201
|
+
default_factory=dict
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def from_catalogue(cls, catalogue: Catalogue) -> "InstalledEstate":
|
|
206
|
+
"""Reverse the whole catalogue, recording ambiguity rather than refusing it.
|
|
207
|
+
|
|
208
|
+
Two logical objects at one physical address is a real fault and load
|
|
209
|
+
planning must not proceed through one — but *where* it stops matters. An
|
|
210
|
+
estate accumulates Registry rows for every item ever bound to a target, so
|
|
211
|
+
a Warehouse that was rebound years ago can carry a duplicate claim
|
|
212
|
+
indefinitely; refusing here would make that stale row stop a load of an
|
|
213
|
+
unrelated Lakehouse, which is a fault report about the wrong thing.
|
|
214
|
+
|
|
215
|
+
So the finding is kept and :func:`load_dag` refuses when it touches the
|
|
216
|
+
request — which is exactly when the request is genuinely ambiguous.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
installations = _installations(catalogue)
|
|
220
|
+
objects: dict[WeaverDocumentId, InstalledObject] = {}
|
|
221
|
+
primitives: dict[WeaverDocumentId, InstalledObject] = {}
|
|
222
|
+
physical_owner: dict[tuple, WeaverDocumentId] = {}
|
|
223
|
+
ambiguous: dict[PhysicalTargetRef, list[str]] = {}
|
|
224
|
+
for identity, document in sorted(
|
|
225
|
+
catalogue.registered.items(), key=lambda pair: str(pair[0])
|
|
226
|
+
):
|
|
227
|
+
target = installations.get(identity.item)
|
|
228
|
+
if target is None:
|
|
229
|
+
# Registry without Installation: the estate says an object is
|
|
230
|
+
# certified but not where it lives. Refused here rather than
|
|
231
|
+
# skipped, because skipping it would silently shrink the graph.
|
|
232
|
+
raise LoadError(
|
|
233
|
+
f"{identity} is registered but {identity.item} has no "
|
|
234
|
+
"installation row, so its physical target is unknown"
|
|
235
|
+
)
|
|
236
|
+
installed = InstalledObject(identity, document.object_type, target)
|
|
237
|
+
where = installed.physical
|
|
238
|
+
key = (
|
|
239
|
+
where.target_kind,
|
|
240
|
+
where.target_id.casefold(),
|
|
241
|
+
where.schema.casefold(),
|
|
242
|
+
where.object.casefold(),
|
|
243
|
+
where.object_type,
|
|
244
|
+
)
|
|
245
|
+
owner = physical_owner.get(key)
|
|
246
|
+
if owner is not None:
|
|
247
|
+
ambiguous.setdefault(target, []).append(
|
|
248
|
+
f"{owner} and {identity} both resolve to {where}"
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
physical_owner[key] = identity
|
|
252
|
+
if identity.is_load_artefact:
|
|
253
|
+
primitives[identity] = installed
|
|
254
|
+
else:
|
|
255
|
+
objects[identity] = installed
|
|
256
|
+
return cls(
|
|
257
|
+
installations=MappingProxyType(installations),
|
|
258
|
+
objects=MappingProxyType(objects),
|
|
259
|
+
primitives=MappingProxyType(primitives),
|
|
260
|
+
dependencies=_dependencies(catalogue),
|
|
261
|
+
aliases=_aliases(catalogue),
|
|
262
|
+
ambiguous=MappingProxyType(
|
|
263
|
+
{target: tuple(found) for target, found in ambiguous.items()}
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def target_for(self, item: WeaverItemId) -> PhysicalTargetRef:
|
|
268
|
+
target = self.installations.get(item)
|
|
269
|
+
if target is None:
|
|
270
|
+
raise LoadError(f"{item} has no installation row in the catalogue")
|
|
271
|
+
return target
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def targets(self) -> tuple[PhysicalTargetRef, ...]:
|
|
275
|
+
return tuple(
|
|
276
|
+
sorted(set(self.installations.values()), key=lambda ref: (ref.kind, ref.name))
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _installations(catalogue: Catalogue) -> dict[WeaverItemId, PhysicalTargetRef]:
|
|
281
|
+
"""Each logical item's bound physical target, keyed for reverse lookup.
|
|
282
|
+
|
|
283
|
+
Several logical items may name one physical target, and that is not an error
|
|
284
|
+
to catch here. A request names a *target*, and what it means is "everything
|
|
285
|
+
installed there" — which is answerable whoever installed it, as long as no
|
|
286
|
+
two objects claim one address. That narrower question is the one ambiguity
|
|
287
|
+
actually turns on, and :meth:`InstalledEstate.from_catalogue` asks it per
|
|
288
|
+
object.
|
|
289
|
+
|
|
290
|
+
Refusing at the item level instead looked equivalent and was not: an estate
|
|
291
|
+
accumulates Installation rows from every item ever bound to a target, so a
|
|
292
|
+
binding that has since moved on would stop a load of a target it no longer
|
|
293
|
+
has a single object in.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
bound: dict[WeaverItemId, PhysicalTargetRef] = {}
|
|
297
|
+
for item, tables in catalogue.rows.items():
|
|
298
|
+
for row in tables.get(INSTALLATION.name, ()):
|
|
299
|
+
name = str(row.get("target_name") or "")
|
|
300
|
+
if not name:
|
|
301
|
+
raise LoadError(
|
|
302
|
+
f"the installation row for {item} names no physical target"
|
|
303
|
+
)
|
|
304
|
+
kind = _TARGET_KIND_FOR_ITEM.get(item.item_type)
|
|
305
|
+
if kind is None:
|
|
306
|
+
raise LoadError(
|
|
307
|
+
f"{item} has item type {item.item_type!r}, which names no "
|
|
308
|
+
"physical target kind"
|
|
309
|
+
)
|
|
310
|
+
bound[item] = PhysicalTargetRef(kind=kind, name=name)
|
|
311
|
+
return bound
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _dependencies(catalogue: Catalogue) -> tuple[InstalledDependency, ...]:
|
|
315
|
+
found = []
|
|
316
|
+
for item, tables in catalogue.rows.items():
|
|
317
|
+
for row in tables.get(DEPENDENCY.name, ()):
|
|
318
|
+
consumer = _registry_identity(catalogue, item, row)
|
|
319
|
+
if consumer is None:
|
|
320
|
+
continue
|
|
321
|
+
found.append(
|
|
322
|
+
InstalledDependency(
|
|
323
|
+
consumer=consumer,
|
|
324
|
+
reference=str(row.get("dependency_name") or ""),
|
|
325
|
+
is_within_item=bool(row.get("is_within_item")),
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
return tuple(sorted(found, key=lambda edge: (str(edge.consumer), edge.reference)))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _aliases(catalogue: Catalogue) -> tuple[InstalledAlias, ...]:
|
|
332
|
+
found = []
|
|
333
|
+
for item, tables in catalogue.rows.items():
|
|
334
|
+
for row in tables.get(ALIAS.name, ()):
|
|
335
|
+
destination = _document_id(
|
|
336
|
+
item,
|
|
337
|
+
str(row.get("destination_schema_name") or ""),
|
|
338
|
+
str(row.get("destination_object_name") or ""),
|
|
339
|
+
)
|
|
340
|
+
source_item = WeaverItemId(
|
|
341
|
+
str(row.get("source_item_type") or ""),
|
|
342
|
+
str(row.get("source_item_name") or ""),
|
|
343
|
+
)
|
|
344
|
+
source = _document_id(
|
|
345
|
+
source_item,
|
|
346
|
+
str(row.get("source_schema_name") or ""),
|
|
347
|
+
str(row.get("source_object_name") or ""),
|
|
348
|
+
)
|
|
349
|
+
found.append(InstalledAlias(destination=destination, source=source))
|
|
350
|
+
return tuple(sorted(found, key=lambda alias: str(alias.destination)))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
_FILES_PREFIX = "Files/"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _document_id(item: WeaverItemId, schema: str, name: str) -> WeaverDocumentId:
|
|
357
|
+
"""One stored ``schema_name``/``object_name`` pair back as an identity."""
|
|
358
|
+
|
|
359
|
+
is_files = schema.startswith(_FILES_PREFIX)
|
|
360
|
+
return WeaverDocumentId(
|
|
361
|
+
item,
|
|
362
|
+
ObjectId(schema[len(_FILES_PREFIX) :] if is_files else schema, name),
|
|
363
|
+
is_files=is_files,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
#: What separates schema from object in a Python module name. A module name
|
|
368
|
+
#: cannot carry a dot, so ``Sales.Seed`` is spelled ``Sales__Seed``.
|
|
369
|
+
_PYTHON_ID_SEPARATOR = "__"
|
|
370
|
+
|
|
371
|
+
#: The one directory beneath an item that holds runtime source rather than
|
|
372
|
+
#: declarations. An import of it names a helper, never a Weaver object.
|
|
373
|
+
_LIB = "lib"
|
|
374
|
+
|
|
375
|
+
#: The item-relative directory a Folder document lives in.
|
|
376
|
+
_FILES = "Files"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _is_python_module_reference(reference: str) -> bool:
|
|
380
|
+
"""Whether a stored dependency names a Python module rather than an object.
|
|
381
|
+
|
|
382
|
+
The catalogue records a dependency *exactly as its author wrote it*, and for
|
|
383
|
+
a Python object the author wrote an import — ``.Files.Sales__Seed``, or
|
|
384
|
+
``Files.Sales__Seed``. So reversing the graph means reapplying the rule that
|
|
385
|
+
turned one into an identity, and telling the two spellings apart first.
|
|
386
|
+
|
|
387
|
+
A leading dot is a relative import and can be nothing else. Otherwise the
|
|
388
|
+
tell is the separator: a module name cannot carry a dot, so a Python object
|
|
389
|
+
module spells ``Schema.Object`` as ``Schema__Object`` — which a
|
|
390
|
+
``Schema.Object`` reference never does.
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
if not reference:
|
|
394
|
+
return False
|
|
395
|
+
if reference.startswith("."):
|
|
396
|
+
return True
|
|
397
|
+
return _PYTHON_ID_SEPARATOR in reference.rsplit(".", 1)[-1]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _python_module_identity(
|
|
401
|
+
item: WeaverItemId, reference: str
|
|
402
|
+
) -> WeaverDocumentId | None:
|
|
403
|
+
"""The object one written import names, or ``None`` if it names none.
|
|
404
|
+
|
|
405
|
+
The mirror of :func:`weaver.declaration.item_dependencies._python_references`,
|
|
406
|
+
and it has to be: what the build resolved on the way in is what this resolves
|
|
407
|
+
on the way back out, so the two use one rule for the ``__`` split — including
|
|
408
|
+
the case where the schema is itself underscores.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
from .declaration.source import python_id_parts
|
|
412
|
+
|
|
413
|
+
components = [part for part in reference.split(".") if part]
|
|
414
|
+
if not components or components[0] == _LIB:
|
|
415
|
+
return None
|
|
416
|
+
parts = python_id_parts(components[-1])
|
|
417
|
+
if len(parts) != 2 or not all(part.strip() for part in parts):
|
|
418
|
+
return None
|
|
419
|
+
return WeaverDocumentId(
|
|
420
|
+
item,
|
|
421
|
+
ObjectId(parts[0].strip(), parts[1].strip()),
|
|
422
|
+
is_files=components[0] == _FILES,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _registry_identity(catalogue, item, row) -> WeaverDocumentId | None:
|
|
427
|
+
"""The document a dictionary row describes, when the Registry certifies it.
|
|
428
|
+
|
|
429
|
+
A dictionary row for something no Registry row certifies describes an object
|
|
430
|
+
that is declared and not installed, so it contributes no edge — the graph is
|
|
431
|
+
of what is *there*.
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
identity = _document_id(
|
|
435
|
+
item, str(row.get("schema_name") or ""), str(row.get("object_name") or "")
|
|
436
|
+
)
|
|
437
|
+
return identity if identity in catalogue.registered else None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# --- primitives ---------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def primitive_candidates(
|
|
444
|
+
identity: WeaverDocumentId, object_type: str
|
|
445
|
+
) -> tuple[tuple[str, WeaverDocumentId], ...]:
|
|
446
|
+
"""Where an object's installed load primitive would be, and what kind it is.
|
|
447
|
+
|
|
448
|
+
Derived from identity and object type alone, because that is all a build has
|
|
449
|
+
when it decides where to put one — the naming is the contract, not a lookup
|
|
450
|
+
table. Returning candidates rather than an answer is what lets the Registry
|
|
451
|
+
settle it: a Lakehouse table's load is either a deployed module or a
|
|
452
|
+
generated Spark SQL file depending on what its author wrote, and the estate
|
|
453
|
+
knows which was installed.
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
item = identity.item
|
|
457
|
+
schema, name = identity.object_id.schema, identity.object_id.object
|
|
458
|
+
if item.item_type == WAREHOUSE:
|
|
459
|
+
if object_type != "table":
|
|
460
|
+
return ()
|
|
461
|
+
return ((WAREHOUSE_PROCEDURE, load_procedure_id(item, identity.object_id)),)
|
|
462
|
+
if object_type == "folder":
|
|
463
|
+
return (
|
|
464
|
+
(
|
|
465
|
+
PYTHON_FOLDER,
|
|
466
|
+
_deployed_file(item, f"{_FILES_PREFIX}{schema}__{name}.py"),
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
if object_type != "table":
|
|
470
|
+
return ()
|
|
471
|
+
return (
|
|
472
|
+
(PYTHON_TABLE, _deployed_file(item, f"{schema}__{name}.py")),
|
|
473
|
+
(SPARK_SQL_FILE, _deployed_file(item, f"{schema}.{name}.sql")),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _deployed_file(item: WeaverItemId, relative: str) -> WeaverDocumentId:
|
|
478
|
+
"""A file in the deployed runtime tree, as the Registry stores it."""
|
|
479
|
+
|
|
480
|
+
path = f"{LOAD_ROOT}/{relative}"
|
|
481
|
+
directory, _, name = path.rpartition("/")
|
|
482
|
+
return WeaverDocumentId(
|
|
483
|
+
item, ObjectId(schema=directory, object=name), shape=FILE_SHAPE
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# --- the graph ----------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@dataclass(frozen=True)
|
|
491
|
+
class LoadNode:
|
|
492
|
+
"""One unit of physical load work, or the barrier between two of them."""
|
|
493
|
+
|
|
494
|
+
node_id: str
|
|
495
|
+
logical_id: WeaverDocumentId | None
|
|
496
|
+
physical_target: PhysicalTargetRef
|
|
497
|
+
primitive_kind: str
|
|
498
|
+
physical_object: PhysicalObjectRef | None = None
|
|
499
|
+
#: The installed primitive itself — the procedure or the deployed file.
|
|
500
|
+
#: ``None`` for a refresh, which is a capability rather than an artefact.
|
|
501
|
+
primitive_id: WeaverDocumentId | None = None
|
|
502
|
+
primitive_object: PhysicalObjectRef | None = None
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def sort_key(self) -> tuple[str, str, str, str]:
|
|
506
|
+
"""What orders two nodes that became ready at the same moment.
|
|
507
|
+
|
|
508
|
+
Target kind, then target, then logical identity, then primitive kind —
|
|
509
|
+
so the order a plan prints is a property of the estate rather than of
|
|
510
|
+
the dictionary iteration that happened to build it.
|
|
511
|
+
"""
|
|
512
|
+
|
|
513
|
+
return (
|
|
514
|
+
self.physical_target.kind,
|
|
515
|
+
self.physical_target.name,
|
|
516
|
+
str(self.logical_id or ""),
|
|
517
|
+
self.primitive_kind,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@dataclass(frozen=True)
|
|
522
|
+
class LoadDag:
|
|
523
|
+
"""The selected physical load graph: nodes, edges and what was requested.
|
|
524
|
+
|
|
525
|
+
An edge means *the upstream node must complete successfully before the
|
|
526
|
+
downstream node may execute*, and nothing else. It is not a data-flow
|
|
527
|
+
statement and not a claim about what the downstream node reads.
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
nodes: tuple[LoadNode, ...]
|
|
531
|
+
edges: tuple[tuple[str, str], ...]
|
|
532
|
+
requested: tuple[PhysicalTargetRef, ...] = ()
|
|
533
|
+
messages: tuple[LoadMessage, ...] = ()
|
|
534
|
+
|
|
535
|
+
@classmethod
|
|
536
|
+
def from_catalogue(
|
|
537
|
+
cls, catalogue: Catalogue, *, targets: Sequence[PhysicalTargetRef]
|
|
538
|
+
) -> "LoadDag":
|
|
539
|
+
return load_dag(InstalledEstate.from_catalogue(catalogue), targets=targets)
|
|
540
|
+
|
|
541
|
+
@property
|
|
542
|
+
def by_id(self) -> Mapping[str, LoadNode]:
|
|
543
|
+
return {node.node_id: node for node in self.nodes}
|
|
544
|
+
|
|
545
|
+
def upstream(self, node_id: str) -> frozenset[str]:
|
|
546
|
+
return frozenset(
|
|
547
|
+
upstream for upstream, downstream in self.edges if downstream == node_id
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
def descendants(self, node_id: str) -> frozenset[str]:
|
|
551
|
+
"""Every node that may not run once ``node_id`` has failed."""
|
|
552
|
+
|
|
553
|
+
reached: set[str] = set()
|
|
554
|
+
frontier = [node_id]
|
|
555
|
+
while frontier:
|
|
556
|
+
current = frontier.pop()
|
|
557
|
+
for upstream, downstream in self.edges:
|
|
558
|
+
if upstream == current and downstream not in reached:
|
|
559
|
+
reached.add(downstream)
|
|
560
|
+
frontier.append(downstream)
|
|
561
|
+
return frozenset(reached)
|
|
562
|
+
|
|
563
|
+
def order(self) -> tuple[LoadNode, ...]:
|
|
564
|
+
"""The deterministic topological order, or a refusal if there is a cycle.
|
|
565
|
+
|
|
566
|
+
Ready nodes are sorted rather than taken as they come, which is what
|
|
567
|
+
makes a dry run inspectable, a log reproducible and a test stable. The
|
|
568
|
+
cycle check is here rather than in a validator because the sort is the
|
|
569
|
+
one place that can see one.
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
remaining = {node.node_id: node for node in self.nodes}
|
|
573
|
+
pending = {
|
|
574
|
+
node_id: set(self.upstream(node_id)) & set(remaining)
|
|
575
|
+
for node_id in remaining
|
|
576
|
+
}
|
|
577
|
+
ordered: list[LoadNode] = []
|
|
578
|
+
while pending:
|
|
579
|
+
ready = sorted(
|
|
580
|
+
(remaining[node_id] for node_id, waiting in pending.items() if not waiting),
|
|
581
|
+
key=lambda node: node.sort_key,
|
|
582
|
+
)
|
|
583
|
+
if not ready:
|
|
584
|
+
cycle = ", ".join(sorted(pending))
|
|
585
|
+
raise LoadError(
|
|
586
|
+
f"the load graph contains a cycle among: {cycle}",
|
|
587
|
+
)
|
|
588
|
+
for node in ready:
|
|
589
|
+
ordered.append(node)
|
|
590
|
+
del pending[node.node_id]
|
|
591
|
+
done = {node.node_id for node in ready}
|
|
592
|
+
for waiting in pending.values():
|
|
593
|
+
waiting -= done
|
|
594
|
+
return tuple(ordered)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def load_dag(
|
|
598
|
+
estate: InstalledEstate, *, targets: Sequence[PhysicalTargetRef]
|
|
599
|
+
) -> LoadDag:
|
|
600
|
+
"""The physical load graph for one set of requested physical targets.
|
|
601
|
+
|
|
602
|
+
*Load every installed loadable object physically hosted in the requested
|
|
603
|
+
targets, plus every upstream dependency required to load them* — and nothing
|
|
604
|
+
else. Unrelated downstream objects are outside the request; unrelated objects
|
|
605
|
+
in upstream targets are not required by it.
|
|
606
|
+
"""
|
|
607
|
+
|
|
608
|
+
requested = tuple(dict.fromkeys(targets))
|
|
609
|
+
planner = _Planner(estate)
|
|
610
|
+
return planner.plan(requested)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
class _Planner:
|
|
614
|
+
"""One planning run's working state.
|
|
615
|
+
|
|
616
|
+
A class rather than a fold of functions because the traversal, the barrier
|
|
617
|
+
placement and the message stream all read the same three lookups, and passing
|
|
618
|
+
them through six signatures obscured what each step actually decided.
|
|
619
|
+
"""
|
|
620
|
+
|
|
621
|
+
def __init__(self, estate: InstalledEstate) -> None:
|
|
622
|
+
self.estate = estate
|
|
623
|
+
self.messages: list[LoadMessage] = []
|
|
624
|
+
self.nodes: dict[str, LoadNode] = {}
|
|
625
|
+
self.edges: set[tuple[str, str]] = set()
|
|
626
|
+
self.refresh_nodes: dict[str, LoadNode] = {}
|
|
627
|
+
#: Which physical targets a refresh barrier must wait for, by refresh id.
|
|
628
|
+
self.refresh_sources: dict[str, PhysicalTargetRef] = {}
|
|
629
|
+
self._alias_by_destination = {
|
|
630
|
+
alias.destination: alias for alias in estate.aliases
|
|
631
|
+
}
|
|
632
|
+
self._dependencies: dict[WeaverDocumentId, list[InstalledDependency]] = {}
|
|
633
|
+
for edge in estate.dependencies:
|
|
634
|
+
self._dependencies.setdefault(edge.consumer, []).append(edge)
|
|
635
|
+
self._loadable = self._installed_primitives()
|
|
636
|
+
|
|
637
|
+
# --- what owns load work --------------------------------------------------
|
|
638
|
+
|
|
639
|
+
def _installed_primitives(self) -> dict[WeaverDocumentId, tuple[str, InstalledObject]]:
|
|
640
|
+
"""Every data object the estate installed a load primitive for."""
|
|
641
|
+
|
|
642
|
+
found: dict[WeaverDocumentId, tuple[str, InstalledObject]] = {}
|
|
643
|
+
for identity, installed in self.estate.objects.items():
|
|
644
|
+
for kind, primitive_id in primitive_candidates(
|
|
645
|
+
identity, installed.object_type
|
|
646
|
+
):
|
|
647
|
+
primitive = self.estate.primitives.get(primitive_id)
|
|
648
|
+
if primitive is not None:
|
|
649
|
+
found[identity] = (kind, primitive)
|
|
650
|
+
break
|
|
651
|
+
return found
|
|
652
|
+
|
|
653
|
+
# --- planning -------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
def plan(self, requested: tuple[PhysicalTargetRef, ...]) -> LoadDag:
|
|
656
|
+
self._refuse_ambiguity(requested)
|
|
657
|
+
seeds = sorted(
|
|
658
|
+
(
|
|
659
|
+
identity
|
|
660
|
+
for identity in self._loadable
|
|
661
|
+
if self.estate.objects[identity].target in requested
|
|
662
|
+
),
|
|
663
|
+
key=str,
|
|
664
|
+
)
|
|
665
|
+
visited: set[WeaverDocumentId] = set()
|
|
666
|
+
for identity in seeds:
|
|
667
|
+
self._select(identity, visited)
|
|
668
|
+
self._place_refresh_barriers()
|
|
669
|
+
dag = LoadDag(
|
|
670
|
+
nodes=tuple(sorted(self.nodes.values(), key=lambda node: node.sort_key)),
|
|
671
|
+
edges=tuple(sorted(self.edges)),
|
|
672
|
+
requested=requested,
|
|
673
|
+
messages=tuple(self.messages),
|
|
674
|
+
)
|
|
675
|
+
# Ordering is what proves acyclicity, so it is done here rather than left
|
|
676
|
+
# to whoever consumes the graph — a planner that returned a cyclic graph
|
|
677
|
+
# would have made a decision it could not defend.
|
|
678
|
+
dag.order()
|
|
679
|
+
self._refuse_ambiguity(
|
|
680
|
+
tuple(dict.fromkeys(node.physical_target for node in dag.nodes))
|
|
681
|
+
)
|
|
682
|
+
return dag
|
|
683
|
+
|
|
684
|
+
def _refuse_ambiguity(self, targets: tuple[PhysicalTargetRef, ...]) -> None:
|
|
685
|
+
"""Stop if any target this request touches holds a duplicated address.
|
|
686
|
+
|
|
687
|
+
Asked twice — of what was requested, and of what the upstream closure
|
|
688
|
+
walked into — because both are targets this run would dispatch against,
|
|
689
|
+
and neither is known at the moment the other is.
|
|
690
|
+
"""
|
|
691
|
+
|
|
692
|
+
for target in targets:
|
|
693
|
+
found = self.estate.ambiguous.get(target)
|
|
694
|
+
if found:
|
|
695
|
+
raise LoadError(
|
|
696
|
+
f"{target} holds two logical objects at one physical "
|
|
697
|
+
f"address, so a load of it is ambiguous: {found[0]}"
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
def _select(self, identity: WeaverDocumentId, visited: set) -> str:
|
|
701
|
+
"""Add this loadable object and everything it needs, returning its node id."""
|
|
702
|
+
|
|
703
|
+
node = self._load_node(identity)
|
|
704
|
+
if identity in visited:
|
|
705
|
+
return node.node_id
|
|
706
|
+
visited.add(identity)
|
|
707
|
+
for producer, crossed in self._upstream_loadable(identity):
|
|
708
|
+
upstream_id = self._select(producer, visited)
|
|
709
|
+
if crossed is None:
|
|
710
|
+
self.edges.add((upstream_id, node.node_id))
|
|
711
|
+
else:
|
|
712
|
+
# An alias read as SQL: the producer's endpoint has to catch up
|
|
713
|
+
# before the consumer can see it, so the barrier replaces the
|
|
714
|
+
# direct edge rather than sitting beside it.
|
|
715
|
+
refresh_id = self._refresh_node(crossed).node_id
|
|
716
|
+
self.edges.add((refresh_id, node.node_id))
|
|
717
|
+
return node.node_id
|
|
718
|
+
|
|
719
|
+
def _load_node(self, identity: WeaverDocumentId) -> LoadNode:
|
|
720
|
+
kind, primitive = self._loadable[identity]
|
|
721
|
+
installed = self.estate.objects[identity]
|
|
722
|
+
node_id = f"load:{installed.target}/{identity.object_id.qualified}"
|
|
723
|
+
node = self.nodes.get(node_id)
|
|
724
|
+
if node is None:
|
|
725
|
+
node = LoadNode(
|
|
726
|
+
node_id=node_id,
|
|
727
|
+
logical_id=identity,
|
|
728
|
+
physical_target=installed.target,
|
|
729
|
+
primitive_kind=kind,
|
|
730
|
+
physical_object=installed.physical,
|
|
731
|
+
primitive_id=primitive.identity,
|
|
732
|
+
primitive_object=primitive.physical,
|
|
733
|
+
)
|
|
734
|
+
self.nodes[node_id] = node
|
|
735
|
+
return node
|
|
736
|
+
|
|
737
|
+
def _refresh_node(self, target: PhysicalTargetRef) -> LoadNode:
|
|
738
|
+
"""The one refresh barrier for this Lakehouse, made once per run."""
|
|
739
|
+
|
|
740
|
+
node_id = f"refresh:{target}"
|
|
741
|
+
node = self.refresh_nodes.get(node_id)
|
|
742
|
+
if node is None:
|
|
743
|
+
node = LoadNode(
|
|
744
|
+
node_id=node_id,
|
|
745
|
+
logical_id=None,
|
|
746
|
+
physical_target=target,
|
|
747
|
+
primitive_kind=ENDPOINT_REFRESH,
|
|
748
|
+
)
|
|
749
|
+
self.refresh_nodes[node_id] = node
|
|
750
|
+
self.nodes[node_id] = node
|
|
751
|
+
self.refresh_sources[node_id] = target
|
|
752
|
+
return node
|
|
753
|
+
|
|
754
|
+
def _place_refresh_barriers(self) -> None:
|
|
755
|
+
"""Every selected load in a refreshed Lakehouse runs before its barrier.
|
|
756
|
+
|
|
757
|
+
Deliberately broad: one barrier per affected Lakehouse, behind *all* of
|
|
758
|
+
that Lakehouse's selected loads rather than only the ones an alias names.
|
|
759
|
+
A narrower placement would have to know which tables a consumer's query
|
|
760
|
+
actually touches, and the catalogue records the alias, not the shape of
|
|
761
|
+
the read.
|
|
762
|
+
"""
|
|
763
|
+
|
|
764
|
+
for node_id, target in self.refresh_sources.items():
|
|
765
|
+
for node in list(self.nodes.values()):
|
|
766
|
+
if node.primitive_kind == ENDPOINT_REFRESH:
|
|
767
|
+
continue
|
|
768
|
+
if node.physical_target == target:
|
|
769
|
+
self.edges.add((node.node_id, node_id))
|
|
770
|
+
|
|
771
|
+
# --- dependency resolution -------------------------------------------------
|
|
772
|
+
|
|
773
|
+
def _upstream_loadable(
|
|
774
|
+
self, identity: WeaverDocumentId
|
|
775
|
+
) -> tuple[tuple[WeaverDocumentId, PhysicalTargetRef | None], ...]:
|
|
776
|
+
"""The loadable ancestors of one object, and where each hop crossed.
|
|
777
|
+
|
|
778
|
+
Passing through non-loadable producers is what makes a view a conduit:
|
|
779
|
+
it owns no load work, so it is not a node, but a consumer of it still
|
|
780
|
+
depends on whatever fills the tables behind it.
|
|
781
|
+
"""
|
|
782
|
+
|
|
783
|
+
found: dict[WeaverDocumentId, PhysicalTargetRef | None] = {}
|
|
784
|
+
seen: set[WeaverDocumentId] = set()
|
|
785
|
+
frontier: list[tuple[WeaverDocumentId, PhysicalTargetRef | None]] = [
|
|
786
|
+
(identity, None)
|
|
787
|
+
]
|
|
788
|
+
while frontier:
|
|
789
|
+
current, crossing = frontier.pop()
|
|
790
|
+
for producer, hop in self._direct_producers(current):
|
|
791
|
+
crossed = crossing or hop
|
|
792
|
+
if producer in self._loadable:
|
|
793
|
+
# A closer crossing wins: the barrier belongs to the hop that
|
|
794
|
+
# actually left the consumer's engine.
|
|
795
|
+
if producer not in found or found[producer] is None:
|
|
796
|
+
found[producer] = crossed
|
|
797
|
+
continue
|
|
798
|
+
if (producer, crossed) in seen:
|
|
799
|
+
continue
|
|
800
|
+
seen.add((producer, crossed))
|
|
801
|
+
frontier.append((producer, crossed))
|
|
802
|
+
return tuple(sorted(found.items(), key=lambda pair: str(pair[0])))
|
|
803
|
+
|
|
804
|
+
def _direct_producers(
|
|
805
|
+
self, consumer: WeaverDocumentId
|
|
806
|
+
) -> tuple[tuple[WeaverDocumentId, PhysicalTargetRef | None], ...]:
|
|
807
|
+
"""What one object reads directly, and the barrier each read crosses."""
|
|
808
|
+
|
|
809
|
+
producers: list[tuple[WeaverDocumentId, PhysicalTargetRef | None]] = []
|
|
810
|
+
consumer_target = self.estate.objects[consumer].target
|
|
811
|
+
for edge in self._dependencies.get(consumer, ()):
|
|
812
|
+
resolved = self._resolve_reference(consumer, edge)
|
|
813
|
+
if resolved is None:
|
|
814
|
+
continue
|
|
815
|
+
producer, through_alias = resolved
|
|
816
|
+
producer_target = self.estate.objects[producer].target
|
|
817
|
+
crossing = None
|
|
818
|
+
if (
|
|
819
|
+
through_alias
|
|
820
|
+
and producer_target.is_lakehouse
|
|
821
|
+
and not consumer_target.is_lakehouse
|
|
822
|
+
):
|
|
823
|
+
# Lakehouse to Warehouse is the one crossing read through a SQL
|
|
824
|
+
# analytics endpoint. A Lakehouse-to-Lakehouse alias is a OneLake
|
|
825
|
+
# shortcut — Delta on both sides, and nothing to synchronise.
|
|
826
|
+
crossing = producer_target
|
|
827
|
+
producers.append((producer, crossing))
|
|
828
|
+
return tuple(producers)
|
|
829
|
+
|
|
830
|
+
def _resolve_reference(
|
|
831
|
+
self, consumer: WeaverDocumentId, edge: InstalledDependency
|
|
832
|
+
) -> tuple[WeaverDocumentId, bool] | None:
|
|
833
|
+
"""What one written reference names, in the consumer's own namespace.
|
|
834
|
+
|
|
835
|
+
Aliases are consulted *before* native objects, and the order is not a
|
|
836
|
+
preference. An alias destination is registered in the consuming item like
|
|
837
|
+
any other object — that is what makes it addressable — so a native lookup
|
|
838
|
+
would find it and stop there, and the crossing would disappear.
|
|
839
|
+
"""
|
|
840
|
+
|
|
841
|
+
reference = edge.reference
|
|
842
|
+
if _is_python_module_reference(reference):
|
|
843
|
+
producer = _python_module_identity(consumer.item, reference)
|
|
844
|
+
if producer is None:
|
|
845
|
+
# A `lib/` helper, or an import that names no object at all. It
|
|
846
|
+
# is real source and it is not a Weaver object, so it orders
|
|
847
|
+
# nothing.
|
|
848
|
+
return None
|
|
849
|
+
if producer not in self.estate.objects:
|
|
850
|
+
raise LoadError(
|
|
851
|
+
f"{consumer} imports {reference!r}, which resolves to "
|
|
852
|
+
f"{producer} — not an installed object"
|
|
853
|
+
)
|
|
854
|
+
return producer, False
|
|
855
|
+
parts = reference.split(".")
|
|
856
|
+
if len(parts) > 2:
|
|
857
|
+
# A fully qualified physical read. It names something outside the
|
|
858
|
+
# estate's logical graph, so there is nothing here to order against.
|
|
859
|
+
self.messages.append(
|
|
860
|
+
info(
|
|
861
|
+
DEPENDENCY_EXTERNAL,
|
|
862
|
+
f"{consumer} reads {reference}, which names a physical object "
|
|
863
|
+
"directly and is not part of the load graph",
|
|
864
|
+
source="load_plan",
|
|
865
|
+
)
|
|
866
|
+
)
|
|
867
|
+
return None
|
|
868
|
+
if len(parts) != 2:
|
|
869
|
+
raise LoadError(
|
|
870
|
+
f"{consumer} declares dependency {reference!r}, which is not a "
|
|
871
|
+
"Schema.Object reference"
|
|
872
|
+
)
|
|
873
|
+
candidate = WeaverDocumentId(consumer.item, ObjectId(parts[0], parts[1]))
|
|
874
|
+
alias = self._alias_by_destination.get(candidate)
|
|
875
|
+
if alias is not None:
|
|
876
|
+
if alias.source not in self.estate.objects:
|
|
877
|
+
raise LoadError(
|
|
878
|
+
f"{consumer} reads alias {reference}, which points at "
|
|
879
|
+
f"{alias.source} — not an installed object"
|
|
880
|
+
)
|
|
881
|
+
return alias.source, True
|
|
882
|
+
if candidate in self.estate.objects:
|
|
883
|
+
return candidate, False
|
|
884
|
+
folder = replace(candidate, is_files=True)
|
|
885
|
+
if folder in self.estate.objects:
|
|
886
|
+
return folder, False
|
|
887
|
+
raise LoadError(
|
|
888
|
+
f"{consumer} declares dependency {reference!r}, which resolves to "
|
|
889
|
+
"neither an installed object nor an alias in its own item"
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
__all__ = [
|
|
894
|
+
"ENDPOINT_REFRESH",
|
|
895
|
+
"InstalledAlias",
|
|
896
|
+
"InstalledDependency",
|
|
897
|
+
"InstalledEstate",
|
|
898
|
+
"InstalledObject",
|
|
899
|
+
"LAKEHOUSE_TARGET",
|
|
900
|
+
"LoadDag",
|
|
901
|
+
"LoadNode",
|
|
902
|
+
"PRIMITIVE_KINDS",
|
|
903
|
+
"PYTHON_FOLDER",
|
|
904
|
+
"PYTHON_TABLE",
|
|
905
|
+
"PhysicalObjectRef",
|
|
906
|
+
"PhysicalTargetRef",
|
|
907
|
+
"SPARK_SQL_FILE",
|
|
908
|
+
"WAREHOUSE_PROCEDURE",
|
|
909
|
+
"WAREHOUSE_TARGET",
|
|
910
|
+
"load_dag",
|
|
911
|
+
"primitive_candidates",
|
|
912
|
+
]
|