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,396 @@
|
|
|
1
|
+
"""Render a plan as a job (create, reset, bundle) or as a one-time run (runs/submit).
|
|
2
|
+
|
|
3
|
+
Pure: the same plan and options give the same settings, and nothing here reaches a workspace.
|
|
4
|
+
Problems go to the `Report` (PLD001, PLD003, PLD004, PLD005) and the function returns None.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import importlib.metadata
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
from typing import Final
|
|
16
|
+
|
|
17
|
+
from pydantic import JsonValue, ValidationError
|
|
18
|
+
|
|
19
|
+
from pipeloom.diagnostics import Report
|
|
20
|
+
from pipeloom.plan.manifest import canonical_json
|
|
21
|
+
from pipeloom.plan.model import Plan
|
|
22
|
+
from pipeloom_databricks import keys
|
|
23
|
+
from pipeloom_databricks.grouping import TaskUnit, build_tasks
|
|
24
|
+
from pipeloom_databricks.jobs_model import (
|
|
25
|
+
EnvironmentSpec,
|
|
26
|
+
JobCluster,
|
|
27
|
+
JobEnvironment,
|
|
28
|
+
JobParameter,
|
|
29
|
+
JobSettings,
|
|
30
|
+
PythonWheelTask,
|
|
31
|
+
QueueSettings,
|
|
32
|
+
SubmitSpec,
|
|
33
|
+
Task,
|
|
34
|
+
TaskDependency,
|
|
35
|
+
to_json,
|
|
36
|
+
)
|
|
37
|
+
from pipeloom_databricks.options import (
|
|
38
|
+
DatabricksDeployOptions,
|
|
39
|
+
JobClusterCompute,
|
|
40
|
+
ServerlessCompute,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
PACKAGE_NAME: Final = "pipeloom_databricks"
|
|
44
|
+
ENTRY_POINT: Final = "pipeloom_databricks_task"
|
|
45
|
+
OWNED_SETTINGS: Final = (
|
|
46
|
+
"tasks",
|
|
47
|
+
"job_clusters",
|
|
48
|
+
"environments",
|
|
49
|
+
"parameters",
|
|
50
|
+
"max_concurrent_runs",
|
|
51
|
+
"queue",
|
|
52
|
+
"format",
|
|
53
|
+
"git_source",
|
|
54
|
+
"deployment",
|
|
55
|
+
"name",
|
|
56
|
+
)
|
|
57
|
+
"""Job keys pipeloom sets itself. `settings` may not carry them."""
|
|
58
|
+
TAG_PREFIX: Final = "pipeloom_"
|
|
59
|
+
MAX_ENVIRONMENTS: Final = 10
|
|
60
|
+
FINALIZE_TIMEOUT: Final = 1800
|
|
61
|
+
TIMEOUT_MARGIN: Final = 300
|
|
62
|
+
_ABSOLUTE = re.compile(r"^(/|[A-Za-z][A-Za-z0-9+.-]+:/)")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Locations:
|
|
67
|
+
"""Where the rendered tasks find the project, and what the target says about the runtime."""
|
|
68
|
+
|
|
69
|
+
project_root: str
|
|
70
|
+
"""The project directory as the task sees it (a workspace or volume path)."""
|
|
71
|
+
manifest_path: str
|
|
72
|
+
"""The stable manifest shipped with the project, as the task sees it."""
|
|
73
|
+
artifacts_root: str | None
|
|
74
|
+
"""The target's `artifacts.root`; PLD004 when unset or relative."""
|
|
75
|
+
state_shared: bool
|
|
76
|
+
"""Whether the target's state store is shared between machines; PLD004 when not."""
|
|
77
|
+
cancel_grace: int = 30
|
|
78
|
+
"""The target's `cancel_grace` in seconds, added to a model timeout."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class RenderedJob:
|
|
83
|
+
settings: JobSettings
|
|
84
|
+
job_tag: str
|
|
85
|
+
"""The value of the `pipeloom_job` tag: `<project>.<pipeline>.<target>`."""
|
|
86
|
+
settings_hash: str
|
|
87
|
+
tasks: tuple[TaskUnit, ...]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class _Parts:
|
|
92
|
+
opts: DatabricksDeployOptions
|
|
93
|
+
units: tuple[TaskUnit, ...]
|
|
94
|
+
tasks: tuple[Task, ...]
|
|
95
|
+
environments: tuple[JobEnvironment, ...]
|
|
96
|
+
clusters: tuple[JobCluster, ...]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class _Run:
|
|
101
|
+
"""The arguments that carry the run, as a job and as a one-time run spell them."""
|
|
102
|
+
|
|
103
|
+
run_id: str
|
|
104
|
+
full_refresh: str
|
|
105
|
+
job: tuple[str, ...]
|
|
106
|
+
start_ms: str
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_JOB_RUN: Final = _Run(
|
|
110
|
+
"{{job.parameters.pipeloom_run_id}}",
|
|
111
|
+
"{{job.parameters.full_refresh}}",
|
|
112
|
+
("--job-id={{job.id}}", "--job-run-id={{job.run_id}}"),
|
|
113
|
+
"{{job.start_time.timestamp_ms}}",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def deploy_options(plan: Plan, report: Report) -> DatabricksDeployOptions | None:
|
|
118
|
+
"""The plan's deploy options, or None with PLD004."""
|
|
119
|
+
raw = plan.metadata.deploy
|
|
120
|
+
if raw is None or raw.get("kind") != "databricks":
|
|
121
|
+
report.add("PLD004", "the target has no databricks deploy block")
|
|
122
|
+
return None
|
|
123
|
+
if "${secret:" in json.dumps(raw):
|
|
124
|
+
report.add(
|
|
125
|
+
"PLD004",
|
|
126
|
+
"the deploy block holds a ${secret:...} reference, which a rendered job cannot resolve",
|
|
127
|
+
hint="put the secret in the project's plugins or the job's own configuration",
|
|
128
|
+
)
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
return DatabricksDeployOptions.model_validate({k: v for k, v in raw.items() if k != "kind"})
|
|
132
|
+
except ValidationError as err:
|
|
133
|
+
report.add("PLD004", f"invalid deploy options: {err.errors()[0]['msg']}")
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def settings_hash(settings: Mapping[str, JsonValue]) -> str:
|
|
138
|
+
"""sha256 of the canonical settings without the `pipeloom_settings_hash` tag."""
|
|
139
|
+
doc = dict(settings)
|
|
140
|
+
tags = doc.get("tags")
|
|
141
|
+
if isinstance(tags, dict):
|
|
142
|
+
doc["tags"] = {k: v for k, v in tags.items() if k != "pipeloom_settings_hash"}
|
|
143
|
+
return hashlib.sha256(canonical_json(doc)).hexdigest()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def job_name(plan: Plan, pipeline: str) -> str:
|
|
147
|
+
return f"pipeloom_{plan.metadata.project}_{pipeline}_{plan.metadata.target}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _check_options(opts: DatabricksDeployOptions, locs: Locations, report: Report) -> None:
|
|
151
|
+
def bad(message: str, hint: str | None = None) -> None:
|
|
152
|
+
report.add("PLD004", message, hint=hint)
|
|
153
|
+
|
|
154
|
+
for name, compute in sorted(opts.compute.items()):
|
|
155
|
+
if isinstance(compute, ServerlessCompute) and compute.environment_version == "1":
|
|
156
|
+
bad(
|
|
157
|
+
f"compute '{name}' uses serverless environment version 1 (Python 3.10)",
|
|
158
|
+
"use environment_version 2 or later",
|
|
159
|
+
)
|
|
160
|
+
wanted = [opts.default_compute, *(r.compute for r in opts.compute_rules)]
|
|
161
|
+
for name in sorted({n for n in wanted if n not in opts.compute}):
|
|
162
|
+
bad(f"compute '{name}' is not defined", f"compute: {', '.join(sorted(opts.compute))}")
|
|
163
|
+
for key in sorted(set(opts.settings) & set(OWNED_SETTINGS)):
|
|
164
|
+
bad(f"settings.{key} is set by pipeloom", "remove it from settings")
|
|
165
|
+
tags = opts.settings.get("tags", {})
|
|
166
|
+
if not isinstance(tags, dict):
|
|
167
|
+
bad("settings.tags must be a mapping")
|
|
168
|
+
else:
|
|
169
|
+
for key in sorted(k for k in tags if k.startswith(TAG_PREFIX)):
|
|
170
|
+
bad(f"settings.tags.{key}: the {TAG_PREFIX} prefix is reserved")
|
|
171
|
+
if locs.artifacts_root is None or not _ABSOLUTE.match(locs.artifacts_root):
|
|
172
|
+
bad(
|
|
173
|
+
"the target's artifacts.root is unset or relative",
|
|
174
|
+
"set an absolute path, such as a /Volumes path, that every task can write",
|
|
175
|
+
)
|
|
176
|
+
if not locs.state_shared:
|
|
177
|
+
bad(
|
|
178
|
+
"the target's state store is not shared between machines",
|
|
179
|
+
"use a shared state store, such as the table store",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _own_version(plan: Plan) -> str:
|
|
184
|
+
try:
|
|
185
|
+
return importlib.metadata.version("pipeloom-databricks")
|
|
186
|
+
except importlib.metadata.PackageNotFoundError:
|
|
187
|
+
return plan.metadata.pipeloom_version
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _pins(plan: Plan, versions: Mapping[str, str] | None) -> dict[str, str]:
|
|
191
|
+
"""Distribution -> version the job installs: pipeloom, this renderer and every plugin."""
|
|
192
|
+
pins = {"pipeloom": plan.metadata.pipeloom_version, "pipeloom-databricks": _own_version(plan)}
|
|
193
|
+
for info in plan.metadata.plugins.values():
|
|
194
|
+
if info.distribution and info.version:
|
|
195
|
+
pins.setdefault(info.distribution, info.version)
|
|
196
|
+
pins.update(versions or {})
|
|
197
|
+
return dict(sorted(pins.items()))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _args(
|
|
201
|
+
plan: Plan, pipeline: str, locs: Locations, run: _Run, command: str, selector: str | None
|
|
202
|
+
) -> tuple[str, ...]:
|
|
203
|
+
out = [
|
|
204
|
+
command,
|
|
205
|
+
f"--project-dir={locs.project_root}",
|
|
206
|
+
f"--manifest={locs.manifest_path}",
|
|
207
|
+
f"--target={plan.metadata.target}",
|
|
208
|
+
f"--pipeline={pipeline}",
|
|
209
|
+
]
|
|
210
|
+
if selector is not None:
|
|
211
|
+
out.append(selector)
|
|
212
|
+
out += [
|
|
213
|
+
f"--plan-hash={plan.metadata.plan_hash}",
|
|
214
|
+
f"--run-id={run.run_id}",
|
|
215
|
+
f"--full-refresh={run.full_refresh}",
|
|
216
|
+
*run.job,
|
|
217
|
+
]
|
|
218
|
+
if selector is not None:
|
|
219
|
+
out += ["--task-key={{task.name}}", "--task-run-id={{task.run_id}}"]
|
|
220
|
+
out.append(f"--start-ms={run.start_ms}")
|
|
221
|
+
return tuple(out)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _assemble(
|
|
225
|
+
plan: Plan,
|
|
226
|
+
pipeline: str,
|
|
227
|
+
locs: Locations,
|
|
228
|
+
report: Report,
|
|
229
|
+
versions: Mapping[str, str] | None,
|
|
230
|
+
run: _Run,
|
|
231
|
+
*,
|
|
232
|
+
ephemeral: bool,
|
|
233
|
+
) -> _Parts | None:
|
|
234
|
+
opts = deploy_options(plan, report)
|
|
235
|
+
if opts is None:
|
|
236
|
+
return None
|
|
237
|
+
before = report.error_count
|
|
238
|
+
_check_options(opts, locs, report)
|
|
239
|
+
if report.error_count > before:
|
|
240
|
+
return None
|
|
241
|
+
units = build_tasks(plan, opts, pipeline, report)
|
|
242
|
+
if report.error_count > before:
|
|
243
|
+
return None
|
|
244
|
+
used = sorted({u.compute for u in units} | {opts.default_compute})
|
|
245
|
+
serverless = [c for c in used if isinstance(opts.compute[c], ServerlessCompute)]
|
|
246
|
+
if len(serverless) > MAX_ENVIRONMENTS:
|
|
247
|
+
report.add(
|
|
248
|
+
"PLD004",
|
|
249
|
+
f"the job would use {len(serverless)} serverless environments, the limit is "
|
|
250
|
+
f"{MAX_ENVIRONMENTS}",
|
|
251
|
+
hint="use fewer computes in this pipeline",
|
|
252
|
+
)
|
|
253
|
+
if ephemeral and len(serverless) < len(used):
|
|
254
|
+
report.add(
|
|
255
|
+
"PLD004",
|
|
256
|
+
"an ephemeral run cannot use a job cluster",
|
|
257
|
+
hint="use serverless compute, or deploy the job",
|
|
258
|
+
)
|
|
259
|
+
if report.error_count > before:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
pins = [f"{name}=={version}" for name, version in _pins(plan, versions).items()]
|
|
263
|
+
environments: list[JobEnvironment] = []
|
|
264
|
+
clusters: list[JobCluster] = []
|
|
265
|
+
for name in used:
|
|
266
|
+
compute = opts.compute[name]
|
|
267
|
+
if isinstance(compute, ServerlessCompute):
|
|
268
|
+
deps = tuple(compute.dependencies) if compute.dependencies is not None else tuple(pins)
|
|
269
|
+
spec = EnvironmentSpec(compute.environment_version, deps)
|
|
270
|
+
environments.append(JobEnvironment(f"pipeloom_{name}", spec))
|
|
271
|
+
elif not ephemeral:
|
|
272
|
+
clusters.append(JobCluster(f"pipeloom_{name}", compute.new_cluster))
|
|
273
|
+
|
|
274
|
+
def place(
|
|
275
|
+
compute_key: str,
|
|
276
|
+
) -> tuple[str | None, str | None, tuple[Mapping[str, JsonValue], ...]]:
|
|
277
|
+
compute = opts.compute[compute_key]
|
|
278
|
+
if isinstance(compute, JobClusterCompute):
|
|
279
|
+
libs = compute.libraries
|
|
280
|
+
if libs is None:
|
|
281
|
+
libs = [{"pypi": {"package": p}} for p in pins]
|
|
282
|
+
return None, f"pipeloom_{compute_key}", tuple(libs)
|
|
283
|
+
return f"pipeloom_{compute_key}", None, ()
|
|
284
|
+
|
|
285
|
+
tasks: list[Task] = []
|
|
286
|
+
for unit in units:
|
|
287
|
+
env_key, cluster_key, libraries = place(unit.compute)
|
|
288
|
+
configs = [plan.nodes[plan.units[u].nodes[-1]].config for u in unit.units]
|
|
289
|
+
retries = max(c.retries for c in configs)
|
|
290
|
+
timeouts = [c.timeout for c in configs]
|
|
291
|
+
selector = (
|
|
292
|
+
f"--unit={unit.units[0]}" if opts.task_grouping == "none" else f"--group={unit.key}"
|
|
293
|
+
)
|
|
294
|
+
tasks.append(
|
|
295
|
+
Task(
|
|
296
|
+
task_key=unit.key,
|
|
297
|
+
depends_on=tuple(TaskDependency(p) for p in unit.parents),
|
|
298
|
+
run_if=None,
|
|
299
|
+
environment_key=env_key,
|
|
300
|
+
job_cluster_key=cluster_key,
|
|
301
|
+
libraries=libraries,
|
|
302
|
+
python_wheel_task=PythonWheelTask(
|
|
303
|
+
PACKAGE_NAME, ENTRY_POINT, _args(plan, pipeline, locs, run, "unit", selector)
|
|
304
|
+
),
|
|
305
|
+
max_retries=retries,
|
|
306
|
+
min_retry_interval_millis=(
|
|
307
|
+
max(c.retry_delay for c in configs) * 1000 if retries else None
|
|
308
|
+
),
|
|
309
|
+
timeout_seconds=(
|
|
310
|
+
sum(t + locs.cancel_grace for t in timeouts if t) + TIMEOUT_MARGIN
|
|
311
|
+
if all(timeouts)
|
|
312
|
+
else None
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
waits = {p for u in units for p in u.parents}
|
|
317
|
+
env_key, cluster_key, libraries = place(opts.default_compute)
|
|
318
|
+
tasks.append(
|
|
319
|
+
Task(
|
|
320
|
+
task_key=keys.FINALIZE_KEY,
|
|
321
|
+
depends_on=tuple(TaskDependency(u.key) for u in units if u.key not in waits),
|
|
322
|
+
run_if="ALL_DONE",
|
|
323
|
+
environment_key=env_key,
|
|
324
|
+
job_cluster_key=cluster_key,
|
|
325
|
+
libraries=libraries,
|
|
326
|
+
python_wheel_task=PythonWheelTask(
|
|
327
|
+
PACKAGE_NAME, ENTRY_POINT, _args(plan, pipeline, locs, run, "finalize", None)
|
|
328
|
+
),
|
|
329
|
+
max_retries=1,
|
|
330
|
+
min_retry_interval_millis=None,
|
|
331
|
+
timeout_seconds=FINALIZE_TIMEOUT,
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
return _Parts(opts, units, tuple(tasks), tuple(environments), tuple(clusters))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def render_job(
|
|
338
|
+
plan: Plan,
|
|
339
|
+
pipeline: str,
|
|
340
|
+
locs: Locations,
|
|
341
|
+
report: Report,
|
|
342
|
+
*,
|
|
343
|
+
versions: Mapping[str, str] | None = None,
|
|
344
|
+
) -> RenderedJob | None:
|
|
345
|
+
"""The job of one pipeline, or None with the problems in `report`.
|
|
346
|
+
|
|
347
|
+
`versions` overrides the version pinned for a distribution; a test uses it to keep goldens
|
|
348
|
+
stable across releases.
|
|
349
|
+
"""
|
|
350
|
+
parts = _assemble(plan, pipeline, locs, report, versions, _JOB_RUN, ephemeral=False)
|
|
351
|
+
if parts is None:
|
|
352
|
+
return None
|
|
353
|
+
meta = plan.metadata
|
|
354
|
+
job_tag = f"{meta.project}.{pipeline}.{meta.target}"
|
|
355
|
+
user_tags = parts.opts.settings.get("tags", {})
|
|
356
|
+
assert isinstance(user_tags, dict) # checked by _check_options
|
|
357
|
+
tags = {
|
|
358
|
+
**{k: str(v) for k, v in user_tags.items()},
|
|
359
|
+
"pipeloom_job": job_tag,
|
|
360
|
+
"pipeloom_plan_hash": meta.plan_hash,
|
|
361
|
+
"pipeloom_version": _pins(plan, versions)["pipeloom"],
|
|
362
|
+
}
|
|
363
|
+
settings = JobSettings(
|
|
364
|
+
name=job_name(plan, pipeline),
|
|
365
|
+
tags=dict(sorted(tags.items())),
|
|
366
|
+
parameters=(JobParameter("pipeloom_run_id", ""), JobParameter("full_refresh", "false")),
|
|
367
|
+
max_concurrent_runs=1,
|
|
368
|
+
queue=QueueSettings(),
|
|
369
|
+
environments=parts.environments,
|
|
370
|
+
job_clusters=parts.clusters,
|
|
371
|
+
tasks=parts.tasks,
|
|
372
|
+
extra={k: v for k, v in parts.opts.settings.items() if k != "tags"},
|
|
373
|
+
)
|
|
374
|
+
digest = settings_hash(to_json(settings))
|
|
375
|
+
tags["pipeloom_settings_hash"] = digest
|
|
376
|
+
settings = replace(settings, tags=dict(sorted(tags.items())))
|
|
377
|
+
return RenderedJob(settings, job_tag, digest, parts.units)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def render_submit(
|
|
381
|
+
plan: Plan,
|
|
382
|
+
pipeline: str,
|
|
383
|
+
locs: Locations,
|
|
384
|
+
report: Report,
|
|
385
|
+
*,
|
|
386
|
+
run_id: str,
|
|
387
|
+
full_refresh: bool,
|
|
388
|
+
start_ms: int,
|
|
389
|
+
versions: Mapping[str, str] | None = None,
|
|
390
|
+
) -> SubmitSpec | None:
|
|
391
|
+
"""The body of a one-time run: serverless only, the run id and start given literally."""
|
|
392
|
+
run = _Run(run_id, "true" if full_refresh else "false", (), str(start_ms))
|
|
393
|
+
parts = _assemble(plan, pipeline, locs, report, versions, run, ephemeral=True)
|
|
394
|
+
if parts is None:
|
|
395
|
+
return None
|
|
396
|
+
return SubmitSpec(job_name(plan, pipeline), parts.tasks, parts.environments, QueueSettings())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Repair a finished job run: rerun the tasks that did not succeed, and finalize again."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from pipeloom.diagnostics import Report
|
|
8
|
+
from pipeloom.secrets.redaction import Redactor
|
|
9
|
+
from pipeloom_databricks.client import DatabricksClient, RunInfo
|
|
10
|
+
from pipeloom_databricks.deploy import api_errors
|
|
11
|
+
from pipeloom_databricks.keys import FINALIZE_KEY
|
|
12
|
+
from pipeloom_databricks.run import TERMINAL
|
|
13
|
+
|
|
14
|
+
REPAIRABLE: Final = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"FAILED",
|
|
17
|
+
"TIMEDOUT",
|
|
18
|
+
"CANCELED",
|
|
19
|
+
"UPSTREAM_FAILED",
|
|
20
|
+
"UPSTREAM_CANCELED",
|
|
21
|
+
"MAXIMUM_CONCURRENT_RUNS_REACHED",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
"""Task result states a repair runs again. SUCCESS and EXCLUDED tasks keep their result."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def rerun_set(run: RunInfo) -> list[str]:
|
|
28
|
+
"""The tasks to run again, sorted: those in a repairable state, plus finalize when anything
|
|
29
|
+
else is repaired. Empty when nothing failed."""
|
|
30
|
+
failed = {t.task_key for t in run.tasks if t.result_state in REPAIRABLE}
|
|
31
|
+
if not failed:
|
|
32
|
+
return []
|
|
33
|
+
if any(t.task_key == FINALIZE_KEY for t in run.tasks):
|
|
34
|
+
failed.add(FINALIZE_KEY)
|
|
35
|
+
return sorted(failed)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def repair(
|
|
39
|
+
client: DatabricksClient,
|
|
40
|
+
job_run_id: int,
|
|
41
|
+
report: Report,
|
|
42
|
+
*,
|
|
43
|
+
redactor: Redactor | None = None,
|
|
44
|
+
) -> int | None:
|
|
45
|
+
"""Repair job run `job_run_id` and return the repair id.
|
|
46
|
+
|
|
47
|
+
None with no error in `report` means there was nothing to repair; None with PLD014 means the
|
|
48
|
+
run is still active; None with PLD009 means an API call failed.
|
|
49
|
+
"""
|
|
50
|
+
with api_errors(report, redactor):
|
|
51
|
+
run = client.get_run(job_run_id)
|
|
52
|
+
if run.life_cycle_state not in TERMINAL:
|
|
53
|
+
report.add(
|
|
54
|
+
"PLD014",
|
|
55
|
+
f"run {job_run_id} is still {run.life_cycle_state}",
|
|
56
|
+
hint="wait for it to end, or cancel it, then repair",
|
|
57
|
+
context={"job_run_id": job_run_id, "state": run.life_cycle_state},
|
|
58
|
+
)
|
|
59
|
+
return None
|
|
60
|
+
rerun = rerun_set(run)
|
|
61
|
+
if not rerun:
|
|
62
|
+
return None
|
|
63
|
+
return client.repair_run(
|
|
64
|
+
job_run_id, rerun_tasks=rerun, latest_repair_id=run.latest_repair_id
|
|
65
|
+
)
|
|
66
|
+
return None
|