pipeloom-databricks 0.5.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_databricks/__init__.py +18 -0
- pipeloom_databricks/api.py +45 -0
- pipeloom_databricks/bundle.py +113 -0
- pipeloom_databricks/cli.py +561 -0
- pipeloom_databricks/client/__init__.py +121 -0
- pipeloom_databricks/client/connect.py +84 -0
- pipeloom_databricks/client/sdk.py +275 -0
- pipeloom_databricks/deploy.py +188 -0
- pipeloom_databricks/finalize_states.py +72 -0
- pipeloom_databricks/grouping.py +158 -0
- pipeloom_databricks/jobs_model.py +116 -0
- pipeloom_databricks/keys.py +45 -0
- pipeloom_databricks/options.py +66 -0
- pipeloom_databricks/py.typed +0 -0
- pipeloom_databricks/render.py +396 -0
- pipeloom_databricks/repair.py +66 -0
- pipeloom_databricks/run.py +357 -0
- pipeloom_databricks/secrets.py +50 -0
- pipeloom_databricks/serialize.py +30 -0
- pipeloom_databricks/spec.py +75 -0
- pipeloom_databricks/task.py +422 -0
- pipeloom_databricks/task_values.py +69 -0
- pipeloom_databricks/testing/__init__.py +1 -0
- pipeloom_databricks/testing/fake.py +293 -0
- pipeloom_databricks/testing/jobsim.py +179 -0
- pipeloom_databricks/unity.py +23 -0
- pipeloom_databricks/upload.py +217 -0
- pipeloom_databricks-0.5.0.dist-info/METADATA +114 -0
- pipeloom_databricks-0.5.0.dist-info/RECORD +33 -0
- pipeloom_databricks-0.5.0.dist-info/WHEEL +4 -0
- pipeloom_databricks-0.5.0.dist-info/entry_points.txt +14 -0
- pipeloom_databricks-0.5.0.dist-info/licenses/LICENSE +202 -0
- pipeloom_databricks-0.5.0.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""`FakeDatabricks`: an in-memory `DatabricksClient` for tests and case files.
|
|
2
|
+
|
|
3
|
+
No network and no SDK. Tests inspect `jobs`, `files`, `runs` and `calls`, and make a call fail with
|
|
4
|
+
`fail["create_job"] = ApiError(...)`. The scenario functions at the end are what a case file names
|
|
5
|
+
in `--client-factory pipeloom_databricks.testing.fake:<scenario>`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
|
|
15
|
+
from pydantic import JsonValue
|
|
16
|
+
|
|
17
|
+
from pipeloom_databricks.client import ApiError, DirEntry, JobSummary, RunInfo, TaskRunInfo
|
|
18
|
+
|
|
19
|
+
JOB_TAG = "pipeloom_job"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parent(path: str) -> str:
|
|
23
|
+
return path.rsplit("/", 1)[0] if "/" in path.strip("/") else "/"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FakeDatabricks:
|
|
27
|
+
host = "https://fake.databricks.test"
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, user: str = "me@example.com") -> None:
|
|
30
|
+
self.user = user
|
|
31
|
+
self.jobs: dict[int, dict[str, JsonValue]] = {}
|
|
32
|
+
self.files: dict[str, bytes] = {}
|
|
33
|
+
self.dirs: set[str] = {"/"}
|
|
34
|
+
self.modified: dict[str, int] = {}
|
|
35
|
+
self.volume_files: dict[str, bytes] = {}
|
|
36
|
+
self.secrets: dict[tuple[str, str], str] = {}
|
|
37
|
+
"""(scope, key) -> the plain value; `get_secret` returns it base64 encoded."""
|
|
38
|
+
self.runs: dict[int, RunInfo] = {}
|
|
39
|
+
self.run_tokens: dict[str, int] = {}
|
|
40
|
+
self.repairs: list[tuple[int, tuple[str, ...], int | None]] = []
|
|
41
|
+
self.starts: list[tuple[int, dict[str, str], tuple[str, ...] | None, str | None]] = []
|
|
42
|
+
"""Every `run_now`: (job_id, job_parameters, only, idempotency_token)."""
|
|
43
|
+
self.submissions: list[tuple[dict[str, JsonValue], str | None]] = []
|
|
44
|
+
"""Every `submit_run`: (the spec, idempotency_token)."""
|
|
45
|
+
self.calls: list[tuple[str, str]] = []
|
|
46
|
+
"""(method, first argument as text), in order."""
|
|
47
|
+
self.fail: dict[str, ApiError] = {}
|
|
48
|
+
self.stale_polls = 0
|
|
49
|
+
"""After a repair, this many `get_run` calls still show the run as it was before it."""
|
|
50
|
+
self._stale: dict[int, tuple[RunInfo, int]] = {}
|
|
51
|
+
self._next = 100
|
|
52
|
+
self._tick = 0
|
|
53
|
+
|
|
54
|
+
def _enter(self, method: str, arg: object = "") -> None:
|
|
55
|
+
self.calls.append((method, str(arg)))
|
|
56
|
+
if method in self.fail:
|
|
57
|
+
raise self.fail[method]
|
|
58
|
+
|
|
59
|
+
def _id(self) -> int:
|
|
60
|
+
self._next += 1
|
|
61
|
+
return self._next
|
|
62
|
+
|
|
63
|
+
def _touch(self, path: str) -> None:
|
|
64
|
+
self._tick += 1
|
|
65
|
+
self.modified[path] = self._tick
|
|
66
|
+
|
|
67
|
+
def called(self, method: str) -> list[str]:
|
|
68
|
+
return [arg for name, arg in self.calls if name == method]
|
|
69
|
+
|
|
70
|
+
# jobs
|
|
71
|
+
|
|
72
|
+
def list_jobs(self, *, name: str | None = None) -> Iterator[JobSummary]:
|
|
73
|
+
self._enter("list_jobs", name or "")
|
|
74
|
+
for job_id, settings in sorted(self.jobs.items()):
|
|
75
|
+
if name is not None and settings.get("name") != name:
|
|
76
|
+
continue
|
|
77
|
+
tags = settings.get("tags")
|
|
78
|
+
tag_map = {str(k): str(v) for k, v in tags.items()} if isinstance(tags, dict) else {}
|
|
79
|
+
yield JobSummary(job_id, str(settings.get("name", "")), tag_map)
|
|
80
|
+
|
|
81
|
+
def add_job(self, settings: Mapping[str, JsonValue]) -> int:
|
|
82
|
+
"""Put a job in without a call record (test setup)."""
|
|
83
|
+
job_id = self._id()
|
|
84
|
+
self.jobs[job_id] = json.loads(json.dumps(dict(settings)))
|
|
85
|
+
return job_id
|
|
86
|
+
|
|
87
|
+
def create_job(self, settings: Mapping[str, JsonValue]) -> int:
|
|
88
|
+
self._enter("create_job")
|
|
89
|
+
return self.add_job(settings)
|
|
90
|
+
|
|
91
|
+
def reset_job(self, job_id: int, settings: Mapping[str, JsonValue]) -> None:
|
|
92
|
+
self._enter("reset_job", job_id)
|
|
93
|
+
if job_id not in self.jobs:
|
|
94
|
+
raise ApiError(404, f"Job {job_id} does not exist.", "RESOURCE_DOES_NOT_EXIST")
|
|
95
|
+
self.jobs[job_id] = json.loads(json.dumps(dict(settings)))
|
|
96
|
+
|
|
97
|
+
# runs
|
|
98
|
+
|
|
99
|
+
def _start(self, job_id: int | None, params: Mapping[str, str], token: str | None) -> int:
|
|
100
|
+
if token is not None and token in self.run_tokens:
|
|
101
|
+
return self.run_tokens[token]
|
|
102
|
+
run_id = self._id()
|
|
103
|
+
self.runs[run_id] = RunInfo(run_id, job_id, "PENDING", None, dict(params))
|
|
104
|
+
if token is not None:
|
|
105
|
+
self.run_tokens[token] = run_id
|
|
106
|
+
return run_id
|
|
107
|
+
|
|
108
|
+
def run_now(
|
|
109
|
+
self,
|
|
110
|
+
job_id: int,
|
|
111
|
+
*,
|
|
112
|
+
job_parameters: Mapping[str, str],
|
|
113
|
+
only: Sequence[str] | None,
|
|
114
|
+
idempotency_token: str | None,
|
|
115
|
+
) -> int:
|
|
116
|
+
self._enter("run_now", job_id)
|
|
117
|
+
if job_id not in self.jobs:
|
|
118
|
+
raise ApiError(404, f"Job {job_id} does not exist.", "RESOURCE_DOES_NOT_EXIST")
|
|
119
|
+
self.starts.append(
|
|
120
|
+
(
|
|
121
|
+
job_id,
|
|
122
|
+
dict(job_parameters),
|
|
123
|
+
None if only is None else tuple(only),
|
|
124
|
+
idempotency_token,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return self._start(job_id, job_parameters, idempotency_token)
|
|
128
|
+
|
|
129
|
+
def submit_run(self, spec: Mapping[str, JsonValue], *, idempotency_token: str | None) -> int:
|
|
130
|
+
self._enter("submit_run")
|
|
131
|
+
self.submissions.append((json.loads(json.dumps(dict(spec))), idempotency_token))
|
|
132
|
+
return self._start(None, {}, idempotency_token)
|
|
133
|
+
|
|
134
|
+
def set_run(
|
|
135
|
+
self,
|
|
136
|
+
run_id: int,
|
|
137
|
+
*,
|
|
138
|
+
job_id: int | None = None,
|
|
139
|
+
life_cycle_state: str = "TERMINATED",
|
|
140
|
+
result_state: str | None = None,
|
|
141
|
+
tasks: Sequence[TaskRunInfo] = (),
|
|
142
|
+
job_parameters: Mapping[str, str] | None = None,
|
|
143
|
+
latest_repair_id: int | None = None,
|
|
144
|
+
run_page_url: str | None = None,
|
|
145
|
+
repair_states: Mapping[int, str] | None = None,
|
|
146
|
+
) -> RunInfo:
|
|
147
|
+
"""Put a run in without a call record (test setup, or the simulator)."""
|
|
148
|
+
run = RunInfo(
|
|
149
|
+
run_id,
|
|
150
|
+
job_id,
|
|
151
|
+
life_cycle_state,
|
|
152
|
+
result_state,
|
|
153
|
+
dict(job_parameters or {}),
|
|
154
|
+
tuple(tasks),
|
|
155
|
+
latest_repair_id,
|
|
156
|
+
run_page_url,
|
|
157
|
+
repair_states=dict(repair_states or {}),
|
|
158
|
+
)
|
|
159
|
+
self.runs[run_id] = run
|
|
160
|
+
return run
|
|
161
|
+
|
|
162
|
+
def get_run(self, run_id: int) -> RunInfo:
|
|
163
|
+
self._enter("get_run", run_id)
|
|
164
|
+
if run_id not in self.runs:
|
|
165
|
+
raise ApiError(404, f"Run {run_id} does not exist.", "RESOURCE_DOES_NOT_EXIST")
|
|
166
|
+
stale = self._stale.get(run_id)
|
|
167
|
+
if stale is not None and stale[1] > 0:
|
|
168
|
+
self._stale[run_id] = (stale[0], stale[1] - 1)
|
|
169
|
+
return stale[0]
|
|
170
|
+
return self.runs[run_id]
|
|
171
|
+
|
|
172
|
+
def list_runs(self, job_id: int, *, active_only: bool = False) -> Iterator[RunInfo]:
|
|
173
|
+
self._enter("list_runs", job_id)
|
|
174
|
+
for run in self.runs.values():
|
|
175
|
+
active = run.life_cycle_state not in ("TERMINATED", "SKIPPED", "INTERNAL_ERROR")
|
|
176
|
+
if run.job_id == job_id and (active or not active_only):
|
|
177
|
+
yield run
|
|
178
|
+
|
|
179
|
+
def repair_run(
|
|
180
|
+
self, run_id: int, *, rerun_tasks: Sequence[str], latest_repair_id: int | None
|
|
181
|
+
) -> int:
|
|
182
|
+
self._enter("repair_run", run_id)
|
|
183
|
+
if run_id not in self.runs:
|
|
184
|
+
raise ApiError(404, f"Run {run_id} does not exist.", "RESOURCE_DOES_NOT_EXIST")
|
|
185
|
+
if latest_repair_id != self.runs[run_id].latest_repair_id:
|
|
186
|
+
raise ApiError(
|
|
187
|
+
400, "latest_repair_id is not the latest repair.", "INVALID_PARAMETER_VALUE"
|
|
188
|
+
)
|
|
189
|
+
repair_id = self._id()
|
|
190
|
+
self.repairs.append((run_id, tuple(rerun_tasks), latest_repair_id))
|
|
191
|
+
if self.stale_polls:
|
|
192
|
+
before = replace(self.runs[run_id], latest_repair_id=repair_id)
|
|
193
|
+
self._stale[run_id] = (before, self.stale_polls)
|
|
194
|
+
self.runs[run_id] = replace(
|
|
195
|
+
self.runs[run_id],
|
|
196
|
+
life_cycle_state="RUNNING",
|
|
197
|
+
latest_repair_id=repair_id,
|
|
198
|
+
repair_states={**self.runs[run_id].repair_states, repair_id: "RUNNING"},
|
|
199
|
+
)
|
|
200
|
+
return repair_id
|
|
201
|
+
|
|
202
|
+
# workspace
|
|
203
|
+
|
|
204
|
+
def current_user(self) -> str:
|
|
205
|
+
self._enter("current_user")
|
|
206
|
+
return self.user
|
|
207
|
+
|
|
208
|
+
def mkdirs(self, path: str) -> None:
|
|
209
|
+
self._enter("mkdirs", path)
|
|
210
|
+
here = path.rstrip("/") or "/"
|
|
211
|
+
while here not in self.dirs:
|
|
212
|
+
self.dirs.add(here) # like the real API, a folder has no modification time
|
|
213
|
+
here = _parent(here)
|
|
214
|
+
|
|
215
|
+
def upload(self, path: str, data: bytes) -> None:
|
|
216
|
+
self._enter("upload", path)
|
|
217
|
+
if _parent(path) not in self.dirs:
|
|
218
|
+
raise ApiError(404, f"The parent of {path} does not exist.", "RESOURCE_DOES_NOT_EXIST")
|
|
219
|
+
self.files[path] = data
|
|
220
|
+
self._touch(path)
|
|
221
|
+
|
|
222
|
+
def list_dir(self, path: str) -> list[DirEntry]:
|
|
223
|
+
self._enter("list_dir", path)
|
|
224
|
+
here = path.rstrip("/") or "/"
|
|
225
|
+
if here not in self.dirs:
|
|
226
|
+
raise ApiError(404, f"{path} does not exist.", "RESOURCE_DOES_NOT_EXIST")
|
|
227
|
+
kids = [(p, True) for p in self.dirs if p != "/" and _parent(p) == here]
|
|
228
|
+
kids += [(p, False) for p in self.files if _parent(p) == here]
|
|
229
|
+
return [DirEntry(p, d, self.modified.get(p, 0)) for p, d in sorted(kids)]
|
|
230
|
+
|
|
231
|
+
def delete(self, path: str, *, recursive: bool) -> None:
|
|
232
|
+
self._enter("delete", path)
|
|
233
|
+
here = path.rstrip("/")
|
|
234
|
+
inside = [p for p in (*self.dirs, *self.files) if p.startswith(here + "/")]
|
|
235
|
+
if here not in self.dirs and here not in self.files:
|
|
236
|
+
raise ApiError(404, f"{path} does not exist.", "RESOURCE_DOES_NOT_EXIST")
|
|
237
|
+
if inside and not recursive:
|
|
238
|
+
raise ApiError(400, f"{path} is not empty.", "DIRECTORY_NOT_EMPTY")
|
|
239
|
+
for p in inside:
|
|
240
|
+
self.dirs.discard(p)
|
|
241
|
+
self.files.pop(p, None)
|
|
242
|
+
self.dirs.discard(here)
|
|
243
|
+
self.files.pop(here, None)
|
|
244
|
+
|
|
245
|
+
def read_file(self, path: str) -> bytes:
|
|
246
|
+
self._enter("read_file", path)
|
|
247
|
+
if path not in self.volume_files:
|
|
248
|
+
raise ApiError(404, f"{path} does not exist.", "NOT_FOUND")
|
|
249
|
+
return self.volume_files[path]
|
|
250
|
+
|
|
251
|
+
def get_secret(self, scope: str, key: str) -> str:
|
|
252
|
+
self._enter("get_secret", f"{scope}/{key}")
|
|
253
|
+
if (scope, key) not in self.secrets:
|
|
254
|
+
raise ApiError(404, f"Secret {scope}/{key} does not exist.", "RESOURCE_DOES_NOT_EXIST")
|
|
255
|
+
return base64.b64encode(self.secrets[(scope, key)].encode("utf-8")).decode("ascii")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def empty() -> FakeDatabricks:
|
|
259
|
+
"""A workspace with nothing in it."""
|
|
260
|
+
return FakeDatabricks()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def duplicate_jobs() -> FakeDatabricks:
|
|
264
|
+
"""Two jobs that both claim the job tag `shop.main.prod` (PLD006)."""
|
|
265
|
+
client = FakeDatabricks()
|
|
266
|
+
for _ in range(2):
|
|
267
|
+
client.add_job({"name": "pipeloom_shop_main_prod", "tags": {JOB_TAG: "shop.main.prod"}})
|
|
268
|
+
return client
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def stale_job() -> FakeDatabricks:
|
|
272
|
+
"""A job for `shop.main.prod` deployed from other settings than any project renders (PLD008)."""
|
|
273
|
+
client = FakeDatabricks()
|
|
274
|
+
tags: dict[str, JsonValue] = {JOB_TAG: "shop.main.prod", "pipeloom_settings_hash": "0" * 64}
|
|
275
|
+
client.add_job({"name": "pipeloom_shop_main_prod", "tags": tags})
|
|
276
|
+
return client
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def active_run() -> FakeDatabricks:
|
|
280
|
+
"""Job run 900 of job 101 is still running (PLD014)."""
|
|
281
|
+
client = FakeDatabricks()
|
|
282
|
+
job_id = client.add_job(
|
|
283
|
+
{"name": "pipeloom_shop_main_prod", "tags": {JOB_TAG: "shop.main.prod"}}
|
|
284
|
+
)
|
|
285
|
+
client.set_run(900, job_id=job_id, life_cycle_state="RUNNING")
|
|
286
|
+
return client
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def api_down() -> FakeDatabricks:
|
|
290
|
+
"""A workspace whose job creation fails with a 500 (PLD009)."""
|
|
291
|
+
client = FakeDatabricks()
|
|
292
|
+
client.fail["create_job"] = ApiError(500, "INTERNAL_ERROR: the service is unavailable")
|
|
293
|
+
return client
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""A local stand-in for the Jobs service: run a rendered job through the task wrapper.
|
|
2
|
+
|
|
3
|
+
It substitutes the dynamic value references, runs the tasks in dependency order through
|
|
4
|
+
`pipeloom_databricks.task.main` in this process, and honors `run_if`, `only` and a repair.
|
|
5
|
+
It starts no cluster and calls no API. The project's engine, state store and artifacts root
|
|
6
|
+
come from its own configuration, so a test points them at a temporary directory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Collection, Mapping
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import JsonValue
|
|
17
|
+
|
|
18
|
+
from pipeloom_databricks.client import (
|
|
19
|
+
ConnectionNotConfigured,
|
|
20
|
+
DatabricksClient,
|
|
21
|
+
TaskRunInfo,
|
|
22
|
+
)
|
|
23
|
+
from pipeloom_databricks.task import main
|
|
24
|
+
from pipeloom_databricks.task_values import TASK_VALUE_KEY
|
|
25
|
+
from pipeloom_databricks.testing.fake import FakeDatabricks
|
|
26
|
+
|
|
27
|
+
START_MS = 1_700_000_000_000
|
|
28
|
+
"""The job start every simulated run (and repair) reports."""
|
|
29
|
+
_REFERENCE = re.compile(r"\{\{([^{}]+)\}\}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SimRun:
|
|
34
|
+
job_run_id: int
|
|
35
|
+
task_states: dict[str, str] = field(default_factory=dict)
|
|
36
|
+
"""SUCCESS, FAILED, UPSTREAM_FAILED or EXCLUDED, for the tasks this call ran or skipped."""
|
|
37
|
+
exit_codes: dict[str, int] = field(default_factory=dict)
|
|
38
|
+
"""The exit code of every task that ran."""
|
|
39
|
+
task_values: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
"""The `pipeloom` task value each task set, by task key."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Recorder:
|
|
44
|
+
def __init__(self, run: SimRun, task_key: str) -> None:
|
|
45
|
+
self._run = run
|
|
46
|
+
self._key = task_key
|
|
47
|
+
|
|
48
|
+
def set(self, key: str, value: JsonValue) -> None:
|
|
49
|
+
if key == TASK_VALUE_KEY:
|
|
50
|
+
self._run.task_values[self._key] = value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _order(tasks: Mapping[str, Mapping[str, Any]]) -> list[str]:
|
|
54
|
+
"""Dependencies first, ties by key."""
|
|
55
|
+
done: list[str] = []
|
|
56
|
+
waiting = sorted(tasks)
|
|
57
|
+
while waiting:
|
|
58
|
+
ready = [
|
|
59
|
+
k for k in waiting if all(d["task_key"] in done for d in tasks[k].get("depends_on", []))
|
|
60
|
+
]
|
|
61
|
+
if not ready:
|
|
62
|
+
raise ValueError("the job's tasks form a cycle or depend on an unknown task")
|
|
63
|
+
done.append(ready[0])
|
|
64
|
+
waiting.remove(ready[0])
|
|
65
|
+
return done
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _no_client() -> DatabricksClient:
|
|
69
|
+
raise ConnectionNotConfigured("the simulator was given no client")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _record(client: FakeDatabricks, run: SimRun, job_id: int, params: Mapping[str, str]) -> None:
|
|
73
|
+
"""Put the task states known so far into the fake's run, as the Jobs API would report them
|
|
74
|
+
to the finalize task. A repair keeps the tasks it does not run again."""
|
|
75
|
+
old = client.runs.get(run.job_run_id)
|
|
76
|
+
tasks = {t.task_key: t for t in old.tasks} if old else {}
|
|
77
|
+
for key, state in run.task_states.items():
|
|
78
|
+
failed = state == "FAILED"
|
|
79
|
+
tasks[key] = TaskRunInfo(
|
|
80
|
+
key,
|
|
81
|
+
run.job_run_id * 1000 + len(tasks),
|
|
82
|
+
1,
|
|
83
|
+
"TERMINATED",
|
|
84
|
+
state,
|
|
85
|
+
"RUN_EXECUTION_ERROR" if failed else None,
|
|
86
|
+
"the task failed" if failed else None,
|
|
87
|
+
)
|
|
88
|
+
client.set_run(
|
|
89
|
+
run.job_run_id,
|
|
90
|
+
job_id=job_id,
|
|
91
|
+
life_cycle_state="RUNNING",
|
|
92
|
+
tasks=[tasks[k] for k in sorted(tasks)],
|
|
93
|
+
job_parameters=params,
|
|
94
|
+
latest_repair_id=old.latest_repair_id if old else None,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def simulate_job(
|
|
99
|
+
settings: Mapping[str, JsonValue],
|
|
100
|
+
*,
|
|
101
|
+
job_run_id: int,
|
|
102
|
+
job_parameters: Mapping[str, str],
|
|
103
|
+
fail: Collection[str] = (),
|
|
104
|
+
only: Collection[str] | None = None,
|
|
105
|
+
rerun: Collection[str] | None = None,
|
|
106
|
+
job_id: int = 1,
|
|
107
|
+
start_ms: int = START_MS,
|
|
108
|
+
client: FakeDatabricks | None = None,
|
|
109
|
+
) -> SimRun:
|
|
110
|
+
"""Run the job once.
|
|
111
|
+
|
|
112
|
+
`fail` names tasks that die before they run anything (a lost cluster): exit 1, no attempt
|
|
113
|
+
file. `only` runs just those tasks; the others are EXCLUDED. `rerun` is a repair: only
|
|
114
|
+
those tasks run again, the others keep what an earlier call did. With `client`, the task
|
|
115
|
+
states of the run are kept in the fake as they happen, and finalize reads them from it; without
|
|
116
|
+
one finalize finds no connection and runs without states.
|
|
117
|
+
"""
|
|
118
|
+
job: dict[str, Any] = dict(settings)
|
|
119
|
+
tasks: dict[str, Mapping[str, Any]] = {t["task_key"]: t for t in job["tasks"]}
|
|
120
|
+
params: dict[str, str] = {}
|
|
121
|
+
for item in job.get("parameters") or []:
|
|
122
|
+
params[item["name"]] = item["default"]
|
|
123
|
+
params.update(job_parameters)
|
|
124
|
+
|
|
125
|
+
run = SimRun(job_run_id)
|
|
126
|
+
factory = _no_client if client is None else (lambda: client)
|
|
127
|
+
for position, key in enumerate(_order(tasks), start=1):
|
|
128
|
+
task = tasks[key]
|
|
129
|
+
if only is not None and key not in only:
|
|
130
|
+
run.task_states[key] = "EXCLUDED"
|
|
131
|
+
continue
|
|
132
|
+
if rerun is not None and key not in rerun:
|
|
133
|
+
continue
|
|
134
|
+
if client is not None:
|
|
135
|
+
_record(client, run, job_id, params)
|
|
136
|
+
seen = [
|
|
137
|
+
run.task_states.get(d["task_key"], "SUCCESS")
|
|
138
|
+
for d in task.get("depends_on", [])
|
|
139
|
+
if run.task_states.get(d["task_key"]) != "EXCLUDED"
|
|
140
|
+
]
|
|
141
|
+
run_if = task.get("run_if") or "ALL_SUCCESS"
|
|
142
|
+
if run_if not in ("ALL_SUCCESS", "ALL_DONE"):
|
|
143
|
+
raise ValueError(f"run_if {run_if} is not simulated")
|
|
144
|
+
if run_if == "ALL_SUCCESS" and any(s != "SUCCESS" for s in seen):
|
|
145
|
+
run.task_states[key] = "UPSTREAM_FAILED"
|
|
146
|
+
continue
|
|
147
|
+
if key in fail:
|
|
148
|
+
run.task_states[key], run.exit_codes[key] = "FAILED", 1
|
|
149
|
+
continue
|
|
150
|
+
values = {
|
|
151
|
+
"job.id": str(job_id),
|
|
152
|
+
"job.run_id": str(job_run_id),
|
|
153
|
+
"job.start_time.timestamp_ms": str(start_ms),
|
|
154
|
+
"task.name": key,
|
|
155
|
+
"task.run_id": str(job_run_id * 1000 + position),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
def substitute(match: re.Match[str], values: dict[str, str] = values) -> str:
|
|
159
|
+
name = match.group(1).strip()
|
|
160
|
+
if name.startswith("job.parameters."):
|
|
161
|
+
return params.get(name.removeprefix("job.parameters."), match.group(0))
|
|
162
|
+
return values.get(name, match.group(0))
|
|
163
|
+
|
|
164
|
+
argv = [_REFERENCE.sub(substitute, p) for p in task["python_wheel_task"]["parameters"]]
|
|
165
|
+
try:
|
|
166
|
+
main(argv, task_values=_Recorder(run, key), client_factory=factory)
|
|
167
|
+
code = 0
|
|
168
|
+
except SystemExit as stop:
|
|
169
|
+
code = stop.code if isinstance(stop.code, int) else 0
|
|
170
|
+
run.exit_codes[key] = code
|
|
171
|
+
run.task_states[key] = "SUCCESS" if code == 0 else "FAILED"
|
|
172
|
+
if client is not None:
|
|
173
|
+
_record(client, run, job_id, params)
|
|
174
|
+
done = client.runs[job_run_id]
|
|
175
|
+
bad = any(t.result_state not in ("SUCCESS", "EXCLUDED") for t in done.tasks)
|
|
176
|
+
client.runs[job_run_id] = replace(
|
|
177
|
+
done, life_cycle_state="TERMINATED", result_state="FAILED" if bad else "SUCCESS"
|
|
178
|
+
)
|
|
179
|
+
return run
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Unity Catalog: `catalog.schema.name` tables and views through the Spark session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pipeloom.engine.types import CatalogCapabilities, Namespace
|
|
6
|
+
from pipeloom_spark.catalog import SparkSessionCatalog
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UnityCatalog(SparkSessionCatalog):
|
|
10
|
+
"""Always three-part, backticked identifiers. A target whose naming left the catalog empty
|
|
11
|
+
gets the catalog from the options."""
|
|
12
|
+
|
|
13
|
+
capabilities = CatalogCapabilities(views=True)
|
|
14
|
+
|
|
15
|
+
def __init__(self, catalog: str) -> None:
|
|
16
|
+
super().__init__(catalog)
|
|
17
|
+
|
|
18
|
+
def ensure_namespace(self, ns: Namespace) -> None:
|
|
19
|
+
# CREATE SCHEMA IF NOT EXISTS needs CREATE SCHEMA on the catalog even when the schema
|
|
20
|
+
# exists, so a principal that only holds USE SCHEMA must not run it.
|
|
21
|
+
if self.spark.catalog.databaseExists(self._parts(ns.catalog, ns.schema)):
|
|
22
|
+
return
|
|
23
|
+
super().ensure_namespace(ns)
|