pipeloom-spark 0.4.0__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.
- pipeloom_spark/__init__.py +1 -0
- pipeloom_spark/catalog.py +144 -0
- pipeloom_spark/delta.py +242 -0
- pipeloom_spark/engine.py +339 -0
- pipeloom_spark/errors.py +91 -0
- pipeloom_spark/io.py +33 -0
- pipeloom_spark/py.typed +0 -0
- pipeloom_spark/quality.py +195 -0
- pipeloom_spark/session.py +98 -0
- pipeloom_spark/spec.py +151 -0
- pipeloom_spark/sqltext.py +142 -0
- pipeloom_spark/state.py +361 -0
- pipeloom_spark/types.py +108 -0
- pipeloom_spark/write.py +460 -0
- pipeloom_spark-0.4.0.dist-info/METADATA +64 -0
- pipeloom_spark-0.4.0.dist-info/RECORD +20 -0
- pipeloom_spark-0.4.0.dist-info/WHEEL +4 -0
- pipeloom_spark-0.4.0.dist-info/entry_points.txt +9 -0
- pipeloom_spark-0.4.0.dist-info/licenses/LICENSE +202 -0
- pipeloom_spark-0.4.0.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Spark engine plugin for pipeloom. Import `pipeloom_spark.spec` to read its capabilities."""
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Catalogs: Delta tables at a path, and tables by name through the session catalog.
|
|
2
|
+
|
|
3
|
+
Neither imports Spark. The engine binds its session with `attach` for the length of a run.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
10
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from pipeloom.engine.types import CatalogCapabilities, Column, Namespace, Schema, TargetRef
|
|
14
|
+
from pipeloom_spark import errors, sqltext
|
|
15
|
+
from pipeloom_spark.types import to_logical
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pipeloom_spark.session import SessionHandle
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _segment(value: str) -> str:
|
|
22
|
+
if (
|
|
23
|
+
not value
|
|
24
|
+
or value in (".", "..")
|
|
25
|
+
or ":" in value
|
|
26
|
+
or any(ord(c) < 32 for c in value)
|
|
27
|
+
or PurePosixPath(value).name != value
|
|
28
|
+
or PureWindowsPath(value).name != value
|
|
29
|
+
):
|
|
30
|
+
raise ValueError(f"{value!r} is not usable as a directory name")
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def schema_of(frame: Any) -> Schema:
|
|
35
|
+
return tuple(Column(f.name, to_logical(f.dataType), f.nullable) for f in frame.schema.fields)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _SparkCatalog:
|
|
39
|
+
capabilities: CatalogCapabilities
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._spark: Any = None
|
|
43
|
+
|
|
44
|
+
def attach(self, handle: SessionHandle) -> AbstractContextManager[None]:
|
|
45
|
+
return self._attach(handle)
|
|
46
|
+
|
|
47
|
+
@contextmanager
|
|
48
|
+
def _attach(self, handle: SessionHandle) -> Iterator[None]:
|
|
49
|
+
self._spark = handle.spark
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
self._spark = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def spark(self) -> Any:
|
|
57
|
+
if self._spark is None:
|
|
58
|
+
raise RuntimeError("the catalog is used outside an engine session")
|
|
59
|
+
return self._spark
|
|
60
|
+
|
|
61
|
+
def ident(self, target: TargetRef) -> str:
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
def read(self, target: TargetRef) -> Any:
|
|
65
|
+
raise NotImplementedError
|
|
66
|
+
|
|
67
|
+
def ensure_namespace(self, ns: Namespace) -> None:
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
|
|
70
|
+
def exists(self, target: TargetRef) -> bool:
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
def save(self, writer: Any, target: TargetRef) -> None:
|
|
74
|
+
"""Run a configured DataFrameWriter against the target."""
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
def table_schema(self, target: TargetRef) -> Schema | None:
|
|
78
|
+
return schema_of(self.read(target)) if self.exists(target) else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DeltaPathCatalog(_SparkCatalog):
|
|
82
|
+
"""`<root>/<schema>/<name>`; the target's catalog name is ignored. Needs no metastore and
|
|
83
|
+
works across processes."""
|
|
84
|
+
|
|
85
|
+
capabilities = CatalogCapabilities(views=False)
|
|
86
|
+
|
|
87
|
+
def __init__(self, root: str) -> None:
|
|
88
|
+
super().__init__()
|
|
89
|
+
self.root = (
|
|
90
|
+
root.rstrip("/") if sqltext.has_scheme(root) else Path(root).resolve().as_posix()
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def path(self, target: TargetRef) -> str:
|
|
94
|
+
return f"{self.root}/{_segment(target.schema)}/{_segment(target.name)}"
|
|
95
|
+
|
|
96
|
+
def ident(self, target: TargetRef) -> str:
|
|
97
|
+
return f"delta.{sqltext.quote_ident(self.path(target))}"
|
|
98
|
+
|
|
99
|
+
def read(self, target: TargetRef) -> Any:
|
|
100
|
+
return self.spark.read.format("delta").load(self.path(target))
|
|
101
|
+
|
|
102
|
+
def save(self, writer: Any, target: TargetRef) -> None:
|
|
103
|
+
writer.save(self.path(target))
|
|
104
|
+
|
|
105
|
+
def ensure_namespace(self, ns: Namespace) -> None:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def exists(self, target: TargetRef) -> bool:
|
|
109
|
+
try:
|
|
110
|
+
self.spark.sql(f"DESCRIBE DETAIL {self.ident(target)}").collect()
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
if errors.condition(exc) in errors.MISSING:
|
|
113
|
+
return False
|
|
114
|
+
raise
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SparkSessionCatalog(_SparkCatalog):
|
|
119
|
+
"""Tables and views by name through the session catalog (`catalog.schema.name`)."""
|
|
120
|
+
|
|
121
|
+
capabilities = CatalogCapabilities(views=True)
|
|
122
|
+
|
|
123
|
+
def __init__(self, catalog: str | None) -> None:
|
|
124
|
+
super().__init__()
|
|
125
|
+
self.catalog = catalog
|
|
126
|
+
|
|
127
|
+
def _parts(self, catalog: str | None, schema: str, name: str | None = None) -> str:
|
|
128
|
+
parts = [p for p in (catalog or self.catalog, schema, name) if p]
|
|
129
|
+
return ".".join(sqltext.quote_ident(p) for p in parts)
|
|
130
|
+
|
|
131
|
+
def ident(self, target: TargetRef) -> str:
|
|
132
|
+
return self._parts(target.catalog, target.schema, target.name)
|
|
133
|
+
|
|
134
|
+
def read(self, target: TargetRef) -> Any:
|
|
135
|
+
return self.spark.table(self.ident(target))
|
|
136
|
+
|
|
137
|
+
def save(self, writer: Any, target: TargetRef) -> None:
|
|
138
|
+
writer.saveAsTable(self.ident(target))
|
|
139
|
+
|
|
140
|
+
def ensure_namespace(self, ns: Namespace) -> None:
|
|
141
|
+
self.spark.sql(f"CREATE SCHEMA IF NOT EXISTS {self._parts(ns.catalog, ns.schema)}")
|
|
142
|
+
|
|
143
|
+
def exists(self, target: TargetRef) -> bool:
|
|
144
|
+
return bool(self.spark.catalog.tableExists(self.ident(target)))
|
pipeloom_spark/delta.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Delta commit history: idempotency tokens in `userMetadata`, and write metrics read back from
|
|
2
|
+
the commits a write made. SQL text only (`DESCRIBE HISTORY`), so it works on classic, Connect and
|
|
3
|
+
Databricks alike."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass, field, replace
|
|
10
|
+
from typing import Any, Final
|
|
11
|
+
|
|
12
|
+
from pipeloom.diagnostics import JsonValue
|
|
13
|
+
from pipeloom.engine.types import WriteResult
|
|
14
|
+
from pipeloom_spark import errors
|
|
15
|
+
|
|
16
|
+
TOKEN_SCAN: Final = 100
|
|
17
|
+
"""Commits `applied_token` and the metrics window look through."""
|
|
18
|
+
_RESULT_FIELDS: Final = (
|
|
19
|
+
"rows_written",
|
|
20
|
+
"rows_inserted",
|
|
21
|
+
"rows_updated",
|
|
22
|
+
"rows_deleted",
|
|
23
|
+
"bytes_written",
|
|
24
|
+
"target_version",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Commit:
|
|
30
|
+
version: int
|
|
31
|
+
operation: str
|
|
32
|
+
metrics: Mapping[str, str]
|
|
33
|
+
user_metadata: str | None
|
|
34
|
+
parameters: Mapping[str, str] = field(default_factory=dict)
|
|
35
|
+
"""`operationParameters`: `mode` tells an append from an overwrite."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def history(spark: Any, ident: str, limit: int | None) -> list[Commit]:
|
|
39
|
+
"""The table's commits, newest first (`DESCRIBE HISTORY`)."""
|
|
40
|
+
text = f"DESCRIBE HISTORY {ident}" + ("" if limit is None else f" LIMIT {int(limit)}")
|
|
41
|
+
rows = (
|
|
42
|
+
spark.sql(text)
|
|
43
|
+
.select("version", "operation", "operationParameters", "operationMetrics", "userMetadata")
|
|
44
|
+
.collect()
|
|
45
|
+
)
|
|
46
|
+
return [
|
|
47
|
+
Commit(
|
|
48
|
+
int(r["version"]),
|
|
49
|
+
str(r["operation"]),
|
|
50
|
+
dict(r["operationMetrics"] or {}),
|
|
51
|
+
r["userMetadata"],
|
|
52
|
+
dict(r["operationParameters"] or {}),
|
|
53
|
+
)
|
|
54
|
+
for r in rows
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _history_or_none(spark: Any, ident: str, limit: int | None) -> list[Commit] | None:
|
|
59
|
+
try:
|
|
60
|
+
return history(spark, ident, limit)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
if errors.condition(exc) in errors.MISSING:
|
|
63
|
+
return None
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def token_metadata(
|
|
68
|
+
token: str, commit_meta: Mapping[str, JsonValue], result: WriteResult | None
|
|
69
|
+
) -> str:
|
|
70
|
+
"""The `userMetadata` text of a commit that carries `token`."""
|
|
71
|
+
body: dict[str, Any] = {"v": 1, "token": token, "commit_meta": dict(commit_meta)}
|
|
72
|
+
if result is not None:
|
|
73
|
+
body["result"] = {f: getattr(result, f) for f in _RESULT_FIELDS}
|
|
74
|
+
return json.dumps({"pipeloom": body}, sort_keys=True, separators=(",", ":"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def migration_metadata(token: str) -> str:
|
|
78
|
+
return json.dumps({"pipeloom": {"migration_for": token}}, sort_keys=True, separators=(",", ":"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def claim_metadata(token: str) -> str:
|
|
82
|
+
"""The `userMetadata` of a claim commit; it has no `token` key, so `parse_token` ignores it."""
|
|
83
|
+
return json.dumps({"pipeloom": {"v": 1, "claim": token}}, sort_keys=True, separators=(",", ":"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _owner(user_metadata: str | None) -> tuple[str, bool] | None:
|
|
87
|
+
"""The token a pipeloom commit carries and whether the commit is a claim; None otherwise."""
|
|
88
|
+
try:
|
|
89
|
+
body = json.loads(user_metadata or "")["pipeloom"]
|
|
90
|
+
except (ValueError, KeyError, TypeError):
|
|
91
|
+
return None
|
|
92
|
+
if not isinstance(body, dict):
|
|
93
|
+
return None
|
|
94
|
+
is_claim = "token" not in body
|
|
95
|
+
owner = body.get("claim") if is_claim else body.get("token")
|
|
96
|
+
return (owner, is_claim) if isinstance(owner, str) else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def claimed(spark: Any, ident: str, token: str, same_writer: Callable[[str], bool]) -> bool:
|
|
100
|
+
"""Whether a claim commit for `token` exists that no other token of the same writer
|
|
101
|
+
(`same_writer`) committed after: a later run that wrote the model in between voids it.
|
|
102
|
+
The scan window first, then the full history, newest first."""
|
|
103
|
+
for limit in (TOKEN_SCAN, None):
|
|
104
|
+
for commit in _history_or_none(spark, ident, limit) or ():
|
|
105
|
+
found = _owner(commit.user_metadata)
|
|
106
|
+
if found is None:
|
|
107
|
+
continue
|
|
108
|
+
owner, is_claim = found
|
|
109
|
+
if owner == token:
|
|
110
|
+
if is_claim:
|
|
111
|
+
return True
|
|
112
|
+
elif same_writer(owner):
|
|
113
|
+
return False
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_token(
|
|
118
|
+
user_metadata: str | None,
|
|
119
|
+
) -> tuple[str, Mapping[str, JsonValue], WriteResult | None] | None:
|
|
120
|
+
"""The token, commit meta and stored result of a commit; None for any other commit."""
|
|
121
|
+
if not user_metadata:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
body = json.loads(user_metadata)["pipeloom"]
|
|
125
|
+
token = body["token"]
|
|
126
|
+
except (ValueError, KeyError, TypeError):
|
|
127
|
+
return None
|
|
128
|
+
if not isinstance(token, str):
|
|
129
|
+
return None
|
|
130
|
+
meta = body.get("commit_meta")
|
|
131
|
+
stored = body.get("result")
|
|
132
|
+
result = _stored(stored) if isinstance(stored, dict) else None
|
|
133
|
+
return token, meta if isinstance(meta, dict) else {}, result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _stored(stored: Mapping[str, Any]) -> WriteResult:
|
|
137
|
+
return WriteResult(
|
|
138
|
+
rows_written=stored.get("rows_written"),
|
|
139
|
+
rows_inserted=stored.get("rows_inserted"),
|
|
140
|
+
rows_updated=stored.get("rows_updated"),
|
|
141
|
+
rows_deleted=stored.get("rows_deleted"),
|
|
142
|
+
bytes_written=stored.get("bytes_written"),
|
|
143
|
+
target_version=stored.get("target_version"),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _num(metrics: Mapping[str, str], key: str) -> int | None:
|
|
148
|
+
value = metrics.get(key)
|
|
149
|
+
try:
|
|
150
|
+
return None if value is None else int(value)
|
|
151
|
+
except ValueError:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _one(commit: Commit) -> dict[str, int | None]:
|
|
156
|
+
m = commit.metrics
|
|
157
|
+
if commit.operation == "MERGE":
|
|
158
|
+
ins, upd = _num(m, "numTargetRowsInserted"), _num(m, "numTargetRowsUpdated")
|
|
159
|
+
return {
|
|
160
|
+
"rows_written": None if ins is None and upd is None else (ins or 0) + (upd or 0),
|
|
161
|
+
"rows_inserted": ins,
|
|
162
|
+
"rows_updated": upd,
|
|
163
|
+
"rows_deleted": _num(m, "numTargetRowsDeleted"),
|
|
164
|
+
"bytes_written": _num(m, "numTargetBytesAdded"),
|
|
165
|
+
}
|
|
166
|
+
out = _num(m, "numOutputRows")
|
|
167
|
+
appended = (
|
|
168
|
+
commit.operation == "CREATE TABLE AS SELECT" or commit.parameters.get("mode") == "Append"
|
|
169
|
+
)
|
|
170
|
+
return {
|
|
171
|
+
"rows_written": out,
|
|
172
|
+
"rows_inserted": out if appended else None,
|
|
173
|
+
"rows_updated": None,
|
|
174
|
+
"rows_deleted": _num(m, "numDeletedRows"),
|
|
175
|
+
"bytes_written": _num(m, "numOutputBytes"),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def metrics_to_result(commits: Sequence[Commit]) -> WriteResult:
|
|
180
|
+
"""The write result of `commits`: each metric summed over them, None when no commit has it.
|
|
181
|
+
`target_version` is the newest version."""
|
|
182
|
+
totals: dict[str, int | None] = {}
|
|
183
|
+
for commit in commits:
|
|
184
|
+
for key, value in _one(commit).items():
|
|
185
|
+
if value is not None:
|
|
186
|
+
totals[key] = (totals.get(key) or 0) + value
|
|
187
|
+
version = max((c.version for c in commits), default=None)
|
|
188
|
+
return WriteResult(
|
|
189
|
+
rows_written=totals.get("rows_written"),
|
|
190
|
+
rows_inserted=totals.get("rows_inserted"),
|
|
191
|
+
rows_updated=totals.get("rows_updated"),
|
|
192
|
+
rows_deleted=totals.get("rows_deleted"),
|
|
193
|
+
bytes_written=totals.get("bytes_written"),
|
|
194
|
+
target_version=version,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def applied_result(
|
|
199
|
+
spark: Any, ident: str, token: str, limit: int | None = TOKEN_SCAN
|
|
200
|
+
) -> WriteResult | None:
|
|
201
|
+
"""The result (with `skipped=True`) of the newest commit that carries `token`, else None."""
|
|
202
|
+
commits = _history_or_none(spark, ident, limit)
|
|
203
|
+
for commit in commits or ():
|
|
204
|
+
parsed = parse_token(commit.user_metadata)
|
|
205
|
+
if parsed is not None and parsed[0] == token:
|
|
206
|
+
_, meta, stored = parsed
|
|
207
|
+
base = stored if stored is not None else metrics_to_result([commit])
|
|
208
|
+
return replace(base, skipped=True, commit_meta=dict(meta))
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class DeltaMetrics:
|
|
213
|
+
"""`before_write` and `after_write` bracket a write; the result comes from the commits made
|
|
214
|
+
in between, so no session conf and no `DeltaTable` API is needed."""
|
|
215
|
+
|
|
216
|
+
def __init__(self, spark: Any) -> None:
|
|
217
|
+
self._spark = spark
|
|
218
|
+
|
|
219
|
+
def before_write(self, ident: str) -> int | None:
|
|
220
|
+
"""The latest version, None when the table does not exist."""
|
|
221
|
+
commits = _history_or_none(self._spark, ident, 1)
|
|
222
|
+
return commits[0].version if commits else None
|
|
223
|
+
|
|
224
|
+
def after_write(
|
|
225
|
+
self, ident: str, before: int | None, token: str, *, merge: bool
|
|
226
|
+
) -> WriteResult | None:
|
|
227
|
+
"""The summed metrics of the commits after `before` that carry `token`. With `merge`, the
|
|
228
|
+
newest MERGE commit counts too (a SQL MERGE carries no token; a concurrent MERGE on the
|
|
229
|
+
same table inside the window can be mistaken for ours). None when no commit carries the
|
|
230
|
+
token and the write is not a merge."""
|
|
231
|
+
floor = -1 if before is None else before
|
|
232
|
+
window = [c for c in history(self._spark, ident, TOKEN_SCAN) if c.version > floor]
|
|
233
|
+
mine = [c for c in window if (parse_token(c.user_metadata) or ("",))[0] == token]
|
|
234
|
+
if not merge:
|
|
235
|
+
return metrics_to_result(mine) if mine else None
|
|
236
|
+
merges = [c for c in window if c.operation == "MERGE"][:1]
|
|
237
|
+
if not merges and not mine: # a MERGE that changed nothing may not commit
|
|
238
|
+
return WriteResult(0, 0, 0, 0, 0, before)
|
|
239
|
+
return metrics_to_result([*merges, *mine])
|
|
240
|
+
|
|
241
|
+
def token_committed(self, ident: str, before: int | None, token: str) -> bool:
|
|
242
|
+
return self.after_write(ident, before, token, merge=False) is not None
|