serialq 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.
serialq/__init__.py ADDED
@@ -0,0 +1,601 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ serialq — one-at-a-time execution.
4
+
5
+ A cross-process serial gate plus a persistent FIFO job queue, for CLIs and
6
+ APIs that must never run concurrently: strictly-serial LLM backends,
7
+ license-limited tools, shared hardware, flaky rate limits.
8
+
9
+ Two primitives, one guarantee — whoever holds the gate is the only thing
10
+ running:
11
+
12
+ serialq run --gate qwen -- my-llm-cli ask "summarize this"
13
+ serialq enqueue --gate qwen -- my-llm-cli batch job-42.json
14
+ serialq worker --gate qwen # drains the queue, forever
15
+ serialq worker --gate qwen --once # drains the queue, then exits
16
+
17
+ Standard library only. POSIX only (needs fcntl).
18
+ """
19
+
20
+ import argparse
21
+ import fcntl
22
+ import json
23
+ import os
24
+ import re
25
+ import shlex
26
+ import signal
27
+ import subprocess
28
+ import sys
29
+ import time
30
+ from datetime import datetime, timezone
31
+
32
+ VERSION = "0.1.0"
33
+ GATE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
34
+ DEFAULT_GATE = os.environ.get("SERIALQ_GATE", "default")
35
+ TERM_GRACE_SECS = 5
36
+
37
+
38
+ def die(msg, code=1):
39
+ print(f"serialq: error: {msg}", file=sys.stderr)
40
+ sys.exit(code)
41
+
42
+
43
+ def now_iso():
44
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
45
+
46
+
47
+ # ---------------------------------------------------------------- paths/locks
48
+
49
+ def gate_paths(gate):
50
+ if not GATE_RE.match(gate or ""):
51
+ die(f"invalid gate name {gate!r}: use letters, digits, '-' and '_' (max 64 chars)")
52
+ base = os.environ.get(
53
+ "SERIALQ_DIR",
54
+ os.path.join(os.path.expanduser("~"), ".local", "share", "serialq"),
55
+ )
56
+ d = os.path.join(base, "gates", gate)
57
+ os.makedirs(os.path.join(d, "logs"), exist_ok=True)
58
+ return {
59
+ "dir": d,
60
+ "gate_lock": os.path.join(d, "gate.lock"),
61
+ "worker_lock": os.path.join(d, "worker.lock"),
62
+ "jobs": os.path.join(d, "jobs.json"),
63
+ "logs": os.path.join(d, "logs"),
64
+ }
65
+
66
+
67
+ class LockFile:
68
+ """Advisory fcntl lock. The kernel releases it when the holder dies,
69
+ so a crashed process can never leave a stale lock behind."""
70
+
71
+ def __init__(self, path):
72
+ self.path = path
73
+ self.fh = None
74
+
75
+ def acquire(self, timeout=None):
76
+ """blocking=True semantics; timeout=None waits forever.
77
+ Returns True on success, False on timeout."""
78
+ self.fh = open(self.path, "a+b")
79
+ if timeout is None:
80
+ fcntl.flock(self.fh, fcntl.LOCK_EX)
81
+ return True
82
+ deadline = time.monotonic() + timeout
83
+ while True:
84
+ try:
85
+ fcntl.flock(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
86
+ return True
87
+ except BlockingIOError:
88
+ if time.monotonic() >= deadline:
89
+ self.fh.close()
90
+ self.fh = None
91
+ return False
92
+ time.sleep(0.05)
93
+
94
+ def try_acquire(self):
95
+ self.fh = open(self.path, "a+b")
96
+ try:
97
+ fcntl.flock(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
98
+ return True
99
+ except BlockingIOError:
100
+ self.fh.close()
101
+ self.fh = None
102
+ return False
103
+
104
+ def release(self):
105
+ if self.fh is not None:
106
+ try:
107
+ fcntl.flock(self.fh, fcntl.LOCK_UN)
108
+ finally:
109
+ self.fh.close()
110
+ self.fh = None
111
+
112
+ def __enter__(self):
113
+ self.acquire()
114
+ return self
115
+
116
+ def __exit__(self, *exc):
117
+ self.release()
118
+
119
+
120
+ # ---------------------------------------------------------------- job store
121
+
122
+ def _read_jobs(fh, jobs_path):
123
+ fh.seek(0)
124
+ raw = fh.read().strip()
125
+ if not raw:
126
+ return {}
127
+ try:
128
+ jobs = json.loads(raw)
129
+ except json.JSONDecodeError:
130
+ # Never silently drop the queue: quarantine the corrupt file.
131
+ bad = jobs_path + ".corrupt-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
132
+ os.rename(jobs_path, bad)
133
+ print(f"serialq: warning: quarantined corrupt job store to {bad}", file=sys.stderr)
134
+ return {}
135
+ return jobs if isinstance(jobs, dict) else {}
136
+
137
+
138
+ def with_jobs(paths, fn):
139
+ """Run fn(jobs) with the queue lock held; persist when fn returns True."""
140
+ fh = open(paths["jobs"], "a+b")
141
+ try:
142
+ fcntl.flock(fh, fcntl.LOCK_EX)
143
+ jobs = _read_jobs(fh, paths["jobs"])
144
+ if fn(jobs):
145
+ fh.seek(0)
146
+ fh.truncate()
147
+ fh.write(json.dumps(jobs, indent=1, sort_keys=True).encode())
148
+ fh.flush()
149
+ os.fsync(fh.fileno())
150
+ finally:
151
+ fcntl.flock(fh, fcntl.LOCK_UN)
152
+ fh.close()
153
+
154
+
155
+ def _iter_jobs(jobs):
156
+ """Job records only (skips the __meta__ bookkeeping entry)."""
157
+ return [j for k, j in jobs.items() if k != "__meta__" and isinstance(j, dict)]
158
+
159
+
160
+ def new_job_id(jobs):
161
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
162
+ while True:
163
+ jid = f"{stamp}-" + "".join(
164
+ "0123456789abcdef"[b % 16] for b in os.urandom(6)
165
+ )
166
+ if jid not in jobs:
167
+ return jid
168
+
169
+
170
+ def next_seq(jobs):
171
+ """Monotonic insertion counter, assigned under the queue lock so FIFO
172
+ order is exact even when several jobs share a timestamp."""
173
+ meta = jobs.setdefault("__meta__", {"seq": 0})
174
+ meta["seq"] += 1
175
+ return meta["seq"]
176
+
177
+
178
+ # ---------------------------------------------------------------- processes
179
+
180
+ def _kill_tree(proc):
181
+ """SIGTERM the whole process group, escalate to SIGKILL after a grace period."""
182
+ try:
183
+ os.killpg(proc.pid, signal.SIGTERM)
184
+ except (ProcessLookupError, PermissionError):
185
+ return
186
+ try:
187
+ proc.wait(timeout=TERM_GRACE_SECS)
188
+ except subprocess.TimeoutExpired:
189
+ try:
190
+ os.killpg(proc.pid, signal.SIGKILL)
191
+ except (ProcessLookupError, PermissionError):
192
+ pass
193
+ proc.wait()
194
+
195
+
196
+ def run_child(cmd, log_path=None, kill_after=None, forward_signals=False):
197
+ """Run cmd in its own process group.
198
+
199
+ log_path=None inherits stdio (foreground `run`); otherwise stdout+stderr
200
+ are appended to the log file. Returns (exit_code, timed_out).
201
+ With forward_signals, SIGINT/SIGTERM are relayed to the child group first
202
+ so Ctrl-C behaves like a normal foreground command.
203
+ """
204
+ log = open(log_path, "a") if log_path else None
205
+ if log:
206
+ log.write(f"# started {now_iso()} :: {' '.join(shlex.quote(c) for c in cmd)}\n")
207
+ log.flush()
208
+ try:
209
+ proc = subprocess.Popen(
210
+ cmd,
211
+ stdout=log if log else None,
212
+ stderr=subprocess.STDOUT if log else None,
213
+ start_new_session=True,
214
+ )
215
+ except FileNotFoundError:
216
+ msg = f"failed to start: {cmd[0]}: command not found"
217
+ if log:
218
+ log.write(f"# {msg}\n")
219
+ log.close()
220
+ else:
221
+ print(f"serialq: error: {msg}", file=sys.stderr)
222
+ return 127, False
223
+ except OSError as e:
224
+ msg = f"failed to start: {e}"
225
+ if log:
226
+ log.write(f"# {msg}\n")
227
+ log.close()
228
+ else:
229
+ print(f"serialq: error: {msg}", file=sys.stderr)
230
+ return 126, False
231
+
232
+ relay = {}
233
+
234
+ def _relay(signum, _frame):
235
+ try:
236
+ os.killpg(proc.pid, signum)
237
+ except (ProcessLookupError, PermissionError):
238
+ pass
239
+ relay["signum"] = signum
240
+
241
+ if forward_signals:
242
+ old_int = signal.signal(signal.SIGINT, _relay)
243
+ old_term = signal.signal(signal.SIGTERM, _relay)
244
+
245
+ timed_out = False
246
+ deadline = time.monotonic() + kill_after if kill_after else None
247
+ try:
248
+ while True:
249
+ try:
250
+ rc = proc.wait(timeout=0.2)
251
+ break
252
+ except subprocess.TimeoutExpired:
253
+ pass
254
+ if "signum" in relay:
255
+ rc = proc.wait() # child got the signal; reap it
256
+ break
257
+ if deadline is not None and time.monotonic() >= deadline:
258
+ _kill_tree(proc)
259
+ rc = proc.wait()
260
+ timed_out = True
261
+ break
262
+ finally:
263
+ if forward_signals:
264
+ signal.signal(signal.SIGINT, old_int)
265
+ signal.signal(signal.SIGTERM, old_term)
266
+
267
+ if log:
268
+ log.write(f"# ended {now_iso()} :: exit={rc}" + (" (timed out)" if timed_out else "") + "\n")
269
+ log.close()
270
+ if "signum" in relay and relay["signum"] == signal.SIGINT:
271
+ # Behave like a normal interrupted foreground command.
272
+ raise KeyboardInterrupt
273
+ return rc, timed_out
274
+
275
+
276
+ # ---------------------------------------------------------------- commands
277
+
278
+ def cmd_run(args):
279
+ cmd = [c for c in args.cmd if c != "--"]
280
+ if not cmd:
281
+ die("no command given")
282
+ paths = gate_paths(args.gate)
283
+ gate = LockFile(paths["gate_lock"])
284
+ if args.timeout is not None and args.timeout < 0:
285
+ die("--timeout must be >= 0")
286
+ if not gate.acquire(timeout=args.timeout):
287
+ die(f"timed out after {args.timeout}s waiting for gate {args.gate!r}")
288
+ try:
289
+ rc, timed_out = run_child(cmd, kill_after=args.kill_after, forward_signals=True)
290
+ finally:
291
+ gate.release()
292
+ if timed_out:
293
+ print(f"serialq: command killed after {args.kill_after}s", file=sys.stderr)
294
+ sys.exit(rc if rc >= 0 else 128 - rc)
295
+
296
+
297
+ def cmd_enqueue(args):
298
+ cmd = [c for c in args.cmd if c != "--"]
299
+ if not cmd:
300
+ die("no command given")
301
+ if args.retries < 0:
302
+ die("--retries must be >= 0")
303
+ paths = gate_paths(args.gate)
304
+ job = {
305
+ "id": None,
306
+ "gate": args.gate,
307
+ "name": args.name or " ".join(cmd)[:80],
308
+ "cmd": cmd,
309
+ "created": now_iso(),
310
+ "seq": None,
311
+ "status": "queued",
312
+ "attempts": 0,
313
+ "max_retries": args.retries,
314
+ "kill_after": args.kill_after,
315
+ "not_before": 0,
316
+ "started": None,
317
+ "ended": None,
318
+ "exit_code": None,
319
+ "error": None,
320
+ }
321
+
322
+ def _add(jobs):
323
+ job["id"] = new_job_id(jobs)
324
+ job["seq"] = next_seq(jobs)
325
+ jobs[job["id"]] = job
326
+ return True
327
+
328
+ with_jobs(paths, _add)
329
+ print(job["id"])
330
+
331
+
332
+ def _pick_job(jobs):
333
+ """Oldest queued job whose backoff has elapsed (FIFO by insertion order)."""
334
+ now = time.time()
335
+ candidates = [
336
+ j for j in _iter_jobs(jobs)
337
+ if j["status"] == "queued" and j.get("not_before", 0) <= now
338
+ ]
339
+ if not candidates:
340
+ return None
341
+ return min(candidates, key=lambda j: (j.get("seq", 0), j["id"]))
342
+
343
+
344
+ def _has_pending(jobs):
345
+ """Any job not yet in a terminal state (matters for --once + backoff)."""
346
+ return any(j["status"] in ("queued", "running") for j in _iter_jobs(jobs))
347
+
348
+
349
+ def cmd_worker(args):
350
+ paths = gate_paths(args.gate)
351
+ worker_lock = LockFile(paths["worker_lock"])
352
+ if not worker_lock.try_acquire():
353
+ die(f"another worker is already running for gate {args.gate!r}")
354
+ gate = LockFile(paths["gate_lock"])
355
+ stop = {"flag": False}
356
+
357
+ def _on_signal(_signum, _frame):
358
+ stop["flag"] = True
359
+
360
+ signal.signal(signal.SIGTERM, _on_signal)
361
+ signal.signal(signal.SIGINT, _on_signal)
362
+
363
+ # Crash recovery: this worker owns the lifecycle now (it holds
364
+ # worker_lock), so any job still marked running belongs to a dead worker.
365
+ def _recover(jobs):
366
+ changed = False
367
+ for j in _iter_jobs(jobs):
368
+ if j["status"] == "running":
369
+ j["status"] = "queued"
370
+ j["error"] = "requeued: previous worker died mid-job"
371
+ changed = True
372
+ return changed
373
+
374
+ with_jobs(paths, _recover)
375
+
376
+ idle_since = time.monotonic()
377
+ try:
378
+ while not stop["flag"]:
379
+ job = {}
380
+
381
+ def _claim(jobs):
382
+ j = _pick_job(jobs)
383
+ if j is None:
384
+ return False
385
+ j["status"] = "running"
386
+ j["attempts"] += 1
387
+ j["started"] = now_iso()
388
+ j["error"] = None
389
+ job.update(j)
390
+ return True
391
+
392
+ with_jobs(paths, _claim)
393
+ if not job:
394
+ pending = []
395
+
396
+ def _check(jobs):
397
+ pending.append(_has_pending(jobs))
398
+ return False
399
+
400
+ with_jobs(paths, _check)
401
+ if args.once and not pending[0]:
402
+ break
403
+ if args.idle_timeout and time.monotonic() - idle_since >= args.idle_timeout:
404
+ break
405
+ time.sleep(0.5)
406
+ continue
407
+ idle_since = time.monotonic()
408
+
409
+ # Wait for the gate in slices so signals stay responsive.
410
+ while not stop["flag"]:
411
+ if gate.acquire(timeout=0.5):
412
+ break
413
+ if stop["flag"]:
414
+ # Hand the job back untouched; a later worker will run it.
415
+ def _unclaim(jobs):
416
+ j = jobs.get(job["id"])
417
+ if j and j["status"] == "running":
418
+ j["status"] = "queued"
419
+ j["attempts"] -= 1
420
+ return True
421
+ return False
422
+
423
+ with_jobs(paths, _unclaim)
424
+ break
425
+
426
+ try:
427
+ log_path = os.path.join(paths["logs"], job["id"] + ".log")
428
+ rc, timed_out = run_child(
429
+ job["cmd"], log_path=log_path, kill_after=job.get("kill_after")
430
+ )
431
+ finally:
432
+ gate.release()
433
+
434
+ def _finish(jobs):
435
+ j = jobs.get(job["id"])
436
+ if not j:
437
+ return False
438
+ j["ended"] = now_iso()
439
+ j["exit_code"] = rc
440
+ if rc == 0:
441
+ j["status"] = "done"
442
+ return True
443
+ if timed_out:
444
+ j["error"] = f"killed after {job.get('kill_after')}s (timeout)"
445
+ else:
446
+ j["error"] = f"exit code {rc}"
447
+ if j["attempts"] <= j["max_retries"]:
448
+ backoff = min(300, 5 * 2 ** (j["attempts"] - 1))
449
+ j["status"] = "queued"
450
+ j["not_before"] = time.time() + backoff
451
+ j["error"] += f"; retrying in {backoff:.0f}s (attempt {j['attempts']}/{j['max_retries'] + 1})"
452
+ else:
453
+ j["status"] = "failed"
454
+ return True
455
+
456
+ with_jobs(paths, _finish)
457
+ finally:
458
+ worker_lock.release()
459
+
460
+
461
+ def cmd_list(args):
462
+ paths = gate_paths(args.gate)
463
+ rows = []
464
+
465
+ def _collect(jobs):
466
+ for j in sorted(_iter_jobs(jobs), key=lambda j: (j.get("seq", 0), j["id"])):
467
+ if not args.all and j["status"] not in ("queued", "running"):
468
+ continue
469
+ rows.append(j)
470
+ return False
471
+
472
+ with_jobs(paths, _collect)
473
+ if not rows:
474
+ print("(no jobs)" if args.all else "(queue empty)")
475
+ return
476
+ print(f"{'ID':<20}{'NAME':<34}{'STATUS':<10}{'TRY':<5}{'EXIT':<6}CREATED")
477
+ for j in rows:
478
+ exit_code = "" if j["exit_code"] is None else str(j["exit_code"])
479
+ print(f"{j['id']:<20}{j['name'][:33]:<34}{j['status']:<10}{j['attempts']:<5}{exit_code:<6}{j['created']}")
480
+
481
+
482
+ def cmd_log(args):
483
+ paths = gate_paths(args.gate)
484
+ log_path = os.path.join(paths["logs"], args.id + ".log")
485
+ if not os.path.exists(log_path):
486
+ die(f"no log for job {args.id!r}")
487
+ with open(log_path) as f:
488
+ sys.stdout.write(f.read())
489
+
490
+
491
+ def cmd_cancel(args):
492
+ paths = gate_paths(args.gate)
493
+
494
+ def _cancel(jobs):
495
+ j = jobs.get(args.id)
496
+ if j is None:
497
+ die(f"no such job {args.id!r}")
498
+ if j["status"] != "queued":
499
+ die(f"job {args.id!r} is {j['status']}, only queued jobs can be cancelled")
500
+ j["status"] = "cancelled"
501
+ j["ended"] = now_iso()
502
+ return True
503
+
504
+ with_jobs(paths, _cancel)
505
+ print(f"cancelled {args.id}")
506
+
507
+
508
+ def cmd_status(args):
509
+ paths = gate_paths(args.gate)
510
+ gate = LockFile(paths["gate_lock"])
511
+ held = not gate.try_acquire()
512
+ if not held:
513
+ gate.release()
514
+ counts = {"queued": 0, "running": 0, "done": 0, "failed": 0, "cancelled": 0}
515
+ running_id = None
516
+
517
+ def _count(jobs):
518
+ for j in _iter_jobs(jobs):
519
+ counts[j["status"]] = counts.get(j["status"], 0) + 1
520
+ if j["status"] == "running":
521
+ nonlocal_running[0] = j["id"]
522
+ return False
523
+
524
+ nonlocal_running = [None]
525
+ with_jobs(paths, _count)
526
+ running_id = nonlocal_running[0]
527
+ print(f"gate {args.gate!r}: {'BUSY' if held else 'free'}")
528
+ print(f"queued={counts['queued']} running={counts['running']} "
529
+ f"done={counts['done']} failed={counts['failed']} cancelled={counts['cancelled']}")
530
+ if running_id:
531
+ print(f"running job: {running_id}")
532
+
533
+
534
+ # ---------------------------------------------------------------- cli
535
+
536
+ def build_parser():
537
+ p = argparse.ArgumentParser(
538
+ prog="serialq",
539
+ description="One-at-a-time execution: a serial gate and FIFO job queue.",
540
+ )
541
+ p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
542
+ sub = p.add_subparsers(dest="command", required=True)
543
+
544
+ r = sub.add_parser("run", help="run a command under the gate (blocks until free)")
545
+ r.add_argument("-g", "--gate", default=DEFAULT_GATE, help="gate name (default: %(default)s)")
546
+ r.add_argument("--timeout", type=float, default=None,
547
+ help="max seconds to wait for the gate (default: wait forever)")
548
+ r.add_argument("--kill-after", type=float, default=None,
549
+ help="kill the command if it runs longer than this many seconds")
550
+ r.add_argument("cmd", nargs=argparse.REMAINDER, help="command to run (after --)")
551
+ r.set_defaults(func=cmd_run)
552
+
553
+ e = sub.add_parser("enqueue", help="queue a command to run later, prints the job id")
554
+ e.add_argument("-g", "--gate", default=DEFAULT_GATE)
555
+ e.add_argument("--name", default=None, help="human-readable job name")
556
+ e.add_argument("--retries", type=int, default=0, help="retries on failure (default: 0)")
557
+ e.add_argument("--kill-after", type=float, default=None)
558
+ e.add_argument("cmd", nargs=argparse.REMAINDER, help="command to run (after --)")
559
+ e.set_defaults(func=cmd_enqueue)
560
+
561
+ w = sub.add_parser("worker", help="drain the queue serially")
562
+ w.add_argument("-g", "--gate", default=DEFAULT_GATE)
563
+ w.add_argument("--once", action="store_true", help="exit when the queue is empty")
564
+ w.add_argument("--idle-timeout", type=float, default=None,
565
+ help="exit after this many idle seconds (default: run forever)")
566
+ w.set_defaults(func=cmd_worker)
567
+
568
+ l = sub.add_parser("list", help="list jobs")
569
+ l.add_argument("-g", "--gate", default=DEFAULT_GATE)
570
+ l.add_argument("--all", action="store_true", help="include finished jobs")
571
+ l.set_defaults(func=cmd_list)
572
+
573
+ g = sub.add_parser("log", help="print a job's log")
574
+ g.add_argument("-g", "--gate", default=DEFAULT_GATE)
575
+ g.add_argument("id", help="job id")
576
+ g.set_defaults(func=cmd_log)
577
+
578
+ c = sub.add_parser("cancel", help="cancel a queued job")
579
+ c.add_argument("-g", "--gate", default=DEFAULT_GATE)
580
+ c.add_argument("id", help="job id")
581
+ c.set_defaults(func=cmd_cancel)
582
+
583
+ s = sub.add_parser("status", help="show gate and queue status")
584
+ s.add_argument("-g", "--gate", default=DEFAULT_GATE)
585
+ s.set_defaults(func=cmd_status)
586
+
587
+ return p
588
+
589
+
590
+ def main(argv=None):
591
+ args = build_parser().parse_args(argv)
592
+ try:
593
+ args.func(args)
594
+ except KeyboardInterrupt:
595
+ sys.exit(130)
596
+ except BrokenPipeError:
597
+ sys.exit(0)
598
+
599
+
600
+ if __name__ == "__main__":
601
+ main()
serialq/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from serialq import main
2
+
3
+ main()
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: serialq
3
+ Version: 0.1.0
4
+ Summary: One-at-a-time execution: a cross-process serial gate and FIFO job queue
5
+ Author-email: AP <intertermux@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/intertermux-code/serialq
8
+ Project-URL: Issues, https://github.com/intertermux-code/serialq/issues
9
+ Keywords: queue,serial,mutex,lock,job-queue,llm,rate-limit
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # serialq
25
+
26
+ [![ci](https://github.com/intertermux-code/serialq/actions/workflows/ci.yml/badge.svg)](https://github.com/intertermux-code/serialq/actions)
27
+
28
+ One-at-a-time execution. A cross-process serial gate plus a persistent FIFO
29
+ job queue, for CLIs and APIs that must never run concurrently.
30
+
31
+ Some backends are strictly serial: one in-flight call at a time, across
32
+ every model, key, or client — overlap and you get rate-limited, corrupted,
33
+ or billed twice. Cron jobs don't know about each other, shell scripts
34
+ don't coordinate, and "just be careful" stops working at 3am. serialq is
35
+ the bouncer: whoever holds the gate is the only thing running.
36
+
37
+ ```sh
38
+ pip install git+https://github.com/intertermux-code/serialq.git
39
+
40
+ # ad-hoc: blocks until the gate is free, then runs
41
+ serialq run --gate qwen -- my-llm-cli ask "summarize this thread"
42
+
43
+ # fire-and-forget: queue it, a worker runs jobs one by one
44
+ serialq enqueue --gate qwen -- my-llm-cli batch jobs/42.json
45
+ serialq worker --gate qwen # drain forever (systemd, tmux, ...)
46
+ serialq worker --gate qwen --once # drain once, then exit (cron-friendly)
47
+
48
+ serialq list --gate qwen
49
+ serialq log 20261005-a3f9c1
50
+ serialq status --gate qwen
51
+ ```
52
+
53
+ Zero dependencies, standard library only. POSIX only (Linux, macOS) —
54
+ it relies on `fcntl` locks.
55
+
56
+ ## Why not just a lock file?
57
+
58
+ Because lock files go stale. serialq uses `fcntl` advisory locks, which the
59
+ kernel releases when the holder process dies — a crashed job can never wedge
60
+ the gate. The queue goes further:
61
+
62
+ - **Crash recovery.** If a worker is kill -9'd mid-job, the next worker
63
+ re-queues the orphaned job instead of losing it. Only one worker per gate
64
+ can run at a time, so the recovery is unambiguous.
65
+ - **Timeouts that actually kill.** `--kill-after` terminates the whole
66
+ process group (SIGTERM, then SIGKILL), not just the parent.
67
+ - **Retries with backoff.** `enqueue --retries 3` re-queues failures with
68
+ exponential backoff (5s, 10s, 20s … capped at 5 minutes).
69
+ - **Atomic queue writes.** The job store is rewritten under an exclusive
70
+ lock with fsync; a corrupt store is quarantined, never silently dropped.
71
+ - **Ctrl-C behaves.** `serialq run` forwards SIGINT to the child, so
72
+ interactive commands interrupt the way you'd expect.
73
+
74
+ ## Use cases
75
+
76
+ - **Serial-only LLM backends.** Some model gateways allow exactly one
77
+ in-flight request across all models. Prefix every call and stop thinking
78
+ about it:
79
+ ```sh
80
+ serialq run --gate qwen --timeout 3600 -- llm chat model-a "prompt one" &
81
+ serialq run --gate qwen --timeout 3600 -- llm chat model-b "prompt two" &
82
+ wait # they ran sequentially, in order
83
+ ```
84
+ - **Overnight batch pipelines.** Enqueue a hundred jobs, let one worker
85
+ chew through them; check `serialq list --all` in the morning.
86
+ - **License-limited tools.** One floating license, many cron jobs — put
87
+ the tool behind a gate named after the license.
88
+ - **Flaky deploys.** `enqueue --retries 5` on the deploy script; transient
89
+ failures retry themselves with backoff.
90
+
91
+ ## Reference
92
+
93
+ | Command | What it does |
94
+ |---|---|
95
+ | `run [-g GATE] [--timeout S] [--kill-after S] -- CMD…` | Run CMD under the gate, blocking until free. Exits with CMD's exit code. |
96
+ | `enqueue [-g GATE] [--name N] [--retries N] [--kill-after S] -- CMD…` | Queue CMD, print the job id. |
97
+ | `worker [-g GATE] [--once] [--idle-timeout S]` | Run queued jobs FIFO. SIGTERM/SIGINT finish the current job, then stop. |
98
+ | `list [-g GATE] [--all]` | Show queued/running jobs (`--all` includes finished). |
99
+ | `log [-g GATE] ID` | Print a job's captured output. |
100
+ | `cancel [-g GATE] ID` | Cancel a queued job. |
101
+ | `status [-g GATE]` | Gate busy/free plus queue counts. |
102
+
103
+ Gates are just names (`[A-Za-z0-9_-]`, `--gate` or `SERIALQ_GATE` env).
104
+ State lives in `SERIALQ_DIR` (default `~/.local/share/serialq`), one
105
+ directory per gate: the lock files, `jobs.json`, and per-job logs.
106
+
107
+ A systemd user unit template ships in `contrib/`:
108
+
109
+ ```sh
110
+ cp contrib/serialq-worker@.service ~/.config/systemd/user/
111
+ systemctl --user enable --now serialq-worker@qwen
112
+ ```
113
+
114
+ ## Design notes
115
+
116
+ - One module, ~500 lines, no dependencies. The whole thing fits in your head.
117
+ - The gate and the queue are separate locks: `enqueue`/`list` never block
118
+ behind a long-running job.
119
+ - Job ids are `YYYYMMDD-` plus 6 hex chars — sortable, greppable, unique.
120
+ - Exit code 124-style semantics aren't faked: timeouts are reported in the
121
+ job log and on stderr.
122
+
123
+ ## Limitations
124
+
125
+ - POSIX only. Windows would need a different locking primitive.
126
+ - FIFO, no priorities — deliberate. If you need priorities you probably
127
+ need a real queue.
128
+ - The worker is single-threaded by design: one gate, one job at a time.
129
+ That's the point.
130
+
131
+ ## License
132
+
133
+ MIT.
@@ -0,0 +1,8 @@
1
+ serialq/__init__.py,sha256=p3A5XpERu5cxRSF1aPWP39Uc3ai_6-JmNxgg2f-t2Hw,19674
2
+ serialq/__main__.py,sha256=96MjLGteSs9mGyxEPODJy32vcBOBepWx3yYPjcRzujQ,33
3
+ serialq-0.1.0.dist-info/licenses/LICENSE,sha256=SXwHAmyIaABaumaDlbvDM64ikzUcuqCzcnYCtDsCOj0,1078
4
+ serialq-0.1.0.dist-info/METADATA,sha256=4ASBcClkkF4PqgvBfYHsXsCzmPsbjhYI9AVEPdRnUUU,5610
5
+ serialq-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ serialq-0.1.0.dist-info/entry_points.txt,sha256=jom6_6jZixkpC_T_X6utQ9GBazx169onR-pCem6XhHk,41
7
+ serialq-0.1.0.dist-info/top_level.txt,sha256=2ofpTXEPLukF9uZYpveAakaYwtPMnZQpnv2BbT3OJ88,8
8
+ serialq-0.1.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,2 @@
1
+ [console_scripts]
2
+ serialq = serialq:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AP (intertermux-code)
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
+ serialq