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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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