taskferry-cloudrun 0.3.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.
@@ -4,6 +4,16 @@ All notable changes to `taskferry-cloudrun` are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
5
5
  this project adheres to [Semantic Versioning](https://semver.org/).
6
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
+
7
17
  ## [0.3.0] — 2026-09-19
8
18
 
9
19
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskferry-cloudrun
3
- Version: 0.3.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.3.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"
@@ -140,6 +140,8 @@ class CloudRunJobBackend(BaseBackend):
140
140
  location: str | None = None,
141
141
  jobs_client: Any = None,
142
142
  executions_client: Any = None,
143
+ jobs_async_client: Any = None,
144
+ executions_async_client: Any = None,
143
145
  logging_client: Any = None,
144
146
  name: str = "cloudrun",
145
147
  tracked_ids: int = 10_000,
@@ -153,6 +155,8 @@ class CloudRunJobBackend(BaseBackend):
153
155
  self._location = location
154
156
  self._jobs_client = jobs_client
155
157
  self._executions_client = executions_client
158
+ self._jobs_async_client = jobs_async_client
159
+ self._executions_async_client = executions_async_client
156
160
  self._logging_client = logging_client
157
161
  self._name = name
158
162
  # Cloud Run names executions itself, so remember which of its names goes
@@ -195,6 +199,18 @@ class CloudRunJobBackend(BaseBackend):
195
199
  self._logging_client = _logging_v2().Client(project=self._project)
196
200
  return self._logging_client
197
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
+
198
214
  # -- resource names ----------------------------------------------------------- #
199
215
  def job_resource(self, spec: JobSpec) -> str:
200
216
  """Fully-qualified Cloud Run Job resource this spec targets."""
@@ -204,25 +220,28 @@ class CloudRunJobBackend(BaseBackend):
204
220
  return f"projects/{self._project}/locations/{self._location}/jobs/{spec.job}"
205
221
 
206
222
  # -- submission ---------------------------------------------------------------- #
207
- def _submit(self, spec: ExecutionSpec) -> Execution:
208
- assert isinstance(spec, JobSpec)
223
+ def _submit_request(self, spec: JobSpec) -> dict[str, Any]:
209
224
  request: dict[str, Any] = {"name": self.job_resource(spec)}
210
225
  overrides = self._overrides(spec)
211
226
  if overrides:
212
227
  request["overrides"] = overrides
228
+ return request
213
229
 
230
+ def _submit(self, spec: ExecutionSpec) -> Execution:
231
+ assert isinstance(spec, JobSpec)
214
232
  try:
215
- operation = self._jobs().run_job(request=request)
233
+ operation = self._jobs().run_job(request=self._submit_request(spec))
216
234
  except Exception as exc:
217
235
  raise SubmissionError(
218
236
  f"Cloud Run could not start job {spec.job!r} in {self._location}: {exc}",
219
237
  backend=self._name,
220
238
  ) from exc
239
+ return self._execution_from_operation(spec, operation)
221
240
 
241
+ def _execution_from_operation(self, spec: JobSpec, operation: Any) -> Execution:
222
242
  external_id = _execution_name(operation)
223
243
  execution_id = new_execution_id(ExecutionKind.JOB)
224
244
  self._ids.remember(str(execution_id), external_id)
225
-
226
245
  return Execution(
227
246
  id=execution_id,
228
247
  kind=ExecutionKind.JOB,
@@ -264,7 +283,7 @@ class CloudRunJobBackend(BaseBackend):
264
283
  return overrides
265
284
 
266
285
  # -- observation ------------------------------------------------------------------ #
267
- def _get(self, execution_id: ExecutionId) -> Execution:
286
+ def _resolve_external_id(self, execution_id: ExecutionId) -> str:
268
287
  external_id = self._ids.resolve(str(execution_id))
269
288
  if external_id is None:
270
289
  raise ExecutionNotFound(
@@ -273,17 +292,28 @@ class CloudRunJobBackend(BaseBackend):
273
292
  "from another process",
274
293
  backend=self._name,
275
294
  )
295
+ return external_id
296
+
297
+ def _get(self, execution_id: ExecutionId) -> Execution:
298
+ external_id = self._resolve_external_id(execution_id)
276
299
  try:
277
300
  remote = self._executions().get_execution(name=external_id)
278
301
  except Exception as exc:
279
- if _is_not_found(exc):
280
- raise ExecutionNotFound(
281
- f"Cloud Run has no execution {external_id!r}", backend=self._name
282
- ) from exc
283
- raise BackendError(
284
- f"Cloud Run could not read execution {external_id!r}: {exc}", backend=self._name
285
- ) from exc
302
+ raise self._read_error(external_id, exc) from exc
303
+ return self._execution_from_remote(execution_id, external_id, remote)
286
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
+ )
313
+
314
+ def _execution_from_remote(
315
+ self, execution_id: ExecutionId, external_id: str, remote: Any
316
+ ) -> Execution:
287
317
  state, error = map_execution_state(remote)
288
318
  return Execution(
289
319
  id=execution_id,
@@ -320,6 +350,60 @@ class CloudRunJobBackend(BaseBackend):
320
350
  ) from exc
321
351
  return self._get(execution_id)
322
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
+
323
407
  def logs_uri(self, external_id: str) -> str:
324
408
  """A Cloud Logging console link for an execution. No request is made."""
325
409
  return (
@@ -424,6 +508,16 @@ def _run_v2() -> Any:
424
508
  return run_v2
425
509
 
426
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
+
427
521
  def _logging_v2() -> Any:
428
522
  """Import ``google.cloud.logging_v2`` lazily, with an actionable error."""
429
523
  try:
@@ -500,6 +594,8 @@ def make_backend(**options: Any) -> CloudRunJobBackend:
500
594
  location=options.get("location"),
501
595
  jobs_client=options.get("jobs_client"),
502
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"),
503
599
  logging_client=options.get("logging_client"),
504
600
  name=str(options.get("name", "cloudrun")),
505
601
  tracked_ids=int(options.get("tracked_ids", 10_000)),