taskferry-cloudrun 0.2.0__tar.gz → 0.3.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.2.0 → taskferry_cloudrun-0.3.0}/CHANGELOG.md +10 -0
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.3.0}/PKG-INFO +1 -1
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.3.0}/pyproject.toml +1 -1
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.3.0}/src/taskferry_cloudrun/backend.py +128 -0
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.3.0}/.gitignore +0 -0
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.3.0}/LICENSE +0 -0
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.3.0}/README.md +0 -0
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.3.0}/src/taskferry_cloudrun/__init__.py +0 -0
- {taskferry_cloudrun-0.2.0 → taskferry_cloudrun-0.3.0}/src/taskferry_cloudrun/py.typed +0 -0
|
@@ -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.3.0] — 2026-09-19
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Live log streaming** through Cloud Logging. `stream_logs` pages an execution's
|
|
12
|
+
log entries in timestamp order, de-duplicating by insert id, and follows them
|
|
13
|
+
until the execution reaches a terminal state; `logs` returns the whole output
|
|
14
|
+
once. `logs_uri` (the console link) is unchanged.
|
|
15
|
+
- `CloudRunJobBackend` accepts an injected `logging_client`.
|
|
16
|
+
|
|
7
17
|
## [0.2.0] — 2026-07-26
|
|
8
18
|
|
|
9
19
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: taskferry-cloudrun
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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,7 @@ class CloudRunJobBackend(BaseBackend):
|
|
|
138
140
|
location: str | None = None,
|
|
139
141
|
jobs_client: Any = None,
|
|
140
142
|
executions_client: Any = None,
|
|
143
|
+
logging_client: Any = None,
|
|
141
144
|
name: str = "cloudrun",
|
|
142
145
|
tracked_ids: int = 10_000,
|
|
143
146
|
) -> None:
|
|
@@ -150,6 +153,7 @@ class CloudRunJobBackend(BaseBackend):
|
|
|
150
153
|
self._location = location
|
|
151
154
|
self._jobs_client = jobs_client
|
|
152
155
|
self._executions_client = executions_client
|
|
156
|
+
self._logging_client = logging_client
|
|
153
157
|
self._name = name
|
|
154
158
|
# Cloud Run names executions itself, so remember which of its names goes
|
|
155
159
|
# with which Taskferry id; a full resource path is accepted directly.
|
|
@@ -186,6 +190,11 @@ class CloudRunJobBackend(BaseBackend):
|
|
|
186
190
|
self._executions_client = _run_v2().ExecutionsClient()
|
|
187
191
|
return self._executions_client
|
|
188
192
|
|
|
193
|
+
def _logging(self) -> Any:
|
|
194
|
+
if self._logging_client is None:
|
|
195
|
+
self._logging_client = _logging_v2().Client(project=self._project)
|
|
196
|
+
return self._logging_client
|
|
197
|
+
|
|
189
198
|
# -- resource names ----------------------------------------------------------- #
|
|
190
199
|
def job_resource(self, spec: JobSpec) -> str:
|
|
191
200
|
"""Fully-qualified Cloud Run Job resource this spec targets."""
|
|
@@ -318,6 +327,91 @@ class CloudRunJobBackend(BaseBackend):
|
|
|
318
327
|
f"?project={self._project}&query=resource.labels.location%3D%22{self._location}%22"
|
|
319
328
|
)
|
|
320
329
|
|
|
330
|
+
# -- logs ----------------------------------------------------------------------- #
|
|
331
|
+
def logs(self, execution_id: ExecutionId) -> str:
|
|
332
|
+
"""Read the execution's Cloud Logging output once. Requires ``Capability.LOGS``."""
|
|
333
|
+
self.capabilities.require(Capability.LOGS)
|
|
334
|
+
return "\n".join(self.stream_logs(execution_id, follow=False))
|
|
335
|
+
|
|
336
|
+
def stream_logs(
|
|
337
|
+
self,
|
|
338
|
+
execution_id: ExecutionId,
|
|
339
|
+
*,
|
|
340
|
+
follow: bool = True,
|
|
341
|
+
poll_interval: float = 2.0,
|
|
342
|
+
timeout: float | None = None,
|
|
343
|
+
) -> Iterator[str]:
|
|
344
|
+
"""Yield the execution's Cloud Logging entries, following them live by default.
|
|
345
|
+
|
|
346
|
+
Cloud Logging is queried by filter rather than truly streamed, so this
|
|
347
|
+
pages entries in timestamp order, de-duplicating by insert id, and — with
|
|
348
|
+
``follow=True`` — keeps polling for new entries until the execution reaches
|
|
349
|
+
a terminal state. With ``follow=False`` it returns what is there and stops.
|
|
350
|
+
``timeout`` bounds the total stream. Requires ``Capability.LOGS``.
|
|
351
|
+
"""
|
|
352
|
+
self.capabilities.require(Capability.LOGS)
|
|
353
|
+
external_id = self._ids.resolve(str(execution_id))
|
|
354
|
+
if external_id is None:
|
|
355
|
+
raise ExecutionNotFound(
|
|
356
|
+
f"{execution_id!r} was not submitted by this backend instance; pass the "
|
|
357
|
+
"Cloud Run execution name to stream its logs from another process",
|
|
358
|
+
backend=self._name,
|
|
359
|
+
)
|
|
360
|
+
execution_name = external_id.rsplit("/executions/", 1)[-1]
|
|
361
|
+
base_filter = (
|
|
362
|
+
'resource.type="cloud_run_job" '
|
|
363
|
+
f'AND resource.labels.location="{self._location}" '
|
|
364
|
+
f'AND labels."run.googleapis.com/execution_name"="{execution_name}"'
|
|
365
|
+
)
|
|
366
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
367
|
+
client = self._logging()
|
|
368
|
+
seen: set[str] = set()
|
|
369
|
+
since: str | None = None
|
|
370
|
+
while True:
|
|
371
|
+
log_filter = base_filter if since is None else f'{base_filter} AND timestamp>="{since}"'
|
|
372
|
+
try:
|
|
373
|
+
entries = list(
|
|
374
|
+
client.list_entries(
|
|
375
|
+
resource_names=[f"projects/{self._project}"],
|
|
376
|
+
filter_=log_filter,
|
|
377
|
+
order_by="timestamp asc",
|
|
378
|
+
page_size=1000,
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
except Exception as exc:
|
|
382
|
+
if _is_not_found(exc):
|
|
383
|
+
return
|
|
384
|
+
raise BackendError(
|
|
385
|
+
f"Cloud Run could not read logs for {external_id!r}: {exc}", backend=self._name
|
|
386
|
+
) from exc
|
|
387
|
+
new = 0
|
|
388
|
+
for entry in entries:
|
|
389
|
+
insert_id = str(getattr(entry, "insert_id", "") or "")
|
|
390
|
+
if insert_id and insert_id in seen:
|
|
391
|
+
continue
|
|
392
|
+
if insert_id:
|
|
393
|
+
seen.add(insert_id)
|
|
394
|
+
timestamp = getattr(entry, "timestamp", None)
|
|
395
|
+
if timestamp is not None:
|
|
396
|
+
since = _rfc3339(timestamp)
|
|
397
|
+
text = _entry_text(entry)
|
|
398
|
+
if text is not None:
|
|
399
|
+
yield text
|
|
400
|
+
new += 1
|
|
401
|
+
if new == 0:
|
|
402
|
+
if not follow or self._is_terminal(external_id):
|
|
403
|
+
return
|
|
404
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
405
|
+
return
|
|
406
|
+
time.sleep(poll_interval)
|
|
407
|
+
|
|
408
|
+
def _is_terminal(self, external_id: str) -> bool:
|
|
409
|
+
try:
|
|
410
|
+
remote = self._executions().get_execution(name=external_id)
|
|
411
|
+
except Exception: # a vanished execution is, for our purposes, done
|
|
412
|
+
return True
|
|
413
|
+
return map_execution_state(remote)[0].is_terminal
|
|
414
|
+
|
|
321
415
|
|
|
322
416
|
def _run_v2() -> Any:
|
|
323
417
|
"""Import ``google.cloud.run_v2`` lazily, with an actionable error."""
|
|
@@ -330,6 +424,39 @@ def _run_v2() -> Any:
|
|
|
330
424
|
return run_v2
|
|
331
425
|
|
|
332
426
|
|
|
427
|
+
def _logging_v2() -> Any:
|
|
428
|
+
"""Import ``google.cloud.logging_v2`` lazily, with an actionable error."""
|
|
429
|
+
try:
|
|
430
|
+
from google.cloud import logging_v2
|
|
431
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
432
|
+
raise ConfigurationError(
|
|
433
|
+
"the Cloud Run backend needs the Google logging SDK: "
|
|
434
|
+
"pip install 'taskferry-cloudrun[gcp]'"
|
|
435
|
+
) from exc
|
|
436
|
+
return logging_v2
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _entry_text(entry: Any) -> str | None:
|
|
440
|
+
"""Extract a line of text from a Cloud Logging entry (text or struct payload)."""
|
|
441
|
+
payload = getattr(entry, "payload", None)
|
|
442
|
+
if payload is None:
|
|
443
|
+
return None
|
|
444
|
+
if isinstance(payload, str):
|
|
445
|
+
return payload.rstrip("\n")
|
|
446
|
+
if isinstance(payload, dict):
|
|
447
|
+
message = payload.get("message")
|
|
448
|
+
return str(message if message is not None else payload).rstrip("\n")
|
|
449
|
+
return str(payload).rstrip("\n")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _rfc3339(timestamp: Any) -> str:
|
|
453
|
+
"""Format a timestamp for a Cloud Logging ``timestamp>=`` filter."""
|
|
454
|
+
if isinstance(timestamp, datetime):
|
|
455
|
+
moment = timestamp if timestamp.tzinfo else timestamp.replace(tzinfo=UTC)
|
|
456
|
+
return moment.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
457
|
+
return str(timestamp)
|
|
458
|
+
|
|
459
|
+
|
|
333
460
|
def _execution_name(operation: Any) -> str | None:
|
|
334
461
|
"""Pull the execution resource name out of the long-running operation."""
|
|
335
462
|
metadata = getattr(operation, "metadata", None)
|
|
@@ -373,6 +500,7 @@ def make_backend(**options: Any) -> CloudRunJobBackend:
|
|
|
373
500
|
location=options.get("location"),
|
|
374
501
|
jobs_client=options.get("jobs_client"),
|
|
375
502
|
executions_client=options.get("executions_client"),
|
|
503
|
+
logging_client=options.get("logging_client"),
|
|
376
504
|
name=str(options.get("name", "cloudrun")),
|
|
377
505
|
tracked_ids=int(options.get("tracked_ids", 10_000)),
|
|
378
506
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|