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.
@@ -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.2.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "taskferry-cloudrun"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Google Cloud Run Jobs backend for Taskferry — serverless batch workloads."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -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 _submit(self, spec: ExecutionSpec) -> Execution:
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=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 _get(self, execution_id: ExecutionId) -> Execution:
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
- if _is_not_found(exc):
271
- raise ExecutionNotFound(
272
- f"Cloud Run has no execution {external_id!r}", backend=self._name
273
- ) from exc
274
- raise BackendError(
275
- f"Cloud Run could not read execution {external_id!r}: {exc}", backend=self._name
276
- ) from exc
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.