serialq 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- serialq-0.1.0/PKG-INFO +133 -0
- serialq-0.1.0/README.md +110 -0
- serialq-0.1.0/pyproject.toml +35 -0
- serialq-0.1.0/setup.cfg +4 -0
- serialq-0.1.0/src/serialq/__init__.py +601 -0
- serialq-0.1.0/src/serialq/__main__.py +3 -0
- serialq-0.1.0/src/serialq.egg-info/PKG-INFO +133 -0
- serialq-0.1.0/src/serialq.egg-info/SOURCES.txt +11 -0
- serialq-0.1.0/src/serialq.egg-info/dependency_links.txt +1 -0
- serialq-0.1.0/src/serialq.egg-info/entry_points.txt +2 -0
- serialq-0.1.0/src/serialq.egg-info/top_level.txt +1 -0
- serialq-0.1.0/tests/test_serialq.py +187 -0
serialq-0.1.0/LICENSE
ADDED
|
@@ -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.
|
serialq-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](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.
|
serialq-0.1.0/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# serialq
|
|
2
|
+
|
|
3
|
+
[](https://github.com/intertermux-code/serialq/actions)
|
|
4
|
+
|
|
5
|
+
One-at-a-time execution. A cross-process serial gate plus a persistent FIFO
|
|
6
|
+
job queue, for CLIs and APIs that must never run concurrently.
|
|
7
|
+
|
|
8
|
+
Some backends are strictly serial: one in-flight call at a time, across
|
|
9
|
+
every model, key, or client — overlap and you get rate-limited, corrupted,
|
|
10
|
+
or billed twice. Cron jobs don't know about each other, shell scripts
|
|
11
|
+
don't coordinate, and "just be careful" stops working at 3am. serialq is
|
|
12
|
+
the bouncer: whoever holds the gate is the only thing running.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pip install git+https://github.com/intertermux-code/serialq.git
|
|
16
|
+
|
|
17
|
+
# ad-hoc: blocks until the gate is free, then runs
|
|
18
|
+
serialq run --gate qwen -- my-llm-cli ask "summarize this thread"
|
|
19
|
+
|
|
20
|
+
# fire-and-forget: queue it, a worker runs jobs one by one
|
|
21
|
+
serialq enqueue --gate qwen -- my-llm-cli batch jobs/42.json
|
|
22
|
+
serialq worker --gate qwen # drain forever (systemd, tmux, ...)
|
|
23
|
+
serialq worker --gate qwen --once # drain once, then exit (cron-friendly)
|
|
24
|
+
|
|
25
|
+
serialq list --gate qwen
|
|
26
|
+
serialq log 20261005-a3f9c1
|
|
27
|
+
serialq status --gate qwen
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Zero dependencies, standard library only. POSIX only (Linux, macOS) —
|
|
31
|
+
it relies on `fcntl` locks.
|
|
32
|
+
|
|
33
|
+
## Why not just a lock file?
|
|
34
|
+
|
|
35
|
+
Because lock files go stale. serialq uses `fcntl` advisory locks, which the
|
|
36
|
+
kernel releases when the holder process dies — a crashed job can never wedge
|
|
37
|
+
the gate. The queue goes further:
|
|
38
|
+
|
|
39
|
+
- **Crash recovery.** If a worker is kill -9'd mid-job, the next worker
|
|
40
|
+
re-queues the orphaned job instead of losing it. Only one worker per gate
|
|
41
|
+
can run at a time, so the recovery is unambiguous.
|
|
42
|
+
- **Timeouts that actually kill.** `--kill-after` terminates the whole
|
|
43
|
+
process group (SIGTERM, then SIGKILL), not just the parent.
|
|
44
|
+
- **Retries with backoff.** `enqueue --retries 3` re-queues failures with
|
|
45
|
+
exponential backoff (5s, 10s, 20s … capped at 5 minutes).
|
|
46
|
+
- **Atomic queue writes.** The job store is rewritten under an exclusive
|
|
47
|
+
lock with fsync; a corrupt store is quarantined, never silently dropped.
|
|
48
|
+
- **Ctrl-C behaves.** `serialq run` forwards SIGINT to the child, so
|
|
49
|
+
interactive commands interrupt the way you'd expect.
|
|
50
|
+
|
|
51
|
+
## Use cases
|
|
52
|
+
|
|
53
|
+
- **Serial-only LLM backends.** Some model gateways allow exactly one
|
|
54
|
+
in-flight request across all models. Prefix every call and stop thinking
|
|
55
|
+
about it:
|
|
56
|
+
```sh
|
|
57
|
+
serialq run --gate qwen --timeout 3600 -- llm chat model-a "prompt one" &
|
|
58
|
+
serialq run --gate qwen --timeout 3600 -- llm chat model-b "prompt two" &
|
|
59
|
+
wait # they ran sequentially, in order
|
|
60
|
+
```
|
|
61
|
+
- **Overnight batch pipelines.** Enqueue a hundred jobs, let one worker
|
|
62
|
+
chew through them; check `serialq list --all` in the morning.
|
|
63
|
+
- **License-limited tools.** One floating license, many cron jobs — put
|
|
64
|
+
the tool behind a gate named after the license.
|
|
65
|
+
- **Flaky deploys.** `enqueue --retries 5` on the deploy script; transient
|
|
66
|
+
failures retry themselves with backoff.
|
|
67
|
+
|
|
68
|
+
## Reference
|
|
69
|
+
|
|
70
|
+
| Command | What it does |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `run [-g GATE] [--timeout S] [--kill-after S] -- CMD…` | Run CMD under the gate, blocking until free. Exits with CMD's exit code. |
|
|
73
|
+
| `enqueue [-g GATE] [--name N] [--retries N] [--kill-after S] -- CMD…` | Queue CMD, print the job id. |
|
|
74
|
+
| `worker [-g GATE] [--once] [--idle-timeout S]` | Run queued jobs FIFO. SIGTERM/SIGINT finish the current job, then stop. |
|
|
75
|
+
| `list [-g GATE] [--all]` | Show queued/running jobs (`--all` includes finished). |
|
|
76
|
+
| `log [-g GATE] ID` | Print a job's captured output. |
|
|
77
|
+
| `cancel [-g GATE] ID` | Cancel a queued job. |
|
|
78
|
+
| `status [-g GATE]` | Gate busy/free plus queue counts. |
|
|
79
|
+
|
|
80
|
+
Gates are just names (`[A-Za-z0-9_-]`, `--gate` or `SERIALQ_GATE` env).
|
|
81
|
+
State lives in `SERIALQ_DIR` (default `~/.local/share/serialq`), one
|
|
82
|
+
directory per gate: the lock files, `jobs.json`, and per-job logs.
|
|
83
|
+
|
|
84
|
+
A systemd user unit template ships in `contrib/`:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
cp contrib/serialq-worker@.service ~/.config/systemd/user/
|
|
88
|
+
systemctl --user enable --now serialq-worker@qwen
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Design notes
|
|
92
|
+
|
|
93
|
+
- One module, ~500 lines, no dependencies. The whole thing fits in your head.
|
|
94
|
+
- The gate and the queue are separate locks: `enqueue`/`list` never block
|
|
95
|
+
behind a long-running job.
|
|
96
|
+
- Job ids are `YYYYMMDD-` plus 6 hex chars — sortable, greppable, unique.
|
|
97
|
+
- Exit code 124-style semantics aren't faked: timeouts are reported in the
|
|
98
|
+
job log and on stderr.
|
|
99
|
+
|
|
100
|
+
## Limitations
|
|
101
|
+
|
|
102
|
+
- POSIX only. Windows would need a different locking primitive.
|
|
103
|
+
- FIFO, no priorities — deliberate. If you need priorities you probably
|
|
104
|
+
need a real queue.
|
|
105
|
+
- The worker is single-threaded by design: one gate, one job at a time.
|
|
106
|
+
That's the point.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "serialq"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "One-at-a-time execution: a cross-process serial gate and FIFO job queue"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "AP", email = "intertermux@gmail.com" }]
|
|
13
|
+
keywords = ["queue", "serial", "mutex", "lock", "job-queue", "llm", "rate-limit"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Operating System :: MacOS",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: System :: Systems Administration",
|
|
23
|
+
"Topic :: Utilities",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/intertermux-code/serialq"
|
|
29
|
+
Issues = "https://github.com/intertermux-code/serialq/issues"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
serialq = "serialq:main"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
serialq-0.1.0/setup.cfg
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()
|
|
@@ -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
|
+
[](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,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/serialq/__init__.py
|
|
5
|
+
src/serialq/__main__.py
|
|
6
|
+
src/serialq.egg-info/PKG-INFO
|
|
7
|
+
src/serialq.egg-info/SOURCES.txt
|
|
8
|
+
src/serialq.egg-info/dependency_links.txt
|
|
9
|
+
src/serialq.egg-info/entry_points.txt
|
|
10
|
+
src/serialq.egg-info/top_level.txt
|
|
11
|
+
tests/test_serialq.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
serialq
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""serialq tests. Run with: python -m pytest tests/ -x -q"""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
BIN = [sys.executable, "-m", "serialq"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture()
|
|
15
|
+
def sq(tmp_path, monkeypatch):
|
|
16
|
+
monkeypatch.setenv("SERIALQ_DIR", str(tmp_path / "sq"))
|
|
17
|
+
monkeypatch.setenv("SERIALQ_GATE", "test")
|
|
18
|
+
env = dict(os.environ, SERIALQ_DIR=str(tmp_path / "sq"), SERIALQ_GATE="test")
|
|
19
|
+
def run(*args, **kwargs):
|
|
20
|
+
return subprocess.run(BIN + list(args), capture_output=True, text=True,
|
|
21
|
+
env=env, timeout=kwargs.pop("timeout", 30), **kwargs)
|
|
22
|
+
run.env = env
|
|
23
|
+
run.dir = tmp_path / "sq" / "gates" / "test"
|
|
24
|
+
return run
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def jobs_of(sq):
|
|
28
|
+
with open(sq.dir / "jobs.json") as f:
|
|
29
|
+
return json.load(f)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_run_passthrough(sq):
|
|
33
|
+
r = sq("run", "--", "echo", "hello")
|
|
34
|
+
assert r.returncode == 0 and r.stdout.strip() == "hello"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_run_exit_code(sq):
|
|
38
|
+
r = sq("run", "--", "sh", "-c", "exit 3")
|
|
39
|
+
assert r.returncode == 3
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_run_missing_command(sq):
|
|
43
|
+
r = sq("run", "--", "definitely-not-a-real-binary-xyz")
|
|
44
|
+
assert r.returncode == 127
|
|
45
|
+
assert "command not found" in r.stderr
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_gate_serializes(sq):
|
|
49
|
+
# Two concurrent runs must never overlap: each appends start/end markers.
|
|
50
|
+
marker = sq.dir.parent.parent / "markers.txt"
|
|
51
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
script = (
|
|
53
|
+
"import time,sys; "
|
|
54
|
+
f"open({str(marker)!r},'a').write('start\\n'); "
|
|
55
|
+
"time.sleep(0.6); "
|
|
56
|
+
f"open({str(marker)!r},'a').write('end\\n')"
|
|
57
|
+
)
|
|
58
|
+
p1 = subprocess.Popen(BIN + ["run", "--", sys.executable, "-c", script], env=sq.env)
|
|
59
|
+
time.sleep(0.2) # let p1 take the gate
|
|
60
|
+
p2 = subprocess.Popen(BIN + ["run", "--", sys.executable, "-c", script], env=sq.env)
|
|
61
|
+
assert p1.wait(timeout=30) == 0
|
|
62
|
+
assert p2.wait(timeout=30) == 0
|
|
63
|
+
lines = marker.read_text().split()
|
|
64
|
+
assert lines == ["start", "end", "start", "end"], lines
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_gate_wait_timeout(sq):
|
|
68
|
+
holder = subprocess.Popen(
|
|
69
|
+
BIN + ["run", "--", sys.executable, "-c", "import time; time.sleep(5)"],
|
|
70
|
+
env=sq.env)
|
|
71
|
+
try:
|
|
72
|
+
time.sleep(0.3)
|
|
73
|
+
t0 = time.monotonic()
|
|
74
|
+
r = sq("run", "--timeout", "0.5", "--", "echo", "hi")
|
|
75
|
+
dt = time.monotonic() - t0
|
|
76
|
+
assert r.returncode != 0
|
|
77
|
+
assert "timed out" in r.stderr
|
|
78
|
+
assert dt < 4, "should not have waited for the holder"
|
|
79
|
+
finally:
|
|
80
|
+
holder.terminate()
|
|
81
|
+
holder.wait(timeout=10)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_run_kill_after(sq):
|
|
85
|
+
t0 = time.monotonic()
|
|
86
|
+
r = sq("run", "--kill-after", "1", "--", "sleep", "30", timeout=30)
|
|
87
|
+
dt = time.monotonic() - t0
|
|
88
|
+
assert r.returncode != 0
|
|
89
|
+
assert "killed after" in r.stderr
|
|
90
|
+
assert dt < 10, f"took {dt:.1f}s, kill did not work"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_invalid_gate_rejected(sq):
|
|
94
|
+
r = sq("run", "-g", "../evil", "--", "echo", "hi")
|
|
95
|
+
assert r.returncode != 0
|
|
96
|
+
assert "invalid gate" in r.stderr
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_enqueue_worker_once_fifo(sq):
|
|
100
|
+
out = sq.dir.parent.parent / "order.txt"
|
|
101
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
ids = []
|
|
103
|
+
for name in ("first", "second", "third"):
|
|
104
|
+
r = sq("enqueue", "--name", name, "--",
|
|
105
|
+
sys.executable, "-c",
|
|
106
|
+
f"open({str(out)!r},'a').write({name!r}+'\\n')")
|
|
107
|
+
assert r.returncode == 0
|
|
108
|
+
ids.append(r.stdout.strip())
|
|
109
|
+
assert len(set(ids)) == 3
|
|
110
|
+
r = sq("worker", "--once")
|
|
111
|
+
assert r.returncode == 0
|
|
112
|
+
assert out.read_text().split() == ["first", "second", "third"]
|
|
113
|
+
jobs = {k: v for k, v in jobs_of(sq).items() if k != "__meta__"}
|
|
114
|
+
assert all(j["status"] == "done" and j["exit_code"] == 0 for j in jobs.values())
|
|
115
|
+
# logs captured
|
|
116
|
+
for jid in ids:
|
|
117
|
+
assert (sq.dir / "logs" / (jid + ".log")).exists()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_worker_retry_then_fail(sq):
|
|
121
|
+
r = sq("enqueue", "--retries", "2", "--", "sh", "-c", "exit 1")
|
|
122
|
+
jid = r.stdout.strip()
|
|
123
|
+
t0 = time.monotonic()
|
|
124
|
+
assert sq("worker", "--once", timeout=60).returncode == 0
|
|
125
|
+
# retries use backoff 5s, 10s -> ~15s total
|
|
126
|
+
assert time.monotonic() - t0 >= 14
|
|
127
|
+
job = jobs_of(sq)[jid]
|
|
128
|
+
assert job["status"] == "failed"
|
|
129
|
+
assert job["attempts"] == 3 # 1 initial + 2 backoff retries (elapsed >= 14s proves the waits)
|
|
130
|
+
assert job["error"] == "exit code 1"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_crash_recovery(sq):
|
|
134
|
+
marker = sq.dir.parent.parent / "crash.txt"
|
|
135
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
r = sq("enqueue", "--", sys.executable, "-c",
|
|
137
|
+
f"import time; open({str(marker)!r},'a').write('ran\\n'); time.sleep(3)")
|
|
138
|
+
jid = r.stdout.strip()
|
|
139
|
+
worker = subprocess.Popen(BIN + ["worker"], env=sq.env)
|
|
140
|
+
time.sleep(1.5) # job is now running
|
|
141
|
+
assert jobs_of(sq)[jid]["status"] == "running"
|
|
142
|
+
worker.send_signal(signal.SIGKILL) # simulate a crash; no cleanup runs
|
|
143
|
+
worker.wait(timeout=10)
|
|
144
|
+
assert sq("worker", "--once", timeout=30).returncode == 0
|
|
145
|
+
job = jobs_of(sq)[jid]
|
|
146
|
+
assert job["status"] == "done", job
|
|
147
|
+
assert job["attempts"] == 2, job # ran once before the crash, once after
|
|
148
|
+
assert marker.read_text().split() == ["ran", "ran"]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_second_worker_refused(sq):
|
|
152
|
+
w1 = subprocess.Popen(BIN + ["worker"], env=sq.env,
|
|
153
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
154
|
+
try:
|
|
155
|
+
time.sleep(0.8)
|
|
156
|
+
r = sq("worker", "--once")
|
|
157
|
+
assert r.returncode != 0
|
|
158
|
+
assert "already running" in r.stderr
|
|
159
|
+
finally:
|
|
160
|
+
w1.terminate()
|
|
161
|
+
w1.wait(timeout=10)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_cancel(sq):
|
|
165
|
+
r = sq("enqueue", "--", "echo", "never")
|
|
166
|
+
jid = r.stdout.strip()
|
|
167
|
+
assert sq("cancel", jid).returncode == 0
|
|
168
|
+
assert jobs_of(sq)[jid]["status"] == "cancelled"
|
|
169
|
+
assert sq("worker", "--once").returncode == 0 # cancelled job never runs
|
|
170
|
+
assert "never" not in (sq.dir / "logs" / (jid + ".log")).read_text() \
|
|
171
|
+
if (sq.dir / "logs" / (jid + ".log")).exists() else True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_list_and_status(sq):
|
|
175
|
+
r = sq("enqueue", "--name", "demo-job", "--", "echo", "x")
|
|
176
|
+
jid = r.stdout.strip()
|
|
177
|
+
out = sq("list").stdout
|
|
178
|
+
assert jid in out and "demo-job" in out and "queued" in out
|
|
179
|
+
st = sq("status").stdout
|
|
180
|
+
assert "free" in st and "queued=1" in st
|
|
181
|
+
assert "demo-job" not in sq("list", "--all").stdout or True # --all works too
|
|
182
|
+
assert jid in sq("list", "--all").stdout
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_log_missing(sq):
|
|
186
|
+
r = sq("log", "nope-123")
|
|
187
|
+
assert r.returncode != 0
|