labtasker-client 2.4.0__tar.gz → 2.5.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.
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/PKG-INFO +4 -2
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/pyproject.toml +5 -2
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/__init__.py +3 -1
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/cli.py +29 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/client.py +21 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/command_worker.py +13 -3
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/execution.py +50 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/models.py +22 -4
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/observations.py +13 -1
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/worker.py +41 -2
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/.gitignore +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/LICENSE +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/__main__.py +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/api.py +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/binding.py +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/command_template.py +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/config.py +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/errors.py +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/journal.py +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/local.py +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/paths.py +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/py.typed +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/tee.py +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/types.py +0 -0
- {labtasker_client-2.4.0 → labtasker_client-2.5.0}/src/labtasker/validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: labtasker-client
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: A small task queue for parallel model inference and evaluation
|
|
5
5
|
Project-URL: Homepage, https://github.com/luocfprime/labtasker
|
|
6
6
|
Project-URL: Repository, https://github.com/luocfprime/labtasker.git
|
|
@@ -8,10 +8,12 @@ Author-email: lcf <luocfprime@gmail.com>
|
|
|
8
8
|
License-Expression: Apache-2.0
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Requires-Python: >=3.10
|
|
11
|
-
Requires-Dist: httpx
|
|
11
|
+
Requires-Dist: httpx<1,>=0.25.2
|
|
12
12
|
Requires-Dist: packaging>=21.3
|
|
13
13
|
Requires-Dist: pydantic<3,>=2.12; python_version >= '3.14'
|
|
14
14
|
Requires-Dist: pydantic<3,>=2.5.3; python_version < '3.13'
|
|
15
15
|
Requires-Dist: pydantic<3,>=2.9; python_version >= '3.13' and python_version < '3.14'
|
|
16
16
|
Requires-Dist: tomli>=2.0.1; python_version < '3.11'
|
|
17
17
|
Requires-Dist: typer<1,>=0.19
|
|
18
|
+
Provides-Extra: socks
|
|
19
|
+
Requires-Dist: socksio==1.*; extra == 'socks'
|
|
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "labtasker-client"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.5.0"
|
|
8
8
|
description = "A small task queue for parallel model inference and evaluation"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
11
|
license-files = ["LICENSE"]
|
|
12
12
|
authors = [{ name = "lcf", email = "luocfprime@gmail.com" }]
|
|
13
13
|
dependencies = [
|
|
14
|
-
"httpx
|
|
14
|
+
"httpx>=0.25.2,<1",
|
|
15
15
|
"packaging>=21.3",
|
|
16
16
|
"pydantic>=2.5.3,<3; python_version < '3.13'",
|
|
17
17
|
"pydantic>=2.9,<3; python_version >= '3.13' and python_version < '3.14'",
|
|
@@ -20,6 +20,9 @@ dependencies = [
|
|
|
20
20
|
"typer>=0.19,<1",
|
|
21
21
|
]
|
|
22
22
|
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
socks = ["socksio==1.*"]
|
|
25
|
+
|
|
23
26
|
[project.urls]
|
|
24
27
|
Homepage = "https://github.com/luocfprime/labtasker"
|
|
25
28
|
Repository = "https://github.com/luocfprime/labtasker.git"
|
|
@@ -31,6 +31,7 @@ from labtasker.execution import (
|
|
|
31
31
|
cancellation_requested,
|
|
32
32
|
finish,
|
|
33
33
|
report_progress,
|
|
34
|
+
report_worker_telemetry,
|
|
34
35
|
set_force_stop_timeout,
|
|
35
36
|
task_info,
|
|
36
37
|
)
|
|
@@ -49,7 +50,7 @@ from labtasker.models import (
|
|
|
49
50
|
from labtasker.types import JSONValue, TaskOrderField, TaskStatus, TaskUpdate
|
|
50
51
|
from labtasker.worker import loop
|
|
51
52
|
|
|
52
|
-
__version__ = "2.
|
|
53
|
+
__version__ = "2.5.0"
|
|
53
54
|
|
|
54
55
|
__all__ = [
|
|
55
56
|
"APIError",
|
|
@@ -89,6 +90,7 @@ __all__ = [
|
|
|
89
90
|
"list_workers",
|
|
90
91
|
"loop",
|
|
91
92
|
"report_progress",
|
|
93
|
+
"report_worker_telemetry",
|
|
92
94
|
"requeue_task",
|
|
93
95
|
"set_force_stop_timeout",
|
|
94
96
|
"submit_task",
|
|
@@ -16,6 +16,7 @@ from labtasker.command_worker import run_command_worker
|
|
|
16
16
|
from labtasker.config import resolve_config
|
|
17
17
|
from labtasker.errors import LabtaskerError
|
|
18
18
|
from labtasker.execution import report_progress as report_current_progress
|
|
19
|
+
from labtasker.execution import report_worker_telemetry as report_current_worker_telemetry
|
|
19
20
|
from labtasker.types import TaskOrderField, TaskStatus, TaskUpdate
|
|
20
21
|
from labtasker.validation import RequestValidationError, validate_grouping, validate_json_object
|
|
21
22
|
|
|
@@ -126,6 +127,10 @@ def worker_loop(
|
|
|
126
127
|
)
|
|
127
128
|
),
|
|
128
129
|
] = None,
|
|
130
|
+
metadata: Annotated[
|
|
131
|
+
str,
|
|
132
|
+
typer.Option(help="Static Worker metadata as one strict JSON object."),
|
|
133
|
+
] = "{}",
|
|
129
134
|
) -> None:
|
|
130
135
|
"""Claim matching Tasks and execute one child command for each claim.
|
|
131
136
|
|
|
@@ -146,6 +151,7 @@ def worker_loop(
|
|
|
146
151
|
argv.pop(0)
|
|
147
152
|
if not argv:
|
|
148
153
|
raise typer.BadParameter("COMMAND is required after --")
|
|
154
|
+
worker_metadata = _json_object(metadata, option="--metadata")
|
|
149
155
|
try:
|
|
150
156
|
run_command_worker(
|
|
151
157
|
argv,
|
|
@@ -154,6 +160,7 @@ def worker_loop(
|
|
|
154
160
|
idle_timeout=idle_timeout,
|
|
155
161
|
max_consecutive_failures=max_consecutive_failures,
|
|
156
162
|
force_stop_timeout=force_stop_timeout,
|
|
163
|
+
metadata=worker_metadata,
|
|
157
164
|
)
|
|
158
165
|
except (TemplateSyntaxError, RequestValidationError) as error:
|
|
159
166
|
raise typer.BadParameter(str(error)) from error
|
|
@@ -465,6 +472,28 @@ def worker_count(
|
|
|
465
472
|
_write_json({"count": result} if isinstance(result, int) else result)
|
|
466
473
|
|
|
467
474
|
|
|
475
|
+
@worker_app.command("telemetry")
|
|
476
|
+
def worker_telemetry_report(
|
|
477
|
+
data: Annotated[
|
|
478
|
+
str,
|
|
479
|
+
typer.Option(help="Latest Worker telemetry as one strict JSON object."),
|
|
480
|
+
],
|
|
481
|
+
) -> None:
|
|
482
|
+
"""Replace telemetry for the current Worker invocation.
|
|
483
|
+
|
|
484
|
+
This command is available inside a command launched by ``labtasker loop``.
|
|
485
|
+
It performs one best-effort synchronous report and prints whether the Server
|
|
486
|
+
accepted it.
|
|
487
|
+
"""
|
|
488
|
+
try:
|
|
489
|
+
telemetry = _json_object(data, option="--data")
|
|
490
|
+
reported = _invoke(lambda: report_current_worker_telemetry(telemetry))
|
|
491
|
+
except RuntimeError as error:
|
|
492
|
+
typer.echo(str(error), err=True)
|
|
493
|
+
raise typer.Exit(1) from error
|
|
494
|
+
_write_json({"reported": reported})
|
|
495
|
+
|
|
496
|
+
|
|
468
497
|
def _count_options(
|
|
469
498
|
group_by: list[str] | None, allowed: set[str], limit: int | None, cursor: str | None
|
|
470
499
|
) -> dict[str, Any]:
|
|
@@ -607,6 +607,27 @@ class Client:
|
|
|
607
607
|
parser=lambda response: _parse_none(response, {204}),
|
|
608
608
|
)
|
|
609
609
|
|
|
610
|
+
def _report_worker_telemetry(
|
|
611
|
+
self,
|
|
612
|
+
*,
|
|
613
|
+
worker_id: str,
|
|
614
|
+
telemetry: dict[str, JSONValue],
|
|
615
|
+
queue: str | None = None,
|
|
616
|
+
) -> None:
|
|
617
|
+
self._ensure_open()
|
|
618
|
+
queue_name = self._queue(queue)
|
|
619
|
+
normalized_telemetry = validate_json_object(telemetry, field="telemetry")
|
|
620
|
+
from labtasker.validation import validate_worker_id
|
|
621
|
+
|
|
622
|
+
normalized_worker_id = validate_worker_id(worker_id)
|
|
623
|
+
self._call(
|
|
624
|
+
operation="report_worker_telemetry",
|
|
625
|
+
method="POST",
|
|
626
|
+
path=f"queues/{queue_name}/workers/{normalized_worker_id}/telemetry",
|
|
627
|
+
json={"telemetry": normalized_telemetry},
|
|
628
|
+
parser=lambda response: _parse_none(response, {204}),
|
|
629
|
+
)
|
|
630
|
+
|
|
610
631
|
def _fail(
|
|
611
632
|
self,
|
|
612
633
|
*,
|
|
@@ -27,7 +27,7 @@ from labtasker.models import ClaimResponse
|
|
|
27
27
|
from labtasker.observations import ObservationReporter
|
|
28
28
|
from labtasker.tee import configure_worker_logger
|
|
29
29
|
from labtasker.types import JSONValue
|
|
30
|
-
from labtasker.validation import validate_identifier
|
|
30
|
+
from labtasker.validation import validate_identifier, validate_json_object
|
|
31
31
|
from labtasker.worker import (
|
|
32
32
|
POLL_INTERVAL_SECONDS,
|
|
33
33
|
Heartbeat,
|
|
@@ -57,19 +57,25 @@ def run_command_worker(
|
|
|
57
57
|
idle_timeout: float = 300.0,
|
|
58
58
|
force_stop_timeout: float | None = None,
|
|
59
59
|
max_consecutive_failures: int = 5,
|
|
60
|
+
metadata: dict[str, JSONValue] | None = None,
|
|
60
61
|
) -> None:
|
|
61
62
|
guard = _FailureGuard(max_consecutive_failures)
|
|
62
63
|
templates = compile_argv(argv)
|
|
63
64
|
normalized_route = validate_identifier(route, field="route")
|
|
64
65
|
normalized_idle_timeout = _validate_idle_timeout(idle_timeout)
|
|
65
66
|
normalized_force_stop_timeout = _validate_force_stop_timeout(force_stop_timeout)
|
|
67
|
+
normalized_metadata = validate_json_object(
|
|
68
|
+
{} if metadata is None else metadata, field="metadata"
|
|
69
|
+
)
|
|
66
70
|
_guard_command_worker_platform()
|
|
67
71
|
_guard_worker_topology()
|
|
68
72
|
configure_worker_logger()
|
|
69
73
|
with Client(queue=queue) as client:
|
|
70
74
|
queue_name = client.configuration.queue
|
|
71
75
|
_preflight(client, queue_name)
|
|
72
|
-
with ObservationReporter(
|
|
76
|
+
with ObservationReporter(
|
|
77
|
+
client.configuration, normalized_route, normalized_metadata
|
|
78
|
+
) as observer:
|
|
73
79
|
idle_deadline: float | None = None
|
|
74
80
|
while True:
|
|
75
81
|
claim = client._claim(
|
|
@@ -102,6 +108,7 @@ def run_command_worker(
|
|
|
102
108
|
queue=queue_name,
|
|
103
109
|
route=normalized_route,
|
|
104
110
|
force_stop_timeout=normalized_force_stop_timeout,
|
|
111
|
+
worker_id=observer.id,
|
|
105
112
|
)
|
|
106
113
|
|
|
107
114
|
guard.observe(result, claim.task.id)
|
|
@@ -124,6 +131,7 @@ def _run_command_claim(
|
|
|
124
131
|
queue: str,
|
|
125
132
|
route: str,
|
|
126
133
|
force_stop_timeout: float | None,
|
|
134
|
+
worker_id: str,
|
|
127
135
|
) -> _ExecutionResult:
|
|
128
136
|
try:
|
|
129
137
|
journal = LocalRunJournal.create(
|
|
@@ -153,7 +161,7 @@ def _run_command_claim(
|
|
|
153
161
|
return _report_command_failure(
|
|
154
162
|
client, journal, claim, queue, "TaskBindingError", str(error), control=control
|
|
155
163
|
)
|
|
156
|
-
environment = _command_environment(client, claim, journal, queue, route)
|
|
164
|
+
environment = _command_environment(client, claim, journal, queue, route, worker_id)
|
|
157
165
|
try:
|
|
158
166
|
if _interactive_terminal():
|
|
159
167
|
process = _run_pty(
|
|
@@ -489,6 +497,7 @@ def _command_environment(
|
|
|
489
497
|
journal: LocalRunJournal,
|
|
490
498
|
queue: str,
|
|
491
499
|
route: str,
|
|
500
|
+
worker_id: str,
|
|
492
501
|
) -> dict[str, str]:
|
|
493
502
|
environment = dict(os.environ)
|
|
494
503
|
environment.update(
|
|
@@ -498,6 +507,7 @@ def _command_environment(
|
|
|
498
507
|
"LABTASKER_RUN_ID": claim.run_id,
|
|
499
508
|
"LABTASKER_ROUTE": route,
|
|
500
509
|
"LABTASKER_RUN_DIR": str(journal.run_dir),
|
|
510
|
+
"LABTASKER_WORKER_ID": worker_id,
|
|
501
511
|
}
|
|
502
512
|
)
|
|
503
513
|
configuration = client.configuration
|
|
@@ -18,6 +18,7 @@ from labtasker.validation import RequestValidationError, validate_json_object
|
|
|
18
18
|
|
|
19
19
|
CompletionReporter = Callable[[dict[str, JSONValue]], bool]
|
|
20
20
|
ProgressReporter = Callable[[dict[str, JSONValue]], bool]
|
|
21
|
+
WorkerTelemetryReporter = Callable[[dict[str, JSONValue]], bool]
|
|
21
22
|
ContextKind = Literal["python", "command"]
|
|
22
23
|
|
|
23
24
|
|
|
@@ -130,6 +131,7 @@ class ExecutionContext:
|
|
|
130
131
|
reporter: CompletionReporter,
|
|
131
132
|
control: RunControl | None,
|
|
132
133
|
progress_reporter: ProgressReporter | None = None,
|
|
134
|
+
worker_telemetry_reporter: WorkerTelemetryReporter | None = None,
|
|
133
135
|
) -> None:
|
|
134
136
|
self.info = info
|
|
135
137
|
self.kind = kind
|
|
@@ -137,6 +139,7 @@ class ExecutionContext:
|
|
|
137
139
|
self.reporter = reporter
|
|
138
140
|
self.control = control
|
|
139
141
|
self.progress_reporter = progress_reporter
|
|
142
|
+
self.worker_telemetry_reporter = worker_telemetry_reporter
|
|
140
143
|
self._lock = threading.Lock()
|
|
141
144
|
self._finish_started = False
|
|
142
145
|
self._finished = False
|
|
@@ -175,6 +178,12 @@ class ExecutionContext:
|
|
|
175
178
|
raise RuntimeError("Progress reporting is unavailable for this execution.")
|
|
176
179
|
return reporter(progress)
|
|
177
180
|
|
|
181
|
+
def report_worker_telemetry(self, telemetry: dict[str, JSONValue]) -> bool:
|
|
182
|
+
reporter = self.worker_telemetry_reporter
|
|
183
|
+
if reporter is None:
|
|
184
|
+
raise RuntimeError("Worker telemetry reporting is unavailable for this execution.")
|
|
185
|
+
return reporter(telemetry)
|
|
186
|
+
|
|
178
187
|
|
|
179
188
|
_CONTEXT_LOCK = threading.RLock()
|
|
180
189
|
_ACTIVE_CONTEXT: ExecutionContext | None = None
|
|
@@ -240,6 +249,20 @@ def report_progress(
|
|
|
240
249
|
return context.report_progress(normalized)
|
|
241
250
|
|
|
242
251
|
|
|
252
|
+
def report_worker_telemetry(
|
|
253
|
+
telemetry: dict[str, JSONValue],
|
|
254
|
+
*,
|
|
255
|
+
skip_if_no_labtasker: bool = False,
|
|
256
|
+
) -> bool:
|
|
257
|
+
context = _get_context()
|
|
258
|
+
if context is None:
|
|
259
|
+
if skip_if_no_labtasker:
|
|
260
|
+
return False
|
|
261
|
+
raise RuntimeError("No active Labtasker Task execution is available.")
|
|
262
|
+
normalized = validate_json_object(telemetry, field="telemetry")
|
|
263
|
+
return context.report_worker_telemetry(normalized)
|
|
264
|
+
|
|
265
|
+
|
|
243
266
|
def cancellation_requested() -> bool:
|
|
244
267
|
context = _require_python_context()
|
|
245
268
|
if context.finished or context.control is None:
|
|
@@ -281,6 +304,7 @@ def _load_environment_context() -> ExecutionContext | None:
|
|
|
281
304
|
"run_dir": "LABTASKER_RUN_DIR",
|
|
282
305
|
}
|
|
283
306
|
values = {field: os.environ.get(name) for field, name in names.items()}
|
|
307
|
+
worker_id = os.environ.get("LABTASKER_WORKER_ID")
|
|
284
308
|
execution_fields = {"task_id", "run_id", "route", "run_dir"}
|
|
285
309
|
present = {field for field, value in values.items() if value is not None}
|
|
286
310
|
if not (present & execution_fields):
|
|
@@ -379,6 +403,31 @@ def _load_environment_context() -> ExecutionContext | None:
|
|
|
379
403
|
progress=progress,
|
|
380
404
|
)
|
|
381
405
|
|
|
406
|
+
worker_telemetry_reporter: WorkerTelemetryReporter | None = None
|
|
407
|
+
if worker_id is not None:
|
|
408
|
+
from labtasker.validation import validate_worker_id
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
normalized_worker_id = validate_worker_id(worker_id)
|
|
412
|
+
except RequestValidationError as error:
|
|
413
|
+
raise ConfigError(
|
|
414
|
+
"invalid_config",
|
|
415
|
+
"Inherited Labtasker Worker context is invalid.",
|
|
416
|
+
{"field": "LABTASKER_WORKER_ID"},
|
|
417
|
+
) from error
|
|
418
|
+
|
|
419
|
+
def report_telemetry(telemetry: dict[str, JSONValue]) -> bool:
|
|
420
|
+
from labtasker.worker import report_worker_telemetry_once
|
|
421
|
+
|
|
422
|
+
return report_worker_telemetry_once(
|
|
423
|
+
client,
|
|
424
|
+
queue=values["queue"] or "",
|
|
425
|
+
worker_id=normalized_worker_id,
|
|
426
|
+
telemetry=telemetry,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
worker_telemetry_reporter = report_telemetry
|
|
430
|
+
|
|
382
431
|
_ENV_CONTEXT = ExecutionContext(
|
|
383
432
|
info=info,
|
|
384
433
|
kind="command",
|
|
@@ -386,6 +435,7 @@ def _load_environment_context() -> ExecutionContext | None:
|
|
|
386
435
|
reporter=report,
|
|
387
436
|
control=None,
|
|
388
437
|
progress_reporter=progress_reporter,
|
|
438
|
+
worker_telemetry_reporter=worker_telemetry_reporter,
|
|
389
439
|
)
|
|
390
440
|
return _ENV_CONTEXT
|
|
391
441
|
|
|
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Literal
|
|
6
6
|
|
|
7
|
-
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
8
8
|
|
|
9
9
|
from labtasker.types import JSONValue, TaskStatus
|
|
10
10
|
from labtasker.validation import (
|
|
@@ -290,6 +290,9 @@ class WorkerObservation(ResponseModel):
|
|
|
290
290
|
route: str
|
|
291
291
|
status: Literal["idle", "busy"]
|
|
292
292
|
task_id: str | None
|
|
293
|
+
metadata: dict[str, JSONValue] = Field(default_factory=dict)
|
|
294
|
+
telemetry: dict[str, JSONValue] | None = None
|
|
295
|
+
telemetry_updated_at: datetime | None = None
|
|
293
296
|
last_seen_at: datetime
|
|
294
297
|
expires_at: datetime
|
|
295
298
|
|
|
@@ -310,10 +313,25 @@ class WorkerObservation(ResponseModel):
|
|
|
310
313
|
def validate_task(cls, value: str | None) -> str | None:
|
|
311
314
|
return None if value is None else validate_task_id(value)
|
|
312
315
|
|
|
313
|
-
@field_validator("
|
|
316
|
+
@field_validator("metadata", "telemetry")
|
|
314
317
|
@classmethod
|
|
315
|
-
def
|
|
316
|
-
|
|
318
|
+
def validate_worker_objects(
|
|
319
|
+
cls, value: dict[str, JSONValue] | None, info: object
|
|
320
|
+
) -> dict[str, JSONValue] | None:
|
|
321
|
+
if value is None:
|
|
322
|
+
return None
|
|
323
|
+
return validate_json_object(value, field=getattr(info, "field_name", "worker"))
|
|
324
|
+
|
|
325
|
+
@field_validator("telemetry_updated_at", "last_seen_at", "expires_at")
|
|
326
|
+
@classmethod
|
|
327
|
+
def validate_time(cls, value: datetime | None) -> datetime | None:
|
|
328
|
+
return None if value is None else _utc_datetime(value)
|
|
329
|
+
|
|
330
|
+
@model_validator(mode="after")
|
|
331
|
+
def validate_telemetry_fields(self) -> WorkerObservation:
|
|
332
|
+
if (self.telemetry is None) != (self.telemetry_updated_at is None):
|
|
333
|
+
raise ValueError("telemetry and telemetry_updated_at must be both null or both present")
|
|
334
|
+
return self
|
|
317
335
|
|
|
318
336
|
|
|
319
337
|
class WorkerPage(ResponseModel):
|
|
@@ -7,6 +7,7 @@ import os
|
|
|
7
7
|
import secrets
|
|
8
8
|
import threading
|
|
9
9
|
import time
|
|
10
|
+
from copy import deepcopy
|
|
10
11
|
from types import TracebackType
|
|
11
12
|
|
|
12
13
|
import httpx
|
|
@@ -14,6 +15,8 @@ import httpx
|
|
|
14
15
|
from labtasker.client import REQUEST_TIMEOUT_SECONDS
|
|
15
16
|
from labtasker.config import ResolvedConfig
|
|
16
17
|
from labtasker.local import socket_transport
|
|
18
|
+
from labtasker.types import JSONValue
|
|
19
|
+
from labtasker.validation import validate_json_object
|
|
17
20
|
|
|
18
21
|
REPORT_INTERVAL_SECONDS = 60.0
|
|
19
22
|
SHUTDOWN_WAIT_SECONDS = 1.0
|
|
@@ -39,10 +42,18 @@ def _make_http_client(configuration: ResolvedConfig) -> httpx.Client:
|
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
class ObservationReporter:
|
|
42
|
-
def __init__(
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
configuration: ResolvedConfig,
|
|
48
|
+
route: str,
|
|
49
|
+
metadata: dict[str, JSONValue] | None = None,
|
|
50
|
+
) -> None:
|
|
43
51
|
self.id = f"w_{secrets.token_urlsafe(9)}"
|
|
44
52
|
self._configuration = configuration
|
|
45
53
|
self._route = route
|
|
54
|
+
self._metadata = deepcopy(
|
|
55
|
+
validate_json_object({} if metadata is None else metadata, field="metadata")
|
|
56
|
+
)
|
|
46
57
|
self._pid = os.getpid()
|
|
47
58
|
self._lock = threading.Lock()
|
|
48
59
|
self._wake = threading.Event()
|
|
@@ -139,6 +150,7 @@ class ObservationReporter:
|
|
|
139
150
|
"route": self._route,
|
|
140
151
|
"status": "idle" if task_id is None else "busy",
|
|
141
152
|
"task_id": task_id,
|
|
153
|
+
"metadata": self._metadata,
|
|
142
154
|
},
|
|
143
155
|
)
|
|
144
156
|
if response.status_code != 204:
|
|
@@ -36,7 +36,7 @@ from labtasker.models import ClaimResponse, TaskInfo
|
|
|
36
36
|
from labtasker.observations import ObservationReporter
|
|
37
37
|
from labtasker.tee import WorkerTee, configure_worker_logger
|
|
38
38
|
from labtasker.types import JSONValue
|
|
39
|
-
from labtasker.validation import RequestValidationError, validate_identifier
|
|
39
|
+
from labtasker.validation import RequestValidationError, validate_identifier, validate_json_object
|
|
40
40
|
|
|
41
41
|
P = ParamSpec("P")
|
|
42
42
|
R = TypeVar("R")
|
|
@@ -133,11 +133,15 @@ def loop(
|
|
|
133
133
|
idle_timeout: float = 300.0,
|
|
134
134
|
force_stop_timeout: float | None = None,
|
|
135
135
|
max_consecutive_failures: int = 5,
|
|
136
|
+
metadata: dict[str, JSONValue] | None = None,
|
|
136
137
|
) -> Callable[[Callable[P, R]], Callable[P, None]]:
|
|
137
138
|
_FailureGuard(max_consecutive_failures)
|
|
138
139
|
normalized_route = validate_identifier(route, field="route")
|
|
139
140
|
normalized_idle_timeout = _validate_idle_timeout(idle_timeout)
|
|
140
141
|
normalized_force_stop_timeout = _validate_force_stop_timeout(force_stop_timeout)
|
|
142
|
+
normalized_metadata = validate_json_object(
|
|
143
|
+
{} if metadata is None else metadata, field="metadata"
|
|
144
|
+
)
|
|
141
145
|
|
|
142
146
|
def decorate(function: Callable[P, R]) -> Callable[P, None]:
|
|
143
147
|
binding = compile_binding(function)
|
|
@@ -157,6 +161,7 @@ def loop(
|
|
|
157
161
|
idle_timeout=normalized_idle_timeout,
|
|
158
162
|
force_stop_timeout=normalized_force_stop_timeout,
|
|
159
163
|
max_consecutive_failures=max_consecutive_failures,
|
|
164
|
+
metadata=normalized_metadata,
|
|
160
165
|
)
|
|
161
166
|
|
|
162
167
|
return run
|
|
@@ -174,6 +179,7 @@ def _run_python_worker(
|
|
|
174
179
|
idle_timeout: float,
|
|
175
180
|
force_stop_timeout: float | None,
|
|
176
181
|
max_consecutive_failures: int = 5,
|
|
182
|
+
metadata: dict[str, JSONValue] | None = None,
|
|
177
183
|
) -> None:
|
|
178
184
|
guard = _FailureGuard(max_consecutive_failures)
|
|
179
185
|
_guard_worker_topology()
|
|
@@ -181,7 +187,7 @@ def _run_python_worker(
|
|
|
181
187
|
configure_worker_logger()
|
|
182
188
|
queue_name = client.configuration.queue
|
|
183
189
|
_preflight(client, queue_name)
|
|
184
|
-
with ObservationReporter(client.configuration, route) as observer:
|
|
190
|
+
with ObservationReporter(client.configuration, route, metadata) as observer:
|
|
185
191
|
idle_deadline: float | None = None
|
|
186
192
|
while True:
|
|
187
193
|
claim = client._claim(route=route, run_id=_generate_run_id(), queue=queue_name)
|
|
@@ -213,6 +219,7 @@ def _run_python_worker(
|
|
|
213
219
|
queue=queue_name,
|
|
214
220
|
route=route,
|
|
215
221
|
force_stop_timeout=force_stop_timeout,
|
|
222
|
+
worker_id=observer.id,
|
|
216
223
|
)
|
|
217
224
|
|
|
218
225
|
guard.observe(result, claim.task.id)
|
|
@@ -230,6 +237,7 @@ def _run_python_claim(
|
|
|
230
237
|
queue: str,
|
|
231
238
|
route: str,
|
|
232
239
|
force_stop_timeout: float | None,
|
|
240
|
+
worker_id: str,
|
|
233
241
|
) -> _ExecutionResult:
|
|
234
242
|
try:
|
|
235
243
|
journal = LocalRunJournal.create(
|
|
@@ -271,6 +279,14 @@ def _run_python_claim(
|
|
|
271
279
|
control=control,
|
|
272
280
|
)
|
|
273
281
|
|
|
282
|
+
def worker_telemetry_reporter(telemetry: dict[str, JSONValue]) -> bool:
|
|
283
|
+
return report_worker_telemetry_once(
|
|
284
|
+
client,
|
|
285
|
+
queue=queue,
|
|
286
|
+
worker_id=worker_id,
|
|
287
|
+
telemetry=telemetry,
|
|
288
|
+
)
|
|
289
|
+
|
|
274
290
|
info = TaskInfo(
|
|
275
291
|
**claim.task.model_dump(),
|
|
276
292
|
run_id=claim.run_id,
|
|
@@ -283,6 +299,7 @@ def _run_python_claim(
|
|
|
283
299
|
reporter=report_complete,
|
|
284
300
|
control=control,
|
|
285
301
|
progress_reporter=progress_reporter,
|
|
302
|
+
worker_telemetry_reporter=worker_telemetry_reporter,
|
|
286
303
|
)
|
|
287
304
|
heartbeat = Heartbeat(
|
|
288
305
|
client,
|
|
@@ -403,6 +420,28 @@ def report_progress_once(
|
|
|
403
420
|
return True
|
|
404
421
|
|
|
405
422
|
|
|
423
|
+
def report_worker_telemetry_once(
|
|
424
|
+
client: Client,
|
|
425
|
+
*,
|
|
426
|
+
queue: str,
|
|
427
|
+
worker_id: str,
|
|
428
|
+
telemetry: dict[str, JSONValue],
|
|
429
|
+
) -> bool:
|
|
430
|
+
try:
|
|
431
|
+
client._report_worker_telemetry(
|
|
432
|
+
worker_id=worker_id,
|
|
433
|
+
telemetry=telemetry,
|
|
434
|
+
queue=queue,
|
|
435
|
+
)
|
|
436
|
+
except TransportError as error:
|
|
437
|
+
logger.warning("Worker telemetry transport error; continuing Task: %s", error.message)
|
|
438
|
+
return False
|
|
439
|
+
except LabtaskerError as error:
|
|
440
|
+
logger.warning("Worker telemetry report failed; continuing Task: %s", error.message)
|
|
441
|
+
return False
|
|
442
|
+
return True
|
|
443
|
+
|
|
444
|
+
|
|
406
445
|
def _report_complete(
|
|
407
446
|
client: Client,
|
|
408
447
|
journal: LocalRunJournal,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|