taskferry-cloudrun 0.2.0__tar.gz → 0.4.0__tar.gz
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.
- taskferry_cloudrun-0.4.0/CHANGELOG.md +32 -0
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.4.0}/PKG-INFO +1 -1
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.4.0}/pyproject.toml +1 -1
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.4.0}/src/taskferry_cloudrun/backend.py +236 -12
- taskferry_cloudrun-0.2.0/CHANGELOG.md +0 -12
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.4.0}/.gitignore +0 -0
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.4.0}/LICENSE +0 -0
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.4.0}/README.md +0 -0
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.4.0}/src/taskferry_cloudrun/__init__.py +0 -0
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.4.0}/src/taskferry_cloudrun/py.typed +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `taskferry-cloudrun` are documented here.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
|
5
|
+
this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.4.0] — 2026-09-19
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Native async** submit, state and cancel via the run_v2 async clients
|
|
12
|
+
(`JobsAsyncClient`, `ExecutionsAsyncClient`). `asubmit`/`aget`/`acancel` use
|
|
13
|
+
them when available, skipping the worker thread, and fall back to the thread
|
|
14
|
+
path when the SDK is absent. No new dependency — the async clients ship with
|
|
15
|
+
`google-cloud-run`.
|
|
16
|
+
|
|
17
|
+
## [0.3.0] — 2026-09-19
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Live log streaming** through Cloud Logging. `stream_logs` pages an execution's
|
|
22
|
+
log entries in timestamp order, de-duplicating by insert id, and follows them
|
|
23
|
+
until the execution reaches a terminal state; `logs` returns the whole output
|
|
24
|
+
once. `logs_uri` (the console link) is unchanged.
|
|
25
|
+
- `CloudRunJobBackend` accepts an injected `logging_client`.
|
|
26
|
+
|
|
27
|
+
## [0.2.0] — 2026-07-26
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Initial release, extracted from the Django-coupled backends of `taskferry-django` 0.1
|
|
32
|
+
and rebuilt against the framework-agnostic Taskferry ports.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: taskferry-cloudrun
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Google Cloud Run Jobs backend for Taskferry — serverless batch workloads.
|
|
5
5
|
Project-URL: Homepage, https://github.com/xiidigital/taskferry
|
|
6
6
|
Project-URL: Documentation, https://taskferry.dev
|
|
@@ -37,6 +37,8 @@ Route GPU work at a backend that really allocates GPUs.
|
|
|
37
37
|
|
|
38
38
|
from __future__ import annotations
|
|
39
39
|
|
|
40
|
+
import time
|
|
41
|
+
from collections.abc import Iterator
|
|
40
42
|
from datetime import UTC, datetime
|
|
41
43
|
from typing import Any
|
|
42
44
|
|
|
@@ -138,6 +140,9 @@ class CloudRunJobBackend(BaseBackend):
|
|
|
138
140
|
location: str | None = None,
|
|
139
141
|
jobs_client: Any = None,
|
|
140
142
|
executions_client: Any = None,
|
|
143
|
+
jobs_async_client: Any = None,
|
|
144
|
+
executions_async_client: Any = None,
|
|
145
|
+
logging_client: Any = None,
|
|
141
146
|
name: str = "cloudrun",
|
|
142
147
|
tracked_ids: int = 10_000,
|
|
143
148
|
) -> None:
|
|
@@ -150,6 +155,9 @@ class CloudRunJobBackend(BaseBackend):
|
|
|
150
155
|
self._location = location
|
|
151
156
|
self._jobs_client = jobs_client
|
|
152
157
|
self._executions_client = executions_client
|
|
158
|
+
self._jobs_async_client = jobs_async_client
|
|
159
|
+
self._executions_async_client = executions_async_client
|
|
160
|
+
self._logging_client = logging_client
|
|
153
161
|
self._name = name
|
|
154
162
|
# Cloud Run names executions itself, so remember which of its names goes
|
|
155
163
|
# with which Taskferry id; a full resource path is accepted directly.
|
|
@@ -186,6 +194,23 @@ class CloudRunJobBackend(BaseBackend):
|
|
|
186
194
|
self._executions_client = _run_v2().ExecutionsClient()
|
|
187
195
|
return self._executions_client
|
|
188
196
|
|
|
197
|
+
def _logging(self) -> Any:
|
|
198
|
+
if self._logging_client is None:
|
|
199
|
+
self._logging_client = _logging_v2().Client(project=self._project)
|
|
200
|
+
return self._logging_client
|
|
201
|
+
|
|
202
|
+
def _async_jobs(self) -> Any:
|
|
203
|
+
"""The async Jobs client: injected, lazily built, or ``None`` if the SDK
|
|
204
|
+
is absent (the caller then falls back to the thread path)."""
|
|
205
|
+
if self._jobs_async_client is None and _has_run_v2():
|
|
206
|
+
self._jobs_async_client = _run_v2().JobsAsyncClient()
|
|
207
|
+
return self._jobs_async_client
|
|
208
|
+
|
|
209
|
+
def _async_executions(self) -> Any:
|
|
210
|
+
if self._executions_async_client is None and _has_run_v2():
|
|
211
|
+
self._executions_async_client = _run_v2().ExecutionsAsyncClient()
|
|
212
|
+
return self._executions_async_client
|
|
213
|
+
|
|
189
214
|
# -- resource names ----------------------------------------------------------- #
|
|
190
215
|
def job_resource(self, spec: JobSpec) -> str:
|
|
191
216
|
"""Fully-qualified Cloud Run Job resource this spec targets."""
|
|
@@ -195,25 +220,28 @@ class CloudRunJobBackend(BaseBackend):
|
|
|
195
220
|
return f"projects/{self._project}/locations/{self._location}/jobs/{spec.job}"
|
|
196
221
|
|
|
197
222
|
# -- submission ---------------------------------------------------------------- #
|
|
198
|
-
def
|
|
199
|
-
assert isinstance(spec, JobSpec)
|
|
223
|
+
def _submit_request(self, spec: JobSpec) -> dict[str, Any]:
|
|
200
224
|
request: dict[str, Any] = {"name": self.job_resource(spec)}
|
|
201
225
|
overrides = self._overrides(spec)
|
|
202
226
|
if overrides:
|
|
203
227
|
request["overrides"] = overrides
|
|
228
|
+
return request
|
|
204
229
|
|
|
230
|
+
def _submit(self, spec: ExecutionSpec) -> Execution:
|
|
231
|
+
assert isinstance(spec, JobSpec)
|
|
205
232
|
try:
|
|
206
|
-
operation = self._jobs().run_job(request=
|
|
233
|
+
operation = self._jobs().run_job(request=self._submit_request(spec))
|
|
207
234
|
except Exception as exc:
|
|
208
235
|
raise SubmissionError(
|
|
209
236
|
f"Cloud Run could not start job {spec.job!r} in {self._location}: {exc}",
|
|
210
237
|
backend=self._name,
|
|
211
238
|
) from exc
|
|
239
|
+
return self._execution_from_operation(spec, operation)
|
|
212
240
|
|
|
241
|
+
def _execution_from_operation(self, spec: JobSpec, operation: Any) -> Execution:
|
|
213
242
|
external_id = _execution_name(operation)
|
|
214
243
|
execution_id = new_execution_id(ExecutionKind.JOB)
|
|
215
244
|
self._ids.remember(str(execution_id), external_id)
|
|
216
|
-
|
|
217
245
|
return Execution(
|
|
218
246
|
id=execution_id,
|
|
219
247
|
kind=ExecutionKind.JOB,
|
|
@@ -255,7 +283,7 @@ class CloudRunJobBackend(BaseBackend):
|
|
|
255
283
|
return overrides
|
|
256
284
|
|
|
257
285
|
# -- observation ------------------------------------------------------------------ #
|
|
258
|
-
def
|
|
286
|
+
def _resolve_external_id(self, execution_id: ExecutionId) -> str:
|
|
259
287
|
external_id = self._ids.resolve(str(execution_id))
|
|
260
288
|
if external_id is None:
|
|
261
289
|
raise ExecutionNotFound(
|
|
@@ -264,17 +292,28 @@ class CloudRunJobBackend(BaseBackend):
|
|
|
264
292
|
"from another process",
|
|
265
293
|
backend=self._name,
|
|
266
294
|
)
|
|
295
|
+
return external_id
|
|
296
|
+
|
|
297
|
+
def _get(self, execution_id: ExecutionId) -> Execution:
|
|
298
|
+
external_id = self._resolve_external_id(execution_id)
|
|
267
299
|
try:
|
|
268
300
|
remote = self._executions().get_execution(name=external_id)
|
|
269
301
|
except Exception as exc:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
302
|
+
raise self._read_error(external_id, exc) from exc
|
|
303
|
+
return self._execution_from_remote(execution_id, external_id, remote)
|
|
304
|
+
|
|
305
|
+
def _read_error(self, external_id: str, exc: Exception) -> Exception:
|
|
306
|
+
if _is_not_found(exc):
|
|
307
|
+
return ExecutionNotFound(
|
|
308
|
+
f"Cloud Run has no execution {external_id!r}", backend=self._name
|
|
309
|
+
)
|
|
310
|
+
return BackendError(
|
|
311
|
+
f"Cloud Run could not read execution {external_id!r}: {exc}", backend=self._name
|
|
312
|
+
)
|
|
277
313
|
|
|
314
|
+
def _execution_from_remote(
|
|
315
|
+
self, execution_id: ExecutionId, external_id: str, remote: Any
|
|
316
|
+
) -> Execution:
|
|
278
317
|
state, error = map_execution_state(remote)
|
|
279
318
|
return Execution(
|
|
280
319
|
id=execution_id,
|
|
@@ -311,6 +350,60 @@ class CloudRunJobBackend(BaseBackend):
|
|
|
311
350
|
) from exc
|
|
312
351
|
return self._get(execution_id)
|
|
313
352
|
|
|
353
|
+
# -- native async (run_v2 async clients) -------------------------------------- #
|
|
354
|
+
async def asubmit(self, spec: ExecutionSpec) -> Execution:
|
|
355
|
+
"""Native async submit via ``JobsAsyncClient``, else the thread path."""
|
|
356
|
+
client = self._async_jobs()
|
|
357
|
+
if client is None:
|
|
358
|
+
return await super().asubmit(spec)
|
|
359
|
+
assert isinstance(spec, JobSpec)
|
|
360
|
+
self.validate(spec)
|
|
361
|
+
self.hooks.before_submit(spec, self.name)
|
|
362
|
+
try:
|
|
363
|
+
operation = await client.run_job(request=self._submit_request(spec))
|
|
364
|
+
execution = self._execution_from_operation(spec, operation)
|
|
365
|
+
except Exception as exc:
|
|
366
|
+
error = SubmissionError(
|
|
367
|
+
f"Cloud Run could not start job {spec.job!r} in {self._location}: {exc}",
|
|
368
|
+
backend=self._name,
|
|
369
|
+
)
|
|
370
|
+
self.hooks.on_submit_error(spec, self.name, error)
|
|
371
|
+
raise error from exc
|
|
372
|
+
self.hooks.after_submit(spec, execution)
|
|
373
|
+
return execution
|
|
374
|
+
|
|
375
|
+
async def aget(self, execution_id: ExecutionId | str) -> Execution:
|
|
376
|
+
"""Native async state read via ``ExecutionsAsyncClient``."""
|
|
377
|
+
client = self._async_executions()
|
|
378
|
+
if client is None:
|
|
379
|
+
return await super().aget(execution_id)
|
|
380
|
+
self.capabilities.require(Capability.STATE)
|
|
381
|
+
eid = ExecutionId(str(execution_id))
|
|
382
|
+
external_id = self._resolve_external_id(eid)
|
|
383
|
+
try:
|
|
384
|
+
remote = await client.get_execution(name=external_id)
|
|
385
|
+
except Exception as exc:
|
|
386
|
+
raise self._read_error(external_id, exc) from exc
|
|
387
|
+
return self._execution_from_remote(eid, external_id, remote)
|
|
388
|
+
|
|
389
|
+
async def acancel(self, execution_id: ExecutionId | str) -> Execution:
|
|
390
|
+
"""Native async cancel via ``ExecutionsAsyncClient``."""
|
|
391
|
+
client = self._async_executions()
|
|
392
|
+
if client is None:
|
|
393
|
+
return await super().acancel(execution_id)
|
|
394
|
+
self.capabilities.require(Capability.CANCEL)
|
|
395
|
+
eid = ExecutionId(str(execution_id))
|
|
396
|
+
external_id = self._resolve_external_id(eid)
|
|
397
|
+
try:
|
|
398
|
+
await client.cancel_execution(name=external_id)
|
|
399
|
+
except Exception as exc:
|
|
400
|
+
raise BackendError(
|
|
401
|
+
f"Cloud Run could not cancel execution {external_id!r}: {exc}", backend=self._name
|
|
402
|
+
) from exc
|
|
403
|
+
execution = await self.aget(eid)
|
|
404
|
+
self.hooks.on_cancel(execution)
|
|
405
|
+
return execution
|
|
406
|
+
|
|
314
407
|
def logs_uri(self, external_id: str) -> str:
|
|
315
408
|
"""A Cloud Logging console link for an execution. No request is made."""
|
|
316
409
|
return (
|
|
@@ -318,6 +411,91 @@ class CloudRunJobBackend(BaseBackend):
|
|
|
318
411
|
f"?project={self._project}&query=resource.labels.location%3D%22{self._location}%22"
|
|
319
412
|
)
|
|
320
413
|
|
|
414
|
+
# -- logs ----------------------------------------------------------------------- #
|
|
415
|
+
def logs(self, execution_id: ExecutionId) -> str:
|
|
416
|
+
"""Read the execution's Cloud Logging output once. Requires ``Capability.LOGS``."""
|
|
417
|
+
self.capabilities.require(Capability.LOGS)
|
|
418
|
+
return "\n".join(self.stream_logs(execution_id, follow=False))
|
|
419
|
+
|
|
420
|
+
def stream_logs(
|
|
421
|
+
self,
|
|
422
|
+
execution_id: ExecutionId,
|
|
423
|
+
*,
|
|
424
|
+
follow: bool = True,
|
|
425
|
+
poll_interval: float = 2.0,
|
|
426
|
+
timeout: float | None = None,
|
|
427
|
+
) -> Iterator[str]:
|
|
428
|
+
"""Yield the execution's Cloud Logging entries, following them live by default.
|
|
429
|
+
|
|
430
|
+
Cloud Logging is queried by filter rather than truly streamed, so this
|
|
431
|
+
pages entries in timestamp order, de-duplicating by insert id, and — with
|
|
432
|
+
``follow=True`` — keeps polling for new entries until the execution reaches
|
|
433
|
+
a terminal state. With ``follow=False`` it returns what is there and stops.
|
|
434
|
+
``timeout`` bounds the total stream. Requires ``Capability.LOGS``.
|
|
435
|
+
"""
|
|
436
|
+
self.capabilities.require(Capability.LOGS)
|
|
437
|
+
external_id = self._ids.resolve(str(execution_id))
|
|
438
|
+
if external_id is None:
|
|
439
|
+
raise ExecutionNotFound(
|
|
440
|
+
f"{execution_id!r} was not submitted by this backend instance; pass the "
|
|
441
|
+
"Cloud Run execution name to stream its logs from another process",
|
|
442
|
+
backend=self._name,
|
|
443
|
+
)
|
|
444
|
+
execution_name = external_id.rsplit("/executions/", 1)[-1]
|
|
445
|
+
base_filter = (
|
|
446
|
+
'resource.type="cloud_run_job" '
|
|
447
|
+
f'AND resource.labels.location="{self._location}" '
|
|
448
|
+
f'AND labels."run.googleapis.com/execution_name"="{execution_name}"'
|
|
449
|
+
)
|
|
450
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
451
|
+
client = self._logging()
|
|
452
|
+
seen: set[str] = set()
|
|
453
|
+
since: str | None = None
|
|
454
|
+
while True:
|
|
455
|
+
log_filter = base_filter if since is None else f'{base_filter} AND timestamp>="{since}"'
|
|
456
|
+
try:
|
|
457
|
+
entries = list(
|
|
458
|
+
client.list_entries(
|
|
459
|
+
resource_names=[f"projects/{self._project}"],
|
|
460
|
+
filter_=log_filter,
|
|
461
|
+
order_by="timestamp asc",
|
|
462
|
+
page_size=1000,
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
except Exception as exc:
|
|
466
|
+
if _is_not_found(exc):
|
|
467
|
+
return
|
|
468
|
+
raise BackendError(
|
|
469
|
+
f"Cloud Run could not read logs for {external_id!r}: {exc}", backend=self._name
|
|
470
|
+
) from exc
|
|
471
|
+
new = 0
|
|
472
|
+
for entry in entries:
|
|
473
|
+
insert_id = str(getattr(entry, "insert_id", "") or "")
|
|
474
|
+
if insert_id and insert_id in seen:
|
|
475
|
+
continue
|
|
476
|
+
if insert_id:
|
|
477
|
+
seen.add(insert_id)
|
|
478
|
+
timestamp = getattr(entry, "timestamp", None)
|
|
479
|
+
if timestamp is not None:
|
|
480
|
+
since = _rfc3339(timestamp)
|
|
481
|
+
text = _entry_text(entry)
|
|
482
|
+
if text is not None:
|
|
483
|
+
yield text
|
|
484
|
+
new += 1
|
|
485
|
+
if new == 0:
|
|
486
|
+
if not follow or self._is_terminal(external_id):
|
|
487
|
+
return
|
|
488
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
489
|
+
return
|
|
490
|
+
time.sleep(poll_interval)
|
|
491
|
+
|
|
492
|
+
def _is_terminal(self, external_id: str) -> bool:
|
|
493
|
+
try:
|
|
494
|
+
remote = self._executions().get_execution(name=external_id)
|
|
495
|
+
except Exception: # a vanished execution is, for our purposes, done
|
|
496
|
+
return True
|
|
497
|
+
return map_execution_state(remote)[0].is_terminal
|
|
498
|
+
|
|
321
499
|
|
|
322
500
|
def _run_v2() -> Any:
|
|
323
501
|
"""Import ``google.cloud.run_v2`` lazily, with an actionable error."""
|
|
@@ -330,6 +508,49 @@ def _run_v2() -> Any:
|
|
|
330
508
|
return run_v2
|
|
331
509
|
|
|
332
510
|
|
|
511
|
+
def _has_run_v2() -> bool:
|
|
512
|
+
"""Whether the Cloud Run SDK is importable, without importing it."""
|
|
513
|
+
from importlib.util import find_spec
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
return find_spec("google.cloud.run_v2") is not None
|
|
517
|
+
except ModuleNotFoundError:
|
|
518
|
+
return False
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _logging_v2() -> Any:
|
|
522
|
+
"""Import ``google.cloud.logging_v2`` lazily, with an actionable error."""
|
|
523
|
+
try:
|
|
524
|
+
from google.cloud import logging_v2
|
|
525
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
526
|
+
raise ConfigurationError(
|
|
527
|
+
"the Cloud Run backend needs the Google logging SDK: "
|
|
528
|
+
"pip install 'taskferry-cloudrun[gcp]'"
|
|
529
|
+
) from exc
|
|
530
|
+
return logging_v2
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _entry_text(entry: Any) -> str | None:
|
|
534
|
+
"""Extract a line of text from a Cloud Logging entry (text or struct payload)."""
|
|
535
|
+
payload = getattr(entry, "payload", None)
|
|
536
|
+
if payload is None:
|
|
537
|
+
return None
|
|
538
|
+
if isinstance(payload, str):
|
|
539
|
+
return payload.rstrip("\n")
|
|
540
|
+
if isinstance(payload, dict):
|
|
541
|
+
message = payload.get("message")
|
|
542
|
+
return str(message if message is not None else payload).rstrip("\n")
|
|
543
|
+
return str(payload).rstrip("\n")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _rfc3339(timestamp: Any) -> str:
|
|
547
|
+
"""Format a timestamp for a Cloud Logging ``timestamp>=`` filter."""
|
|
548
|
+
if isinstance(timestamp, datetime):
|
|
549
|
+
moment = timestamp if timestamp.tzinfo else timestamp.replace(tzinfo=UTC)
|
|
550
|
+
return moment.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
551
|
+
return str(timestamp)
|
|
552
|
+
|
|
553
|
+
|
|
333
554
|
def _execution_name(operation: Any) -> str | None:
|
|
334
555
|
"""Pull the execution resource name out of the long-running operation."""
|
|
335
556
|
metadata = getattr(operation, "metadata", None)
|
|
@@ -373,6 +594,9 @@ def make_backend(**options: Any) -> CloudRunJobBackend:
|
|
|
373
594
|
location=options.get("location"),
|
|
374
595
|
jobs_client=options.get("jobs_client"),
|
|
375
596
|
executions_client=options.get("executions_client"),
|
|
597
|
+
jobs_async_client=options.get("jobs_async_client"),
|
|
598
|
+
executions_async_client=options.get("executions_async_client"),
|
|
599
|
+
logging_client=options.get("logging_client"),
|
|
376
600
|
name=str(options.get("name", "cloudrun")),
|
|
377
601
|
tracked_ids=int(options.get("tracked_ids", 10_000)),
|
|
378
602
|
)
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to `taskferry-cloudrun` are documented here.
|
|
4
|
-
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
|
5
|
-
this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
-
|
|
7
|
-
## [0.2.0] — 2026-07-26
|
|
8
|
-
|
|
9
|
-
### Added
|
|
10
|
-
|
|
11
|
-
- Initial release, extracted from the Django-coupled backends of `taskferry-django` 0.1
|
|
12
|
-
and rebuilt against the framework-agnostic Taskferry ports.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|