cairnq 0.2.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.
- cairnq-0.2.0/.gitignore +25 -0
- cairnq-0.2.0/LICENSE +21 -0
- cairnq-0.2.0/PKG-INFO +122 -0
- cairnq-0.2.0/README.md +96 -0
- cairnq-0.2.0/bench/run.py +156 -0
- cairnq-0.2.0/cairnq/__init__.py +53 -0
- cairnq-0.2.0/cairnq/_ids.py +29 -0
- cairnq-0.2.0/cairnq/_protocol/migrations/postgres/0001_init.sql +75 -0
- cairnq-0.2.0/cairnq/_protocol/migrations/postgres/0002_purge_index.sql +6 -0
- cairnq-0.2.0/cairnq/_protocol/migrations/postgres/0003_notify.sql +38 -0
- cairnq-0.2.0/cairnq/_protocol/migrations/sqlite/0001_init.sql +74 -0
- cairnq-0.2.0/cairnq/_protocol/migrations/sqlite/0002_purge_index.sql +6 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/cancel.sql +16 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/claim.sql +40 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/complete.sql +17 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/fail.sql +41 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/get.sql +2 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/get_by_key.sql +4 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/get_key.sql +3 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/heartbeat.sql +12 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/insert_task.sql +23 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/list.sql +15 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/lock_key.sql +9 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/progress.sql +14 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/protocol_version.sql +4 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/purge.sql +25 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/recover_leases.sql +44 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/retry.sql +20 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/stats.sql +8 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/succeed.sql +16 -0
- cairnq-0.2.0/cairnq/_protocol/sql/postgres/upsert_key.sql +12 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/cancel.sql +12 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/claim.sql +31 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/claimable_probe.sql +18 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/complete.sql +18 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/fail.sql +41 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/get.sql +2 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/get_by_key.sql +4 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/get_key.sql +3 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/heartbeat.sql +10 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/insert_task.sql +16 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/list.sql +13 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/lock_key.sql +5 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/progress.sql +14 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/protocol_version.sql +4 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/purge.sql +18 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/recover_leases.sql +35 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/retry.sql +19 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/stats.sql +8 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/succeed.sql +15 -0
- cairnq-0.2.0/cairnq/_protocol/sql/sqlite/upsert_key.sql +7 -0
- cairnq-0.2.0/cairnq/_sql.py +50 -0
- cairnq-0.2.0/cairnq/_wait.py +49 -0
- cairnq-0.2.0/cairnq/client.py +152 -0
- cairnq-0.2.0/cairnq/context.py +145 -0
- cairnq-0.2.0/cairnq/errors.py +158 -0
- cairnq-0.2.0/cairnq/models.py +110 -0
- cairnq-0.2.0/cairnq/store/__init__.py +5 -0
- cairnq-0.2.0/cairnq/store/base.py +423 -0
- cairnq-0.2.0/cairnq/store/postgres.py +301 -0
- cairnq-0.2.0/cairnq/store/sqlite.py +249 -0
- cairnq-0.2.0/cairnq/worker.py +486 -0
- cairnq-0.2.0/pyproject.toml +51 -0
- cairnq-0.2.0/tests/__init__.py +0 -0
- cairnq-0.2.0/tests/_runner.py +166 -0
- cairnq-0.2.0/tests/conftest.py +18 -0
- cairnq-0.2.0/tests/helpers.py +22 -0
- cairnq-0.2.0/tests/test_api_parity.py +48 -0
- cairnq-0.2.0/tests/test_client_worker.py +224 -0
- cairnq-0.2.0/tests/test_conformance.py +66 -0
- cairnq-0.2.0/tests/test_dialect_param_coverage.py +114 -0
- cairnq-0.2.0/tests/test_edge_cases.py +112 -0
- cairnq-0.2.0/tests/test_handler_routing.py +67 -0
- cairnq-0.2.0/tests/test_migration_concurrency.py +46 -0
- cairnq-0.2.0/tests/test_migrations.py +91 -0
- cairnq-0.2.0/tests/test_postgres.py +168 -0
- cairnq-0.2.0/tests/test_postgres_live.py +152 -0
- cairnq-0.2.0/tests/test_protocol_resolution.py +29 -0
- cairnq-0.2.0/tests/test_retry_backoff.py +47 -0
- cairnq-0.2.0/tests/test_sqlite_concurrency.py +64 -0
- cairnq-0.2.0/tests/test_store.py +155 -0
- cairnq-0.2.0/tests/test_version.py +17 -0
- cairnq-0.2.0/tests/test_wait_backoff.py +43 -0
- cairnq-0.2.0/tests/test_worker_resilience.py +245 -0
- cairnq-0.2.0/tests/test_worker_signals.py +84 -0
- cairnq-0.2.0/uv.lock +267 -0
cairnq-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.venv/
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
*.egg-info/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
|
|
10
|
+
# Node
|
|
11
|
+
node_modules/
|
|
12
|
+
dist/
|
|
13
|
+
*.tsbuildinfo
|
|
14
|
+
|
|
15
|
+
# Vendored protocol (generated by scripts/vendor-protocol.mjs at publish time).
|
|
16
|
+
# py lands beside the package; node lands in dist/_protocol (already ignored above).
|
|
17
|
+
cairnq-py/cairnq/_protocol/
|
|
18
|
+
|
|
19
|
+
# SQLite working files
|
|
20
|
+
*.db
|
|
21
|
+
*.db-wal
|
|
22
|
+
*.db-shm
|
|
23
|
+
|
|
24
|
+
# Editor / OS
|
|
25
|
+
.DS_Store
|
cairnq-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jannchie
|
|
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.
|
cairnq-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cairnq
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: SQLite-first, cross-language, storage-centered durable task runtime
|
|
5
|
+
Project-URL: Homepage, https://github.com/Jannchie/cairnq
|
|
6
|
+
Project-URL: Repository, https://github.com/Jannchie/cairnq
|
|
7
|
+
Project-URL: Issues, https://github.com/Jannchie/cairnq/issues
|
|
8
|
+
Author-email: Jannchie <jannchie@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: aiosqlite,cross-language,durable,job-queue,sqlite,task-queue,task-runtime,worker
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: aiosqlite>=0.19
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
23
|
+
Provides-Extra: postgres
|
|
24
|
+
Requires-Dist: asyncpg>=0.29; extra == 'postgres'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# cairnq (Python)
|
|
28
|
+
|
|
29
|
+
SQLite-first, cross-language, storage-centered durable task runtime. The Python
|
|
30
|
+
SDK. API and worker processes coordinate only through a shared SQLite file.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from cairnq import CairnQ, Worker
|
|
34
|
+
|
|
35
|
+
# Worker side — a handler always receives (ctx, payload).
|
|
36
|
+
worker = Worker.sqlite("tasks.db")
|
|
37
|
+
|
|
38
|
+
@worker.task # registered under the function name, "create_summary"
|
|
39
|
+
async def create_summary(ctx, payload):
|
|
40
|
+
await ctx.progress(0.2, "reading")
|
|
41
|
+
return {"summary": await llm.summarize(payload["text"])}
|
|
42
|
+
|
|
43
|
+
worker.serve() # blocking entry point; Ctrl-C closes cleanly
|
|
44
|
+
|
|
45
|
+
# API side (in your server) — submit returns immediately.
|
|
46
|
+
tasks = CairnQ.sqlite("tasks.db")
|
|
47
|
+
task = await tasks.submit("create_summary", {"text": text}, key=f"summary:{aid}")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`@worker.task` defaults the task name to the function's name. Pass a string for a
|
|
51
|
+
dotted/namespaced name: `@worker.task("summary.create")`.
|
|
52
|
+
|
|
53
|
+
Synchronous call (submit + wait):
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from cairnq import TaskFailed, TaskTimeout
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
result = await tasks.call("create_summary", {"text": text}, wait_timeout_ms=10_000)
|
|
60
|
+
except TaskFailed as e:
|
|
61
|
+
log(e.code, e.message, e.retryable) # envelope fields, no e.error["code"] digging
|
|
62
|
+
except TaskTimeout as e:
|
|
63
|
+
... # e.task_id keeps running
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Inspect a task by id/key without memorizing status strings:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
task = await tasks.get_by_key(key)
|
|
70
|
+
if task and task.succeeded: # also .failed / .canceled / .running / .queued / .is_terminal
|
|
71
|
+
use(task.result)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Optionally define a task once and share the symbol across both ends — the name
|
|
75
|
+
lives in one place (no string drift), and `call()` is typed as the task's result:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from cairnq import TaskDef
|
|
79
|
+
|
|
80
|
+
summarize = TaskDef[dict, dict]("summarize")
|
|
81
|
+
|
|
82
|
+
@worker.task(summarize) # registered under summarize.name
|
|
83
|
+
async def handle(ctx, payload): ...
|
|
84
|
+
|
|
85
|
+
result = await tasks.call(summarize, {"text": text})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Opt-in: every API still accepts a plain name string (cross-language callers use it).
|
|
89
|
+
|
|
90
|
+
## Running it in production
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
worker = Worker.sqlite(
|
|
94
|
+
"tasks.db",
|
|
95
|
+
concurrency=4,
|
|
96
|
+
retry_backoff_ms=1_000, # doubles per attempt, capped by retry_backoff_max_ms (30s); 0 disables
|
|
97
|
+
on_error=lambda exc, info: log.warning("worker survived %s: %s", info, exc),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Nothing else deletes rows. Sweep terminal tasks on a schedule.
|
|
101
|
+
await tasks.purge(older_than_ms=7 * 24 * 3600_000)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A handler that does real side effects should bail out when it loses its lease —
|
|
105
|
+
the task is already running on another worker and nothing it writes is recorded:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
@worker.task("long.job")
|
|
109
|
+
async def long_job(ctx, payload):
|
|
110
|
+
for chunk in chunks:
|
|
111
|
+
if ctx.lost_lease or await ctx.canceled():
|
|
112
|
+
return
|
|
113
|
+
await process(chunk)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Multi-host
|
|
117
|
+
|
|
118
|
+
Same code, Postgres instead of the file — `CairnQ.postgres(dsn)` /
|
|
119
|
+
`Worker.postgres(dsn)`. Install with `pip install cairnq[postgres]`.
|
|
120
|
+
|
|
121
|
+
The protocol (schema + canonical SQL) lives in `../cairnq-protocol` and is shared
|
|
122
|
+
verbatim with the TypeScript SDK. See `../cairnq-protocol/PROTOCOL.md`.
|
cairnq-0.2.0/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# cairnq (Python)
|
|
2
|
+
|
|
3
|
+
SQLite-first, cross-language, storage-centered durable task runtime. The Python
|
|
4
|
+
SDK. API and worker processes coordinate only through a shared SQLite file.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from cairnq import CairnQ, Worker
|
|
8
|
+
|
|
9
|
+
# Worker side — a handler always receives (ctx, payload).
|
|
10
|
+
worker = Worker.sqlite("tasks.db")
|
|
11
|
+
|
|
12
|
+
@worker.task # registered under the function name, "create_summary"
|
|
13
|
+
async def create_summary(ctx, payload):
|
|
14
|
+
await ctx.progress(0.2, "reading")
|
|
15
|
+
return {"summary": await llm.summarize(payload["text"])}
|
|
16
|
+
|
|
17
|
+
worker.serve() # blocking entry point; Ctrl-C closes cleanly
|
|
18
|
+
|
|
19
|
+
# API side (in your server) — submit returns immediately.
|
|
20
|
+
tasks = CairnQ.sqlite("tasks.db")
|
|
21
|
+
task = await tasks.submit("create_summary", {"text": text}, key=f"summary:{aid}")
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`@worker.task` defaults the task name to the function's name. Pass a string for a
|
|
25
|
+
dotted/namespaced name: `@worker.task("summary.create")`.
|
|
26
|
+
|
|
27
|
+
Synchronous call (submit + wait):
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from cairnq import TaskFailed, TaskTimeout
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
result = await tasks.call("create_summary", {"text": text}, wait_timeout_ms=10_000)
|
|
34
|
+
except TaskFailed as e:
|
|
35
|
+
log(e.code, e.message, e.retryable) # envelope fields, no e.error["code"] digging
|
|
36
|
+
except TaskTimeout as e:
|
|
37
|
+
... # e.task_id keeps running
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Inspect a task by id/key without memorizing status strings:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
task = await tasks.get_by_key(key)
|
|
44
|
+
if task and task.succeeded: # also .failed / .canceled / .running / .queued / .is_terminal
|
|
45
|
+
use(task.result)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Optionally define a task once and share the symbol across both ends — the name
|
|
49
|
+
lives in one place (no string drift), and `call()` is typed as the task's result:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from cairnq import TaskDef
|
|
53
|
+
|
|
54
|
+
summarize = TaskDef[dict, dict]("summarize")
|
|
55
|
+
|
|
56
|
+
@worker.task(summarize) # registered under summarize.name
|
|
57
|
+
async def handle(ctx, payload): ...
|
|
58
|
+
|
|
59
|
+
result = await tasks.call(summarize, {"text": text})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Opt-in: every API still accepts a plain name string (cross-language callers use it).
|
|
63
|
+
|
|
64
|
+
## Running it in production
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
worker = Worker.sqlite(
|
|
68
|
+
"tasks.db",
|
|
69
|
+
concurrency=4,
|
|
70
|
+
retry_backoff_ms=1_000, # doubles per attempt, capped by retry_backoff_max_ms (30s); 0 disables
|
|
71
|
+
on_error=lambda exc, info: log.warning("worker survived %s: %s", info, exc),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Nothing else deletes rows. Sweep terminal tasks on a schedule.
|
|
75
|
+
await tasks.purge(older_than_ms=7 * 24 * 3600_000)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A handler that does real side effects should bail out when it loses its lease —
|
|
79
|
+
the task is already running on another worker and nothing it writes is recorded:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
@worker.task("long.job")
|
|
83
|
+
async def long_job(ctx, payload):
|
|
84
|
+
for chunk in chunks:
|
|
85
|
+
if ctx.lost_lease or await ctx.canceled():
|
|
86
|
+
return
|
|
87
|
+
await process(chunk)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Multi-host
|
|
91
|
+
|
|
92
|
+
Same code, Postgres instead of the file — `CairnQ.postgres(dsn)` /
|
|
93
|
+
`Worker.postgres(dsn)`. Install with `pip install cairnq[postgres]`.
|
|
94
|
+
|
|
95
|
+
The protocol (schema + canonical SQL) lives in `../cairnq-protocol` and is shared
|
|
96
|
+
verbatim with the TypeScript SDK. See `../cairnq-protocol/PROTOCOL.md`.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""CairnQ micro-benchmark (Python SDK).
|
|
2
|
+
|
|
3
|
+
uv run python bench/run.py # SQLite on a temp file
|
|
4
|
+
uv run python bench/run.py postgres # against CAIRNQ_TEST_PG_DSN (use a throwaway DB)
|
|
5
|
+
|
|
6
|
+
Workload sizes, poll settings and row labels MUST match cairnq-node/bench/run.ts
|
|
7
|
+
— the two benches exist to be compared against each other.
|
|
8
|
+
|
|
9
|
+
Reports client-op throughput (submit/get/cancel/purge), worker drain throughput,
|
|
10
|
+
and end-to-end call() latency — the latency rows are the ones that show the
|
|
11
|
+
polling floor: with the default intervals a call can't finish faster than the
|
|
12
|
+
worker's claim poll plus wait()'s read poll.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from time import perf_counter
|
|
22
|
+
|
|
23
|
+
from cairnq import CairnQ, PostgresStore, SQLiteStore, TaskDef, TaskStore, Worker
|
|
24
|
+
|
|
25
|
+
N_SUBMIT = 1000
|
|
26
|
+
N_GET = 2000
|
|
27
|
+
N_DRAIN = 500
|
|
28
|
+
N_CALL = 40
|
|
29
|
+
|
|
30
|
+
noop = TaskDef("bench.noop")
|
|
31
|
+
rows: list[list[str]] = []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ops_per_sec(n: int, ms: float) -> int:
|
|
35
|
+
return round(n / ms * 1000)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pct(sorted_ms: list[float], q: float) -> float:
|
|
39
|
+
return sorted_ms[min(len(sorted_ms) - 1, int(q * len(sorted_ms)))]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def timed(label, n, fn):
|
|
43
|
+
t0 = perf_counter()
|
|
44
|
+
out = await fn()
|
|
45
|
+
rows.append([label, f"{ops_per_sec(n, (perf_counter() - t0) * 1000)} ops/s"])
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def push_latency(label: str, lat: list[float]) -> None:
|
|
50
|
+
rows.append([label, f"p50 {pct(lat, 0.5):.0f} ms", f"p95 {pct(lat, 0.95):.0f} ms"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def call_latencies(tasks: CairnQ, **call_kwargs) -> list[float]:
|
|
54
|
+
lat = []
|
|
55
|
+
for _ in range(N_CALL):
|
|
56
|
+
t0 = perf_counter()
|
|
57
|
+
await tasks.call(noop, {}, wait_timeout_ms=15_000, **call_kwargs)
|
|
58
|
+
lat.append((perf_counter() - t0) * 1000)
|
|
59
|
+
return sorted(lat)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def purge_all(tasks: CairnQ) -> None:
|
|
63
|
+
"""Sweep every terminal task, honoring purge's bounded-batch contract."""
|
|
64
|
+
while len(await tasks.purge(older_than_ms=0, limit=1_000)) == 1_000:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def drain_settled(tasks: CairnQ) -> None:
|
|
69
|
+
"""The drain counter fires on the last handler's return; its complete write —
|
|
70
|
+
and the handful still in flight — land just after. Wait them out."""
|
|
71
|
+
while await tasks.list(status="running", limit=1):
|
|
72
|
+
await asyncio.sleep(0.005)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def run(backend: str, tasks: CairnQ, worker_store: TaskStore) -> None:
|
|
76
|
+
await tasks.connect()
|
|
77
|
+
|
|
78
|
+
# ---- client ops, no worker running. "bench.unclaimed" is a name no worker
|
|
79
|
+
# registers, so these rows sit queued until the cancel pass below.
|
|
80
|
+
async def submit_all() -> list[str]:
|
|
81
|
+
return [(await tasks.submit("bench.unclaimed", {"i": i})).id for i in range(N_SUBMIT)]
|
|
82
|
+
|
|
83
|
+
ids = await timed("submit", N_SUBMIT, submit_all)
|
|
84
|
+
n_ids = len(ids)
|
|
85
|
+
|
|
86
|
+
async def get_all() -> None:
|
|
87
|
+
for i in range(N_GET):
|
|
88
|
+
await tasks.get(ids[i % n_ids])
|
|
89
|
+
|
|
90
|
+
await timed("get", N_GET, get_all)
|
|
91
|
+
|
|
92
|
+
async def cancel_all() -> None:
|
|
93
|
+
for task_id in ids:
|
|
94
|
+
await tasks.cancel(task_id)
|
|
95
|
+
|
|
96
|
+
await timed("cancel", N_SUBMIT, cancel_all)
|
|
97
|
+
await timed("purge", N_SUBMIT, lambda: purge_all(tasks))
|
|
98
|
+
|
|
99
|
+
# ---- drain throughput + call latency at the default poll intervals. The
|
|
100
|
+
# worker store connects up front so one-time setup stays out of the clock;
|
|
101
|
+
# both workers share it (they never run at the same time).
|
|
102
|
+
await asyncio.gather(*(tasks.submit(noop, {"i": i}) for i in range(N_DRAIN)))
|
|
103
|
+
done = 0
|
|
104
|
+
all_done = asyncio.Event()
|
|
105
|
+
|
|
106
|
+
def counting_noop(ctx, payload):
|
|
107
|
+
nonlocal done
|
|
108
|
+
done += 1
|
|
109
|
+
if done == N_DRAIN:
|
|
110
|
+
all_done.set()
|
|
111
|
+
return {}
|
|
112
|
+
|
|
113
|
+
w = Worker(worker_store, ["default"], concurrency=8)
|
|
114
|
+
w.task(noop)(counting_noop)
|
|
115
|
+
await worker_store.connect()
|
|
116
|
+
t0 = perf_counter()
|
|
117
|
+
async with w.background():
|
|
118
|
+
await all_done.wait()
|
|
119
|
+
await drain_settled(tasks)
|
|
120
|
+
drain_ms = (perf_counter() - t0) * 1000
|
|
121
|
+
lat = await call_latencies(tasks)
|
|
122
|
+
rows.append([f"drain {N_DRAIN} (conc 8)", f"{ops_per_sec(N_DRAIN, drain_ms)} tasks/s"])
|
|
123
|
+
push_latency("call e2e (default polls)", lat)
|
|
124
|
+
|
|
125
|
+
# ---- call latency with the poll intervals tuned down.
|
|
126
|
+
w2 = Worker(worker_store, ["default"], concurrency=8, poll_interval_ms=25)
|
|
127
|
+
w2.task(noop)(lambda ctx, payload: {})
|
|
128
|
+
async with w2.background():
|
|
129
|
+
lat2 = await call_latencies(tasks, poll_ms=10)
|
|
130
|
+
push_latency("call e2e (poll 25ms, wait 10ms)", lat2)
|
|
131
|
+
|
|
132
|
+
if backend == "postgres":
|
|
133
|
+
await purge_all(tasks) # shared DB — leave it clean
|
|
134
|
+
await tasks.close()
|
|
135
|
+
await worker_store.close()
|
|
136
|
+
|
|
137
|
+
print(f"cairnq-py bench backend={backend}")
|
|
138
|
+
for row in rows:
|
|
139
|
+
print(f" {row[0]:<32}{' '.join(row[1:])}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def main() -> None:
|
|
143
|
+
backend = sys.argv[1] if len(sys.argv) > 1 else "sqlite"
|
|
144
|
+
if backend == "postgres":
|
|
145
|
+
dsn = os.environ.get("CAIRNQ_TEST_PG_DSN")
|
|
146
|
+
if not dsn:
|
|
147
|
+
raise SystemExit("set CAIRNQ_TEST_PG_DSN (point it at a throwaway database)")
|
|
148
|
+
await run(backend, CairnQ.postgres(dsn), PostgresStore(dsn))
|
|
149
|
+
else:
|
|
150
|
+
with tempfile.TemporaryDirectory(prefix="cairnq-") as tmp:
|
|
151
|
+
path = os.path.join(tmp, "tasks.db")
|
|
152
|
+
await run(backend, CairnQ.sqlite(path), SQLiteStore(path))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""CairnQ — SQLite-first, cross-language, storage-centered durable task runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _pkg_version
|
|
7
|
+
|
|
8
|
+
from .client import CairnQ
|
|
9
|
+
from .context import TaskContext
|
|
10
|
+
from .errors import (
|
|
11
|
+
AlreadyExists,
|
|
12
|
+
CairnQError,
|
|
13
|
+
LostLease,
|
|
14
|
+
ProtocolVersionMismatch,
|
|
15
|
+
SerializationError,
|
|
16
|
+
TaskCanceled,
|
|
17
|
+
TaskError,
|
|
18
|
+
TaskFailed,
|
|
19
|
+
TaskTimeout,
|
|
20
|
+
)
|
|
21
|
+
from .models import STATUSES, Task, TaskDef, TaskStatus
|
|
22
|
+
from .store import Conflict, PostgresStore, SQLiteStore, TaskStore
|
|
23
|
+
from .worker import Worker
|
|
24
|
+
|
|
25
|
+
# Read from the installed package metadata rather than repeated here: the version
|
|
26
|
+
# lives in pyproject.toml alone, so a release bump can't leave this behind.
|
|
27
|
+
try:
|
|
28
|
+
__version__ = _pkg_version("cairnq")
|
|
29
|
+
except PackageNotFoundError: # source tree without an install
|
|
30
|
+
__version__ = "0.0.0.dev0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"CairnQ",
|
|
34
|
+
"Worker",
|
|
35
|
+
"TaskContext",
|
|
36
|
+
"Task",
|
|
37
|
+
"TaskDef",
|
|
38
|
+
"TaskStatus",
|
|
39
|
+
"STATUSES",
|
|
40
|
+
"Conflict",
|
|
41
|
+
"TaskStore",
|
|
42
|
+
"SQLiteStore",
|
|
43
|
+
"PostgresStore",
|
|
44
|
+
"CairnQError",
|
|
45
|
+
"AlreadyExists",
|
|
46
|
+
"TaskTimeout",
|
|
47
|
+
"TaskFailed",
|
|
48
|
+
"TaskCanceled",
|
|
49
|
+
"TaskError",
|
|
50
|
+
"LostLease",
|
|
51
|
+
"ProtocolVersionMismatch",
|
|
52
|
+
"SerializationError",
|
|
53
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""ULID-style id generation. Format pinned by PROTOCOL.md and asserted by
|
|
2
|
+
conformance: `<prefix>_` + 26-char Crockford base32 (48-bit ms timestamp +
|
|
3
|
+
80-bit randomness), lexicographically time-sortable. Must match the TS SDK."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
_CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def new_ulid(ts_ms: int | None = None) -> str:
|
|
14
|
+
if ts_ms is None:
|
|
15
|
+
ts_ms = int(time.time() * 1000)
|
|
16
|
+
value = (ts_ms << 80) | int.from_bytes(os.urandom(10), "big")
|
|
17
|
+
chars = []
|
|
18
|
+
for _ in range(26):
|
|
19
|
+
chars.append(_CROCKFORD[value & 0x1F])
|
|
20
|
+
value >>= 5
|
|
21
|
+
return "".join(reversed(chars))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def new_id(prefix: str = "task") -> str:
|
|
25
|
+
return f"{prefix}_{new_ulid()}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def now_ms() -> int:
|
|
29
|
+
return int(time.time() * 1000)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
-- CairnQ canonical schema (Postgres dialect) — protocol_version 1
|
|
2
|
+
-- Single source of truth for the task table on Postgres. Idempotent; safe to
|
|
3
|
+
-- re-run. Time is stored as bigint epoch milliseconds (`*_ms`), generated by the
|
|
4
|
+
-- DB clock inside each statement (NOT supplied by the SDK — see sql/postgres/*).
|
|
5
|
+
-- JSON columns are jsonb. Status is a CHECK constraint (not a PG enum) so the
|
|
6
|
+
-- conformance status-set check matches across dialects. Ordered migrations are
|
|
7
|
+
-- canonical; there is no separate schema.sql.
|
|
8
|
+
|
|
9
|
+
create table if not exists cairnq_tasks (
|
|
10
|
+
id text primary key,
|
|
11
|
+
|
|
12
|
+
name text not null,
|
|
13
|
+
queue text not null default 'default',
|
|
14
|
+
|
|
15
|
+
status text not null check (
|
|
16
|
+
status in ('queued', 'running', 'succeeded', 'failed', 'canceled')
|
|
17
|
+
),
|
|
18
|
+
|
|
19
|
+
payload jsonb not null,
|
|
20
|
+
result jsonb,
|
|
21
|
+
error jsonb,
|
|
22
|
+
metadata jsonb not null default '{}'::jsonb,
|
|
23
|
+
|
|
24
|
+
progress double precision,
|
|
25
|
+
message text,
|
|
26
|
+
|
|
27
|
+
attempt integer not null default 0,
|
|
28
|
+
max_attempts integer not null default 3,
|
|
29
|
+
|
|
30
|
+
priority integer not null default 0,
|
|
31
|
+
|
|
32
|
+
worker_id text,
|
|
33
|
+
lease_until_ms bigint,
|
|
34
|
+
|
|
35
|
+
run_at_ms bigint not null,
|
|
36
|
+
|
|
37
|
+
cancel_requested_at_ms bigint,
|
|
38
|
+
|
|
39
|
+
parent_id text,
|
|
40
|
+
root_id text,
|
|
41
|
+
correlation_id text,
|
|
42
|
+
|
|
43
|
+
created_at_ms bigint not null,
|
|
44
|
+
updated_at_ms bigint not null,
|
|
45
|
+
completed_at_ms bigint
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
-- Serves the claim query: WHERE queue=? AND status='queued' ORDER BY priority
|
|
49
|
+
-- desc, created_at_ms asc (run_at_ms applied as a residual filter).
|
|
50
|
+
create index if not exists cairnq_tasks_claim_idx
|
|
51
|
+
on cairnq_tasks (queue, status, priority desc, created_at_ms);
|
|
52
|
+
create index if not exists cairnq_tasks_status_idx on cairnq_tasks (status);
|
|
53
|
+
create index if not exists cairnq_tasks_name_idx on cairnq_tasks (name);
|
|
54
|
+
create index if not exists cairnq_tasks_root_idx on cairnq_tasks (root_id);
|
|
55
|
+
create index if not exists cairnq_tasks_correlation_idx on cairnq_tasks (correlation_id);
|
|
56
|
+
|
|
57
|
+
-- key = business-stable pointer to the *current* task for that key.
|
|
58
|
+
-- task_id = one concrete execution. Kept separate (not a unique constraint on
|
|
59
|
+
-- tasks) so reuse / reject / replace are natural.
|
|
60
|
+
create table if not exists cairnq_task_keys (
|
|
61
|
+
key text primary key,
|
|
62
|
+
task_id text not null references cairnq_tasks(id) on delete cascade,
|
|
63
|
+
created_at_ms bigint not null,
|
|
64
|
+
updated_at_ms bigint not null
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
create table if not exists cairnq_meta (
|
|
68
|
+
key text primary key,
|
|
69
|
+
value text not null
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
insert into cairnq_meta (key, value) values ('protocol_version', '1')
|
|
73
|
+
on conflict (key) do nothing;
|
|
74
|
+
insert into cairnq_meta (key, value) values ('schema_version', '1')
|
|
75
|
+
on conflict (key) do nothing;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- Serves purge.sql: scan terminal tasks in completion order. Without it the
|
|
2
|
+
-- retention sweep is a full table scan of exactly the rows that accumulate most.
|
|
3
|
+
create index if not exists cairnq_tasks_completed_idx
|
|
4
|
+
on cairnq_tasks (completed_at_ms);
|
|
5
|
+
|
|
6
|
+
update cairnq_meta set value = '2' where key = 'schema_version';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- Push-based wakeups (Postgres only). A row trigger emits:
|
|
2
|
+
-- cairnq_queued (payload: queue name) when a task becomes claimable-soon:
|
|
3
|
+
-- inserted queued, or requeued by a
|
|
4
|
+
-- retryable fail / retry / recovery;
|
|
5
|
+
-- cairnq_done (payload: task id) when a task reaches a terminal status.
|
|
6
|
+
-- The trigger lives in the database, not in the SDKs, so every writer — either
|
|
7
|
+
-- SDK, any version, even hand-run SQL — wakes listeners. See PROTOCOL.md
|
|
8
|
+
-- ("Push wakeups") for the contract; in short, notifications only cut a poll
|
|
9
|
+
-- sleep short and are never required for correctness. Additive:
|
|
10
|
+
-- protocol_version stays 1.
|
|
11
|
+
--
|
|
12
|
+
-- Trigger guards, hottest write first:
|
|
13
|
+
-- - WHEN keeps claim (-> 'running', the most frequent status write) from
|
|
14
|
+
-- entering plpgsql at all;
|
|
15
|
+
-- - UPDATE OF status keeps heartbeat/progress from firing the trigger;
|
|
16
|
+
-- - the IS DISTINCT FROM checks skip a SET that rewrites the same value
|
|
17
|
+
-- (e.g. cancel.sql on an already-running task).
|
|
18
|
+
|
|
19
|
+
create or replace function cairnq_notify() returns trigger as $$
|
|
20
|
+
begin
|
|
21
|
+
if new.status = 'queued'
|
|
22
|
+
and (tg_op = 'INSERT' or old.status is distinct from new.status) then
|
|
23
|
+
perform pg_notify('cairnq_queued', new.queue);
|
|
24
|
+
elsif tg_op = 'UPDATE'
|
|
25
|
+
and new.status in ('succeeded', 'failed', 'canceled')
|
|
26
|
+
and old.status not in ('succeeded', 'failed', 'canceled') then
|
|
27
|
+
perform pg_notify('cairnq_done', new.id);
|
|
28
|
+
end if;
|
|
29
|
+
return null;
|
|
30
|
+
end;
|
|
31
|
+
$$ language plpgsql;
|
|
32
|
+
|
|
33
|
+
drop trigger if exists cairnq_tasks_notify on cairnq_tasks;
|
|
34
|
+
create trigger cairnq_tasks_notify
|
|
35
|
+
after insert or update of status on cairnq_tasks
|
|
36
|
+
for each row
|
|
37
|
+
when (new.status is distinct from 'running')
|
|
38
|
+
execute function cairnq_notify();
|