workerz 0.0.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.
- workerz-0.0.0/PKG-INFO +23 -0
- workerz-0.0.0/README.md +1 -0
- workerz-0.0.0/pyproject.toml +26 -0
- workerz-0.0.0/setup.cfg +4 -0
- workerz-0.0.0/src/workerz/__init__.py +1 -0
- workerz-0.0.0/src/workerz/coordinator/__init__.py +0 -0
- workerz-0.0.0/src/workerz/coordinator/__main__.py +3 -0
- workerz-0.0.0/src/workerz/coordinator/app.py +376 -0
- workerz-0.0.0/src/workerz/dashboard/__init__.py +0 -0
- workerz-0.0.0/src/workerz/dashboard/__main__.py +3 -0
- workerz-0.0.0/src/workerz/dashboard/app.py +80 -0
- workerz-0.0.0/src/workerz/dashboard/index.html +149 -0
- workerz-0.0.0/src/workerz/exceptions.py +11 -0
- workerz-0.0.0/src/workerz/protocol.py +61 -0
- workerz-0.0.0/src/workerz/sdk/__init__.py +0 -0
- workerz-0.0.0/src/workerz/sdk/client.py +93 -0
- workerz-0.0.0/src/workerz/task.py +3 -0
- workerz-0.0.0/src/workerz/worker/__init__.py +0 -0
- workerz-0.0.0/src/workerz/worker/__main__.py +15 -0
- workerz-0.0.0/src/workerz/worker/worker.py +130 -0
- workerz-0.0.0/src/workerz.egg-info/PKG-INFO +23 -0
- workerz-0.0.0/src/workerz.egg-info/SOURCES.txt +23 -0
- workerz-0.0.0/src/workerz.egg-info/dependency_links.txt +1 -0
- workerz-0.0.0/src/workerz.egg-info/requires.txt +23 -0
- workerz-0.0.0/src/workerz.egg-info/top_level.txt +1 -0
workerz-0.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: workerz
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Better Workers.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Provides-Extra: coordinator
|
|
7
|
+
Requires-Dist: starlette>=0.41; extra == "coordinator"
|
|
8
|
+
Requires-Dist: uvicorn>=0.29; extra == "coordinator"
|
|
9
|
+
Requires-Dist: redis>=5.0; extra == "coordinator"
|
|
10
|
+
Requires-Dist: msgspec>=0.18; extra == "coordinator"
|
|
11
|
+
Provides-Extra: dashboard
|
|
12
|
+
Requires-Dist: starlette>=0.41; extra == "dashboard"
|
|
13
|
+
Requires-Dist: uvicorn>=0.29; extra == "dashboard"
|
|
14
|
+
Requires-Dist: redis>=5.0; extra == "dashboard"
|
|
15
|
+
Provides-Extra: worker
|
|
16
|
+
Requires-Dist: msgspec>=0.18; extra == "worker"
|
|
17
|
+
Provides-Extra: sdk
|
|
18
|
+
Requires-Dist: httpx>=0.27; extra == "sdk"
|
|
19
|
+
Provides-Extra: full
|
|
20
|
+
Requires-Dist: workerz[coordinator]; extra == "full"
|
|
21
|
+
Requires-Dist: workerz[dashboard]; extra == "full"
|
|
22
|
+
Requires-Dist: workerz[worker]; extra == "full"
|
|
23
|
+
Requires-Dist: workerz[sdk]; extra == "full"
|
workerz-0.0.0/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
workers.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=72"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "workerz"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Better Workers."
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
coordinator = ["starlette>=0.41", "uvicorn>=0.29", "redis>=5.0", "msgspec>=0.18"]
|
|
14
|
+
dashboard = ["starlette>=0.41", "uvicorn>=0.29", "redis>=5.0"]
|
|
15
|
+
worker = ["msgspec>=0.18"]
|
|
16
|
+
sdk = ["httpx>=0.27"]
|
|
17
|
+
full = ["workerz[coordinator]", "workerz[dashboard]", "workerz[worker]", "workerz[sdk]"]
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.package-data]
|
|
23
|
+
"workerz.dashboard" = ["index.html"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.dynamic]
|
|
26
|
+
version = {attr = "workerz.__version__"}
|
workerz-0.0.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.0"
|
|
File without changes
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Coordinator — stateless re: jobs (all job state lives in Redis).
|
|
3
|
+
In-memory: only TCP writer handles.
|
|
4
|
+
|
|
5
|
+
HTTP routes:
|
|
6
|
+
POST /job submit job
|
|
7
|
+
GET /job/{id} get job (status + result envelope)
|
|
8
|
+
DELETE /job/{id} cancel job
|
|
9
|
+
|
|
10
|
+
TCP: workers connect, coordinator pushes Dispatch messages.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import uuid
|
|
17
|
+
from contextlib import asynccontextmanager
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
import redis.asyncio as aioredis
|
|
21
|
+
from starlette.applications import Starlette
|
|
22
|
+
from starlette.requests import Request
|
|
23
|
+
from starlette.responses import JSONResponse
|
|
24
|
+
from starlette.routing import Route
|
|
25
|
+
|
|
26
|
+
from workerz.protocol import (
|
|
27
|
+
Cancel, Dispatch, JobStatus, JobUpdate, Ping, Pong, Register,
|
|
28
|
+
recv_msg, send_msg,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
TCP_HOST = os.environ.get("WORKERZ_TCP_HOST", "0.0.0.0")
|
|
32
|
+
TCP_PORT = int(os.environ.get("WORKERZ_TCP_PORT", 7777))
|
|
33
|
+
HTTP_PORT = int(os.environ.get("WORKERZ_HTTP_PORT", 8000))
|
|
34
|
+
REDIS_URL = os.environ.get("WORKERZ_REDIS_URL", "redis://localhost:6379")
|
|
35
|
+
|
|
36
|
+
PING_INTERVAL = 10
|
|
37
|
+
PONG_TIMEOUT = 15
|
|
38
|
+
|
|
39
|
+
# Only thing kept in memory: writer handles keyed by worker_id
|
|
40
|
+
_writers: dict[str, asyncio.StreamWriter] = {}
|
|
41
|
+
|
|
42
|
+
rdb: aioredis.Redis = None
|
|
43
|
+
queue: asyncio.Queue = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _now() -> str:
|
|
47
|
+
return datetime.now(timezone.utc).isoformat()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Redis helpers ─────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
async def _save_worker(worker_id: str, labels: list[str], status: str, current_job: str = ""):
|
|
53
|
+
await rdb.sadd("workerz:workers", worker_id)
|
|
54
|
+
await rdb.hset(f"workerz:worker:{worker_id}", mapping={
|
|
55
|
+
"id": worker_id,
|
|
56
|
+
"labels": json.dumps(labels),
|
|
57
|
+
"status": status,
|
|
58
|
+
"current_job": current_job,
|
|
59
|
+
"last_seen": _now(),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _delete_worker(worker_id: str):
|
|
64
|
+
await rdb.srem("workerz:workers", worker_id)
|
|
65
|
+
await rdb.delete(f"workerz:worker:{worker_id}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _save_job(job: dict):
|
|
69
|
+
await rdb.sadd("workerz:jobs", job["id"])
|
|
70
|
+
await rdb.hset(f"workerz:job:{job['id']}", mapping={
|
|
71
|
+
"id": job["id"],
|
|
72
|
+
"task": job["task"],
|
|
73
|
+
"args": json.dumps(job["args"]),
|
|
74
|
+
"kwargs": json.dumps(job["kwargs"]),
|
|
75
|
+
"labels": json.dumps(job.get("labels") or []),
|
|
76
|
+
"status": job["status"],
|
|
77
|
+
"worker_id": job.get("worker_id") or "",
|
|
78
|
+
"result": job.get("result") or "",
|
|
79
|
+
"error": job.get("error") or "",
|
|
80
|
+
"warnings": json.dumps(job.get("warnings") or []),
|
|
81
|
+
"infos": json.dumps(job.get("infos") or []),
|
|
82
|
+
"debug": json.dumps(job.get("debug") or []),
|
|
83
|
+
"meta": json.dumps(job.get("meta") or {}),
|
|
84
|
+
"created_at": job["created_at"],
|
|
85
|
+
"finished_at": job.get("finished_at") or "",
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _load_job(job_id: str) -> dict | None:
|
|
90
|
+
raw = await rdb.hgetall(f"workerz:job:{job_id}")
|
|
91
|
+
if not raw:
|
|
92
|
+
return None
|
|
93
|
+
return {
|
|
94
|
+
"id": raw["id"],
|
|
95
|
+
"task": raw["task"],
|
|
96
|
+
"args": json.loads(raw["args"]),
|
|
97
|
+
"kwargs": json.loads(raw["kwargs"]),
|
|
98
|
+
"labels": json.loads(raw.get("labels", "[]")),
|
|
99
|
+
"status": raw["status"],
|
|
100
|
+
"worker_id": raw.get("worker_id") or None,
|
|
101
|
+
"result": json.loads(raw["result"]) if raw.get("result") else None,
|
|
102
|
+
"error": raw.get("error") or None,
|
|
103
|
+
"warnings": json.loads(raw.get("warnings", "[]")),
|
|
104
|
+
"infos": json.loads(raw.get("infos", "[]")),
|
|
105
|
+
"debug": json.loads(raw.get("debug", "[]")),
|
|
106
|
+
"meta": json.loads(raw.get("meta", "{}")),
|
|
107
|
+
"created_at": raw["created_at"],
|
|
108
|
+
"finished_at": raw.get("finished_at") or None,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _load_all_workers() -> list[dict]:
|
|
113
|
+
ids = await rdb.smembers("workerz:workers")
|
|
114
|
+
out = []
|
|
115
|
+
for wid in ids:
|
|
116
|
+
raw = await rdb.hgetall(f"workerz:worker:{wid}")
|
|
117
|
+
if raw:
|
|
118
|
+
out.append({
|
|
119
|
+
"id": raw["id"],
|
|
120
|
+
"labels": json.loads(raw.get("labels", "[]")),
|
|
121
|
+
"status": raw.get("status", "offline"),
|
|
122
|
+
"current_job": raw.get("current_job") or None,
|
|
123
|
+
"last_seen": raw.get("last_seen"),
|
|
124
|
+
})
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── Dispatch ──────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
async def _find_idle_worker(labels: list[str]) -> tuple[str, list[str]] | None:
|
|
131
|
+
"""Return (worker_id, worker_labels) of an idle worker matching all requested labels, or None."""
|
|
132
|
+
for wid, writer in _writers.items():
|
|
133
|
+
raw = await rdb.hgetall(f"workerz:worker:{wid}")
|
|
134
|
+
if not raw or raw.get("status") != "idle":
|
|
135
|
+
continue
|
|
136
|
+
worker_labels = set(json.loads(raw.get("labels", "[]")))
|
|
137
|
+
if not labels or set(labels).issubset(worker_labels):
|
|
138
|
+
return wid, list(worker_labels)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _dispatch_next():
|
|
143
|
+
"""Try to assign queued jobs to idle workers."""
|
|
144
|
+
pending = []
|
|
145
|
+
while not queue.empty():
|
|
146
|
+
job = await queue.get()
|
|
147
|
+
match = await _find_idle_worker(job.get("labels") or [])
|
|
148
|
+
if match:
|
|
149
|
+
wid, _ = match
|
|
150
|
+
await _assign(wid, job)
|
|
151
|
+
else:
|
|
152
|
+
pending.append(job)
|
|
153
|
+
for job in pending:
|
|
154
|
+
await queue.put(job)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _assign(worker_id: str, job: dict):
|
|
158
|
+
writer = _writers.get(worker_id)
|
|
159
|
+
if not writer:
|
|
160
|
+
await queue.put(job)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
raw = await rdb.hgetall(f"workerz:worker:{worker_id}")
|
|
164
|
+
labels = json.loads(raw.get("labels", "[]")) if raw else []
|
|
165
|
+
|
|
166
|
+
job["status"] = "dispatched"
|
|
167
|
+
job["worker_id"] = worker_id
|
|
168
|
+
await _save_job(job)
|
|
169
|
+
await _save_worker(worker_id, labels, "busy", job["id"])
|
|
170
|
+
await send_msg(writer, Dispatch(
|
|
171
|
+
job_id=job["id"], task=job["task"],
|
|
172
|
+
args=job["args"], kwargs=job["kwargs"],
|
|
173
|
+
))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── TCP ───────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
async def handle_worker(reader, writer):
|
|
179
|
+
worker_id = None
|
|
180
|
+
labels = []
|
|
181
|
+
try:
|
|
182
|
+
msg = await recv_msg(reader)
|
|
183
|
+
if not isinstance(msg, Register):
|
|
184
|
+
writer.close()
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
worker_id = msg.worker_id
|
|
188
|
+
labels = msg.labels
|
|
189
|
+
_writers[worker_id] = writer
|
|
190
|
+
|
|
191
|
+
await _save_worker(worker_id, labels, "idle")
|
|
192
|
+
print(f"[coordinator] worker {worker_id} connected labels={labels}")
|
|
193
|
+
|
|
194
|
+
await _dispatch_next()
|
|
195
|
+
asyncio.create_task(_ping_loop(worker_id, writer))
|
|
196
|
+
|
|
197
|
+
while True:
|
|
198
|
+
msg = await recv_msg(reader)
|
|
199
|
+
|
|
200
|
+
if isinstance(msg, Pong):
|
|
201
|
+
pass # heartbeat ack, nothing to do
|
|
202
|
+
|
|
203
|
+
elif isinstance(msg, JobStatus):
|
|
204
|
+
job = await _load_job(msg.job_id)
|
|
205
|
+
if not job:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
if msg.status == "running":
|
|
209
|
+
job["status"] = "running"
|
|
210
|
+
await _save_job(job)
|
|
211
|
+
await _save_worker(worker_id, labels, "busy", msg.job_id)
|
|
212
|
+
|
|
213
|
+
elif msg.status in ("done", "error", "cancelled"):
|
|
214
|
+
job.update({
|
|
215
|
+
"status": msg.status,
|
|
216
|
+
"result": json.loads(msg.result) if msg.result else None,
|
|
217
|
+
"error": msg.error,
|
|
218
|
+
"warnings": msg.warnings,
|
|
219
|
+
"infos": msg.infos,
|
|
220
|
+
"debug": msg.debug,
|
|
221
|
+
"finished_at": _now(),
|
|
222
|
+
})
|
|
223
|
+
await _save_job(job)
|
|
224
|
+
await _save_worker(worker_id, labels, "idle")
|
|
225
|
+
await _dispatch_next()
|
|
226
|
+
|
|
227
|
+
elif isinstance(msg, JobUpdate):
|
|
228
|
+
# Merge meta into job
|
|
229
|
+
job = await _load_job(msg.job_id)
|
|
230
|
+
if job:
|
|
231
|
+
job["meta"].update(msg.meta)
|
|
232
|
+
await _save_job(job)
|
|
233
|
+
|
|
234
|
+
except (asyncio.IncompleteReadError, ConnectionResetError):
|
|
235
|
+
pass
|
|
236
|
+
finally:
|
|
237
|
+
if worker_id:
|
|
238
|
+
_writers.pop(worker_id, None)
|
|
239
|
+
await _save_worker(worker_id, labels, "offline")
|
|
240
|
+
print(f"[coordinator] worker {worker_id} disconnected")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def _ping_loop(worker_id: str, writer):
|
|
244
|
+
loop = asyncio.get_event_loop()
|
|
245
|
+
last_pong = loop.time()
|
|
246
|
+
try:
|
|
247
|
+
while _writers.get(worker_id) is writer:
|
|
248
|
+
await asyncio.sleep(PING_INTERVAL)
|
|
249
|
+
if _writers.get(worker_id) is not writer:
|
|
250
|
+
break
|
|
251
|
+
await send_msg(writer, Ping())
|
|
252
|
+
await asyncio.sleep(PONG_TIMEOUT)
|
|
253
|
+
# If no pong within window, drop connection
|
|
254
|
+
raw = await rdb.hgetall(f"workerz:worker:{worker_id}")
|
|
255
|
+
if raw.get("status") == "offline":
|
|
256
|
+
break
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ── HTTP ──────────────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
async def route_post_job(request: Request):
|
|
264
|
+
body = await request.json()
|
|
265
|
+
labels = body.get("labels") or []
|
|
266
|
+
|
|
267
|
+
# Fail fast if no worker with these labels is registered at all
|
|
268
|
+
if labels:
|
|
269
|
+
all_workers = await _load_all_workers()
|
|
270
|
+
label_set = set(labels)
|
|
271
|
+
if not any(label_set.issubset(set(w["labels"])) for w in all_workers):
|
|
272
|
+
return JSONResponse(
|
|
273
|
+
{"error": "no_worker", "detail": f"no worker registered with labels {labels}"},
|
|
274
|
+
status_code=409,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
job_id = str(uuid.uuid4())
|
|
278
|
+
job = {
|
|
279
|
+
"id": job_id,
|
|
280
|
+
"task": body["task"],
|
|
281
|
+
"args": body.get("args", []),
|
|
282
|
+
"kwargs": body.get("kwargs", {}),
|
|
283
|
+
"labels": labels,
|
|
284
|
+
"status": "queued",
|
|
285
|
+
"worker_id": None,
|
|
286
|
+
"result": None,
|
|
287
|
+
"error": None,
|
|
288
|
+
"warnings": [],
|
|
289
|
+
"infos": [],
|
|
290
|
+
"debug": [],
|
|
291
|
+
"meta": {},
|
|
292
|
+
"created_at": _now(),
|
|
293
|
+
"finished_at": None,
|
|
294
|
+
}
|
|
295
|
+
await _save_job(job)
|
|
296
|
+
|
|
297
|
+
match = await _find_idle_worker(labels)
|
|
298
|
+
if match:
|
|
299
|
+
wid, _ = match
|
|
300
|
+
await _assign(wid, job)
|
|
301
|
+
else:
|
|
302
|
+
await queue.put(job)
|
|
303
|
+
|
|
304
|
+
return JSONResponse({"job_id": job_id})
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def route_get_job(request: Request):
|
|
308
|
+
job = await _load_job(request.path_params["job_id"])
|
|
309
|
+
if not job:
|
|
310
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
311
|
+
return JSONResponse(job)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def route_cancel_job(request: Request):
|
|
315
|
+
job_id = request.path_params["job_id"]
|
|
316
|
+
job = await _load_job(job_id)
|
|
317
|
+
if not job:
|
|
318
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
319
|
+
|
|
320
|
+
if job["status"] == "queued":
|
|
321
|
+
job.update({"status": "cancelled", "finished_at": _now()})
|
|
322
|
+
await _save_job(job)
|
|
323
|
+
return JSONResponse({"cancelled": True})
|
|
324
|
+
|
|
325
|
+
if job["status"] in ("dispatched", "running"):
|
|
326
|
+
wid = job.get("worker_id")
|
|
327
|
+
if wid and _writers.get(wid):
|
|
328
|
+
await send_msg(_writers[wid], Cancel(job_id=job_id))
|
|
329
|
+
job.update({"status": "cancelled", "finished_at": _now()})
|
|
330
|
+
await _save_job(job)
|
|
331
|
+
return JSONResponse({"cancelling": True})
|
|
332
|
+
|
|
333
|
+
return JSONResponse({"error": "not cancellable", "status": job["status"]})
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ── Lifespan ──────────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
@asynccontextmanager
|
|
339
|
+
async def lifespan(app):
|
|
340
|
+
global rdb, queue
|
|
341
|
+
queue = asyncio.Queue()
|
|
342
|
+
rdb = aioredis.from_url(REDIS_URL, decode_responses=True)
|
|
343
|
+
|
|
344
|
+
# Re-queue jobs that were in-flight when coordinator last died
|
|
345
|
+
for jid in await rdb.smembers("workerz:jobs"):
|
|
346
|
+
raw = await rdb.hgetall(f"workerz:job:{jid}")
|
|
347
|
+
if raw and raw.get("status") in ("queued", "dispatched"):
|
|
348
|
+
job = await _load_job(jid)
|
|
349
|
+
if job:
|
|
350
|
+
job["status"] = "queued"
|
|
351
|
+
job["worker_id"] = None
|
|
352
|
+
await _save_job(job)
|
|
353
|
+
await queue.put(job)
|
|
354
|
+
|
|
355
|
+
# Mark all workers offline (they'll re-register via TCP)
|
|
356
|
+
for wid in await rdb.smembers("workerz:workers"):
|
|
357
|
+
raw = await rdb.hgetall(f"workerz:worker:{wid}")
|
|
358
|
+
if raw:
|
|
359
|
+
await rdb.hset(f"workerz:worker:{wid}", mapping={"status": "offline", "current_job": ""})
|
|
360
|
+
|
|
361
|
+
tcp = await asyncio.start_server(handle_worker, TCP_HOST, TCP_PORT)
|
|
362
|
+
asyncio.get_event_loop().create_task(tcp.serve_forever())
|
|
363
|
+
print(f"coordinator http://0.0.0.0:{HTTP_PORT} tcp://0.0.0.0:{TCP_PORT} redis={REDIS_URL}")
|
|
364
|
+
yield
|
|
365
|
+
tcp.close()
|
|
366
|
+
await rdb.aclose()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
app = Starlette(
|
|
370
|
+
lifespan=lifespan,
|
|
371
|
+
routes=[
|
|
372
|
+
Route("/job", route_post_job, methods=["POST"]),
|
|
373
|
+
Route("/job/{job_id}", route_get_job, methods=["GET"]),
|
|
374
|
+
Route("/job/{job_id}", route_cancel_job, methods=["DELETE"]),
|
|
375
|
+
],
|
|
376
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import redis.asyncio as aioredis
|
|
7
|
+
from starlette.applications import Starlette
|
|
8
|
+
from starlette.requests import Request
|
|
9
|
+
from starlette.responses import HTMLResponse, JSONResponse
|
|
10
|
+
from starlette.routing import Route
|
|
11
|
+
|
|
12
|
+
REDIS_URL = os.environ.get("WORKERZ_REDIS_URL", "redis://localhost:6379")
|
|
13
|
+
HTTP_PORT = int(os.environ.get("WORKERZ_DASHBOARD_PORT", 8080))
|
|
14
|
+
|
|
15
|
+
rdb: aioredis.Redis = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def route_dashboard(request: Request):
|
|
19
|
+
html = (Path(__file__).parent / "index.html").read_text()
|
|
20
|
+
return HTMLResponse(html)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def route_jobs(request: Request):
|
|
24
|
+
ids = await rdb.smembers("workerz:jobs")
|
|
25
|
+
jobs = []
|
|
26
|
+
for jid in ids:
|
|
27
|
+
raw = await rdb.hgetall(f"workerz:job:{jid}")
|
|
28
|
+
if raw:
|
|
29
|
+
jobs.append({
|
|
30
|
+
"id": raw["id"],
|
|
31
|
+
"task": raw["task"],
|
|
32
|
+
"labels": json.loads(raw.get("labels", "[]")),
|
|
33
|
+
"status": raw["status"],
|
|
34
|
+
"worker_id": raw.get("worker_id") or None,
|
|
35
|
+
"result": json.loads(raw["result"]) if raw.get("result") else None,
|
|
36
|
+
"error": raw.get("error") or None,
|
|
37
|
+
"warnings": json.loads(raw.get("warnings", "[]")),
|
|
38
|
+
"infos": json.loads(raw.get("infos", "[]")),
|
|
39
|
+
"debug": json.loads(raw.get("debug", "[]")),
|
|
40
|
+
"meta": json.loads(raw.get("meta", "{}")),
|
|
41
|
+
"created_at": raw["created_at"],
|
|
42
|
+
"finished_at": raw.get("finished_at") or None,
|
|
43
|
+
})
|
|
44
|
+
jobs.sort(key=lambda j: j["created_at"], reverse=True)
|
|
45
|
+
return JSONResponse(jobs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def route_workers(request: Request):
|
|
49
|
+
ids = await rdb.smembers("workerz:workers")
|
|
50
|
+
workers = []
|
|
51
|
+
for wid in ids:
|
|
52
|
+
raw = await rdb.hgetall(f"workerz:worker:{wid}")
|
|
53
|
+
if raw:
|
|
54
|
+
workers.append({
|
|
55
|
+
"id": raw["id"],
|
|
56
|
+
"labels": json.loads(raw.get("labels", "[]")),
|
|
57
|
+
"status": raw.get("status", "offline"),
|
|
58
|
+
"current_job": raw.get("current_job") or None,
|
|
59
|
+
"last_seen": raw.get("last_seen"),
|
|
60
|
+
})
|
|
61
|
+
return JSONResponse(workers)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@asynccontextmanager
|
|
65
|
+
async def lifespan(app):
|
|
66
|
+
global rdb
|
|
67
|
+
rdb = aioredis.from_url(REDIS_URL, decode_responses=True)
|
|
68
|
+
print(f"dashboard http://0.0.0.0:{HTTP_PORT} redis={REDIS_URL}")
|
|
69
|
+
yield
|
|
70
|
+
await rdb.aclose()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
app = Starlette(
|
|
74
|
+
lifespan=lifespan,
|
|
75
|
+
routes=[
|
|
76
|
+
Route("/", route_dashboard),
|
|
77
|
+
Route("/jobs", route_jobs),
|
|
78
|
+
Route("/workers", route_workers),
|
|
79
|
+
],
|
|
80
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>workerz</title>
|
|
6
|
+
<link rel="stylesheet" href="https://assets.ubuntu.com/v1/vanilla_framework_version_4.51.0.min.css" />
|
|
7
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
|
8
|
+
<style>
|
|
9
|
+
html { background:#666; padding:0.5in; overflow:auto; min-width:10in; display:flex; justify-content:center; }
|
|
10
|
+
body { box-sizing:border-box; width:8.27in; min-height:11.69in; margin:0; padding:0.4in;
|
|
11
|
+
background:#fff; border-radius:1px; box-shadow:0 0 1in -0.25in rgba(0,0,0,.5); overflow:hidden; }
|
|
12
|
+
.p-table td, .p-table th { padding:0.25rem 0.5rem !important; font-size:0.72rem; }
|
|
13
|
+
.p-table th { font-size:0.65rem; }
|
|
14
|
+
.p-muted-heading { margin-bottom:0.4rem !important; font-size:0.65rem; }
|
|
15
|
+
.p-status-label { font-size:0.6rem; padding:1px 5px; }
|
|
16
|
+
</style>
|
|
17
|
+
</head>
|
|
18
|
+
<body class="u-no-margin u-no-padding">
|
|
19
|
+
<div style="background:#000;color:#fff;padding:0.35rem 0.5rem;border-radius:0.2em;
|
|
20
|
+
margin-bottom:0.8rem;display:flex;align-items:center;justify-content:space-between;">
|
|
21
|
+
<div>
|
|
22
|
+
<strong style="letter-spacing:0.4em;text-transform:uppercase;font-size:0.8rem;">Workerz</strong>
|
|
23
|
+
<span class="u-text--muted" style="font-size:0.68rem;margin-left:0.8rem;">
|
|
24
|
+
Dashboard — <span id="hdr-host"></span>
|
|
25
|
+
</span>
|
|
26
|
+
</div>
|
|
27
|
+
<div style="font-size:0.68rem;color:#aaa;text-align:right;line-height:1.8;">
|
|
28
|
+
Workers <strong id="hdr-worker-count" style="color:#fff">0</strong>
|
|
29
|
+
 Queued <strong id="hdr-queued" style="color:#fff">0</strong>
|
|
30
|
+
 <span id="hdr-time" style="color:#aaa"></span>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<!-- Stats -->
|
|
35
|
+
<div class="row u-no-margin--bottom" style="margin-bottom:0.8rem;">
|
|
36
|
+
<div class="col-2"><div class="p-card u-no-margin u-align--center" style="padding:0.4rem 0.2rem;">
|
|
37
|
+
<div class="p-heading--4 u-no-margin" id="stat-total">0</div>
|
|
38
|
+
<div class="u-text--muted" style="font-size:0.6rem;text-transform:uppercase;letter-spacing:.08em;">Total</div>
|
|
39
|
+
</div></div>
|
|
40
|
+
<div class="col-2"><div class="p-card u-no-margin u-align--center" style="padding:0.4rem 0.2rem;">
|
|
41
|
+
<div class="p-heading--4 u-no-margin" id="stat-running">0</div>
|
|
42
|
+
<div class="u-text--muted" style="font-size:0.6rem;text-transform:uppercase;letter-spacing:.08em;">Running</div>
|
|
43
|
+
</div></div>
|
|
44
|
+
<div class="col-2"><div class="p-card u-no-margin u-align--center" style="padding:0.4rem 0.2rem;">
|
|
45
|
+
<div class="p-heading--4 u-no-margin" id="stat-queued">0</div>
|
|
46
|
+
<div class="u-text--muted" style="font-size:0.6rem;text-transform:uppercase;letter-spacing:.08em;">Queued</div>
|
|
47
|
+
</div></div>
|
|
48
|
+
<div class="col-2"><div class="p-card u-no-margin u-align--center" style="padding:0.4rem 0.2rem;">
|
|
49
|
+
<div class="p-heading--4 u-no-margin" id="stat-done">0</div>
|
|
50
|
+
<div class="u-text--muted" style="font-size:0.6rem;text-transform:uppercase;letter-spacing:.08em;">Done</div>
|
|
51
|
+
</div></div>
|
|
52
|
+
<div class="col-2"><div class="p-card u-no-margin u-align--center" style="padding:0.4rem 0.2rem;">
|
|
53
|
+
<div class="p-heading--4 u-no-margin" id="stat-error">0</div>
|
|
54
|
+
<div class="u-text--muted" style="font-size:0.6rem;text-transform:uppercase;letter-spacing:.08em;">Errors</div>
|
|
55
|
+
</div></div>
|
|
56
|
+
<div class="col-2"><div class="p-card u-no-margin u-align--center" style="padding:0.4rem 0.2rem;">
|
|
57
|
+
<div class="p-heading--4 u-no-margin" id="stat-idle">0</div>
|
|
58
|
+
<div class="u-text--muted" style="font-size:0.6rem;text-transform:uppercase;letter-spacing:.08em;">Idle</div>
|
|
59
|
+
</div></div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<!-- Workers -->
|
|
63
|
+
<div class="row" style="margin-bottom:0.8rem;">
|
|
64
|
+
<div class="col-12">
|
|
65
|
+
<p class="p-muted-heading">Workers</p>
|
|
66
|
+
<table class="p-table" style="width:100%">
|
|
67
|
+
<thead><tr><th>ID</th><th>Labels</th><th>Status</th><th>Current Job</th><th>Last Seen</th></tr></thead>
|
|
68
|
+
<tbody id="workers-body">
|
|
69
|
+
<tr><td colspan="5" class="u-align--center u-text--muted"><em>no workers</em></td></tr>
|
|
70
|
+
</tbody>
|
|
71
|
+
</table>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<!-- Jobs -->
|
|
76
|
+
<div class="row">
|
|
77
|
+
<div class="col-12">
|
|
78
|
+
<p class="p-muted-heading">Jobs</p>
|
|
79
|
+
<table class="p-table" style="width:100%">
|
|
80
|
+
<thead><tr><th>Job ID</th><th>Task</th><th>Labels</th><th>Status</th><th>Worker</th><th>Created</th><th>Finished</th></tr></thead>
|
|
81
|
+
<tbody id="jobs-body">
|
|
82
|
+
<tr><td colspan="7" class="u-align--center u-text--muted"><em>no jobs</em></td></tr>
|
|
83
|
+
</tbody>
|
|
84
|
+
</table>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<hr class="u-sv1" style="margin-top:0.8rem;" />
|
|
89
|
+
<p class="u-text--muted u-align--center u-no-margin--bottom" style="font-size:0.65rem;">
|
|
90
|
+
workerz dashboard — polling 1s — read only
|
|
91
|
+
</p>
|
|
92
|
+
|
|
93
|
+
<script>
|
|
94
|
+
const BADGE = {
|
|
95
|
+
idle:"positive", busy:"caution", offline:"", queued:"information",
|
|
96
|
+
dispatched:"", running:"caution", done:"positive", error:"negative", cancelled:""
|
|
97
|
+
};
|
|
98
|
+
const shortId = id => id ? id.slice(0,8) : "—";
|
|
99
|
+
const fmtTime = iso => iso ? new Date(iso).toLocaleTimeString("en-GB",{hour12:false}) : "—";
|
|
100
|
+
const badge = s => { const t=BADGE[s]??""; return t ? `<span class="p-status-label--${t}">${s}</span>` : `<span class="p-status-label">${s}</span>`; };
|
|
101
|
+
const dot = s => { const c={idle:"#0e8420",busy:"#f99b11",offline:"#aaa"}[s]??"#aaa"; return `<span style="color:${c};margin-right:4px;">●</span>`; };
|
|
102
|
+
const mono = t => `<span style="font-family:monospace;color:#999;">${t}</span>`;
|
|
103
|
+
const chips = arr => (arr||[]).map(l=>`<span style="background:#e5e5e5;border-radius:3px;padding:1px 5px;font-size:0.6rem;margin-right:2px;">${l}</span>`).join("");
|
|
104
|
+
|
|
105
|
+
function renderWorkers(workers) {
|
|
106
|
+
const $b = $("#workers-body").empty();
|
|
107
|
+
if (!workers.length) { $b.html('<tr><td colspan="5" class="u-align--center u-text--muted"><em>no workers</em></td></tr>'); return; }
|
|
108
|
+
workers.forEach(w => $b.append(`<tr>
|
|
109
|
+
<td>${mono(shortId(w.id))}</td>
|
|
110
|
+
<td>${chips(w.labels)}</td>
|
|
111
|
+
<td>${dot(w.status)}${badge(w.status)}</td>
|
|
112
|
+
<td>${w.current_job ? mono(shortId(w.current_job)) : "—"}</td>
|
|
113
|
+
<td class="u-text--muted">${fmtTime(w.last_seen)}</td></tr>`));
|
|
114
|
+
$("#hdr-worker-count").text(workers.filter(w=>w.status!=="offline").length);
|
|
115
|
+
$("#stat-idle").text(workers.filter(w=>w.status==="idle").length);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function renderJobs(jobs) {
|
|
119
|
+
["total","running","queued","done","error"].forEach(k => {
|
|
120
|
+
$("#stat-"+k).text(k==="total" ? jobs.length : jobs.filter(j=>j.status===k).length);
|
|
121
|
+
});
|
|
122
|
+
$("#hdr-queued").text(jobs.filter(j=>j.status==="queued").length);
|
|
123
|
+
const $b = $("#jobs-body").empty();
|
|
124
|
+
if (!jobs.length) { $b.html('<tr><td colspan="7" class="u-align--center u-text--muted"><em>no jobs</em></td></tr>'); return; }
|
|
125
|
+
jobs.forEach(j => $b.append(`<tr>
|
|
126
|
+
<td>${mono(shortId(j.id))}</td>
|
|
127
|
+
<td><strong>${j.task}</strong></td>
|
|
128
|
+
<td>${chips(j.labels)}</td>
|
|
129
|
+
<td>${badge(j.status)}</td>
|
|
130
|
+
<td>${j.worker_id ? mono(shortId(j.worker_id)) : "—"}</td>
|
|
131
|
+
<td>${fmtTime(j.created_at)}</td>
|
|
132
|
+
<td>${fmtTime(j.finished_at)}</td></tr>`));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function poll() {
|
|
136
|
+
$.when($.get("/workers"), $.get("/jobs")).done((w,j) => {
|
|
137
|
+
renderWorkers(w[0]);
|
|
138
|
+
renderJobs(j[0]);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
$("#hdr-host").text(window.location.host);
|
|
143
|
+
$("#hdr-time").text(new Date().toLocaleTimeString("en-GB",{hour12:false}));
|
|
144
|
+
setInterval(()=>$("#hdr-time").text(new Date().toLocaleTimeString("en-GB",{hour12:false})), 1000);
|
|
145
|
+
poll();
|
|
146
|
+
setInterval(poll, 1000);
|
|
147
|
+
</script>
|
|
148
|
+
</body>
|
|
149
|
+
</html>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import struct
|
|
2
|
+
import msgspec
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Register(msgspec.Struct, tag=True):
|
|
6
|
+
worker_id: str
|
|
7
|
+
labels: list[str]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Dispatch(msgspec.Struct, tag=True):
|
|
11
|
+
job_id: str
|
|
12
|
+
task: str
|
|
13
|
+
args: list
|
|
14
|
+
kwargs: dict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Cancel(msgspec.Struct, tag=True):
|
|
18
|
+
job_id: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JobStatus(msgspec.Struct, tag=True):
|
|
22
|
+
job_id: str
|
|
23
|
+
status: str # running | done | error | cancelled
|
|
24
|
+
result: str | None = None # json-encoded
|
|
25
|
+
error: str | None = None
|
|
26
|
+
warnings: list[str] = []
|
|
27
|
+
infos: list[str] = []
|
|
28
|
+
debug: list[str] = []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JobUpdate(msgspec.Struct, tag=True):
|
|
32
|
+
"""Worker pushes arbitrary meta mid-run."""
|
|
33
|
+
job_id: str
|
|
34
|
+
meta: dict
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Ping(msgspec.Struct, tag=True):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Pong(msgspec.Struct, tag=True):
|
|
42
|
+
worker_id: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
Message = Register | Dispatch | Cancel | JobStatus | JobUpdate | Ping | Pong
|
|
46
|
+
|
|
47
|
+
_encoder = msgspec.json.Encoder()
|
|
48
|
+
_decoder = msgspec.json.Decoder(Message)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def send_msg(writer, msg):
|
|
52
|
+
data = _encoder.encode(msg)
|
|
53
|
+
writer.write(struct.pack(">I", len(data)) + data)
|
|
54
|
+
await writer.drain()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def recv_msg(reader):
|
|
58
|
+
raw_len = await reader.readexactly(4)
|
|
59
|
+
length = struct.unpack(">I", raw_len)[0]
|
|
60
|
+
data = await reader.readexactly(length)
|
|
61
|
+
return _decoder.decode(data)
|
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import httpx
|
|
3
|
+
from workerz.task import task # re-exported for convenience
|
|
4
|
+
from workerz.exceptions import JobNotFound, WorkerError, NoWorkerAvailable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Job:
|
|
8
|
+
def __init__(self, data: dict, client: "Client"):
|
|
9
|
+
self._data = data
|
|
10
|
+
self._client = client
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def id(self) -> str:
|
|
14
|
+
return self._data["id"]
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def status(self) -> str:
|
|
18
|
+
return self._data["status"]
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def done(self) -> bool:
|
|
22
|
+
return self._data["status"] in ("done", "error", "cancelled")
|
|
23
|
+
|
|
24
|
+
def _refresh(self):
|
|
25
|
+
self._data = self._client._get_job_raw(self.id)
|
|
26
|
+
|
|
27
|
+
def wait(self, poll: float = 0.5, timeout: float = None) -> "Job":
|
|
28
|
+
start = time.monotonic()
|
|
29
|
+
while not self.done:
|
|
30
|
+
if timeout and time.monotonic() - start > timeout:
|
|
31
|
+
raise TimeoutError(f"job {self.id} did not finish within {timeout}s")
|
|
32
|
+
time.sleep(poll)
|
|
33
|
+
self._refresh()
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def get(self):
|
|
37
|
+
if not self.done:
|
|
38
|
+
self.wait()
|
|
39
|
+
return self._data.get("result")
|
|
40
|
+
|
|
41
|
+
def get_or_raise(self):
|
|
42
|
+
if not self.done:
|
|
43
|
+
self.wait()
|
|
44
|
+
if self._data["status"] == "error":
|
|
45
|
+
raise WorkerError(self._data.get("error") or "job failed")
|
|
46
|
+
return self._data.get("result")
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def result(self): return self._data.get("result")
|
|
50
|
+
@property
|
|
51
|
+
def error(self): return self._data.get("error")
|
|
52
|
+
@property
|
|
53
|
+
def warnings(self): return self._data.get("warnings", [])
|
|
54
|
+
@property
|
|
55
|
+
def infos(self): return self._data.get("infos", [])
|
|
56
|
+
@property
|
|
57
|
+
def debug(self): return self._data.get("debug", [])
|
|
58
|
+
@property
|
|
59
|
+
def meta(self): return self._data.get("meta", {})
|
|
60
|
+
|
|
61
|
+
def __repr__(self):
|
|
62
|
+
return f"<Job {self.id[:8]} status={self.status}>"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Client:
|
|
66
|
+
def __init__(self, base_url: str = "http://127.0.0.1:8000"):
|
|
67
|
+
self.base = base_url.rstrip("/")
|
|
68
|
+
|
|
69
|
+
def _get_job_raw(self, job_id: str) -> dict:
|
|
70
|
+
resp = httpx.get(f"{self.base}/job/{job_id}")
|
|
71
|
+
if resp.status_code == 404:
|
|
72
|
+
raise JobNotFound(job_id)
|
|
73
|
+
resp.raise_for_status()
|
|
74
|
+
return resp.json()
|
|
75
|
+
|
|
76
|
+
def run(self, task: str, args: list = None, kwargs: dict = None, labels: list[str] = None) -> Job:
|
|
77
|
+
resp = httpx.post(f"{self.base}/job", json={
|
|
78
|
+
"task": task, "args": args or [], "kwargs": kwargs or {}, "labels": labels or [],
|
|
79
|
+
})
|
|
80
|
+
if resp.status_code == 409:
|
|
81
|
+
raise NoWorkerAvailable(resp.json().get("detail", "no worker available"))
|
|
82
|
+
resp.raise_for_status()
|
|
83
|
+
return Job(self._get_job_raw(resp.json()["job_id"]), self)
|
|
84
|
+
|
|
85
|
+
def job(self, job_id: str) -> Job:
|
|
86
|
+
return Job(self._get_job_raw(job_id), self)
|
|
87
|
+
|
|
88
|
+
def cancel(self, job_id: str) -> dict:
|
|
89
|
+
resp = httpx.delete(f"{self.base}/job/{job_id}")
|
|
90
|
+
if resp.status_code == 404:
|
|
91
|
+
raise JobNotFound(job_id)
|
|
92
|
+
resp.raise_for_status()
|
|
93
|
+
return resp.json()
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
parser = argparse.ArgumentParser(prog="python -m workerz.worker")
|
|
7
|
+
parser.add_argument("file", help="path to tasks .py file")
|
|
8
|
+
args = parser.parse_args()
|
|
9
|
+
|
|
10
|
+
if not os.environ.get("WORKERZ_LABELS"):
|
|
11
|
+
print("ERROR: WORKERZ_LABELS env var is required", file=sys.stderr)
|
|
12
|
+
sys.exit(1)
|
|
13
|
+
|
|
14
|
+
from workerz.worker.worker import main
|
|
15
|
+
asyncio.run(main(filepath=args.file))
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import importlib.util
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from workerz.protocol import Cancel, Dispatch, JobStatus, JobUpdate, Ping, Pong, Register, recv_msg, send_msg
|
|
10
|
+
|
|
11
|
+
COORDINATOR_HOST = os.environ.get("WORKERZ_COORDINATOR_HOST", "127.0.0.1")
|
|
12
|
+
COORDINATOR_TCP = int(os.environ.get("WORKERZ_COORDINATOR_TCP", 7777))
|
|
13
|
+
LABELS = [l.strip() for l in os.environ.get("WORKERZ_LABELS", "").split(",") if l.strip()]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TaskContext:
|
|
17
|
+
"""Injected as first arg into every task. Collects logs + allows mid-run meta pushes."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, job_id: str, writer):
|
|
20
|
+
self.job_id = job_id
|
|
21
|
+
self._writer = writer
|
|
22
|
+
self.warnings: list[str] = []
|
|
23
|
+
self.infos: list[str] = []
|
|
24
|
+
self.debug: list[str] = []
|
|
25
|
+
|
|
26
|
+
def warn(self, msg: str):
|
|
27
|
+
self.warnings.append(str(msg))
|
|
28
|
+
|
|
29
|
+
def info(self, msg: str):
|
|
30
|
+
self.infos.append(str(msg))
|
|
31
|
+
|
|
32
|
+
def debug(self, msg: str):
|
|
33
|
+
self.debug.append(str(msg))
|
|
34
|
+
|
|
35
|
+
async def update(self, **meta):
|
|
36
|
+
"""Push arbitrary meta to coordinator immediately (visible in dashboard)."""
|
|
37
|
+
await send_msg(self._writer, JobUpdate(job_id=self.job_id, meta=meta))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_tasks(filepath: str) -> dict:
|
|
41
|
+
path = Path(filepath).resolve()
|
|
42
|
+
spec = importlib.util.spec_from_file_location(path.stem, path)
|
|
43
|
+
module = importlib.util.module_from_spec(spec)
|
|
44
|
+
spec.loader.exec_module(module)
|
|
45
|
+
return {
|
|
46
|
+
name: fn
|
|
47
|
+
for name, fn in inspect.getmembers(module, inspect.isfunction)
|
|
48
|
+
if getattr(fn, "_is_workerz_task", False)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def run_task(fn, ctx, args, kwargs):
|
|
53
|
+
if asyncio.iscoroutinefunction(fn):
|
|
54
|
+
return await fn(ctx, *args, **kwargs)
|
|
55
|
+
loop = asyncio.get_event_loop()
|
|
56
|
+
return await loop.run_in_executor(None, lambda: fn(ctx, *args, **kwargs))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def main(filepath: str):
|
|
60
|
+
if not LABELS:
|
|
61
|
+
raise RuntimeError("WORKERZ_LABELS is required (comma-separated list)")
|
|
62
|
+
|
|
63
|
+
tasks = load_tasks(filepath)
|
|
64
|
+
worker_id = f"worker-{uuid.uuid4().hex[:8]}"
|
|
65
|
+
active: dict[str, asyncio.Task] = {}
|
|
66
|
+
|
|
67
|
+
print(f"[{worker_id}] labels={LABELS} tasks={list(tasks.keys())}")
|
|
68
|
+
print(f"[{worker_id}] connecting {COORDINATOR_HOST}:{COORDINATOR_TCP}")
|
|
69
|
+
|
|
70
|
+
reader, writer = await asyncio.open_connection(COORDINATOR_HOST, COORDINATOR_TCP)
|
|
71
|
+
|
|
72
|
+
await send_msg(writer, Register(worker_id=worker_id, labels=LABELS))
|
|
73
|
+
print(f"[{worker_id}] registered")
|
|
74
|
+
|
|
75
|
+
async def execute(msg: Dispatch):
|
|
76
|
+
fn = tasks.get(msg.task)
|
|
77
|
+
ctx = TaskContext(job_id=msg.job_id, writer=writer)
|
|
78
|
+
|
|
79
|
+
if not fn:
|
|
80
|
+
await send_msg(writer, JobStatus(
|
|
81
|
+
job_id=msg.job_id, status="error",
|
|
82
|
+
error=f"unknown task: {msg.task}",
|
|
83
|
+
))
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
await send_msg(writer, JobStatus(job_id=msg.job_id, status="running"))
|
|
87
|
+
handle = asyncio.create_task(run_task(fn, ctx, msg.args, msg.kwargs))
|
|
88
|
+
active[msg.job_id] = handle
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
result = await handle
|
|
92
|
+
await send_msg(writer, JobStatus(
|
|
93
|
+
job_id=msg.job_id, status="done",
|
|
94
|
+
result=json.dumps(result) if result is not None else None,
|
|
95
|
+
warnings=ctx.warnings, infos=ctx.infos, debug=ctx.debug,
|
|
96
|
+
))
|
|
97
|
+
|
|
98
|
+
except asyncio.CancelledError:
|
|
99
|
+
await send_msg(writer, JobStatus(
|
|
100
|
+
job_id=msg.job_id, status="cancelled",
|
|
101
|
+
warnings=ctx.warnings, infos=ctx.infos, debug=ctx.debug,
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
await send_msg(writer, JobStatus(
|
|
106
|
+
job_id=msg.job_id, status="error",
|
|
107
|
+
error=str(e),
|
|
108
|
+
warnings=ctx.warnings, infos=ctx.infos, debug=ctx.debug,
|
|
109
|
+
))
|
|
110
|
+
|
|
111
|
+
finally:
|
|
112
|
+
active.pop(msg.job_id, None)
|
|
113
|
+
|
|
114
|
+
while True:
|
|
115
|
+
try:
|
|
116
|
+
msg = await recv_msg(reader)
|
|
117
|
+
except (asyncio.IncompleteReadError, ConnectionResetError):
|
|
118
|
+
print(f"[{worker_id}] coordinator disconnected, reconnecting...")
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
if isinstance(msg, Dispatch):
|
|
122
|
+
asyncio.create_task(execute(msg))
|
|
123
|
+
elif isinstance(msg, Cancel):
|
|
124
|
+
handle = active.get(msg.job_id)
|
|
125
|
+
if handle:
|
|
126
|
+
handle.cancel()
|
|
127
|
+
elif isinstance(msg, Ping):
|
|
128
|
+
await send_msg(writer, Pong(worker_id=worker_id))
|
|
129
|
+
|
|
130
|
+
writer.close()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: workerz
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Better Workers.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Provides-Extra: coordinator
|
|
7
|
+
Requires-Dist: starlette>=0.41; extra == "coordinator"
|
|
8
|
+
Requires-Dist: uvicorn>=0.29; extra == "coordinator"
|
|
9
|
+
Requires-Dist: redis>=5.0; extra == "coordinator"
|
|
10
|
+
Requires-Dist: msgspec>=0.18; extra == "coordinator"
|
|
11
|
+
Provides-Extra: dashboard
|
|
12
|
+
Requires-Dist: starlette>=0.41; extra == "dashboard"
|
|
13
|
+
Requires-Dist: uvicorn>=0.29; extra == "dashboard"
|
|
14
|
+
Requires-Dist: redis>=5.0; extra == "dashboard"
|
|
15
|
+
Provides-Extra: worker
|
|
16
|
+
Requires-Dist: msgspec>=0.18; extra == "worker"
|
|
17
|
+
Provides-Extra: sdk
|
|
18
|
+
Requires-Dist: httpx>=0.27; extra == "sdk"
|
|
19
|
+
Provides-Extra: full
|
|
20
|
+
Requires-Dist: workerz[coordinator]; extra == "full"
|
|
21
|
+
Requires-Dist: workerz[dashboard]; extra == "full"
|
|
22
|
+
Requires-Dist: workerz[worker]; extra == "full"
|
|
23
|
+
Requires-Dist: workerz[sdk]; extra == "full"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/workerz/__init__.py
|
|
4
|
+
src/workerz/exceptions.py
|
|
5
|
+
src/workerz/protocol.py
|
|
6
|
+
src/workerz/task.py
|
|
7
|
+
src/workerz.egg-info/PKG-INFO
|
|
8
|
+
src/workerz.egg-info/SOURCES.txt
|
|
9
|
+
src/workerz.egg-info/dependency_links.txt
|
|
10
|
+
src/workerz.egg-info/requires.txt
|
|
11
|
+
src/workerz.egg-info/top_level.txt
|
|
12
|
+
src/workerz/coordinator/__init__.py
|
|
13
|
+
src/workerz/coordinator/__main__.py
|
|
14
|
+
src/workerz/coordinator/app.py
|
|
15
|
+
src/workerz/dashboard/__init__.py
|
|
16
|
+
src/workerz/dashboard/__main__.py
|
|
17
|
+
src/workerz/dashboard/app.py
|
|
18
|
+
src/workerz/dashboard/index.html
|
|
19
|
+
src/workerz/sdk/__init__.py
|
|
20
|
+
src/workerz/sdk/client.py
|
|
21
|
+
src/workerz/worker/__init__.py
|
|
22
|
+
src/workerz/worker/__main__.py
|
|
23
|
+
src/workerz/worker/worker.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
|
|
2
|
+
[coordinator]
|
|
3
|
+
starlette>=0.41
|
|
4
|
+
uvicorn>=0.29
|
|
5
|
+
redis>=5.0
|
|
6
|
+
msgspec>=0.18
|
|
7
|
+
|
|
8
|
+
[dashboard]
|
|
9
|
+
starlette>=0.41
|
|
10
|
+
uvicorn>=0.29
|
|
11
|
+
redis>=5.0
|
|
12
|
+
|
|
13
|
+
[full]
|
|
14
|
+
workerz[coordinator]
|
|
15
|
+
workerz[dashboard]
|
|
16
|
+
workerz[worker]
|
|
17
|
+
workerz[sdk]
|
|
18
|
+
|
|
19
|
+
[sdk]
|
|
20
|
+
httpx>=0.27
|
|
21
|
+
|
|
22
|
+
[worker]
|
|
23
|
+
msgspec>=0.18
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
workerz
|