datatorch 0.8.2__tar.gz → 0.8.3__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.
- {datatorch-0.8.2 → datatorch-0.8.3}/PKG-INFO +1 -1
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/__init__.py +53 -14
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/client.py +79 -3
- datatorch-0.8.3/datatorch/runner/daemon.py +372 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/step/step.py +29 -6
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/PKG-INFO +1 -1
- {datatorch-0.8.2 → datatorch-0.8.3}/setup.py +1 -1
- datatorch-0.8.2/datatorch/runner/daemon.py +0 -210
- {datatorch-0.8.2 → datatorch-0.8.3}/README.md +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/agent/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/api.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/client.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/annotation.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/base.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/dataset.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/file.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/label.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/project.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/settings.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/sources/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/sources/image/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/sources/image/bounding_box.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/sources/image/segmentations.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/sources/image/typings.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/sources/source.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/storage_link.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/entity/user.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/pull.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/scripts/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/scripts/import_coco.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/scripts/import_coco_server.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/scripts/import_pixmask.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/scripts/utils/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/scripts/utils/simplify.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/utils.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/api/where.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/action/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/action/create.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/action/pull.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/action/run.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/groups.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/import_cmds/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/import_cmds/coco.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/import_cmds/pixmask.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/main/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/main/login.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/main/logout.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/main/pull.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/main/upgrade.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/main/version.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/pipeline/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/pipeline/generate.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/pipeline/run.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/pipeline/upload.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/runner/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/runner/create.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/runner/dir.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/runner/start.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/spinner.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/upload/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/upload/folder.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/cli/utils.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/core/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/core/env.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/core/folder.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/core/settings.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/directory.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/log_handler.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/logging.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/monitoring.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/action/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/action/action.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/action/cache.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/action/config.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/job/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/job/job.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/pipeline.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/resolver.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/command.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/docker.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/factory.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/node.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/python.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/runner.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/runner/shell.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/step/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/pipelines/template.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/services/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/utils/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/utils/docker.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/runner/utils/process.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/__init__.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/converters.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/files.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/hash_table.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/objects.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/package.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/string_style.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch/utils/url.py +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/SOURCES.txt +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/dependency_links.txt +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/entry_points.txt +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/not-zip-safe +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/requires.txt +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/datatorch.egg-info/top_level.txt +0 -0
- {datatorch-0.8.2 → datatorch-0.8.3}/setup.cfg +0 -0
|
@@ -5,9 +5,10 @@ import os
|
|
|
5
5
|
|
|
6
6
|
from logging.handlers import RotatingFileHandler
|
|
7
7
|
from asyncio import IncompleteReadError
|
|
8
|
+
from typing import List, Optional
|
|
8
9
|
|
|
9
10
|
from .directory import runner_directory
|
|
10
|
-
from .daemon import RunnerDaemon, tasks
|
|
11
|
+
from .daemon import RunnerDaemon, tasks # noqa: F401 (tasks: re-exported)
|
|
11
12
|
|
|
12
13
|
from gql import Client as GqlClient
|
|
13
14
|
from gql.transport.exceptions import TransportError, TransportQueryError
|
|
@@ -29,6 +30,9 @@ logger = logging.getLogger(__name__)
|
|
|
29
30
|
_url = runner_directory.settings.api_url
|
|
30
31
|
_agent_token = runner_directory.settings.runner_token
|
|
31
32
|
|
|
33
|
+
# The one daemon of this process; it outlives every websocket connection.
|
|
34
|
+
_daemon: Optional[RunnerDaemon] = None
|
|
35
|
+
|
|
32
36
|
|
|
33
37
|
BACKOFF_INIT_WAIT = 2
|
|
34
38
|
BACKOFF_FACTOR = 1.5
|
|
@@ -54,14 +58,21 @@ def setup_logging() -> None:
|
|
|
54
58
|
logger.setLevel(logging.DEBUG)
|
|
55
59
|
|
|
56
60
|
|
|
61
|
+
def running_steps() -> List[asyncio.Task]:
|
|
62
|
+
"""The step tasks this process is running right now."""
|
|
63
|
+
return _daemon.running_steps() if _daemon else []
|
|
64
|
+
|
|
65
|
+
|
|
57
66
|
async def _exit_jobs() -> None:
|
|
58
|
-
"""
|
|
59
|
-
|
|
67
|
+
"""Cancels the running steps. A real shutdown only (`stop`): a lost
|
|
68
|
+
connection does not come through here any more (0.8.3)."""
|
|
69
|
+
steps = running_steps()
|
|
70
|
+
logger.info(f"Exiting {len(steps)} active step(s).")
|
|
60
71
|
|
|
61
|
-
for task in
|
|
72
|
+
for task in steps:
|
|
62
73
|
task.cancel()
|
|
63
74
|
|
|
64
|
-
await asyncio.gather(*
|
|
75
|
+
await asyncio.gather(*steps, return_exceptions=True)
|
|
65
76
|
|
|
66
77
|
|
|
67
78
|
async def _close_transport(transport: WebsocketsTransport):
|
|
@@ -93,12 +104,24 @@ async def _exit_tasks() -> None:
|
|
|
93
104
|
|
|
94
105
|
|
|
95
106
|
async def _restart_after_failure(transport, backoff_wait: float) -> float:
|
|
96
|
-
"""
|
|
107
|
+
"""The connection failed: stops this connection's services, closes the
|
|
108
|
+
transport and sleeps with backoff before the caller reconnects.
|
|
109
|
+
|
|
110
|
+
Running steps are left running. Before 0.8.3 every step was cancelled
|
|
111
|
+
here, so a four-second websocket blip (a rolling API deploy, a keepalive
|
|
112
|
+
ping timeout) killed hours of work; on 2026-11-09 three such drops
|
|
113
|
+
aborted the org-root dataset-scout seed step 46 seconds in. A step
|
|
114
|
+
that finishes while disconnected holds its result until the daemon is
|
|
115
|
+
connected again (see `RunnerDaemon._report`).
|
|
97
116
|
|
|
98
117
|
Returns the next backoff wait."""
|
|
99
|
-
|
|
118
|
+
if _daemon is not None:
|
|
119
|
+
_daemon.disconnect()
|
|
100
120
|
await _close_transport(transport)
|
|
101
|
-
|
|
121
|
+
|
|
122
|
+
steps = running_steps()
|
|
123
|
+
if steps:
|
|
124
|
+
logger.info(f"{len(steps)} step(s) keep running while the connection is down.")
|
|
102
125
|
|
|
103
126
|
backoff_wait = min(BACKOFF_MAX, backoff_wait * BACKOFF_FACTOR)
|
|
104
127
|
logger.debug(
|
|
@@ -116,16 +139,27 @@ async def start() -> None:
|
|
|
116
139
|
click.style(f"Starting DataTorch Runner v{get_version()}", fg="blue", bold=True)
|
|
117
140
|
)
|
|
118
141
|
logger.debug(f"API Endpoint at {_url}")
|
|
119
|
-
backoff_wait = BACKOFF_INIT_WAIT
|
|
120
142
|
|
|
121
143
|
transport = DtClient.create_socket_transport(_url, _agent_token, agent=True)
|
|
122
144
|
client = GqlClient(transport=transport, fetch_schema_from_transport=True)
|
|
145
|
+
await serve_forever(client, transport)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def serve_forever(client, transport, daemon: Optional[RunnerDaemon] = None):
|
|
149
|
+
"""Connects, serves, and reconnects with backoff until cancelled.
|
|
150
|
+
|
|
151
|
+
`client` is an async gql client (`async with client as session`),
|
|
152
|
+
`transport` its websocket transport. One `RunnerDaemon` is used for
|
|
153
|
+
every connection, so its running steps survive a reconnect."""
|
|
154
|
+
global _daemon
|
|
155
|
+
_daemon = daemon or RunnerDaemon()
|
|
156
|
+
backoff_wait = BACKOFF_INIT_WAIT
|
|
123
157
|
|
|
124
158
|
while True:
|
|
125
159
|
try:
|
|
126
160
|
async with client as session:
|
|
127
161
|
backoff_wait = BACKOFF_INIT_WAIT
|
|
128
|
-
await
|
|
162
|
+
await _daemon.serve(session)
|
|
129
163
|
|
|
130
164
|
except (asyncio.CancelledError, InvalidURI):
|
|
131
165
|
break
|
|
@@ -148,7 +182,6 @@ async def start() -> None:
|
|
|
148
182
|
asyncio.TimeoutError,
|
|
149
183
|
) as e:
|
|
150
184
|
logger.error(e)
|
|
151
|
-
backoff_wait = await _restart_after_failure(transport, backoff_wait)
|
|
152
185
|
|
|
153
186
|
except Exception:
|
|
154
187
|
# Last resort: the loop must never die. On 2026-09-12 an
|
|
@@ -156,13 +189,19 @@ async def start() -> None:
|
|
|
156
189
|
# with no agent loop, so the runner showed OFFLINE while its
|
|
157
190
|
# window looked fine.
|
|
158
191
|
logger.exception("Unexpected error in the agent loop; restarting.")
|
|
192
|
+
|
|
193
|
+
try:
|
|
159
194
|
backoff_wait = await _restart_after_failure(transport, backoff_wait)
|
|
195
|
+
except asyncio.CancelledError:
|
|
196
|
+
# A shutdown that lands during the backoff sleep.
|
|
197
|
+
break
|
|
160
198
|
|
|
161
199
|
logger.info("Exiting job processing task.")
|
|
162
200
|
|
|
163
201
|
|
|
164
202
|
async def stop() -> None:
|
|
165
|
-
"""Stop all run tasks
|
|
203
|
+
"""Stop all run tasks (SIGINT/SIGTERM): running steps are cancelled,
|
|
204
|
+
then every other task, then the loop."""
|
|
166
205
|
|
|
167
206
|
print(" ")
|
|
168
207
|
|
|
@@ -171,8 +210,8 @@ async def stop() -> None:
|
|
|
171
210
|
logger.info("Closing runner jobs.")
|
|
172
211
|
await _exit_jobs()
|
|
173
212
|
|
|
174
|
-
|
|
175
|
-
|
|
213
|
+
if _daemon is not None:
|
|
214
|
+
_daemon.disconnect()
|
|
176
215
|
|
|
177
216
|
logger.info("Closing all other tasks.")
|
|
178
217
|
await _exit_tasks()
|
|
@@ -2,13 +2,52 @@ from typing import AsyncGenerator, List, Optional, cast
|
|
|
2
2
|
from gql import gql
|
|
3
3
|
from .directory import runner_directory
|
|
4
4
|
from gql.client import AsyncClientSession
|
|
5
|
+
from gql.transport.exceptions import TransportError, TransportQueryError
|
|
6
|
+
from websockets.exceptions import ConnectionClosed
|
|
5
7
|
|
|
6
8
|
from typing_extensions import TypedDict
|
|
9
|
+
from asyncio import IncompleteReadError
|
|
10
|
+
import asyncio
|
|
7
11
|
import logging
|
|
8
12
|
|
|
9
13
|
logger = logging.getLogger(__name__)
|
|
10
14
|
|
|
11
15
|
|
|
16
|
+
class RunnerDisconnected(ConnectionError):
|
|
17
|
+
"""The runner has no live websocket session right now.
|
|
18
|
+
|
|
19
|
+
`AgentApiClient.execute` raises it at once instead of sending into a
|
|
20
|
+
dead transport. Callers whose message must get through (a finished
|
|
21
|
+
step's result) wait on `AgentApiClient.wait_connected` and try again;
|
|
22
|
+
callers that can afford to lose a message (a log batch, a metric) let
|
|
23
|
+
it fail and send the next one.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_transient(error: BaseException) -> bool:
|
|
28
|
+
"""Whether an API call failed because of the connection, so the same
|
|
29
|
+
call may succeed once the runner is connected again.
|
|
30
|
+
|
|
31
|
+
A `TransportQueryError` carrying GraphQL errors is the server's answer
|
|
32
|
+
(the mutation was refused) and is not transient; the same class with
|
|
33
|
+
no errors is gql's "query completed without any answer", which is the
|
|
34
|
+
socket dropping mid-call.
|
|
35
|
+
"""
|
|
36
|
+
if isinstance(error, TransportQueryError):
|
|
37
|
+
return not error.errors
|
|
38
|
+
return isinstance(
|
|
39
|
+
error,
|
|
40
|
+
(
|
|
41
|
+
RunnerDisconnected,
|
|
42
|
+
TransportError,
|
|
43
|
+
ConnectionClosed,
|
|
44
|
+
OSError,
|
|
45
|
+
IncompleteReadError,
|
|
46
|
+
asyncio.TimeoutError,
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
12
51
|
class Log(TypedDict):
|
|
13
52
|
createdAt: str
|
|
14
53
|
message: str
|
|
@@ -67,8 +106,41 @@ STEP_DISPATCH_FIELDS = """
|
|
|
67
106
|
|
|
68
107
|
|
|
69
108
|
class AgentApiClient(object):
|
|
70
|
-
|
|
109
|
+
"""The runner's side of the runner wire protocol.
|
|
110
|
+
|
|
111
|
+
One client lives as long as the daemon; the gql session behind it
|
|
112
|
+
changes with every reconnect (`attach`/`detach`). While detached every
|
|
113
|
+
call fails at once with `RunnerDisconnected`, so a step that finishes
|
|
114
|
+
during an outage neither hangs on a dead socket nor reports into it.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, session: Optional[AsyncClientSession] = None):
|
|
118
|
+
self.session = session
|
|
119
|
+
self._connected = asyncio.Event()
|
|
120
|
+
if session is not None:
|
|
121
|
+
self._connected.set()
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def connected(self) -> bool:
|
|
125
|
+
return self._connected.is_set()
|
|
126
|
+
|
|
127
|
+
def attach(self, session: AsyncClientSession) -> None:
|
|
128
|
+
"""A (re)connected session: calls go through again."""
|
|
71
129
|
self.session = session
|
|
130
|
+
self._connected.set()
|
|
131
|
+
|
|
132
|
+
def detach(self) -> None:
|
|
133
|
+
"""The connection is gone: refuse calls until the next `attach`."""
|
|
134
|
+
self.session = None
|
|
135
|
+
self._connected.clear()
|
|
136
|
+
|
|
137
|
+
async def wait_connected(self, timeout: Optional[float] = None) -> bool:
|
|
138
|
+
"""Waits until a session is attached. False when `timeout` ran out."""
|
|
139
|
+
try:
|
|
140
|
+
await asyncio.wait_for(self._connected.wait(), timeout)
|
|
141
|
+
return True
|
|
142
|
+
except asyncio.TimeoutError:
|
|
143
|
+
return False
|
|
72
144
|
|
|
73
145
|
def agent_steps(self):
|
|
74
146
|
"""Subscribe to ready steps dispatched to this runner."""
|
|
@@ -300,10 +372,14 @@ class AgentApiClient(object):
|
|
|
300
372
|
logger.debug(f"Transport close raised while forcing reconnect: {e}")
|
|
301
373
|
|
|
302
374
|
async def execute(self, query, *args, params: dict = {}, **kwargs) -> dict:
|
|
303
|
-
"""Wrapper around execute
|
|
375
|
+
"""Wrapper around execute. Raises `RunnerDisconnected` while no
|
|
376
|
+
session is attached (see the class docstring)."""
|
|
377
|
+
session = self.session
|
|
378
|
+
if session is None or not self._connected.is_set():
|
|
379
|
+
raise RunnerDisconnected("The runner is not connected to DataTorch.")
|
|
304
380
|
removed_none = dict((k, v) for k, v in params.items() if v is not None)
|
|
305
381
|
if type(query) == str:
|
|
306
382
|
query = gql(query)
|
|
307
|
-
return await
|
|
383
|
+
return await session.execute(
|
|
308
384
|
query, *args, variable_values=removed_none, **kwargs
|
|
309
385
|
)
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from typing import Awaitable, Callable, Dict, List, Optional
|
|
6
|
+
from .client import AgentApiClient, AgentStepDispatch, is_transient
|
|
7
|
+
from .pipelines.template import Variables
|
|
8
|
+
from .log_handler import AgentAPIHandler
|
|
9
|
+
from .monitoring import AgentSystemStats
|
|
10
|
+
from .directory import runner_directory
|
|
11
|
+
|
|
12
|
+
from gql.client import AsyncClientSession
|
|
13
|
+
from datatorch.runner.pipelines.step import Step
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Every task the daemon spawned that is still running: step tasks and the
|
|
18
|
+
# per-connection service tasks (monitoring, the cancel and command
|
|
19
|
+
# listeners). A task removes itself when it finishes, so the list counts
|
|
20
|
+
# what is really in flight. Before 0.8.3 it only ever grew (the prod runner
|
|
21
|
+
# logged "Exiting 71 active jobs", then 74, then 77 across three reconnects
|
|
22
|
+
# on 2026-11-09).
|
|
23
|
+
tasks: List[asyncio.Task] = []
|
|
24
|
+
|
|
25
|
+
# A finished step's result (its last log batch and the completion
|
|
26
|
+
# mutation) is retried across a lost connection for this long; after that
|
|
27
|
+
# the runner logs the loss and drops the result. The server fails a
|
|
28
|
+
# RUNNING step once its runner has been unseen for about two minutes, so a
|
|
29
|
+
# much longer outage has already decided the step's fate there.
|
|
30
|
+
REPORT_DEADLINE_SECONDS = 600
|
|
31
|
+
# Pause between two attempts while connected (the socket may be up but the
|
|
32
|
+
# call still failing, e.g. the API pod is restarting behind it).
|
|
33
|
+
REPORT_RETRY_SECONDS = 5
|
|
34
|
+
# One attempt may not sit on a half-open socket for longer than this.
|
|
35
|
+
REPORT_CALL_TIMEOUT_SECONDS = 60
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _track(task: asyncio.Task) -> asyncio.Task:
|
|
39
|
+
tasks.append(task)
|
|
40
|
+
task.add_done_callback(_untrack)
|
|
41
|
+
return task
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _untrack(task: asyncio.Task) -> None:
|
|
45
|
+
try:
|
|
46
|
+
tasks.remove(task)
|
|
47
|
+
except ValueError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RunnerDaemon(object):
|
|
52
|
+
"""
|
|
53
|
+
Runner protocol v2: the server sequences steps. The runner receives one
|
|
54
|
+
ready step at a time — its inputs already resolved server-side —
|
|
55
|
+
claims it, executes it, and reports the result. Job-level state and
|
|
56
|
+
cross-step dataflow live entirely on the server.
|
|
57
|
+
|
|
58
|
+
One daemon lives for the whole process and outlives the websocket:
|
|
59
|
+
`serve(session)` runs one connection's worth of work (recover pending
|
|
60
|
+
steps, listen for pushes, monitor) and returns or raises when that
|
|
61
|
+
connection ends. Running steps belong to the daemon, not to the
|
|
62
|
+
connection, so a dropped socket leaves them running; they report
|
|
63
|
+
through whichever session is attached when they finish (`_report`).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
async def run(cls, session):
|
|
68
|
+
"""One connection, one daemon (the pre-0.8.3 shape, kept for
|
|
69
|
+
callers outside this package). `runner.start` keeps a single
|
|
70
|
+
daemon across reconnects instead."""
|
|
71
|
+
daemon = cls()
|
|
72
|
+
await daemon.serve(session)
|
|
73
|
+
|
|
74
|
+
def __init__(self, api: Optional[AgentApiClient] = None):
|
|
75
|
+
self.api = api or AgentApiClient()
|
|
76
|
+
self.directory = runner_directory
|
|
77
|
+
# Running step tasks, keyed by stepId, so a server cancel/timeout
|
|
78
|
+
# signal can abort the right one. Entries are cleaned up when the
|
|
79
|
+
# step finishes.
|
|
80
|
+
self._step_tasks: Dict[str, asyncio.Task] = {}
|
|
81
|
+
# Tasks that live only as long as the current connection.
|
|
82
|
+
self._service_tasks: List[asyncio.Task] = []
|
|
83
|
+
self.system_stats: Optional[AgentSystemStats] = None
|
|
84
|
+
logger.debug(f"Switch to agent directory: {self.directory.dir}")
|
|
85
|
+
os.chdir(self.directory.dir)
|
|
86
|
+
|
|
87
|
+
self._init_logger()
|
|
88
|
+
|
|
89
|
+
def _init_logger(self):
|
|
90
|
+
self.logger = logging.getLogger("datatorch.runner")
|
|
91
|
+
self.logger_api_handler = AgentAPIHandler(self.api)
|
|
92
|
+
self.logger.addHandler(self.logger_api_handler)
|
|
93
|
+
self.logger.debug("Runner logger has been initialized.")
|
|
94
|
+
|
|
95
|
+
# -- connection lifecycle -------------------------------------------
|
|
96
|
+
|
|
97
|
+
async def serve(self, session: AsyncClientSession):
|
|
98
|
+
"""Works one connection until it ends.
|
|
99
|
+
|
|
100
|
+
Attaches the session (steps finishing now report through it),
|
|
101
|
+
starts the per-connection services, then runs the dispatch loop.
|
|
102
|
+
However the loop ends, the services are stopped and the API is
|
|
103
|
+
detached; running steps are left alone."""
|
|
104
|
+
self.api.attach(session)
|
|
105
|
+
try:
|
|
106
|
+
self._start_services()
|
|
107
|
+
await self.process_loop()
|
|
108
|
+
finally:
|
|
109
|
+
self.disconnect()
|
|
110
|
+
|
|
111
|
+
def disconnect(self) -> None:
|
|
112
|
+
"""The connection is gone (or being closed).
|
|
113
|
+
|
|
114
|
+
Refuses API calls until the next `serve` attaches a session and
|
|
115
|
+
cancels the per-connection services. Running steps are NOT
|
|
116
|
+
cancelled: a websocket blip is not a reason to kill a four-hour
|
|
117
|
+
step. Idempotent."""
|
|
118
|
+
self.api.detach()
|
|
119
|
+
for task in self._service_tasks:
|
|
120
|
+
if not task.done():
|
|
121
|
+
task.cancel()
|
|
122
|
+
self._service_tasks = []
|
|
123
|
+
|
|
124
|
+
def running_steps(self) -> List[asyncio.Task]:
|
|
125
|
+
"""The step tasks still in flight."""
|
|
126
|
+
return [task for task in self._step_tasks.values() if not task.done()]
|
|
127
|
+
|
|
128
|
+
def _service(self, coro) -> asyncio.Task:
|
|
129
|
+
loop = asyncio.get_running_loop()
|
|
130
|
+
task = _track(loop.create_task(coro))
|
|
131
|
+
self._service_tasks.append(task)
|
|
132
|
+
return task
|
|
133
|
+
|
|
134
|
+
def _start_services(self):
|
|
135
|
+
self.system_stats = AgentSystemStats(self)
|
|
136
|
+
self._service(self.system_stats.start())
|
|
137
|
+
|
|
138
|
+
# -- dispatch -------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
async def process_loop(self):
|
|
141
|
+
"""Drains steps missed while offline, then waits for pushes.
|
|
142
|
+
|
|
143
|
+
Runs the dispatch stream alongside a cancel-signal listener: the
|
|
144
|
+
server pushes a stop signal when a running step is canceled or
|
|
145
|
+
times out, and the agent aborts that step's task.
|
|
146
|
+
"""
|
|
147
|
+
pending = await self.api.pending_steps()
|
|
148
|
+
if pending:
|
|
149
|
+
logger.info(f"Recovered {len(pending)} pending step(s) on connect.")
|
|
150
|
+
for dispatch in pending:
|
|
151
|
+
self._start_step(dispatch)
|
|
152
|
+
|
|
153
|
+
logger.info("Waiting for steps.")
|
|
154
|
+
cancel_task = self._service(self._cancel_loop())
|
|
155
|
+
command_task = self._service(self._command_loop())
|
|
156
|
+
try:
|
|
157
|
+
async for request in self.api.agent_steps():
|
|
158
|
+
self._start_step(request.get("step"))
|
|
159
|
+
finally:
|
|
160
|
+
cancel_task.cancel()
|
|
161
|
+
command_task.cancel()
|
|
162
|
+
|
|
163
|
+
async def _command_loop(self):
|
|
164
|
+
"""Answers the server's requests about this runner's own action
|
|
165
|
+
folders (read or write `actions/local/<name>/<tag>`). Nothing
|
|
166
|
+
outside those folders is ever read or written: the directory
|
|
167
|
+
refuses any other path. A failure is the answer's error, never a
|
|
168
|
+
crash of the loop."""
|
|
169
|
+
try:
|
|
170
|
+
async for request in self.api.runner_commands():
|
|
171
|
+
command = request.get("command") or {}
|
|
172
|
+
self._service(self._answer_command(command))
|
|
173
|
+
except asyncio.CancelledError:
|
|
174
|
+
raise
|
|
175
|
+
except Exception as ex:
|
|
176
|
+
# A server from before runner commands has no such subscription;
|
|
177
|
+
# steps still flow, only editing from DataTorch is off.
|
|
178
|
+
logger.warning(f"Runner commands are not available: {ex}")
|
|
179
|
+
|
|
180
|
+
async def _answer_command(self, command: dict):
|
|
181
|
+
command_id = command.get("id")
|
|
182
|
+
kind = command.get("kind")
|
|
183
|
+
payload = command.get("payload") or {}
|
|
184
|
+
result, error = None, None
|
|
185
|
+
try:
|
|
186
|
+
name, tag = payload.get("name"), payload.get("tag")
|
|
187
|
+
if kind == "READ_LOCAL_ACTION":
|
|
188
|
+
result = {"files": runner_directory.local_action_files(name, tag)}
|
|
189
|
+
elif kind == "WRITE_LOCAL_ACTION":
|
|
190
|
+
files = runner_directory.write_local_action_files(
|
|
191
|
+
name, tag, payload.get("files") or [], payload.get("delete") or []
|
|
192
|
+
)
|
|
193
|
+
result = {"files": files}
|
|
194
|
+
logger.info(f"Wrote actions/local/{name}/{tag} on request.")
|
|
195
|
+
# Report the folder's yaml now, so the picker lists it at once.
|
|
196
|
+
try:
|
|
197
|
+
await self.api.report_local_actions(
|
|
198
|
+
runner_directory.local_actions()
|
|
199
|
+
)
|
|
200
|
+
except Exception as ex:
|
|
201
|
+
logger.warning(
|
|
202
|
+
f"Could not report local actions after a write: {ex}"
|
|
203
|
+
)
|
|
204
|
+
else:
|
|
205
|
+
error = f"Unknown runner command '{kind}'."
|
|
206
|
+
except (ValueError, FileNotFoundError, OSError) as ex:
|
|
207
|
+
error = str(ex)
|
|
208
|
+
except Exception as ex: # the loop must survive anything
|
|
209
|
+
logger.exception("Runner command failed.")
|
|
210
|
+
error = f"{type(ex).__name__}: {ex}"
|
|
211
|
+
try:
|
|
212
|
+
await self.api.complete_runner_command(
|
|
213
|
+
command_id, result=result, error=error
|
|
214
|
+
)
|
|
215
|
+
except Exception as ex:
|
|
216
|
+
logger.warning(f"Could not answer runner command {command_id}: {ex}")
|
|
217
|
+
|
|
218
|
+
async def _cancel_loop(self):
|
|
219
|
+
"""Aborts a running step's task when the server signals a stop."""
|
|
220
|
+
async for request in self.api.agent_step_cancels():
|
|
221
|
+
cancel = request.get("cancel") or {}
|
|
222
|
+
step_id = cancel.get("stepId")
|
|
223
|
+
task = self._step_tasks.get(step_id)
|
|
224
|
+
if task and not task.done():
|
|
225
|
+
logger.info(f"Received stop signal for step {step_id}.")
|
|
226
|
+
task.cancel()
|
|
227
|
+
else:
|
|
228
|
+
# Unknown/finished step — the signal is best-effort and the
|
|
229
|
+
# server has already moved the step terminal.
|
|
230
|
+
logger.debug(f"Stop signal for step {step_id} ignored (not running).")
|
|
231
|
+
|
|
232
|
+
def _start_step(self, dispatch: AgentStepDispatch):
|
|
233
|
+
"""Schedules a step and tracks its task for cancellation."""
|
|
234
|
+
step_id = dispatch.get("stepId")
|
|
235
|
+
running = self._step_tasks.get(step_id)
|
|
236
|
+
if running and not running.done():
|
|
237
|
+
# A second push for a step this process already works on (a
|
|
238
|
+
# dispatch repeated across a reconnect). The claim would be
|
|
239
|
+
# refused anyway; don't start a second task for it.
|
|
240
|
+
logger.debug(f"Step {step_id} is already running here; push ignored.")
|
|
241
|
+
return
|
|
242
|
+
loop = asyncio.get_running_loop()
|
|
243
|
+
task = _track(loop.create_task(self._run_step(dispatch)))
|
|
244
|
+
self._step_tasks[step_id] = task
|
|
245
|
+
|
|
246
|
+
async def _run_step(self, dispatch: AgentStepDispatch):
|
|
247
|
+
"""Claims and executes a single dispatched step."""
|
|
248
|
+
step_id = dispatch.get("stepId")
|
|
249
|
+
step_name = dispatch.get("stepName") or dispatch.get("action")
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
try:
|
|
253
|
+
claimed = await self.api.claim_step(step_id)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
# The connection went before the claim got through. The
|
|
256
|
+
# step stays QUEUED on the server and comes back through
|
|
257
|
+
# `pending_steps` on reconnect (or the reconciler
|
|
258
|
+
# re-drives it), so nothing is lost by stopping here.
|
|
259
|
+
logger.warning(
|
|
260
|
+
f"Could not claim step {step_id} ({e}); "
|
|
261
|
+
"it is offered again on reconnect."
|
|
262
|
+
)
|
|
263
|
+
return
|
|
264
|
+
if not claimed:
|
|
265
|
+
# Another process (or an earlier duplicate dispatch) owns it.
|
|
266
|
+
logger.debug(f"Step {step_id} already claimed; skipping.")
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
logger.info(f"Step received (name: {step_name}, id: {step_id})")
|
|
270
|
+
step = Step.from_dispatch(dispatch, api=self.api)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
outputs = await step.run(Variables())
|
|
274
|
+
except asyncio.CancelledError:
|
|
275
|
+
# A server stop signal (the step is already terminal
|
|
276
|
+
# server-side) or a real shutdown of this runner: the child
|
|
277
|
+
# process was killed, so don't report a result. The last
|
|
278
|
+
# log lines are best effort: the connection may be the very
|
|
279
|
+
# thing that is gone.
|
|
280
|
+
logger.info(f"Step {step_id} aborted.")
|
|
281
|
+
try:
|
|
282
|
+
await step.upload_logs()
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
raise
|
|
286
|
+
except Exception as e:
|
|
287
|
+
error_message = str(e)
|
|
288
|
+
logger.error(f"Step {step_id} failed: {error_message}")
|
|
289
|
+
step.log(f"Step failed: {error_message}.")
|
|
290
|
+
await self._report(
|
|
291
|
+
step,
|
|
292
|
+
lambda: self.api.complete_step(
|
|
293
|
+
step_id, success=False, error_message=error_message
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
reported = await self._report(
|
|
299
|
+
step,
|
|
300
|
+
lambda: self.api.complete_step(
|
|
301
|
+
step_id,
|
|
302
|
+
success=True,
|
|
303
|
+
output=outputs,
|
|
304
|
+
rendered_input=step.rendered_inputs,
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
if reported:
|
|
308
|
+
logger.info(f"Step finished (name: {step_name}, id: {step_id})")
|
|
309
|
+
finally:
|
|
310
|
+
self._step_tasks.pop(step_id, None)
|
|
311
|
+
|
|
312
|
+
async def _report(self, step: Step, send: Callable[[], Awaitable[bool]]) -> bool:
|
|
313
|
+
"""Gets a finished step's result to the server, across an outage.
|
|
314
|
+
|
|
315
|
+
Ships the step's remaining log lines, then the completion. While
|
|
316
|
+
the runner is disconnected it waits for the next session; a call
|
|
317
|
+
that fails because of the connection is retried. It gives up after
|
|
318
|
+
`REPORT_DEADLINE_SECONDS`, logging the loss. Returns whether the
|
|
319
|
+
server accepted the result: False also when the server had already
|
|
320
|
+
moved the step terminal (failed by the reconciler while this runner
|
|
321
|
+
was offline), which is logged and otherwise absorbed.
|
|
322
|
+
"""
|
|
323
|
+
loop = asyncio.get_running_loop()
|
|
324
|
+
deadline = loop.time() + REPORT_DEADLINE_SECONDS
|
|
325
|
+
logs_sent = False
|
|
326
|
+
waiting_logged = False
|
|
327
|
+
while True:
|
|
328
|
+
remaining = deadline - loop.time()
|
|
329
|
+
if remaining <= 0:
|
|
330
|
+
logger.error(
|
|
331
|
+
f"Step {step.id}: could not report its result within "
|
|
332
|
+
f"{REPORT_DEADLINE_SECONDS} seconds of finishing; the result "
|
|
333
|
+
"is dropped (the server fails a step whose runner stays "
|
|
334
|
+
"unseen, so it is not left hanging)."
|
|
335
|
+
)
|
|
336
|
+
return False
|
|
337
|
+
if not self.api.connected:
|
|
338
|
+
if not waiting_logged:
|
|
339
|
+
logger.warning(
|
|
340
|
+
f"Step {step.id} finished while disconnected; holding "
|
|
341
|
+
"its result until the connection is back."
|
|
342
|
+
)
|
|
343
|
+
waiting_logged = True
|
|
344
|
+
await self.api.wait_connected(timeout=remaining)
|
|
345
|
+
continue
|
|
346
|
+
try:
|
|
347
|
+
if not logs_sent:
|
|
348
|
+
await asyncio.wait_for(
|
|
349
|
+
step.upload_logs(), REPORT_CALL_TIMEOUT_SECONDS
|
|
350
|
+
)
|
|
351
|
+
logs_sent = True
|
|
352
|
+
accepted = await asyncio.wait_for(send(), REPORT_CALL_TIMEOUT_SECONDS)
|
|
353
|
+
except asyncio.CancelledError:
|
|
354
|
+
raise
|
|
355
|
+
except Exception as e:
|
|
356
|
+
if not is_transient(e):
|
|
357
|
+
logger.error(f"Step {step.id}: the server refused the result: {e}")
|
|
358
|
+
return False
|
|
359
|
+
logger.warning(f"Step {step.id}: reporting failed ({e}); retrying.")
|
|
360
|
+
await asyncio.sleep(min(REPORT_RETRY_SECONDS, max(remaining, 0)))
|
|
361
|
+
continue
|
|
362
|
+
if not accepted:
|
|
363
|
+
logger.warning(
|
|
364
|
+
f"Step {step.id}: the server did not accept the result; the "
|
|
365
|
+
"step was already terminal there (failed while this runner "
|
|
366
|
+
"was offline, canceled, or timed out)."
|
|
367
|
+
)
|
|
368
|
+
return bool(accepted)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# Pre-rename name, kept for callers outside this package.
|
|
372
|
+
Agent = RunnerDaemon
|