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.
Files changed (86) hide show
  1. cairnq-0.2.0/.gitignore +25 -0
  2. cairnq-0.2.0/LICENSE +21 -0
  3. cairnq-0.2.0/PKG-INFO +122 -0
  4. cairnq-0.2.0/README.md +96 -0
  5. cairnq-0.2.0/bench/run.py +156 -0
  6. cairnq-0.2.0/cairnq/__init__.py +53 -0
  7. cairnq-0.2.0/cairnq/_ids.py +29 -0
  8. cairnq-0.2.0/cairnq/_protocol/migrations/postgres/0001_init.sql +75 -0
  9. cairnq-0.2.0/cairnq/_protocol/migrations/postgres/0002_purge_index.sql +6 -0
  10. cairnq-0.2.0/cairnq/_protocol/migrations/postgres/0003_notify.sql +38 -0
  11. cairnq-0.2.0/cairnq/_protocol/migrations/sqlite/0001_init.sql +74 -0
  12. cairnq-0.2.0/cairnq/_protocol/migrations/sqlite/0002_purge_index.sql +6 -0
  13. cairnq-0.2.0/cairnq/_protocol/sql/postgres/cancel.sql +16 -0
  14. cairnq-0.2.0/cairnq/_protocol/sql/postgres/claim.sql +40 -0
  15. cairnq-0.2.0/cairnq/_protocol/sql/postgres/complete.sql +17 -0
  16. cairnq-0.2.0/cairnq/_protocol/sql/postgres/fail.sql +41 -0
  17. cairnq-0.2.0/cairnq/_protocol/sql/postgres/get.sql +2 -0
  18. cairnq-0.2.0/cairnq/_protocol/sql/postgres/get_by_key.sql +4 -0
  19. cairnq-0.2.0/cairnq/_protocol/sql/postgres/get_key.sql +3 -0
  20. cairnq-0.2.0/cairnq/_protocol/sql/postgres/heartbeat.sql +12 -0
  21. cairnq-0.2.0/cairnq/_protocol/sql/postgres/insert_task.sql +23 -0
  22. cairnq-0.2.0/cairnq/_protocol/sql/postgres/list.sql +15 -0
  23. cairnq-0.2.0/cairnq/_protocol/sql/postgres/lock_key.sql +9 -0
  24. cairnq-0.2.0/cairnq/_protocol/sql/postgres/progress.sql +14 -0
  25. cairnq-0.2.0/cairnq/_protocol/sql/postgres/protocol_version.sql +4 -0
  26. cairnq-0.2.0/cairnq/_protocol/sql/postgres/purge.sql +25 -0
  27. cairnq-0.2.0/cairnq/_protocol/sql/postgres/recover_leases.sql +44 -0
  28. cairnq-0.2.0/cairnq/_protocol/sql/postgres/retry.sql +20 -0
  29. cairnq-0.2.0/cairnq/_protocol/sql/postgres/stats.sql +8 -0
  30. cairnq-0.2.0/cairnq/_protocol/sql/postgres/succeed.sql +16 -0
  31. cairnq-0.2.0/cairnq/_protocol/sql/postgres/upsert_key.sql +12 -0
  32. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/cancel.sql +12 -0
  33. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/claim.sql +31 -0
  34. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/claimable_probe.sql +18 -0
  35. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/complete.sql +18 -0
  36. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/fail.sql +41 -0
  37. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/get.sql +2 -0
  38. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/get_by_key.sql +4 -0
  39. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/get_key.sql +3 -0
  40. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/heartbeat.sql +10 -0
  41. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/insert_task.sql +16 -0
  42. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/list.sql +13 -0
  43. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/lock_key.sql +5 -0
  44. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/progress.sql +14 -0
  45. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/protocol_version.sql +4 -0
  46. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/purge.sql +18 -0
  47. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/recover_leases.sql +35 -0
  48. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/retry.sql +19 -0
  49. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/stats.sql +8 -0
  50. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/succeed.sql +15 -0
  51. cairnq-0.2.0/cairnq/_protocol/sql/sqlite/upsert_key.sql +7 -0
  52. cairnq-0.2.0/cairnq/_sql.py +50 -0
  53. cairnq-0.2.0/cairnq/_wait.py +49 -0
  54. cairnq-0.2.0/cairnq/client.py +152 -0
  55. cairnq-0.2.0/cairnq/context.py +145 -0
  56. cairnq-0.2.0/cairnq/errors.py +158 -0
  57. cairnq-0.2.0/cairnq/models.py +110 -0
  58. cairnq-0.2.0/cairnq/store/__init__.py +5 -0
  59. cairnq-0.2.0/cairnq/store/base.py +423 -0
  60. cairnq-0.2.0/cairnq/store/postgres.py +301 -0
  61. cairnq-0.2.0/cairnq/store/sqlite.py +249 -0
  62. cairnq-0.2.0/cairnq/worker.py +486 -0
  63. cairnq-0.2.0/pyproject.toml +51 -0
  64. cairnq-0.2.0/tests/__init__.py +0 -0
  65. cairnq-0.2.0/tests/_runner.py +166 -0
  66. cairnq-0.2.0/tests/conftest.py +18 -0
  67. cairnq-0.2.0/tests/helpers.py +22 -0
  68. cairnq-0.2.0/tests/test_api_parity.py +48 -0
  69. cairnq-0.2.0/tests/test_client_worker.py +224 -0
  70. cairnq-0.2.0/tests/test_conformance.py +66 -0
  71. cairnq-0.2.0/tests/test_dialect_param_coverage.py +114 -0
  72. cairnq-0.2.0/tests/test_edge_cases.py +112 -0
  73. cairnq-0.2.0/tests/test_handler_routing.py +67 -0
  74. cairnq-0.2.0/tests/test_migration_concurrency.py +46 -0
  75. cairnq-0.2.0/tests/test_migrations.py +91 -0
  76. cairnq-0.2.0/tests/test_postgres.py +168 -0
  77. cairnq-0.2.0/tests/test_postgres_live.py +152 -0
  78. cairnq-0.2.0/tests/test_protocol_resolution.py +29 -0
  79. cairnq-0.2.0/tests/test_retry_backoff.py +47 -0
  80. cairnq-0.2.0/tests/test_sqlite_concurrency.py +64 -0
  81. cairnq-0.2.0/tests/test_store.py +155 -0
  82. cairnq-0.2.0/tests/test_version.py +17 -0
  83. cairnq-0.2.0/tests/test_wait_backoff.py +43 -0
  84. cairnq-0.2.0/tests/test_worker_resilience.py +245 -0
  85. cairnq-0.2.0/tests/test_worker_signals.py +84 -0
  86. cairnq-0.2.0/uv.lock +267 -0
@@ -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();