toro-queue 0.1.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.
toro/queue.py ADDED
@@ -0,0 +1,545 @@
1
+ """Queue: the producer side. Adds jobs and inspects their state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ from typing import Any, cast
9
+
10
+ from redis.asyncio import Redis
11
+
12
+ from . import scripts
13
+ from .connection import connect
14
+ from .errors import JobFailedError
15
+ from .job import Job, JobOptions, JobState
16
+ from .keys import Keys
17
+ from .scheduler import next_run, valid_cron
18
+
19
+
20
+ def _now_ms() -> int:
21
+ return int(time.time() * 1000)
22
+
23
+
24
+ def _clamp_priority(p: int) -> int:
25
+ return max(0, min(int(p), scripts.PRIORITY_OFFSET))
26
+
27
+
28
+ class Queue:
29
+ """The producer side: add jobs, schedule them, and inspect queue state."""
30
+
31
+ def __init__(
32
+ self,
33
+ name: str,
34
+ *,
35
+ connection: Redis | None = None,
36
+ url: str = "redis://localhost:6379",
37
+ prefix: str = "toro",
38
+ default_job_options: dict | None = None,
39
+ ) -> None:
40
+ self.name = name
41
+ # Defaults merged into every add() (per-call options win) — e.g.
42
+ # default_job_options={"remove_on_complete": 1000} so you don't repeat it.
43
+ self.default_job_options = dict(default_job_options or {})
44
+ self.keys = Keys(name, prefix)
45
+ # NB: created with decode_responses=True, so every command returns str —
46
+ # redis-py's async client isn't generic over that, hence the casts below.
47
+ self.redis = connection or connect(url)
48
+ self._add_job = self.redis.register_script(scripts.ADD_JOB)
49
+ self._retry_job = self.redis.register_script(scripts.RETRY_JOB)
50
+ self._remove_job = self.redis.register_script(scripts.REMOVE_JOB)
51
+ self._add_scheduled = self.redis.register_script(scripts.ADD_SCHEDULED)
52
+ self._promote_job = self.redis.register_script(scripts.PROMOTE_JOB)
53
+
54
+ async def add(
55
+ self,
56
+ name: str,
57
+ data: Any = None,
58
+ *,
59
+ job_id: str | None = None,
60
+ deduplication: dict | None = None,
61
+ **opts: Any,
62
+ ) -> Job:
63
+ """Enqueue a job. Returns the created Job (with its id).
64
+
65
+ `priority`: higher = more urgent (global order across the whole queue);
66
+ the default 0 is the least-urgent band, processed FIFO among itself.
67
+
68
+ `job_id`: a custom id. Adding a second job with an id that already exists
69
+ is IDEMPOTENT — it's ignored, not duplicated (id-based dedup). Once the job
70
+ is removed, the id is free to reuse. Must be a non-empty, non-all-digits
71
+ string (all-digit ids collide with auto-generated ones).
72
+
73
+ `deduplication`: `{"id": str, "ttl": ms}` — a throttle window. While the
74
+ ttl is live, repeat adds with the same dedup id are ignored and the
75
+ already-queued job's id is returned. Self-expiring; independent of job_id.
76
+ """
77
+ options = JobOptions(**{**self.default_job_options, **opts})
78
+ options.priority = _clamp_priority(options.priority)
79
+ if job_id is not None:
80
+ job_id = str(job_id)
81
+ if not job_id or job_id.isdigit():
82
+ raise ValueError(
83
+ "custom job_id must be a non-empty, non-all-digits string "
84
+ "(digits collide with auto-generated ids) — try e.g. 'order-123'"
85
+ )
86
+ dedup_id, dedup_ttl = "", 0
87
+ if deduplication is not None:
88
+ dedup_id = str(deduplication.get("id") or "")
89
+ dedup_ttl = int(deduplication.get("ttl") or 0)
90
+ if not dedup_id or dedup_ttl <= 0:
91
+ raise ValueError("deduplication needs {'id': str, 'ttl': positive ms}")
92
+ now = _now_ms()
93
+ new_id = str(
94
+ await self._add_job(
95
+ keys=[
96
+ self.keys.id,
97
+ self.keys.prioritized,
98
+ self.keys.marker,
99
+ self.keys.delayed,
100
+ self.keys.base,
101
+ self.keys.pc,
102
+ ],
103
+ args=[
104
+ name,
105
+ json.dumps(data),
106
+ json.dumps(options.to_dict()),
107
+ now,
108
+ options.delay,
109
+ options.priority,
110
+ job_id or "",
111
+ dedup_id,
112
+ dedup_ttl,
113
+ ],
114
+ )
115
+ )
116
+ # Signal the change so a live dashboard refreshes on enqueue, not only when a
117
+ # job finishes (completed/failed publish from the Lua side). result() and the
118
+ # dashboard both tolerate this event type.
119
+ await self.redis.publish(self.keys.events, json.dumps({"jobId": new_id, "event": "added"}))
120
+ return Job(
121
+ id=new_id,
122
+ name=name,
123
+ data=data,
124
+ opts=options,
125
+ timestamp=now,
126
+ state="delayed" if options.delay > 0 else "wait",
127
+ _queue=self,
128
+ )
129
+
130
+ async def result(self, job_id: str, *, timeout: float = 30.0) -> Any:
131
+ """Wait for a job to finish; return its return value, or raise JobFailedError.
132
+
133
+ Subscribes before checking state, so it won't miss the outcome of a job
134
+ that finishes while we wait. Works even if the job hash was auto-removed,
135
+ as long as result() was awaited before the job finished.
136
+ """
137
+ pubsub = self.redis.pubsub()
138
+ await pubsub.subscribe(self.keys.events)
139
+ try:
140
+ job = await self.get_job(job_id)
141
+ if job is not None and job.state == "completed":
142
+ return job.returnvalue
143
+ if job is not None and job.state == "failed":
144
+ raise JobFailedError(job.failed_reason)
145
+ loop = asyncio.get_running_loop() # correct idiom inside a coroutine
146
+ deadline = loop.time() + timeout
147
+ while True:
148
+ remaining = deadline - loop.time()
149
+ if remaining <= 0:
150
+ raise TimeoutError(f"job {job_id} did not finish within {timeout}s")
151
+ msg = await pubsub.get_message(ignore_subscribe_messages=True, timeout=remaining)
152
+ if msg is None:
153
+ continue
154
+ data = json.loads(msg["data"])
155
+ if str(data.get("jobId")) != str(job_id):
156
+ continue
157
+ event = data.get("event")
158
+ if event == "completed":
159
+ return data.get("result")
160
+ if event == "failed":
161
+ raise JobFailedError(data.get("reason"))
162
+ # ignore non-terminal events (e.g. "added") and keep waiting
163
+ finally:
164
+ await pubsub.aclose()
165
+
166
+ # ---- schedulers (cron / repeatable) -----------------------------------
167
+
168
+ async def add_scheduler(
169
+ self,
170
+ scheduler_id: str,
171
+ *,
172
+ every: int | None = None,
173
+ cron: str | None = None,
174
+ name: str | None = None,
175
+ data: Any = None,
176
+ priority: int = 0,
177
+ **job_opts: Any,
178
+ ) -> str:
179
+ """Register a repeatable schedule. Exactly one of `every` (ms) or `cron`.
180
+
181
+ Stores a scheduler record and enqueues the first occurrence as a delayed
182
+ job; each occurrence mints its successor when a worker picks it up.
183
+ Re-calling with the same id updates the schedule.
184
+ """
185
+ scheduler_id = str(scheduler_id)
186
+ if not scheduler_id or ":" in scheduler_id or any(ord(c) < 0x20 for c in scheduler_id):
187
+ # it's interpolated into Redis keys ({base}repeat:<id>) and the occurrence
188
+ # id (repeat:<id>:<when>); ':' or control chars let one scheduler collide
189
+ # with another's keys — same class of guard as custom job_id.
190
+ raise ValueError(
191
+ "scheduler_id must be a non-empty string with no ':' or control "
192
+ "characters (it's used as a Redis key segment) — try e.g. 'nightly-rollup'"
193
+ )
194
+ if (every is None) == (cron is None):
195
+ raise ValueError("pass exactly one of `every` or `cron`")
196
+ if cron is not None and not valid_cron(cron):
197
+ # fail at enqueue, not later inside a worker's _schedule_next (a silent
198
+ # scheduler that errors on the backend)
199
+ raise ValueError(f"invalid cron expression: {cron!r}")
200
+ opts = JobOptions(priority=_clamp_priority(priority), **job_opts).to_dict()
201
+ template = {
202
+ "name": name or scheduler_id,
203
+ "every": str(every) if every else "",
204
+ "cron": cron or "",
205
+ "data": json.dumps(data),
206
+ "opts": json.dumps(opts),
207
+ }
208
+ # redis-py's hset overloads don't resolve a plain dict[str, str] mapping.
209
+ await self.redis.hset(self.keys.scheduler(scheduler_id), mapping=template) # ty: ignore[no-matching-overload]
210
+ when = next_run(_now_ms(), every=every, cron=cron)
211
+ await self.redis.zadd(self.keys.repeat, {scheduler_id: when})
212
+ await self._enqueue_occurrence(scheduler_id, when, template)
213
+ return scheduler_id
214
+
215
+ async def _enqueue_occurrence(self, scheduler_id: str, when: int, template: dict) -> None:
216
+ opts = json.loads(template["opts"])
217
+ await self._add_scheduled(
218
+ keys=[self.keys.delayed, self.keys.base],
219
+ args=[
220
+ f"repeat:{scheduler_id}:{when}",
221
+ template["name"],
222
+ template["data"],
223
+ template["opts"],
224
+ _now_ms(),
225
+ when,
226
+ opts.get("priority", 0),
227
+ scheduler_id,
228
+ ],
229
+ )
230
+
231
+ async def remove_scheduler(self, scheduler_id: str) -> None:
232
+ """Stop a schedule and drop its pending occurrence."""
233
+ score = await self.redis.zscore(self.keys.repeat, scheduler_id)
234
+ await self.redis.zrem(self.keys.repeat, scheduler_id)
235
+ await self.redis.delete(self.keys.scheduler(scheduler_id))
236
+ if score is not None:
237
+ await self.remove_job(f"repeat:{scheduler_id}:{int(score)}")
238
+
239
+ async def trigger_scheduler(self, scheduler_id: str) -> bool:
240
+ """Enqueue one immediate occurrence of a scheduler (a manual 'run now').
241
+
242
+ Carries the scheduler's configured options (priority/attempts/backoff/
243
+ auto-removal) so a manual run matches a scheduled one — but runs immediately
244
+ (`delay` is omitted, not taken from the stored opts).
245
+ """
246
+ t = await self.redis.hgetall(self.keys.scheduler(scheduler_id))
247
+ if not t:
248
+ return False
249
+ name = cast("str", t.get("name", scheduler_id))
250
+ opts = JobOptions.from_dict(json.loads(t.get("opts") or "{}"))
251
+ await self.add(
252
+ name,
253
+ json.loads(t.get("data") or "null"),
254
+ attempts=opts.attempts,
255
+ backoff=opts.backoff,
256
+ priority=opts.priority,
257
+ remove_on_complete=opts.remove_on_complete,
258
+ remove_on_fail=opts.remove_on_fail,
259
+ )
260
+ return True
261
+
262
+ async def schedulers(self) -> list[dict]:
263
+ """List active schedulers (for the dashboard)."""
264
+ entries = cast(
265
+ "list[tuple[str, float]]",
266
+ await self.redis.zrange(self.keys.repeat, 0, -1, withscores=True),
267
+ )
268
+ if not entries:
269
+ return []
270
+ pipe = self.redis.pipeline(transaction=False) # read fan-out; no MULTI/EXEC needed
271
+ for sid, _ in entries:
272
+ pipe.hgetall(self.keys.scheduler(sid))
273
+ templates = cast("list[dict[str, str]]", await pipe.execute())
274
+ out = []
275
+ for (sid, when), t in zip(entries, templates, strict=False):
276
+ out.append(
277
+ {
278
+ "id": sid,
279
+ "name": t.get("name", sid),
280
+ "next": int(when),
281
+ "every": int(t["every"]) if t.get("every") else None,
282
+ "cron": t.get("cron") or None,
283
+ }
284
+ )
285
+ return out
286
+
287
+ async def get_job(self, job_id: str) -> Job | None:
288
+ h = await self.redis.hgetall(self.keys.job(job_id))
289
+ if not h:
290
+ return None
291
+ return Job.from_hash(job_id, h)
292
+
293
+ async def get_logs(self, job_id: str, start: int = 0, end: int = -1) -> list[str]:
294
+ return cast("list[str]", await self.redis.lrange(self.keys.logs(job_id), start, end))
295
+
296
+ async def counts(self) -> dict[str, int]:
297
+ """Quick snapshot of how many jobs sit in each state. `wait` = waiting
298
+ jobs in the prioritized set.
299
+ """
300
+ pipe = self.redis.pipeline(transaction=False) # read fan-out; no MULTI/EXEC needed
301
+ pipe.zcard(self.keys.prioritized)
302
+ pipe.llen(self.keys.active)
303
+ pipe.zcard(self.keys.delayed)
304
+ pipe.zcard(self.keys.completed)
305
+ pipe.zcard(self.keys.failed)
306
+ wait, active, delayed, completed, failed = await pipe.execute()
307
+ return {
308
+ "wait": wait,
309
+ "active": active,
310
+ "delayed": delayed,
311
+ "completed": completed,
312
+ "failed": failed,
313
+ }
314
+
315
+ async def workers(self, *, stale_after: int = 30_000) -> list[dict]:
316
+ """Live workers, from the presence records their heartbeats write. An entry
317
+ with no heartbeat for `stale_after` ms is treated as dead and pruned here,
318
+ so a crashed worker (which never deregistered) disappears on its own.
319
+ """
320
+ now = _now_ms()
321
+ ids = cast("list[str]", await self.redis.zrange(self.keys.workers, 0, -1))
322
+ if not ids:
323
+ return []
324
+ pipe = self.redis.pipeline(transaction=False) # read fan-out; no MULTI/EXEC needed
325
+ for wid in ids:
326
+ pipe.hgetall(self.keys.worker(wid))
327
+ hashes = cast("list[dict]", await pipe.execute())
328
+ live: list[dict] = []
329
+ dead: list[tuple[str, dict]] = []
330
+ for wid, h in zip(ids, hashes, strict=True):
331
+ heartbeat = int(h.get("heartbeat", 0)) if h else 0
332
+ if not h or now - heartbeat > stale_after:
333
+ dead.append((wid, h or {}))
334
+ continue
335
+ live.append(
336
+ {
337
+ "id": wid,
338
+ "host": h.get("host", "?"),
339
+ "pid": int(h.get("pid", 0)),
340
+ "queue": h.get("queue", self.name),
341
+ "concurrency": int(h.get("concurrency", 0)),
342
+ "started": int(h.get("started", 0)),
343
+ "heartbeat": heartbeat,
344
+ "processed": int(h.get("processed", 0)),
345
+ "failed": int(h.get("failed", 0)),
346
+ "current": json.loads(h.get("current", "[]")),
347
+ "state": h.get("state", "running"),
348
+ }
349
+ )
350
+ if dead:
351
+ # A stale worker crashed/was killed without deregistering — log it as
352
+ # "lost" (vs a graceful "stopped") before pruning, so its death is visible.
353
+ # Kept transactional: record-then-prune must be atomic, else a partial
354
+ # failure leaves a worker re-recorded (duplicate death) or pruned silently.
355
+ pipe = self.redis.pipeline()
356
+ for wid, h in dead:
357
+ if h:
358
+ pipe.lpush(
359
+ self.keys.departed,
360
+ json.dumps(
361
+ {
362
+ "id": wid,
363
+ "host": h.get("host", "?"),
364
+ "pid": int(h.get("pid", 0)),
365
+ "queue": h.get("queue", self.name),
366
+ "concurrency": int(h.get("concurrency", 0)),
367
+ "processed": int(h.get("processed", 0)),
368
+ "failed": int(h.get("failed", 0)),
369
+ "started": int(h.get("started", 0)),
370
+ "last_seen": int(h.get("heartbeat", 0)),
371
+ "current": json.loads(h.get("current", "[]")),
372
+ "reason": "lost",
373
+ "at": now,
374
+ }
375
+ ),
376
+ )
377
+ pipe.ltrim(self.keys.departed, 0, 49)
378
+ pipe.zrem(self.keys.workers, *[w for w, _ in dead])
379
+ pipe.delete(*(self.keys.worker(w) for w, _ in dead))
380
+ await pipe.execute()
381
+ live.sort(key=lambda w: w["started"])
382
+ return live
383
+
384
+ async def departed_workers(self, limit: int = 20) -> list[dict]:
385
+ """Recent worker departures, newest first — graceful stops ("stopped") and
386
+ lost heartbeats ("lost"). A bounded death-log so the dashboard can show what
387
+ left, when, and why, instead of workers silently vanishing.
388
+ """
389
+ raw = cast("list[str]", await self.redis.lrange(self.keys.departed, 0, limit - 1))
390
+ return [json.loads(r) for r in raw]
391
+
392
+ async def clear_departed(self) -> int:
393
+ """Drop the recorded worker departures (the post-mortem log). Returns the count
394
+ cleared. Live workers re-appear via their heartbeats; this only clears history.
395
+ """
396
+ n = await self.redis.llen(self.keys.departed)
397
+ await self.redis.delete(self.keys.departed)
398
+ return n
399
+
400
+ async def get_jobs(self, state: JobState, start: int = 0, end: int = 20) -> list[Job]:
401
+ """Page through job ids in a given state and hydrate them into Jobs.
402
+ `wait` returns jobs in global priority order (most urgent first).
403
+ """
404
+ if state in ("wait", "prioritized"):
405
+ ids = await self.redis.zrange(self.keys.prioritized, start, end)
406
+ elif state == "active":
407
+ ids = await self.redis.lrange(self.keys.active, start, end)
408
+ elif state == "delayed":
409
+ ids = await self.redis.zrange(self.keys.delayed, start, end)
410
+ elif state in ("completed", "failed"):
411
+ ids = await self.redis.zrevrange(getattr(self.keys, state), start, end)
412
+ else:
413
+ raise ValueError(f"unknown state: {state}")
414
+ if not ids:
415
+ return []
416
+ # Hydrate the whole page in one round trip instead of one HGETALL per job.
417
+ pipe = self.redis.pipeline(transaction=False) # read fan-out; no MULTI/EXEC needed
418
+ for job_id in ids:
419
+ pipe.hgetall(self.keys.job(cast("str", job_id)))
420
+ hashes = await pipe.execute()
421
+ return [
422
+ Job.from_hash(cast("str", jid), h) for jid, h in zip(ids, hashes, strict=False) if h
423
+ ]
424
+
425
+ async def retry_job(self, job_id: str) -> bool:
426
+ """Move a failed job back to the queue for another attempt."""
427
+ res = await self._retry_job(
428
+ keys=[
429
+ self.keys.failed,
430
+ self.keys.prioritized,
431
+ self.keys.marker,
432
+ self.keys.job(job_id),
433
+ self.keys.pc,
434
+ ],
435
+ args=[job_id],
436
+ )
437
+ return bool(res)
438
+
439
+ async def remove_job(self, job_id: str) -> bool:
440
+ """Delete a job from every state and drop its hash."""
441
+ res = await self._remove_job(
442
+ keys=[
443
+ self.keys.prioritized,
444
+ self.keys.active,
445
+ self.keys.delayed,
446
+ self.keys.completed,
447
+ self.keys.failed,
448
+ self.keys.job(job_id),
449
+ ],
450
+ args=[job_id],
451
+ )
452
+ return bool(res)
453
+
454
+ async def promote_job(self, job_id: str) -> bool:
455
+ """Move a delayed job into the queue to run now."""
456
+ res = await self._promote_job(
457
+ keys=[
458
+ self.keys.delayed,
459
+ self.keys.prioritized,
460
+ self.keys.marker,
461
+ self.keys.job(job_id),
462
+ self.keys.pc,
463
+ ],
464
+ args=[job_id],
465
+ )
466
+ return bool(res)
467
+
468
+ async def _ids(self, state: JobState, limit: int) -> list[str]:
469
+ if state in ("wait", "prioritized"):
470
+ return cast("list[str]", await self.redis.zrange(self.keys.prioritized, 0, limit - 1))
471
+ if state == "active":
472
+ return cast("list[str]", await self.redis.lrange(self.keys.active, 0, limit - 1))
473
+ if state in ("delayed", "completed", "failed"):
474
+ zset = getattr(self.keys, state)
475
+ return cast("list[str]", await self.redis.zrange(zset, 0, limit - 1))
476
+ raise ValueError(f"unknown state: {state}")
477
+
478
+ async def search(self, state: JobState, query: str, scan_limit: int = 500) -> list[Job]:
479
+ """Substring-search `name`/`data` within a state's most recent `scan_limit`
480
+ jobs (Redis hashes aren't queryable, so this is a bounded scan + filter).
481
+ Returns the matches; the caller should surface the scan bound honestly.
482
+ """
483
+ ids = await self._ids(state, scan_limit)
484
+ if not ids:
485
+ return []
486
+ pipe = self.redis.pipeline(transaction=False) # read fan-out; no MULTI/EXEC needed
487
+ for job_id in ids:
488
+ pipe.hgetall(self.keys.job(job_id))
489
+ hashes = await pipe.execute()
490
+ q = query.lower()
491
+ out = []
492
+ for job_id, h in zip(ids, hashes, strict=False):
493
+ if h and (q in h.get("name", "").lower() or q in h.get("data", "").lower()):
494
+ out.append(Job.from_hash(job_id, h))
495
+ return out
496
+
497
+ async def retry_all_failed(self, limit: int = 1000) -> int:
498
+ """Re-queue every failed job. Returns how many were retried."""
499
+ ids = await self._ids("failed", limit)
500
+ for job_id in ids:
501
+ await self.retry_job(job_id)
502
+ return len(ids)
503
+
504
+ async def clean(self, state: JobState, limit: int = 1000) -> int:
505
+ """Remove every job in a state (up to `limit`). Returns how many were removed.
506
+
507
+ Pipelines the per-job removals — one round trip per batch, not one per job —
508
+ so clearing a large state stays fast (thousands of jobs in well under a second).
509
+ """
510
+ ids = await self._ids(state, limit)
511
+ if not ids:
512
+ return 0
513
+ sha = await self.redis.script_load(scripts.REMOVE_JOB) # ensure loaded for EVALSHA
514
+ pipe = self.redis.pipeline(transaction=False)
515
+ for job_id in ids:
516
+ pipe.evalsha(
517
+ sha,
518
+ 6,
519
+ self.keys.prioritized,
520
+ self.keys.active,
521
+ self.keys.delayed,
522
+ self.keys.completed,
523
+ self.keys.failed,
524
+ self.keys.job(job_id),
525
+ job_id,
526
+ )
527
+ await pipe.execute()
528
+ return len(ids)
529
+
530
+ # ---- queue control ----------------------------------------------------
531
+
532
+ async def pause(self) -> None:
533
+ """Stop workers from claiming new jobs (in-flight jobs still finish)."""
534
+ await self.redis.set(self.keys.meta_paused, "1")
535
+
536
+ async def resume(self) -> None:
537
+ """Resume claiming, and wake idle workers."""
538
+ await self.redis.delete(self.keys.meta_paused)
539
+ await self.redis.zadd(self.keys.marker, {"0": 0})
540
+
541
+ async def is_paused(self) -> bool:
542
+ return bool(await self.redis.exists(self.keys.meta_paused))
543
+
544
+ async def close(self) -> None:
545
+ await self.redis.aclose()
toro/scheduler.py ADDED
@@ -0,0 +1,37 @@
1
+ """Computing the next run time for repeatable schedules.
2
+
3
+ Two modes:
4
+ * every=ms — fixed interval, slot-aligned to the grid (no drift / backlog burst)
5
+ * cron="*/5 * * * *" — cron expression (via croniter), evaluated in UTC
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timezone
11
+
12
+
13
+ def valid_cron(cron: str) -> bool:
14
+ """Whether `cron` parses as a cron expression — validate before storing a schedule."""
15
+ try:
16
+ from croniter import croniter # noqa: PLC0415 — optional dep, imported lazily
17
+ except ImportError as exc: # pragma: no cover
18
+ raise RuntimeError("cron schedules need croniter: pip install croniter") from exc
19
+ return bool(croniter.is_valid(cron))
20
+
21
+
22
+ def next_run(now_ms: int, *, every: int | None = None, cron: str | None = None) -> int:
23
+ """Next occurrence (epoch ms) strictly after now_ms."""
24
+ if every:
25
+ every = int(every)
26
+ # Align to the interval grid so successive runs don't drift, and a late
27
+ # tick catches up to the next slot instead of firing a backlog.
28
+ return (now_ms // every + 1) * every
29
+ if cron:
30
+ try:
31
+ from croniter import croniter # noqa: PLC0415 — optional dep, imported lazily
32
+ except ImportError as exc: # pragma: no cover
33
+ raise RuntimeError("cron schedules need croniter: pip install croniter") from exc
34
+ base = datetime.fromtimestamp(now_ms / 1000, tz=timezone.utc)
35
+ nxt = croniter(cron, base).get_next(datetime)
36
+ return int(nxt.timestamp() * 1000)
37
+ raise ValueError("a schedule needs either `every` (ms) or `cron`")