gridrunner 0.6.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.
- gridrunner/__init__.py +25 -0
- gridrunner/ids.py +29 -0
- gridrunner/sdk.py +624 -0
- gridrunner-0.6.0.dist-info/METADATA +284 -0
- gridrunner-0.6.0.dist-info/RECORD +8 -0
- gridrunner-0.6.0.dist-info/WHEEL +5 -0
- gridrunner-0.6.0.dist-info/licenses/LICENSE +21 -0
- gridrunner-0.6.0.dist-info/top_level.txt +1 -0
gridrunner/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""GRIDRUNNER producer SDK — fire-and-forget INGEST job events and the
|
|
2
|
+
SIMULATE run stream."""
|
|
3
|
+
|
|
4
|
+
from .ids import ulid
|
|
5
|
+
from .sdk import (
|
|
6
|
+
Chunk, Client, Job, Run, connect, emit, init, job, manifest, ping,
|
|
7
|
+
ping_once, run,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__version__ = "0.6.0"
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Chunk",
|
|
13
|
+
"Client",
|
|
14
|
+
"Job",
|
|
15
|
+
"Run",
|
|
16
|
+
"connect",
|
|
17
|
+
"emit",
|
|
18
|
+
"init",
|
|
19
|
+
"job",
|
|
20
|
+
"manifest",
|
|
21
|
+
"ping",
|
|
22
|
+
"ping_once",
|
|
23
|
+
"run",
|
|
24
|
+
"ulid",
|
|
25
|
+
]
|
gridrunner/ids.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""ULID generation (stdlib-only)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
_ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
8
|
+
_lock = threading.Lock()
|
|
9
|
+
_last: list = [0, 0] # [ts_ms, randomness as int]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _encode(value: int, length: int) -> str:
|
|
13
|
+
out = []
|
|
14
|
+
for _ in range(length):
|
|
15
|
+
out.append(_ENCODING[value & 0x1F])
|
|
16
|
+
value >>= 5
|
|
17
|
+
return "".join(reversed(out))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ulid() -> str:
|
|
21
|
+
with _lock:
|
|
22
|
+
ts = int(time.time() * 1000)
|
|
23
|
+
if ts == _last[0]:
|
|
24
|
+
_last[1] += 1
|
|
25
|
+
else:
|
|
26
|
+
_last[0] = ts
|
|
27
|
+
_last[1] = int.from_bytes(os.urandom(10), "big")
|
|
28
|
+
rand = _last[1] & ((1 << 80) - 1)
|
|
29
|
+
return _encode(ts, 10) + _encode(rand, 16)
|
gridrunner/sdk.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""Producer SDK: INGEST job/chunk events plus the SIMULATE run stream,
|
|
2
|
+
fire-and-forget.
|
|
3
|
+
|
|
4
|
+
Initialized explicitly, like any hosted analytics SDK: pass the core URL
|
|
5
|
+
and its produce token to ``init``; the SDK never reads config files or
|
|
6
|
+
environment variables and never switches targets behind the caller's back.
|
|
7
|
+
|
|
8
|
+
import gridrunner as gr
|
|
9
|
+
gr.init(token="<produce token>", url="https://gridrunner.example.com")
|
|
10
|
+
gr.init() # local dev core, http://127.0.0.1:7077
|
|
11
|
+
|
|
12
|
+
chunks = [{"chunk_id": f"users:{i:03d}", "units": 100_000, "item": "users"}
|
|
13
|
+
for i in range(20)]
|
|
14
|
+
with gr.job("etl.appstore.daily", chunks=chunks) as job:
|
|
15
|
+
for spec in chunks:
|
|
16
|
+
with job.chunk(spec["chunk_id"]):
|
|
17
|
+
pull_batch(spec)
|
|
18
|
+
|
|
19
|
+
Simulation producers stream runs through the same client (INGEST-only
|
|
20
|
+
consumers simply never touch these):
|
|
21
|
+
|
|
22
|
+
run = gr.run("mkt.spend_optimizer", levers=levers, seed=7)
|
|
23
|
+
for t, values in engine.simulate(levers):
|
|
24
|
+
run.tick(t, values)
|
|
25
|
+
run.done(metrics={"net_revenue_total": total})
|
|
26
|
+
|
|
27
|
+
Contractually unable to crash or block the host process: a bounded
|
|
28
|
+
in-memory queue, a background sender thread, drop-with-warning on
|
|
29
|
+
overflow. While the core is unreachable (e.g. a redeploy mid-run) events
|
|
30
|
+
are held in a bounded, liveness-scoped backlog and replayed on reconnect;
|
|
31
|
+
event_id ULIDs make double-delivery harmless. Gridrunner is
|
|
32
|
+
visualization, not persistence: work the core never observed is discarded
|
|
33
|
+
once it finishes instead of being pushed later as stale history.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import atexit
|
|
39
|
+
import datetime as _dt
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
import math
|
|
43
|
+
import queue
|
|
44
|
+
import socket
|
|
45
|
+
import threading
|
|
46
|
+
import time
|
|
47
|
+
import urllib.error
|
|
48
|
+
import urllib.request
|
|
49
|
+
|
|
50
|
+
from .ids import ulid
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _default_host() -> str | None:
|
|
54
|
+
"""Short machine name so operators can see where a job runs."""
|
|
55
|
+
try:
|
|
56
|
+
return socket.gethostname().split(".")[0] or None
|
|
57
|
+
except Exception:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
log = logging.getLogger("gridrunner.sdk")
|
|
61
|
+
|
|
62
|
+
_DEFAULT_LOCAL_URL = "http://127.0.0.1:7077"
|
|
63
|
+
_URL_REQUIRED_WITH_TOKEN = "gridrunner: url= is required alongside a token"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_url(url: str | None, token: str | None) -> str | None:
|
|
67
|
+
if url:
|
|
68
|
+
return url.rstrip("/")
|
|
69
|
+
if token:
|
|
70
|
+
return None
|
|
71
|
+
return _DEFAULT_LOCAL_URL
|
|
72
|
+
|
|
73
|
+
_TERMINAL_TYPES = {"job.completed", "job.failed", "run.completed", "run.failed"}
|
|
74
|
+
_PROTECTED_TYPES = {"job.registered", "run.started", "sim.manifest"} | _TERMINAL_TYPES
|
|
75
|
+
_BATCH_LIMIT = 5000
|
|
76
|
+
_BACKLOG_CAP_PER_STREAM = 2000
|
|
77
|
+
_TICK_CAP_PER_RUN = 512
|
|
78
|
+
_WARN_INTERVAL_S = 30.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _sanitize(obj):
|
|
82
|
+
if isinstance(obj, float):
|
|
83
|
+
return obj if math.isfinite(obj) else None
|
|
84
|
+
if isinstance(obj, dict):
|
|
85
|
+
return {k: _sanitize(v) for k, v in obj.items()}
|
|
86
|
+
if isinstance(obj, (list, tuple)):
|
|
87
|
+
return [_sanitize(v) for v in obj]
|
|
88
|
+
return obj
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _dump_line(event: dict) -> str:
|
|
92
|
+
try:
|
|
93
|
+
return json.dumps(event, separators=(",", ":"), default=str,
|
|
94
|
+
allow_nan=False)
|
|
95
|
+
except ValueError:
|
|
96
|
+
return json.dumps(_sanitize(event), separators=(",", ":"),
|
|
97
|
+
default=str, allow_nan=False)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _stream_key(event: dict) -> str:
|
|
101
|
+
entity = event.get("job_id") or event.get("run_id") or event.get("sim_id")
|
|
102
|
+
if entity:
|
|
103
|
+
return entity
|
|
104
|
+
if event.get("service") or event.get("type") == "ping":
|
|
105
|
+
return f"svc:{event.get('service') or ''}"
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _Backlog:
|
|
110
|
+
"""Undelivered events for work that is still worth finishing on the core.
|
|
111
|
+
|
|
112
|
+
Liveness-scoped, bounded, in memory. While a job/run is alive its
|
|
113
|
+
events are retained (compacted) so the visualization can be
|
|
114
|
+
reconstructed after reconnect. Work the core never observed is dropped
|
|
115
|
+
entirely the moment it finishes: gridrunner is visualization, not
|
|
116
|
+
persistence, and a job that lived and died during an outage must not be
|
|
117
|
+
pushed to the core later as stale history.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self):
|
|
121
|
+
self.events: list[dict] = []
|
|
122
|
+
self.observed: set[str] = set()
|
|
123
|
+
self.dropped_structural = 0
|
|
124
|
+
|
|
125
|
+
def __len__(self) -> int:
|
|
126
|
+
return len(self.events)
|
|
127
|
+
|
|
128
|
+
def add(self, event: dict) -> None:
|
|
129
|
+
etype = event.get("type", "")
|
|
130
|
+
if etype == "job.heartbeat":
|
|
131
|
+
return
|
|
132
|
+
key = _stream_key(event)
|
|
133
|
+
if etype in _TERMINAL_TYPES and key and key not in self.observed:
|
|
134
|
+
self.events = [e for e in self.events if _stream_key(e) != key]
|
|
135
|
+
return
|
|
136
|
+
if etype == "ping" and event.get("status") != "error":
|
|
137
|
+
# ok pings carry no unique information beyond their timestamp;
|
|
138
|
+
# keep only the newest per series during an outage. Error pings
|
|
139
|
+
# are all retained — they are the ones worth replaying late.
|
|
140
|
+
ping_type = event.get("ping_type")
|
|
141
|
+
self.events = [
|
|
142
|
+
e for e in self.events
|
|
143
|
+
if not (e.get("type") == "ping"
|
|
144
|
+
and e.get("status") != "error"
|
|
145
|
+
and _stream_key(e) == key
|
|
146
|
+
and e.get("ping_type") == ping_type)
|
|
147
|
+
]
|
|
148
|
+
if etype == "chunk.progress":
|
|
149
|
+
chunk_id = event.get("chunk_id")
|
|
150
|
+
self.events = [
|
|
151
|
+
e for e in self.events
|
|
152
|
+
if not (e.get("type") == "chunk.progress"
|
|
153
|
+
and _stream_key(e) == key
|
|
154
|
+
and e.get("chunk_id") == chunk_id)
|
|
155
|
+
]
|
|
156
|
+
self.events.append(event)
|
|
157
|
+
if etype == "run.tick":
|
|
158
|
+
self._thin_ticks(key)
|
|
159
|
+
if key:
|
|
160
|
+
self._enforce_cap(key)
|
|
161
|
+
|
|
162
|
+
def take(self, limit: int) -> list[dict]:
|
|
163
|
+
return self.events[:max(limit, 0)]
|
|
164
|
+
|
|
165
|
+
def ack(self, batch: list[dict]) -> None:
|
|
166
|
+
acked = {e.get("event_id") for e in batch}
|
|
167
|
+
self.events = [e for e in self.events if e.get("event_id") not in acked]
|
|
168
|
+
for event in batch:
|
|
169
|
+
key = _stream_key(event)
|
|
170
|
+
if key:
|
|
171
|
+
self.observed.add(key)
|
|
172
|
+
|
|
173
|
+
def _thin_ticks(self, key: str) -> None:
|
|
174
|
+
idxs = [i for i, e in enumerate(self.events)
|
|
175
|
+
if e.get("type") == "run.tick" and _stream_key(e) == key]
|
|
176
|
+
if len(idxs) <= _TICK_CAP_PER_RUN:
|
|
177
|
+
return
|
|
178
|
+
drop = set(idxs[:-1:2])
|
|
179
|
+
self.events = [e for i, e in enumerate(self.events) if i not in drop]
|
|
180
|
+
|
|
181
|
+
def _enforce_cap(self, key: str) -> None:
|
|
182
|
+
idxs = [i for i, e in enumerate(self.events) if _stream_key(e) == key]
|
|
183
|
+
excess = len(idxs) - _BACKLOG_CAP_PER_STREAM
|
|
184
|
+
if excess <= 0:
|
|
185
|
+
return
|
|
186
|
+
drop: set[int] = set()
|
|
187
|
+
for i in idxs:
|
|
188
|
+
if excess <= 0:
|
|
189
|
+
break
|
|
190
|
+
if self.events[i].get("type") not in _PROTECTED_TYPES:
|
|
191
|
+
drop.add(i)
|
|
192
|
+
excess -= 1
|
|
193
|
+
if excess > 0:
|
|
194
|
+
self.dropped_structural += excess
|
|
195
|
+
self.events = [e for i, e in enumerate(self.events) if i not in drop]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Client:
|
|
199
|
+
def __init__(self, url: str, token: str | None = None,
|
|
200
|
+
flush_interval_s: float = 0.2, queue_size: int = 20_000,
|
|
201
|
+
retry_interval_s: float = 5.0):
|
|
202
|
+
self.url = url.rstrip("/")
|
|
203
|
+
self.token = token
|
|
204
|
+
self.flush_interval_s = flush_interval_s
|
|
205
|
+
self.retry_interval_s = retry_interval_s
|
|
206
|
+
self.queue: queue.Queue = queue.Queue(maxsize=queue_size)
|
|
207
|
+
self._backlog = _Backlog()
|
|
208
|
+
self._dropped = 0
|
|
209
|
+
self._last_failure: float | None = None
|
|
210
|
+
self._last_error: str | None = None
|
|
211
|
+
self._last_warn = 0.0
|
|
212
|
+
self._stop = threading.Event()
|
|
213
|
+
self._thread = threading.Thread(target=self._sender, daemon=True,
|
|
214
|
+
name="gridrunner-sdk")
|
|
215
|
+
self._thread.start()
|
|
216
|
+
atexit.register(self.close)
|
|
217
|
+
|
|
218
|
+
def emit(self, event: dict) -> None:
|
|
219
|
+
event = {"event_id": ulid(), "ts": time.time() * 1000, **event}
|
|
220
|
+
try:
|
|
221
|
+
self.queue.put_nowait(event)
|
|
222
|
+
except queue.Full:
|
|
223
|
+
self._dropped += 1
|
|
224
|
+
if self._dropped in (1, 100, 10_000):
|
|
225
|
+
log.warning("gridrunner queue full; dropped %d events", self._dropped)
|
|
226
|
+
|
|
227
|
+
def ping(self, ping_type: str = "default", status: str = "ok",
|
|
228
|
+
description: str | None = None,
|
|
229
|
+
expected_next_ts: "float | _dt.datetime | None" = None) -> None:
|
|
230
|
+
"""Fire-and-forget status report for this producer's recurring work.
|
|
231
|
+
|
|
232
|
+
The ping carries no service name: the core resolves it from the
|
|
233
|
+
produce token at ingestion — the name the producer was registered
|
|
234
|
+
with when its token was minted — so producers can never contaminate
|
|
235
|
+
each other's series. ``ping_type`` is an understandable name of the
|
|
236
|
+
recurring process itself (e.g. ``daily-export``, ``queue-sweep``).
|
|
237
|
+
|
|
238
|
+
Regular pings (``status="ok"``) teach the core the cadence; a missed
|
|
239
|
+
beat surfaces on the PINGS tab. ``status="error"`` reports "alive,
|
|
240
|
+
but something went wrong" and should carry a description.
|
|
241
|
+
|
|
242
|
+
``expected_next_ts`` (epoch ms or a ``datetime``) declares when the
|
|
243
|
+
next ping is due. Declare it when the cadence is too sparse to
|
|
244
|
+
learn quickly (weekly, monthly): GRIDRUNNER alarms on that deadline
|
|
245
|
+
instead of waiting to learn the pattern.
|
|
246
|
+
"""
|
|
247
|
+
self.emit(_ping_event(ping_type, status, description,
|
|
248
|
+
expected_next_ts))
|
|
249
|
+
|
|
250
|
+
def close(self, timeout: float = 3.0) -> None:
|
|
251
|
+
if self._stop.is_set():
|
|
252
|
+
return
|
|
253
|
+
self._stop.set()
|
|
254
|
+
self._thread.join(timeout=timeout)
|
|
255
|
+
|
|
256
|
+
def _sender(self) -> None:
|
|
257
|
+
while True:
|
|
258
|
+
stopping = self._stop.wait(self.flush_interval_s)
|
|
259
|
+
fresh: list[dict] = []
|
|
260
|
+
while len(fresh) < _BATCH_LIMIT:
|
|
261
|
+
try:
|
|
262
|
+
fresh.append(self.queue.get_nowait())
|
|
263
|
+
except queue.Empty:
|
|
264
|
+
break
|
|
265
|
+
now = time.monotonic()
|
|
266
|
+
throttled = (self._last_failure is not None
|
|
267
|
+
and now - self._last_failure < self.retry_interval_s
|
|
268
|
+
and not stopping)
|
|
269
|
+
if throttled:
|
|
270
|
+
for event in fresh:
|
|
271
|
+
self._backlog.add(event)
|
|
272
|
+
else:
|
|
273
|
+
batch = self._backlog.take(_BATCH_LIMIT - len(fresh)) + fresh
|
|
274
|
+
if batch:
|
|
275
|
+
if self._post(batch):
|
|
276
|
+
self._last_failure = None
|
|
277
|
+
self._backlog.ack(batch)
|
|
278
|
+
self._drain_backlog()
|
|
279
|
+
else:
|
|
280
|
+
self._last_failure = now
|
|
281
|
+
for event in fresh:
|
|
282
|
+
self._backlog.add(event)
|
|
283
|
+
self._warn_unreachable()
|
|
284
|
+
if stopping and self.queue.empty():
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
def _drain_backlog(self) -> None:
|
|
288
|
+
while len(self._backlog):
|
|
289
|
+
batch = self._backlog.take(_BATCH_LIMIT)
|
|
290
|
+
if not self._post(batch):
|
|
291
|
+
self._last_failure = time.monotonic()
|
|
292
|
+
return
|
|
293
|
+
self._backlog.ack(batch)
|
|
294
|
+
|
|
295
|
+
def _post(self, batch: list[dict]) -> bool:
|
|
296
|
+
body = "\n".join(_dump_line(e) for e in batch).encode()
|
|
297
|
+
req = urllib.request.Request(
|
|
298
|
+
self.url + "/v1/events", data=body, method="POST",
|
|
299
|
+
headers={"Content-Type": "application/x-ndjson"},
|
|
300
|
+
)
|
|
301
|
+
if self.token:
|
|
302
|
+
req.add_header("Authorization", f"Bearer {self.token}")
|
|
303
|
+
try:
|
|
304
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
305
|
+
ok = 200 <= resp.status < 300
|
|
306
|
+
if ok:
|
|
307
|
+
self._last_error = None
|
|
308
|
+
return ok
|
|
309
|
+
except urllib.error.HTTPError as exc:
|
|
310
|
+
self._last_error = f"HTTP {exc.code}"
|
|
311
|
+
return False
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
self._last_error = exc.__class__.__name__
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
def _warn_unreachable(self) -> None:
|
|
317
|
+
now = time.monotonic()
|
|
318
|
+
if now - self._last_warn < _WARN_INTERVAL_S:
|
|
319
|
+
return
|
|
320
|
+
self._last_warn = now
|
|
321
|
+
if self._last_error == "HTTP 401":
|
|
322
|
+
log.warning(
|
|
323
|
+
"gridrunner core at %s rejected the produce token (401);"
|
|
324
|
+
" holding %d events for live jobs", self.url, len(self._backlog))
|
|
325
|
+
else:
|
|
326
|
+
log.warning(
|
|
327
|
+
"gridrunner core at %s unreachable (%s);"
|
|
328
|
+
" holding %d events for live jobs",
|
|
329
|
+
self.url, self._last_error or "error", len(self._backlog))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
_client: Client | None = None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def init(token: str | None = None, url: str | None = None, **kwargs) -> Client:
|
|
336
|
+
"""Configure the module-level client with explicit credentials.
|
|
337
|
+
|
|
338
|
+
``init(token=..., url=...)`` targets a deployed core; ``init()`` with
|
|
339
|
+
neither targets the unauthenticated local dev core at
|
|
340
|
+
``http://127.0.0.1:7077``. A token without ``url=`` is a
|
|
341
|
+
``ValueError``. The SDK reads no config files and no environment
|
|
342
|
+
variables; the target and token stay exactly as the caller set them
|
|
343
|
+
for the life of the process.
|
|
344
|
+
"""
|
|
345
|
+
global _client
|
|
346
|
+
resolved = _resolve_url(url, token)
|
|
347
|
+
if resolved is None:
|
|
348
|
+
raise ValueError(_URL_REQUIRED_WITH_TOKEN)
|
|
349
|
+
if _client is not None:
|
|
350
|
+
_client.close()
|
|
351
|
+
_client = Client(resolved, token=token, **kwargs)
|
|
352
|
+
return _client
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def connect(url: str | None = None, token: str | None = None, **kwargs) -> Client:
|
|
356
|
+
"""Deprecated alias of :func:`init` (older url-first signature)."""
|
|
357
|
+
return init(token=token, url=url, **kwargs)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def emit(event: dict) -> None:
|
|
361
|
+
if _client is not None:
|
|
362
|
+
_client.emit(event)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _ping_event(ping_type: str, status: str, description: str | None,
|
|
366
|
+
expected_next_ts: "float | _dt.datetime | None") -> dict:
|
|
367
|
+
event: dict = {"type": "ping", "ping_type": ping_type, "status": status}
|
|
368
|
+
if description:
|
|
369
|
+
event["description"] = description
|
|
370
|
+
if expected_next_ts is not None:
|
|
371
|
+
if isinstance(expected_next_ts, _dt.datetime):
|
|
372
|
+
expected_next_ts = expected_next_ts.timestamp() * 1000
|
|
373
|
+
event["expected_next_ts"] = float(expected_next_ts)
|
|
374
|
+
return event
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def ping(ping_type: str = "default", status: str = "ok",
|
|
378
|
+
description: str | None = None,
|
|
379
|
+
expected_next_ts: "float | _dt.datetime | None" = None) -> None:
|
|
380
|
+
"""Fire-and-forget status report, unrelated to job heartbeats.
|
|
381
|
+
|
|
382
|
+
The service name is resolved server-side from the produce token —
|
|
383
|
+
the caller only names the recurring process (``ping_type``). See
|
|
384
|
+
:meth:`Client.ping`.
|
|
385
|
+
"""
|
|
386
|
+
if _client is not None:
|
|
387
|
+
_client.ping(ping_type, status=status, description=description,
|
|
388
|
+
expected_next_ts=expected_next_ts)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def ping_once(ping_type: str = "default", *, token: str | None = None,
|
|
392
|
+
status: str = "ok", description: str | None = None,
|
|
393
|
+
expected_next_ts: "float | _dt.datetime | None" = None,
|
|
394
|
+
url: str | None = None, wait: bool = False,
|
|
395
|
+
timeout_s: float = 5.0) -> bool | None:
|
|
396
|
+
"""One-shot stateless ping: token and ping in the same call, one direct
|
|
397
|
+
HTTP POST to ``/v1/events`` — no ``init``, no persistent client, no
|
|
398
|
+
queue, no backlog. Built for cron scripts and one-liners.
|
|
399
|
+
|
|
400
|
+
Fire-and-forget by default: returns ``None`` immediately and delivers
|
|
401
|
+
on a short-lived non-daemon thread, so a script that exits right after
|
|
402
|
+
pinging doesn't lose the ping — the process lingers at most
|
|
403
|
+
``timeout_s``. ``wait=True`` performs the POST inline and returns
|
|
404
|
+
``True``/``False``. Never raises either way: a monitoring call must
|
|
405
|
+
not break the cron it monitors.
|
|
406
|
+
|
|
407
|
+
``url`` resolves like :func:`init`: required alongside a token, the
|
|
408
|
+
local dev core without one. A token without ``url`` is logged and
|
|
409
|
+
dropped rather than raised — the call still never breaks its cron. The
|
|
410
|
+
service name still comes from the token server-side; the one-shot call
|
|
411
|
+
changes delivery, not identity.
|
|
412
|
+
"""
|
|
413
|
+
resolved = _resolve_url(url, token)
|
|
414
|
+
if resolved is None:
|
|
415
|
+
log.warning("gridrunner ping_once dropped: %s", _URL_REQUIRED_WITH_TOKEN)
|
|
416
|
+
return False if wait else None
|
|
417
|
+
event = {"event_id": ulid(), "ts": time.time() * 1000,
|
|
418
|
+
**_ping_event(ping_type, status, description, expected_next_ts)}
|
|
419
|
+
|
|
420
|
+
def deliver() -> bool:
|
|
421
|
+
req = urllib.request.Request(
|
|
422
|
+
resolved + "/v1/events", data=_dump_line(event).encode(),
|
|
423
|
+
method="POST",
|
|
424
|
+
headers={"Content-Type": "application/x-ndjson"},
|
|
425
|
+
)
|
|
426
|
+
if token:
|
|
427
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
428
|
+
try:
|
|
429
|
+
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
|
|
430
|
+
return 200 <= resp.status < 300
|
|
431
|
+
except Exception as exc:
|
|
432
|
+
log.warning("gridrunner ping_once to %s failed (%s)",
|
|
433
|
+
resolved, exc.__class__.__name__)
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
if wait:
|
|
437
|
+
return deliver()
|
|
438
|
+
threading.Thread(target=deliver, daemon=False,
|
|
439
|
+
name="gridrunner-ping-once").start()
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class Chunk:
|
|
444
|
+
def __init__(self, job: "Job", chunk_id: str, units: float, worker: str | None,
|
|
445
|
+
item: str | None = None):
|
|
446
|
+
self.job = job
|
|
447
|
+
self.chunk_id = chunk_id
|
|
448
|
+
self.units = units
|
|
449
|
+
self.worker = worker
|
|
450
|
+
self.item = item
|
|
451
|
+
self._start = 0.0
|
|
452
|
+
|
|
453
|
+
def progress(self, fraction: float) -> None:
|
|
454
|
+
emit({"type": "chunk.progress", "job_id": self.job.job_id,
|
|
455
|
+
"chunk_id": self.chunk_id, "fraction": max(0.0, min(1.0, fraction))})
|
|
456
|
+
|
|
457
|
+
def __enter__(self) -> "Chunk":
|
|
458
|
+
self._start = time.monotonic()
|
|
459
|
+
event = {"type": "chunk.started", "job_id": self.job.job_id,
|
|
460
|
+
"chunk_id": self.chunk_id}
|
|
461
|
+
if self.worker:
|
|
462
|
+
event["worker"] = self.worker
|
|
463
|
+
if self.item:
|
|
464
|
+
event["item"] = self.item
|
|
465
|
+
emit(event)
|
|
466
|
+
return self
|
|
467
|
+
|
|
468
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
469
|
+
duration_ms = int((time.monotonic() - self._start) * 1000)
|
|
470
|
+
if exc_type is None:
|
|
471
|
+
emit({"type": "chunk.completed", "job_id": self.job.job_id,
|
|
472
|
+
"chunk_id": self.chunk_id, "duration_ms": duration_ms,
|
|
473
|
+
"units": self.units})
|
|
474
|
+
else:
|
|
475
|
+
emit({"type": "chunk.failed", "job_id": self.job.job_id,
|
|
476
|
+
"chunk_id": self.chunk_id, "error": f"{exc_type.__name__}: {exc}"})
|
|
477
|
+
self.job.had_failures = True
|
|
478
|
+
return False
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class Job:
|
|
482
|
+
def __init__(self, job_type_id: str, chunks=None, label: str | None = None,
|
|
483
|
+
total_units: float | None = None, meta: dict | None = None,
|
|
484
|
+
job_id: str | None = None, heartbeat_s: float | None = None,
|
|
485
|
+
expected_silence_s: float | None = None,
|
|
486
|
+
host: str | None = None):
|
|
487
|
+
self.job_id = job_id or f"j_{ulid().lower()}"
|
|
488
|
+
self.job_type_id = job_type_id
|
|
489
|
+
self.label = label or job_type_id
|
|
490
|
+
self.meta = meta or {}
|
|
491
|
+
self.heartbeat_s = heartbeat_s
|
|
492
|
+
self.expected_silence_s = expected_silence_s
|
|
493
|
+
self.host = host or _default_host()
|
|
494
|
+
self._hb_stop: threading.Event | None = None
|
|
495
|
+
self._hb_thread: threading.Thread | None = None
|
|
496
|
+
self.had_failures = False
|
|
497
|
+
self.chunk_specs: list[dict] = []
|
|
498
|
+
self._units_by_id: dict[str, float] = {}
|
|
499
|
+
if isinstance(chunks, dict):
|
|
500
|
+
chunks = [{"chunk_id": k, "units": v, "label": k} for k, v in chunks.items()]
|
|
501
|
+
for i, c in enumerate(chunks or []):
|
|
502
|
+
if isinstance(c, str):
|
|
503
|
+
c = {"chunk_id": c, "units": 1, "label": c}
|
|
504
|
+
c.setdefault("chunk_id", f"c{i:03d}")
|
|
505
|
+
c.setdefault("units", 1)
|
|
506
|
+
c.setdefault("label", c["chunk_id"])
|
|
507
|
+
c.setdefault("item", c["chunk_id"])
|
|
508
|
+
self.chunk_specs.append(c)
|
|
509
|
+
self._units_by_id[c["chunk_id"]] = float(c["units"])
|
|
510
|
+
self.total_units = total_units or sum(self._units_by_id.values()) or 1.0
|
|
511
|
+
|
|
512
|
+
def chunk(self, chunk_id: str, units: float | None = None,
|
|
513
|
+
worker: str | None = None, item: str | None = None) -> Chunk:
|
|
514
|
+
if chunk_id not in self._units_by_id:
|
|
515
|
+
spec = {"chunk_id": chunk_id, "units": units or 1,
|
|
516
|
+
"label": chunk_id, "item": item or chunk_id}
|
|
517
|
+
self.chunk_specs.append(spec)
|
|
518
|
+
self._units_by_id[chunk_id] = float(spec["units"])
|
|
519
|
+
return Chunk(self, chunk_id, units or self._units_by_id[chunk_id],
|
|
520
|
+
worker, item)
|
|
521
|
+
|
|
522
|
+
def register(self) -> "Job":
|
|
523
|
+
event = {"type": "job.registered", "job_id": self.job_id,
|
|
524
|
+
"job_type_id": self.job_type_id, "label": self.label,
|
|
525
|
+
"total_units": self.total_units,
|
|
526
|
+
"chunks": [dict(c) for c in self.chunk_specs],
|
|
527
|
+
"meta": self.meta}
|
|
528
|
+
if self.heartbeat_s:
|
|
529
|
+
event["heartbeat_s"] = float(self.heartbeat_s)
|
|
530
|
+
if self.expected_silence_s:
|
|
531
|
+
event["expected_silence_s"] = float(self.expected_silence_s)
|
|
532
|
+
if self.host:
|
|
533
|
+
event["host"] = self.host
|
|
534
|
+
emit(event)
|
|
535
|
+
self._start_heartbeat()
|
|
536
|
+
return self
|
|
537
|
+
|
|
538
|
+
def _start_heartbeat(self) -> None:
|
|
539
|
+
if not self.heartbeat_s or self._hb_thread is not None:
|
|
540
|
+
return
|
|
541
|
+
stop = threading.Event()
|
|
542
|
+
|
|
543
|
+
def beat() -> None:
|
|
544
|
+
while not stop.wait(self.heartbeat_s):
|
|
545
|
+
emit({"type": "job.heartbeat", "job_id": self.job_id})
|
|
546
|
+
|
|
547
|
+
self._hb_stop = stop
|
|
548
|
+
self._hb_thread = threading.Thread(
|
|
549
|
+
target=beat, daemon=True, name=f"gridrunner-hb-{self.job_id}")
|
|
550
|
+
self._hb_thread.start()
|
|
551
|
+
|
|
552
|
+
def _stop_heartbeat(self) -> None:
|
|
553
|
+
if self._hb_stop is not None:
|
|
554
|
+
self._hb_stop.set()
|
|
555
|
+
if self._hb_thread is not None:
|
|
556
|
+
self._hb_thread.join(timeout=0.2)
|
|
557
|
+
self._hb_stop = None
|
|
558
|
+
self._hb_thread = None
|
|
559
|
+
|
|
560
|
+
def complete(self) -> None:
|
|
561
|
+
self._stop_heartbeat()
|
|
562
|
+
emit({"type": "job.completed", "job_id": self.job_id})
|
|
563
|
+
|
|
564
|
+
def fail(self, error: str = "") -> None:
|
|
565
|
+
self._stop_heartbeat()
|
|
566
|
+
emit({"type": "job.failed", "job_id": self.job_id, "error": error})
|
|
567
|
+
|
|
568
|
+
def __enter__(self) -> "Job":
|
|
569
|
+
return self.register()
|
|
570
|
+
|
|
571
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
572
|
+
if exc_type is None:
|
|
573
|
+
self.complete()
|
|
574
|
+
else:
|
|
575
|
+
self.fail(f"{exc_type.__name__}: {exc}")
|
|
576
|
+
return False
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def job(job_type_id: str, chunks=None, **kwargs) -> Job:
|
|
580
|
+
return Job(job_type_id, chunks=chunks, **kwargs)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# ── SIMULATE surface: run streams and sim manifests ─────────────────────
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
class Run:
|
|
587
|
+
def __init__(self, sim_id: str, levers: dict | None = None, seed: int | None = None,
|
|
588
|
+
found_by: str = "operator", run_id: str | None = None):
|
|
589
|
+
self.run_id = run_id or f"r_{ulid().lower()}"
|
|
590
|
+
self.sim_id = sim_id
|
|
591
|
+
emit({"type": "run.started", "run_id": self.run_id, "sim_id": sim_id,
|
|
592
|
+
"levers": levers or {}, "seed": seed, "found_by": found_by})
|
|
593
|
+
|
|
594
|
+
def tick(self, t: int, values: dict) -> None:
|
|
595
|
+
emit({"type": "run.tick", "run_id": self.run_id, "t": t, "values": values})
|
|
596
|
+
|
|
597
|
+
def done(self, metrics: dict | None = None, feasible: bool = True,
|
|
598
|
+
objective: float | None = None, violated: list | None = None) -> None:
|
|
599
|
+
event = {"type": "run.completed", "run_id": self.run_id,
|
|
600
|
+
"metrics": metrics or {}, "feasible": feasible}
|
|
601
|
+
if objective is not None:
|
|
602
|
+
event["objective"] = objective
|
|
603
|
+
if violated:
|
|
604
|
+
event["violated"] = violated
|
|
605
|
+
emit(event)
|
|
606
|
+
|
|
607
|
+
def fail(self, error: str = "") -> None:
|
|
608
|
+
emit({"type": "run.failed", "run_id": self.run_id, "error": error})
|
|
609
|
+
|
|
610
|
+
def best(self, objective: float) -> None:
|
|
611
|
+
emit({"type": "search.best", "run_id": self.run_id,
|
|
612
|
+
"sim_id": self.sim_id, "objective": objective})
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def run(sim_id: str, levers: dict | None = None, **kwargs) -> Run:
|
|
616
|
+
return Run(sim_id, levers=levers, **kwargs)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def manifest(manifest_dict: dict, version: int | None = None) -> None:
|
|
620
|
+
event = {"type": "sim.manifest", "sim_id": manifest_dict.get("sim_id"),
|
|
621
|
+
"manifest": manifest_dict}
|
|
622
|
+
if version is not None:
|
|
623
|
+
event["version"] = version
|
|
624
|
+
emit(event)
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gridrunner
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Producer SDK for GRIDRUNNER — fire-and-forget job progress events
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# GRIDRUNNER Python SDK
|
|
15
|
+
|
|
16
|
+
Standard-library-only producer client for reporting bounded jobs and
|
|
17
|
+
service status pings — super-light health monitoring for services and
|
|
18
|
+
recurring tasks — to GRIDRUNNER. See the [repository README](../README.md)
|
|
19
|
+
for the job/item/chunk/unit model, the ping mechanic, authentication,
|
|
20
|
+
delivery guarantees, and installation.
|
|
21
|
+
|
|
22
|
+
## Configure
|
|
23
|
+
|
|
24
|
+
Initialize once near process startup with explicit credentials — the SDK
|
|
25
|
+
reads no environment variables and no config files:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import gridrunner as gr
|
|
29
|
+
|
|
30
|
+
client = gr.init(token=read_secret("gridrunner_produce_token"),
|
|
31
|
+
url="https://gridrunner.example.com")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A token without `url=` raises `ValueError`. With neither, `init()` targets
|
|
35
|
+
the local unauthenticated dev core:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
client = gr.init() # http://127.0.0.1:7077
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The token is also your identity: the name your producer was registered
|
|
42
|
+
with when the token was minted becomes the service name of every ping you
|
|
43
|
+
send.
|
|
44
|
+
|
|
45
|
+
All options:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
client = gr.init(
|
|
49
|
+
token=token,
|
|
50
|
+
url="https://gridrunner.example.com",
|
|
51
|
+
flush_interval_s=0.2,
|
|
52
|
+
queue_size=20_000,
|
|
53
|
+
retry_interval_s=5.0,
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Preferred usage
|
|
58
|
+
|
|
59
|
+
Declare chunks up front and use context managers. Successful exits emit
|
|
60
|
+
completion; exceptions emit failure and are re-raised.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import gridrunner as gr
|
|
64
|
+
|
|
65
|
+
gr.init(token=read_secret("gridrunner_produce_token"),
|
|
66
|
+
url="https://gridrunner.example.com")
|
|
67
|
+
|
|
68
|
+
chunks = [
|
|
69
|
+
{
|
|
70
|
+
"chunk_id": f"orders:{month}",
|
|
71
|
+
"item": "orders",
|
|
72
|
+
"label": month,
|
|
73
|
+
"units": estimated_rows,
|
|
74
|
+
}
|
|
75
|
+
for month, estimated_rows in monthly_estimates.items()
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
with gr.job(
|
|
79
|
+
"etl.orders.monthly.v1",
|
|
80
|
+
chunks=chunks,
|
|
81
|
+
label="Export orders",
|
|
82
|
+
meta={"service": "billing-export"},
|
|
83
|
+
heartbeat_s=30,
|
|
84
|
+
) as job:
|
|
85
|
+
for spec in chunks:
|
|
86
|
+
with job.chunk(spec["chunk_id"], worker=worker_name):
|
|
87
|
+
export_month(spec["label"])
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Manual lifecycle
|
|
91
|
+
|
|
92
|
+
Use manual methods when a context manager does not match the host framework:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
job = gr.job(
|
|
96
|
+
"model.forecast.v3",
|
|
97
|
+
chunks=[
|
|
98
|
+
{"chunk_id": "load", "item": "prepare", "units": 1},
|
|
99
|
+
{"chunk_id": "fit", "item": "model", "units": 10},
|
|
100
|
+
{"chunk_id": "write", "item": "output", "units": 1},
|
|
101
|
+
],
|
|
102
|
+
expected_silence_s=300,
|
|
103
|
+
).register()
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
with job.chunk("fit"):
|
|
107
|
+
fit_model()
|
|
108
|
+
job.complete()
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
job.fail(f"{type(exc).__name__}: {exc}")
|
|
111
|
+
raise
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
For one genuinely indivisible chunk, report a measured fraction:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
with job.chunk("fit") as chunk:
|
|
118
|
+
for completed, total in train():
|
|
119
|
+
chunk.progress(completed / total)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Service pings
|
|
123
|
+
|
|
124
|
+
For services and independent regular jobs — cron cycles, daemons,
|
|
125
|
+
schedulers — use the ping mechanic instead of jobs: one call per cycle,
|
|
126
|
+
and GRIDRUNNER learns the cadence and alarms on silence or errors by
|
|
127
|
+
itself.
|
|
128
|
+
|
|
129
|
+
The ping only names the recurring process (`ping_type` — an
|
|
130
|
+
understandable name like `daily-export` or `queue-sweep`). The service
|
|
131
|
+
name is resolved server-side from the produce token — the name the
|
|
132
|
+
producer was registered with — so producers can never contaminate each
|
|
133
|
+
other's series.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
def daily_cycle():
|
|
137
|
+
try:
|
|
138
|
+
run_export()
|
|
139
|
+
gr.ping("daily-export")
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
gr.ping("daily-export", status="error",
|
|
142
|
+
description=f"{type(exc).__name__}: {exc}")
|
|
143
|
+
raise
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
If the cadence is too sparse to learn quickly (weekly, monthly), declare
|
|
147
|
+
when the next ping is due — GRIDRUNNER alarms on that deadline instead of
|
|
148
|
+
waiting to learn the pattern:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
gr.ping("monthly-report", expected_next_ts=next_run_at) # epoch ms or datetime
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
For cron scripts that shouldn't carry a client lifecycle at all, use the
|
|
155
|
+
one-shot call — token and ping in one line, no `init`, no queue:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
gr.ping_once("daily-export", token=tok, url=core_url)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
See [Service pings](../README.md#service-pings) in the repository README
|
|
162
|
+
for the full contract.
|
|
163
|
+
|
|
164
|
+
## Chunk plan formats
|
|
165
|
+
|
|
166
|
+
Full dictionaries preserve item grouping and weights:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
chunks = [
|
|
170
|
+
{"chunk_id": "users:0", "item": "users", "label": "0–49k", "units": 50_000},
|
|
171
|
+
{"chunk_id": "users:1", "item": "users", "label": "50k–99k", "units": 50_000},
|
|
172
|
+
]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Convenience forms are accepted:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
chunks = {"users:0": 50_000, "users:1": 50_000}
|
|
179
|
+
chunks = ["load", "fit", "write"] # each gets one unit
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## API
|
|
183
|
+
|
|
184
|
+
### `init(token=None, url=None, **client_options)`
|
|
185
|
+
|
|
186
|
+
Configures the module-level client used by `emit()`, `job()`, and
|
|
187
|
+
`ping()`. A token requires an explicit `url` (`ValueError` otherwise);
|
|
188
|
+
with neither it targets a local core at `http://127.0.0.1:7077`. The
|
|
189
|
+
target never changes behind the caller's back. `connect(url=None,
|
|
190
|
+
token=None, ...)` remains as a deprecated alias.
|
|
191
|
+
|
|
192
|
+
### `job(job_type_id, chunks=None, **options) -> Job`
|
|
193
|
+
|
|
194
|
+
Options:
|
|
195
|
+
|
|
196
|
+
| Option | Meaning |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `label` | Human-readable run label |
|
|
199
|
+
| `total_units` | Override sum of chunk units |
|
|
200
|
+
| `meta` | JSON metadata attached to registration |
|
|
201
|
+
| `job_id` | Stable caller-provided run ID; otherwise generated ULID |
|
|
202
|
+
| `heartbeat_s` | Automatic heartbeat interval |
|
|
203
|
+
| `expected_silence_s` | Expected quiet-work bound |
|
|
204
|
+
|
|
205
|
+
### `Job`
|
|
206
|
+
|
|
207
|
+
- `register()` — emits `job.registered` and starts heartbeat.
|
|
208
|
+
- `chunk(id, units=None, worker=None, item=None)` — returns a chunk context.
|
|
209
|
+
- `complete()` — stops heartbeat and emits `job.completed`.
|
|
210
|
+
- `fail(error="")` — stops heartbeat and emits `job.failed`.
|
|
211
|
+
|
|
212
|
+
### `Chunk`
|
|
213
|
+
|
|
214
|
+
- Context entry emits `chunk.started`.
|
|
215
|
+
- `progress(fraction)` emits a clamped measured fraction in `[0, 1]`.
|
|
216
|
+
- Normal context exit emits `chunk.completed` with duration.
|
|
217
|
+
- Exceptional context exit emits `chunk.failed` and re-raises.
|
|
218
|
+
|
|
219
|
+
### `ping(ping_type="default", status="ok", description=None, expected_next_ts=None)`
|
|
220
|
+
|
|
221
|
+
Reports a recurring status ping — a mechanic separate from jobs (see the
|
|
222
|
+
[repository README](../README.md#service-pings)). The service name is
|
|
223
|
+
resolved server-side from the produce token; `ping_type` names the
|
|
224
|
+
recurring process:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
gr.ping("daily-export")
|
|
228
|
+
gr.ping("daily-export", status="error", description="table locked")
|
|
229
|
+
gr.ping("monthly-report", expected_next_ts=next_run_at)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`expected_next_ts` (epoch ms or a `datetime`) optionally declares when the
|
|
233
|
+
next ping is due, for cadences too sparse to learn quickly.
|
|
234
|
+
|
|
235
|
+
### `ping_once(ping_type="default", *, token=None, status="ok", description=None, expected_next_ts=None, url=None, wait=False, timeout_s=5.0)`
|
|
236
|
+
|
|
237
|
+
One-shot stateless ping for cron scripts and one-liners: token and ping in
|
|
238
|
+
the same call, one direct HTTP POST — no `init`, no persistent client, no
|
|
239
|
+
queue, no backlog (outage-safe replay is what you give up).
|
|
240
|
+
|
|
241
|
+
Fire-and-forget by default: returns `None` immediately and delivers on a
|
|
242
|
+
short-lived non-daemon thread, so a script that exits right after pinging
|
|
243
|
+
doesn't lose the ping (the process lingers at most `timeout_s`).
|
|
244
|
+
`wait=True` performs the POST inline and returns `True`/`False`. Never
|
|
245
|
+
raises either way. `url` resolves like `init`: required alongside a token
|
|
246
|
+
(a token without `url` is logged and dropped), the local dev core without
|
|
247
|
+
one.
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
gr.ping_once("daily-export", token=tok, url=core_url) # returns immediately
|
|
251
|
+
ok = gr.ping_once("daily-export", token=tok, url=core_url, wait=True) # opt-in: block → bool
|
|
252
|
+
gr.ping_once("daily-export", token=tok, url=core_url, status="error",
|
|
253
|
+
description="table locked")
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### `emit(event)`
|
|
257
|
+
|
|
258
|
+
Queues a raw event on the configured module client. Prefer the structured
|
|
259
|
+
job/chunk API unless integrating an unsupported lifecycle.
|
|
260
|
+
|
|
261
|
+
### `Client`
|
|
262
|
+
|
|
263
|
+
An independent client for applications that cannot use module-level state:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
client = gr.Client(url, token=token)
|
|
267
|
+
client.emit({"type": "job.heartbeat", "job_id": job_id})
|
|
268
|
+
client.close()
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Call `close()` during graceful shutdown to flush the in-memory queue.
|
|
272
|
+
|
|
273
|
+
## Failure behavior
|
|
274
|
+
|
|
275
|
+
- Connection and HTTP errors never propagate into host work.
|
|
276
|
+
- Undelivered events are held in a bounded, liveness-scoped in-memory
|
|
277
|
+
backlog: live jobs keep compacted events (latest progress per chunk, no
|
|
278
|
+
heartbeats; ok pings compact to the newest per series, error pings are
|
|
279
|
+
all kept) and replay on reconnect; jobs that start and finish entirely
|
|
280
|
+
while the core is unreachable are discarded, never sent late.
|
|
281
|
+
- Events are assigned idempotency IDs before queueing.
|
|
282
|
+
- Non-finite floats are converted to JSON `null`.
|
|
283
|
+
- A full queue drops new events and logs at increasing thresholds; held
|
|
284
|
+
backlogs log a rate-limited warning (distinct message on 401).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
gridrunner/__init__.py,sha256=ViIXTA43x2AsmHx9qlebRvcw90qV4c1K0AfLqz-5jU8,436
|
|
2
|
+
gridrunner/ids.py,sha256=dwnwYb1BCVZCv1VCvX-Dmi1RWemLLzLIgmFL3pG8MjY,704
|
|
3
|
+
gridrunner/sdk.py,sha256=d013Wfr1k7W5ULWJgoNGCK9aN0ixSO4291iqYduMGd0,24519
|
|
4
|
+
gridrunner-0.6.0.dist-info/licenses/LICENSE,sha256=VywLwAcDu7Rk1s3he7PX6zq_f95A-8IIhhbzsvYeP4w,1075
|
|
5
|
+
gridrunner-0.6.0.dist-info/METADATA,sha256=Y5LJeNwmfasfVq71fPsQvbkAQnLH-XjZSo6qHyxKIfk,9051
|
|
6
|
+
gridrunner-0.6.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
gridrunner-0.6.0.dist-info/top_level.txt,sha256=09IqiGvxPvqbbvRTvphrimHEz4PQcYJ0Wd-lAlY7E4o,11
|
|
8
|
+
gridrunner-0.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GRIDRUNNER authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gridrunner
|