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