simplebroker-pg 1.0.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.
@@ -0,0 +1,87 @@
1
+ # Accidental database files created by str(BrokerCore) coercion
2
+ <simplebroker.*>
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ *.so
9
+ .Python
10
+ .code
11
+ .claude
12
+ .codex
13
+ docs/
14
+ build/
15
+ develop-eggs/
16
+ dist/
17
+ downloads/
18
+ eggs/
19
+ .eggs/
20
+ lib/
21
+ lib64/
22
+ parts/
23
+ sdist/
24
+ var/
25
+ wheels/
26
+ *.egg-info/
27
+ .installed.cfg
28
+ *.egg
29
+ MANIFEST
30
+
31
+ # Virtual environments
32
+ venv/
33
+ ENV/
34
+ env/
35
+ .venv
36
+
37
+ # IDEs
38
+ .vscode/
39
+ .idea/
40
+ *.swp
41
+ *.swo
42
+ *~
43
+
44
+ # Testing
45
+ .coverage
46
+ .coverage.*
47
+ coverage.xml
48
+ .pytest_cache/
49
+ htmlcov/
50
+ .tox/
51
+ .nox/
52
+ .mypy_cache/
53
+ .dmypy.json
54
+ dmypy.json
55
+ .ruff_cache/
56
+ .ruff/
57
+ .pytest_cache/
58
+
59
+ # SimpleBroker specific
60
+ *.db
61
+ *.db-shm
62
+ *.db-wal
63
+ .broker.db*
64
+ test.db
65
+ benchmark_pragma.py
66
+
67
+ # OS
68
+ .DS_Store
69
+ Thumbs.db
70
+
71
+ # Temporary files
72
+ *.tmp
73
+ *.bak
74
+ *.log
75
+
76
+ # Multi-agent
77
+ .claude
78
+ .mcp.json
79
+ agent_history/
80
+ .broker.db
81
+ .broker.db-shm
82
+ .broker.db-wal
83
+ weft/
84
+ .aider*
85
+ .codex*
86
+ .code*
87
+ .coder*
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: simplebroker-pg
3
+ Version: 1.0.0
4
+ Summary: Postgres backend plugin for SimpleBroker
5
+ Author-email: Van Lindberg <van.lindberg@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: psycopg-pool<4,>=3.1
9
+ Requires-Dist: psycopg[binary]<4,>=3
10
+ Requires-Dist: simplebroker>=3.0.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest-timeout>=2.4.0; extra == 'dev'
13
+ Requires-Dist: pytest>=7.0; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # simplebroker-pg
17
+
18
+ Postgres backend plugin for SimpleBroker.
19
+
20
+ This package is intentionally separate from `simplebroker` itself. SimpleBroker
21
+ remains SQLite-first. This package adds a Postgres backend through the public
22
+ backend plugin hook.
23
+
24
+ ## Requirements
25
+
26
+ - PostgreSQL
27
+ - A dedicated schema for SimpleBroker tables
28
+
29
+ `public` is intentionally rejected.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ # Add to an existing pipx-installed simplebroker (recommended)
35
+ pipx inject simplebroker simplebroker-pg
36
+
37
+ # Or install with uv to use as a library
38
+ uv add simplebroker-pg
39
+
40
+ # Or with pip
41
+ pip install simplebroker-pg
42
+ ```
43
+
44
+ ## Python Usage
45
+
46
+ ```python
47
+ from simplebroker import Queue
48
+ from simplebroker_pg import PostgresRunner
49
+
50
+ runner = PostgresRunner(
51
+ "postgresql://postgres@127.0.0.1:54329/simplebroker_test",
52
+ schema="simplebroker_app",
53
+ )
54
+
55
+ queue = Queue("jobs", runner=runner, persistent=True)
56
+ queue.write("hello")
57
+ print(queue.read())
58
+ ```
59
+
60
+ ## CLI Usage
61
+
62
+ Create `.simplebroker.toml` in the project root:
63
+
64
+ ```toml
65
+ version = 1
66
+ backend = "postgres"
67
+ target = "postgresql://postgres@127.0.0.1:54329/simplebroker_test"
68
+
69
+ [backend_options]
70
+ schema = "simplebroker_app"
71
+ ```
72
+
73
+ Then use the normal CLI from any child directory with project scope enabled:
74
+
75
+ ```bash
76
+ broker init
77
+ broker write jobs hello
78
+ broker read jobs
79
+ ```
80
+
81
+ You can also run entirely from environment variables without a project config:
82
+
83
+ ```bash
84
+ BROKER_BACKEND=postgres \
85
+ BROKER_BACKEND_TARGET='postgresql://postgres@127.0.0.1:54329/simplebroker_test' \
86
+ BROKER_BACKEND_SCHEMA='simplebroker_app' \
87
+ BROKER_BACKEND_PASSWORD='postgres' \
88
+ broker init
89
+ ```
90
+
91
+ Notes:
92
+
93
+ - `BROKER_BACKEND_TARGET` overrides the whole target string.
94
+ - `BROKER_BACKEND_HOST`, `BROKER_BACKEND_PORT`, `BROKER_BACKEND_USER`,
95
+ `BROKER_BACKEND_PASSWORD`, and `BROKER_BACKEND_DATABASE` are only used when there is no
96
+ target from env or toml.
97
+ - `BROKER_BACKEND_PASSWORD` is never written to `.simplebroker.toml`.
98
+ - The Postgres database must already exist. `broker init` creates the managed schema/tables
99
+ inside that database; it does not create the database itself.
100
+ - Missing backend/plugin errors are distinct from target/auth errors. Invalid schema names,
101
+ bad passwords, malformed targets, and missing databases are reported as validation or
102
+ connection failures, not as "backend not available" errors.
@@ -0,0 +1,87 @@
1
+ # simplebroker-pg
2
+
3
+ Postgres backend plugin for SimpleBroker.
4
+
5
+ This package is intentionally separate from `simplebroker` itself. SimpleBroker
6
+ remains SQLite-first. This package adds a Postgres backend through the public
7
+ backend plugin hook.
8
+
9
+ ## Requirements
10
+
11
+ - PostgreSQL
12
+ - A dedicated schema for SimpleBroker tables
13
+
14
+ `public` is intentionally rejected.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ # Add to an existing pipx-installed simplebroker (recommended)
20
+ pipx inject simplebroker simplebroker-pg
21
+
22
+ # Or install with uv to use as a library
23
+ uv add simplebroker-pg
24
+
25
+ # Or with pip
26
+ pip install simplebroker-pg
27
+ ```
28
+
29
+ ## Python Usage
30
+
31
+ ```python
32
+ from simplebroker import Queue
33
+ from simplebroker_pg import PostgresRunner
34
+
35
+ runner = PostgresRunner(
36
+ "postgresql://postgres@127.0.0.1:54329/simplebroker_test",
37
+ schema="simplebroker_app",
38
+ )
39
+
40
+ queue = Queue("jobs", runner=runner, persistent=True)
41
+ queue.write("hello")
42
+ print(queue.read())
43
+ ```
44
+
45
+ ## CLI Usage
46
+
47
+ Create `.simplebroker.toml` in the project root:
48
+
49
+ ```toml
50
+ version = 1
51
+ backend = "postgres"
52
+ target = "postgresql://postgres@127.0.0.1:54329/simplebroker_test"
53
+
54
+ [backend_options]
55
+ schema = "simplebroker_app"
56
+ ```
57
+
58
+ Then use the normal CLI from any child directory with project scope enabled:
59
+
60
+ ```bash
61
+ broker init
62
+ broker write jobs hello
63
+ broker read jobs
64
+ ```
65
+
66
+ You can also run entirely from environment variables without a project config:
67
+
68
+ ```bash
69
+ BROKER_BACKEND=postgres \
70
+ BROKER_BACKEND_TARGET='postgresql://postgres@127.0.0.1:54329/simplebroker_test' \
71
+ BROKER_BACKEND_SCHEMA='simplebroker_app' \
72
+ BROKER_BACKEND_PASSWORD='postgres' \
73
+ broker init
74
+ ```
75
+
76
+ Notes:
77
+
78
+ - `BROKER_BACKEND_TARGET` overrides the whole target string.
79
+ - `BROKER_BACKEND_HOST`, `BROKER_BACKEND_PORT`, `BROKER_BACKEND_USER`,
80
+ `BROKER_BACKEND_PASSWORD`, and `BROKER_BACKEND_DATABASE` are only used when there is no
81
+ target from env or toml.
82
+ - `BROKER_BACKEND_PASSWORD` is never written to `.simplebroker.toml`.
83
+ - The Postgres database must already exist. `broker init` creates the managed schema/tables
84
+ inside that database; it does not create the database itself.
85
+ - Missing backend/plugin errors are distinct from target/auth errors. Invalid schema names,
86
+ bad passwords, malformed targets, and missing databases are reported as validation or
87
+ connection failures, not as "backend not available" errors.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "simplebroker-pg"
7
+ version = "1.0.0"
8
+ description = "Postgres backend plugin for SimpleBroker"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "Van Lindberg", email = "van.lindberg@gmail.com"},
14
+ ]
15
+ dependencies = [
16
+ "simplebroker>=3.0.0",
17
+ "psycopg[binary]>=3,<4",
18
+ "psycopg-pool>=3.1,<4",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "pytest>=7.0",
24
+ "pytest-timeout>=2.4.0",
25
+ ]
26
+
27
+ [project.entry-points."simplebroker.backends"]
28
+ postgres = "simplebroker_pg.plugin:get_backend_plugin"
29
+
30
+ [tool.hatch.build]
31
+ include = [
32
+ "simplebroker_pg/**/*.py",
33
+ "README.md",
34
+ ]
35
+
36
+ [tool.pytest.ini_options]
37
+ minversion = "7.0"
38
+ testpaths = ["tests"]
39
+ addopts = "-ra -q"
40
+ markers = [
41
+ "pg_only: tests that validate the Postgres extension package",
42
+ ]
@@ -0,0 +1,6 @@
1
+ """Postgres backend extension for SimpleBroker."""
2
+
3
+ from .plugin import get_backend_plugin
4
+ from .runner import PostgresRunner
5
+
6
+ __all__ = ["PostgresRunner", "get_backend_plugin"]
@@ -0,0 +1,3 @@
1
+ """Constants for the Postgres SimpleBroker backend."""
2
+
3
+ POSTGRES_SCHEMA_VERSION = 5
@@ -0,0 +1,18 @@
1
+ """Stable identifiers for Postgres advisory locks and notification channels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+
8
+ def stable_lock_key(*parts: str) -> int:
9
+ """Return a stable signed 64-bit advisory lock key."""
10
+ payload = "\x1f".join(parts).encode("utf-8")
11
+ digest = hashlib.blake2b(payload, digest_size=8).digest()
12
+ return int.from_bytes(digest, byteorder="big", signed=True)
13
+
14
+
15
+ def activity_channel_name(schema: str) -> str:
16
+ """Return a stable, identifier-safe LISTEN/NOTIFY channel name."""
17
+ digest = hashlib.md5(schema.encode("utf-8"), usedforsecurity=False).hexdigest()
18
+ return f"simplebroker_{digest[:24]}"
@@ -0,0 +1,269 @@
1
+ """Postgres SQL namespace for SimpleBroker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from simplebroker._sql import RetrieveOperation, RetrieveQuerySpec
6
+
7
+ CHECK_PENDING_MESSAGES = """
8
+ SELECT EXISTS(
9
+ SELECT 1
10
+ FROM messages
11
+ WHERE queue = ? AND claimed = FALSE
12
+ LIMIT 1
13
+ )
14
+ """
15
+
16
+ CHECK_PENDING_MESSAGES_SINCE = """
17
+ SELECT EXISTS(
18
+ SELECT 1
19
+ FROM messages
20
+ WHERE queue = ? AND claimed = FALSE AND ts > ?
21
+ LIMIT 1
22
+ )
23
+ """
24
+
25
+ CHECK_QUEUE_EXISTS = """
26
+ SELECT EXISTS(
27
+ SELECT 1
28
+ FROM messages
29
+ WHERE queue = ?
30
+ LIMIT 1
31
+ )
32
+ """
33
+
34
+ COUNT_CLAIMED_MESSAGES = "SELECT COUNT(*) FROM messages WHERE claimed = TRUE"
35
+ DELETE_ALIAS = "DELETE FROM aliases WHERE alias = ?"
36
+ DELETE_ALL_MESSAGES_COUNT = """
37
+ WITH deleted AS (
38
+ DELETE FROM messages
39
+ RETURNING 1
40
+ )
41
+ SELECT COUNT(*) FROM deleted
42
+ """
43
+ DELETE_QUEUE_MESSAGES_COUNT = """
44
+ WITH deleted AS (
45
+ DELETE FROM messages
46
+ WHERE queue = ?
47
+ RETURNING 1
48
+ )
49
+ SELECT COUNT(*) FROM deleted
50
+ """
51
+ GET_ALIAS_VERSION = "SELECT alias_version FROM meta WHERE singleton = TRUE"
52
+ GET_DISTINCT_QUEUES = "SELECT DISTINCT queue FROM messages ORDER BY queue"
53
+ GET_LAST_TS = "SELECT last_ts FROM meta WHERE singleton = TRUE"
54
+ GET_MAX_MESSAGE_TS = "SELECT COALESCE(MAX(ts), 0) FROM messages"
55
+ GET_OVERALL_STATS = """
56
+ SELECT
57
+ COALESCE(SUM(CASE WHEN claimed THEN 1 ELSE 0 END), 0),
58
+ COUNT(*)
59
+ FROM messages
60
+ """
61
+ GET_QUEUE_STATS = """
62
+ SELECT
63
+ queue,
64
+ SUM(CASE WHEN NOT claimed THEN 1 ELSE 0 END) AS unclaimed_count,
65
+ COUNT(*) AS total_count
66
+ FROM messages
67
+ GROUP BY queue
68
+ ORDER BY queue
69
+ """
70
+ GET_TOTAL_MESSAGE_COUNT = "SELECT COUNT(*) FROM messages"
71
+ GET_VACUUM_STATS = """
72
+ SELECT
73
+ COALESCE(SUM(CASE WHEN claimed THEN 1 ELSE 0 END), 0),
74
+ COUNT(*)
75
+ FROM messages
76
+ """
77
+ INSERT_ALIAS = "INSERT INTO aliases (alias, target) VALUES (?, ?)"
78
+ INSERT_MESSAGE = """
79
+ WITH inserted AS (
80
+ INSERT INTO messages (queue, body, ts)
81
+ VALUES (?, ?, ?)
82
+ RETURNING queue
83
+ )
84
+ SELECT pg_notify(
85
+ 'simplebroker_' || substr(md5(current_schema()), 1, 24),
86
+ queue
87
+ )
88
+ FROM inserted
89
+ """
90
+ LIST_QUEUES_UNCLAIMED = """
91
+ SELECT queue, COUNT(*)
92
+ FROM messages
93
+ WHERE claimed = FALSE
94
+ GROUP BY queue
95
+ ORDER BY queue
96
+ """
97
+ SELECT_ALIASES = "SELECT alias, target FROM aliases ORDER BY alias"
98
+ SELECT_ALIASES_FOR_TARGET = """
99
+ SELECT alias
100
+ FROM aliases
101
+ WHERE target = ?
102
+ ORDER BY alias
103
+ """
104
+ SELECT_META_ALL = """
105
+ SELECT key, value
106
+ FROM (
107
+ SELECT 'alias_version'::TEXT AS key, alias_version::TEXT AS value
108
+ FROM meta
109
+ WHERE singleton = TRUE
110
+ UNION ALL
111
+ SELECT 'last_ts'::TEXT AS key, last_ts::TEXT AS value
112
+ FROM meta
113
+ WHERE singleton = TRUE
114
+ UNION ALL
115
+ SELECT 'magic'::TEXT AS key, magic AS value
116
+ FROM meta
117
+ WHERE singleton = TRUE
118
+ UNION ALL
119
+ SELECT 'schema_version'::TEXT AS key, schema_version::TEXT AS value
120
+ FROM meta
121
+ WHERE singleton = TRUE
122
+ ) AS meta_items
123
+ ORDER BY key
124
+ """
125
+ UPDATE_ALIAS_VERSION = """
126
+ UPDATE meta
127
+ SET alias_version = ?
128
+ WHERE singleton = TRUE
129
+ """
130
+ UPDATE_LAST_TS = "UPDATE meta SET last_ts = ? WHERE singleton = TRUE"
131
+
132
+ DELETE_CLAIMED_BATCH_COUNT = """
133
+ WITH deleted AS (
134
+ DELETE FROM messages
135
+ WHERE order_id IN (
136
+ SELECT order_id
137
+ FROM messages
138
+ WHERE claimed = TRUE
139
+ ORDER BY order_id
140
+ LIMIT ?
141
+ )
142
+ RETURNING 1
143
+ )
144
+ SELECT COUNT(*) FROM deleted
145
+ """
146
+
147
+ DATABASE_SIZE_BYTES = """
148
+ SELECT COALESCE(SUM(pg_total_relation_size(c.oid)), 0)
149
+ FROM pg_class AS c
150
+ JOIN pg_namespace AS n
151
+ ON n.oid = c.relnamespace
152
+ WHERE n.nspname = ?
153
+ AND c.relname IN ('messages', 'meta', 'aliases')
154
+ """
155
+
156
+ LOCK_BROADCAST_SCOPE = "LOCK TABLE messages IN SHARE ROW EXCLUSIVE MODE"
157
+
158
+ COMPACT_TABLE_MESSAGES = "VACUUM (ANALYZE) messages"
159
+ COMPACT_TABLE_META = "VACUUM (ANALYZE) meta"
160
+ COMPACT_TABLE_ALIASES = "VACUUM (ANALYZE) aliases"
161
+
162
+
163
+ def _build_where_clause(spec: RetrieveQuerySpec) -> tuple[list[str], list[object]]:
164
+ """Build Postgres WHERE conditions and parameters for retrieve operations."""
165
+ if spec.exact_timestamp is not None:
166
+ where_conditions = ["ts = ?", "queue = ?"]
167
+ params: list[object] = [spec.exact_timestamp, spec.queue]
168
+ if spec.require_unclaimed:
169
+ where_conditions.append("claimed = FALSE")
170
+ return where_conditions, params
171
+
172
+ where_conditions = ["queue = ?"]
173
+ params = [spec.queue]
174
+ if spec.require_unclaimed:
175
+ where_conditions.append("claimed = FALSE")
176
+ if spec.since_timestamp is not None:
177
+ where_conditions.append("ts > ?")
178
+ params.append(spec.since_timestamp)
179
+ return where_conditions, params
180
+
181
+
182
+ def build_retrieve_query(
183
+ operation: RetrieveOperation,
184
+ spec: RetrieveQuerySpec,
185
+ ) -> tuple[str, tuple[object, ...]]:
186
+ """Build a Postgres retrieve query and its parameter tuple."""
187
+ where_conditions, params = _build_where_clause(spec)
188
+ where_clause = " AND ".join(where_conditions)
189
+
190
+ if operation == "peek":
191
+ return (
192
+ f"""
193
+ SELECT body, ts
194
+ FROM messages
195
+ WHERE {where_clause}
196
+ ORDER BY order_id
197
+ LIMIT ?
198
+ OFFSET ?
199
+ """,
200
+ tuple(params + [spec.limit, spec.offset]),
201
+ )
202
+
203
+ if operation == "claim":
204
+ return (
205
+ f"""
206
+ WITH selected AS (
207
+ SELECT order_id
208
+ FROM messages
209
+ WHERE {where_clause}
210
+ ORDER BY order_id
211
+ LIMIT ?
212
+ ),
213
+ updated AS (
214
+ UPDATE messages
215
+ SET claimed = TRUE
216
+ WHERE order_id IN (SELECT order_id FROM selected)
217
+ RETURNING order_id, body, ts
218
+ )
219
+ SELECT updated.body, updated.ts
220
+ FROM updated
221
+ JOIN selected
222
+ ON selected.order_id = updated.order_id
223
+ ORDER BY selected.order_id
224
+ """,
225
+ tuple(params + [spec.limit]),
226
+ )
227
+
228
+ if operation == "move":
229
+ if spec.target_queue is None:
230
+ raise ValueError("Move retrieve query requires target_queue")
231
+ return (
232
+ f"""
233
+ WITH target_queue AS (
234
+ SELECT ? AS queue_name
235
+ ),
236
+ selected AS (
237
+ SELECT order_id
238
+ FROM messages
239
+ WHERE {where_clause}
240
+ ORDER BY order_id
241
+ LIMIT ?
242
+ ),
243
+ updated AS (
244
+ UPDATE messages
245
+ SET queue = (SELECT queue_name FROM target_queue),
246
+ claimed = FALSE
247
+ WHERE order_id IN (SELECT order_id FROM selected)
248
+ RETURNING order_id, body, ts
249
+ ),
250
+ notified AS (
251
+ SELECT pg_notify(
252
+ 'simplebroker_' || substr(md5(current_schema()), 1, 24),
253
+ (SELECT queue_name FROM target_queue)
254
+ )
255
+ FROM updated
256
+ LIMIT 1
257
+ )
258
+ SELECT updated.body, updated.ts
259
+ FROM updated
260
+ JOIN selected
261
+ ON selected.order_id = updated.order_id
262
+ LEFT JOIN notified
263
+ ON TRUE
264
+ ORDER BY selected.order_id
265
+ """,
266
+ tuple([spec.target_queue] + params + [spec.limit]),
267
+ )
268
+
269
+ raise ValueError(f"Unsupported retrieve operation: {operation}")