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.
@@ -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)))
@@ -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