workerz 0.0.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.0"
File without changes
@@ -0,0 +1,3 @@
1
+ import os, uvicorn
2
+ from workerz.coordinator.app import app
3
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("WORKERZ_HTTP_PORT", 8000)), log_level="info")
@@ -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,3 @@
1
+ import os, uvicorn
2
+ from workerz.dashboard.app import app
3
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("WORKERZ_DASHBOARD_PORT", 8080)), log_level="info")
@@ -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&nbsp;<strong id="hdr-worker-count" style="color:#fff">0</strong>
29
+ &ensp;Queued&nbsp;<strong id="hdr-queued" style="color:#fff">0</strong>
30
+ &ensp;<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;">&#x25cf;</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>
workerz/exceptions.py ADDED
@@ -0,0 +1,11 @@
1
+ class WorkerzError(Exception):
2
+ pass
3
+
4
+ class JobNotFound(WorkerzError):
5
+ pass
6
+
7
+ class WorkerError(WorkerzError):
8
+ pass
9
+
10
+ class NoWorkerAvailable(WorkerzError):
11
+ pass
workerz/protocol.py ADDED
@@ -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
workerz/sdk/client.py ADDED
@@ -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()
workerz/task.py ADDED
@@ -0,0 +1,3 @@
1
+ def task(fn):
2
+ fn._is_workerz_task = True
3
+ return fn
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,20 @@
1
+ workerz/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
2
+ workerz/exceptions.py,sha256=tKRcA4IBy3xfuz9Fr3OzlTlVa0v3IadPLSP5EAIefHw,175
3
+ workerz/protocol.py,sha256=zmTxV_axUQIbxgU88V1nj03uFh9Su3Ot0ac4-AE4ZLA,1326
4
+ workerz/task.py,sha256=CYwEBiN5ODnrZe61_ADaTxet22DKYmBgTNj4G7mmyJY,59
5
+ workerz/coordinator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ workerz/coordinator/__main__.py,sha256=T4wx16RdIa8GAvqvmgDMLl-lvPACehlm3jGaKeSrNzk,163
7
+ workerz/coordinator/app.py,sha256=ZRHLinyrmmMyQgz6kK4q373WiOB9egDcfjY7sRY3iCQ,13456
8
+ workerz/dashboard/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ workerz/dashboard/__main__.py,sha256=tAfaAda45mkOLNXoxnmJOMjU3MP-s2I92QiztJICcGY,166
10
+ workerz/dashboard/app.py,sha256=f4ftKN319mUtupaP2FmgO0RNf-kkrtNqeiY5lp6cu9o,2710
11
+ workerz/dashboard/index.html,sha256=qGn-KFq437AbMn6biJgiv00sVyndmmDzt5jKAnPEAMc,8913
12
+ workerz/sdk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ workerz/sdk/client.py,sha256=Q1bXejD_RcnEloP03uIDwn2aKKRpyK1srflF5XHrV94,3052
14
+ workerz/worker/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ workerz/worker/__main__.py,sha256=qVts2Air4ZlG0-BeDV0INcRtPwigHs546SB9LjpnBXo,413
16
+ workerz/worker/worker.py,sha256=5IKPuWeTNzj0eyG0h-yEb1YTlqbH3GTKCj4OWVJ1I6o,4404
17
+ workerz-0.0.0.dist-info/METADATA,sha256=l_iduMGWzlieXtfKuZ0gz4q0eLYDgt6NAvMnLNIHy7A,869
18
+ workerz-0.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
19
+ workerz-0.0.0.dist-info/top_level.txt,sha256=zu2JHkULJfxCuVXG-7-8Bgqj9aEq7WmWixQ0QrEzgF0,8
20
+ workerz-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ workerz