nexura-postgres 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nexura_postgres/__init__.py +30 -0
- nexura_postgres/checkpoint_store.py +296 -0
- nexura_postgres/execution_store.py +97 -0
- nexura_postgres/pgvector.py +827 -0
- nexura_postgres/py.typed +0 -0
- nexura_postgres/repository.py +360 -0
- nexura_postgres/trigger_store.py +838 -0
- nexura_postgres/workflow_store.py +529 -0
- nexura_postgres-0.4.0.dist-info/METADATA +70 -0
- nexura_postgres-0.4.0.dist-info/RECORD +12 -0
- nexura_postgres-0.4.0.dist-info/WHEEL +4 -0
- nexura_postgres-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 Liceron
|
|
3
|
+
"""PostgreSQL backends: run and session storage, the execution journal, durable SDK agent
|
|
4
|
+
checkpoints (``PostgresCheckpointStore``), durable workflow state
|
|
5
|
+
(``PostgresWorkflowStore``), the trigger job queue (``PostgresTriggerStore``) and the pgvector
|
|
6
|
+
store.
|
|
7
|
+
|
|
8
|
+
Distributed as ``nexura-postgres`` (it depends on ``nexura-runtime`` and asyncpg). The runtime's
|
|
9
|
+
``get_storage_repository("postgresql://...")`` returns ``PostgresRepository`` when this package
|
|
10
|
+
is installed (``nexura_runtime.modules.optional_packages``); without it, that call raises
|
|
11
|
+
``MissingDependencyError`` naming ``pip install nexura-postgres``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from nexura_postgres.checkpoint_store import PostgresCheckpointStore
|
|
17
|
+
from nexura_postgres.execution_store import PostgresExecutionStore
|
|
18
|
+
from nexura_postgres.pgvector import PgVectorStoreAdapter
|
|
19
|
+
from nexura_postgres.repository import PostgresRepository
|
|
20
|
+
from nexura_postgres.trigger_store import PostgresTriggerStore
|
|
21
|
+
from nexura_postgres.workflow_store import PostgresWorkflowStore
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"PgVectorStoreAdapter",
|
|
25
|
+
"PostgresCheckpointStore",
|
|
26
|
+
"PostgresRepository",
|
|
27
|
+
"PostgresExecutionStore",
|
|
28
|
+
"PostgresTriggerStore",
|
|
29
|
+
"PostgresWorkflowStore",
|
|
30
|
+
]
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 Liceron
|
|
3
|
+
"""Postgres checkpoint store for durable SDK agent runs (A6 S5).
|
|
4
|
+
|
|
5
|
+
``Runner(checkpoint_store=PostgresCheckpointStore(dsn))`` keeps the same
|
|
6
|
+
``RunCheckpointStore`` protocol as the SQLite store (checkpoints, the recovery claim with
|
|
7
|
+
fenced saves and deletes, and single-use approvals), on Postgres, plus the execution
|
|
8
|
+
journal (``PostgresExecutionStore``) in the same database: the run's fenced lease from its
|
|
9
|
+
first write, per-call intents and effect records. Lease expiry uses the database clock, so
|
|
10
|
+
several hosts can share one database. Every operation on a run is one transaction under a
|
|
11
|
+
per-run advisory lock.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from nexura_agents.durable import DEFAULT_CLAIM_LEASE_SECONDS, DEFAULT_WORKSPACE, check_fence
|
|
20
|
+
from nexura_agents.errors import ApprovalDecisionError
|
|
21
|
+
|
|
22
|
+
from nexura_postgres.execution_store import PostgresExecutionStore
|
|
23
|
+
|
|
24
|
+
_NOW = "SELECT EXTRACT(EPOCH FROM clock_timestamp())::double precision"
|
|
25
|
+
_SCHEMA = (
|
|
26
|
+
"""CREATE TABLE IF NOT EXISTS nexura_sdk_checkpoints (
|
|
27
|
+
workspace_id TEXT NOT NULL,
|
|
28
|
+
run_id TEXT NOT NULL,
|
|
29
|
+
data TEXT NOT NULL,
|
|
30
|
+
updated_at DOUBLE PRECISION NOT NULL,
|
|
31
|
+
PRIMARY KEY (workspace_id, run_id))""",
|
|
32
|
+
"""CREATE INDEX IF NOT EXISTS nexura_sdk_checkpoints_updated
|
|
33
|
+
ON nexura_sdk_checkpoints (workspace_id, updated_at, run_id)""",
|
|
34
|
+
"""CREATE TABLE IF NOT EXISTS nexura_sdk_run_claims (
|
|
35
|
+
workspace_id TEXT NOT NULL,
|
|
36
|
+
run_id TEXT NOT NULL,
|
|
37
|
+
owner TEXT NOT NULL,
|
|
38
|
+
expires_at DOUBLE PRECISION NOT NULL,
|
|
39
|
+
lease_seconds DOUBLE PRECISION NOT NULL,
|
|
40
|
+
PRIMARY KEY (workspace_id, run_id))""",
|
|
41
|
+
"""CREATE TABLE IF NOT EXISTS nexura_sdk_approvals (
|
|
42
|
+
workspace_id TEXT NOT NULL,
|
|
43
|
+
approval_id TEXT NOT NULL,
|
|
44
|
+
payload TEXT NOT NULL,
|
|
45
|
+
expires_at DOUBLE PRECISION NOT NULL,
|
|
46
|
+
consumed_at DOUBLE PRECISION,
|
|
47
|
+
PRIMARY KEY (workspace_id, approval_id))""",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PostgresCheckpointStore:
|
|
52
|
+
"""Durable, multi-host checkpoint store for ``Runner(checkpoint_store=...)``.
|
|
53
|
+
|
|
54
|
+
Give a DSN (a pool is created on first use and closed by ``close()``) or an asyncpg
|
|
55
|
+
pool you own. Tables are created on first use (or call ``init_db()``). Rows are scoped
|
|
56
|
+
by ``workspace_id``. Checkpoints hold the full transcript: protect the database like
|
|
57
|
+
session data. An asyncpg pool belongs to one event loop; use the store from one loop.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self, dsn: str | None = None, *, pool: Any = None, workspace_id: str = DEFAULT_WORKSPACE
|
|
62
|
+
) -> None:
|
|
63
|
+
if dsn is None and pool is None:
|
|
64
|
+
raise ValueError("Provide a Postgres DSN or pool")
|
|
65
|
+
if not isinstance(workspace_id, str) or not workspace_id:
|
|
66
|
+
raise ValueError("workspace_id must be a nonempty string")
|
|
67
|
+
self.workspace_id = workspace_id
|
|
68
|
+
self._dsn = dsn
|
|
69
|
+
self._pool = pool
|
|
70
|
+
self._owns_pool = pool is None
|
|
71
|
+
self._lock = asyncio.Lock()
|
|
72
|
+
self._ready = False
|
|
73
|
+
self._journal: PostgresExecutionStore | None = None
|
|
74
|
+
self._journal_ready = False
|
|
75
|
+
|
|
76
|
+
async def _get_pool(self) -> Any:
|
|
77
|
+
async with self._lock:
|
|
78
|
+
if self._pool is None:
|
|
79
|
+
import asyncpg # type: ignore[import-untyped]
|
|
80
|
+
|
|
81
|
+
self._pool = await asyncpg.create_pool(self._dsn, min_size=1, max_size=5)
|
|
82
|
+
if not self._ready:
|
|
83
|
+
async with self._pool.acquire() as conn, conn.transaction():
|
|
84
|
+
# Concurrent first use from several processes: create tables once.
|
|
85
|
+
await conn.execute("SELECT pg_advisory_xact_lock(734658102)")
|
|
86
|
+
for statement in _SCHEMA:
|
|
87
|
+
await conn.execute(statement)
|
|
88
|
+
self._ready = True
|
|
89
|
+
return self._pool
|
|
90
|
+
|
|
91
|
+
async def init_db(self) -> None:
|
|
92
|
+
"""Create the checkpoint tables and apply the execution journal's migrations."""
|
|
93
|
+
await self._get_pool()
|
|
94
|
+
await self.open_execution_store()
|
|
95
|
+
|
|
96
|
+
async def open_execution_store(self) -> PostgresExecutionStore:
|
|
97
|
+
"""The execution journal on the same pool (migrated on first use)."""
|
|
98
|
+
pool = await self._get_pool()
|
|
99
|
+
if self._journal is None:
|
|
100
|
+
self._journal = PostgresExecutionStore(pool=pool)
|
|
101
|
+
if not self._journal_ready:
|
|
102
|
+
await self._journal.init_db()
|
|
103
|
+
self._journal_ready = True
|
|
104
|
+
return self._journal
|
|
105
|
+
|
|
106
|
+
async def _run_lock(self, conn: Any, run_id: str) -> float:
|
|
107
|
+
await conn.execute(
|
|
108
|
+
"SELECT pg_advisory_xact_lock(hashtextextended($1::text || '/' || $2::text, 0))",
|
|
109
|
+
self.workspace_id,
|
|
110
|
+
run_id,
|
|
111
|
+
)
|
|
112
|
+
now: float = await conn.fetchval(_NOW)
|
|
113
|
+
return now
|
|
114
|
+
|
|
115
|
+
async def _held(self, conn: Any, run_id: str) -> Any:
|
|
116
|
+
return await conn.fetchrow(
|
|
117
|
+
"SELECT owner, expires_at, lease_seconds FROM nexura_sdk_run_claims "
|
|
118
|
+
"WHERE workspace_id = $1 AND run_id = $2",
|
|
119
|
+
self.workspace_id,
|
|
120
|
+
run_id,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------ checkpoints
|
|
124
|
+
|
|
125
|
+
async def save(self, run_id: str, checkpoint_json: str, owner: str | None = None) -> None:
|
|
126
|
+
"""Store the checkpoint of ``run_id``; fenced by the recovery claim in one transaction."""
|
|
127
|
+
pool = await self._get_pool()
|
|
128
|
+
async with pool.acquire() as conn, conn.transaction():
|
|
129
|
+
now = await self._run_lock(conn, run_id)
|
|
130
|
+
row = await self._held(conn, run_id)
|
|
131
|
+
check_fence(
|
|
132
|
+
run_id,
|
|
133
|
+
owner,
|
|
134
|
+
None if row is None else (str(row["owner"]), float(row["expires_at"])),
|
|
135
|
+
now=now,
|
|
136
|
+
)
|
|
137
|
+
await conn.execute(
|
|
138
|
+
"INSERT INTO nexura_sdk_checkpoints (workspace_id, run_id, data, updated_at) "
|
|
139
|
+
"VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, run_id) DO UPDATE "
|
|
140
|
+
"SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at",
|
|
141
|
+
self.workspace_id,
|
|
142
|
+
run_id,
|
|
143
|
+
checkpoint_json,
|
|
144
|
+
now,
|
|
145
|
+
)
|
|
146
|
+
if row is not None and owner is not None:
|
|
147
|
+
await conn.execute(
|
|
148
|
+
"UPDATE nexura_sdk_run_claims SET expires_at = $3 "
|
|
149
|
+
"WHERE workspace_id = $1 AND run_id = $2 AND owner = $4",
|
|
150
|
+
self.workspace_id,
|
|
151
|
+
run_id,
|
|
152
|
+
now + float(row["lease_seconds"]),
|
|
153
|
+
owner,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def load(self, run_id: str) -> str | None:
|
|
157
|
+
"""Return the checkpoint of ``run_id`` or None."""
|
|
158
|
+
pool = await self._get_pool()
|
|
159
|
+
async with pool.acquire() as conn:
|
|
160
|
+
value = await conn.fetchval(
|
|
161
|
+
"SELECT data FROM nexura_sdk_checkpoints WHERE workspace_id = $1 AND run_id = $2",
|
|
162
|
+
self.workspace_id,
|
|
163
|
+
run_id,
|
|
164
|
+
)
|
|
165
|
+
return None if value is None else str(value)
|
|
166
|
+
|
|
167
|
+
async def delete(self, run_id: str, owner: str | None = None, *, fenced: bool = False) -> None:
|
|
168
|
+
"""Remove the checkpoint and claim of ``run_id`` (``fenced``: only if not stale)."""
|
|
169
|
+
pool = await self._get_pool()
|
|
170
|
+
async with pool.acquire() as conn, conn.transaction():
|
|
171
|
+
now = await self._run_lock(conn, run_id)
|
|
172
|
+
if fenced:
|
|
173
|
+
row = await self._held(conn, run_id)
|
|
174
|
+
check_fence(
|
|
175
|
+
run_id,
|
|
176
|
+
owner,
|
|
177
|
+
None if row is None else (str(row["owner"]), float(row["expires_at"])),
|
|
178
|
+
now=now,
|
|
179
|
+
)
|
|
180
|
+
for table in ("nexura_sdk_checkpoints", "nexura_sdk_run_claims"):
|
|
181
|
+
await conn.execute(
|
|
182
|
+
f"DELETE FROM {table} WHERE workspace_id = $1 AND run_id = $2", # noqa: S608
|
|
183
|
+
self.workspace_id,
|
|
184
|
+
run_id,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def list_incomplete(self, limit: int = 100) -> list[str]:
|
|
188
|
+
"""Run ids with a checkpoint, oldest update first."""
|
|
189
|
+
pool = await self._get_pool()
|
|
190
|
+
async with pool.acquire() as conn:
|
|
191
|
+
rows = await conn.fetch(
|
|
192
|
+
"SELECT run_id FROM nexura_sdk_checkpoints WHERE workspace_id = $1 "
|
|
193
|
+
"ORDER BY updated_at, run_id LIMIT $2",
|
|
194
|
+
self.workspace_id,
|
|
195
|
+
max(int(limit), 0),
|
|
196
|
+
)
|
|
197
|
+
return [str(row["run_id"]) for row in rows]
|
|
198
|
+
|
|
199
|
+
async def claim(
|
|
200
|
+
self, run_id: str, owner: str, lease_seconds: float = DEFAULT_CLAIM_LEASE_SECONDS
|
|
201
|
+
) -> bool:
|
|
202
|
+
"""Take the exclusive recovery claim of ``run_id`` (database clock); False when held."""
|
|
203
|
+
pool = await self._get_pool()
|
|
204
|
+
async with pool.acquire() as conn, conn.transaction():
|
|
205
|
+
now = await self._run_lock(conn, run_id)
|
|
206
|
+
row = await self._held(conn, run_id)
|
|
207
|
+
if row is not None and float(row["expires_at"]) > now and row["owner"] != owner:
|
|
208
|
+
return False
|
|
209
|
+
await conn.execute(
|
|
210
|
+
"INSERT INTO nexura_sdk_run_claims "
|
|
211
|
+
"(workspace_id, run_id, owner, expires_at, lease_seconds) "
|
|
212
|
+
"VALUES ($1, $2, $3, $4, $5) ON CONFLICT (workspace_id, run_id) DO UPDATE "
|
|
213
|
+
"SET owner = EXCLUDED.owner, expires_at = EXCLUDED.expires_at, "
|
|
214
|
+
"lease_seconds = EXCLUDED.lease_seconds",
|
|
215
|
+
self.workspace_id,
|
|
216
|
+
run_id,
|
|
217
|
+
owner,
|
|
218
|
+
now + float(lease_seconds),
|
|
219
|
+
float(lease_seconds),
|
|
220
|
+
)
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
async def release(self, run_id: str, owner: str) -> None:
|
|
224
|
+
"""Give the claim back (no-op when ``owner`` does not hold it)."""
|
|
225
|
+
pool = await self._get_pool()
|
|
226
|
+
async with pool.acquire() as conn:
|
|
227
|
+
await conn.execute(
|
|
228
|
+
"DELETE FROM nexura_sdk_run_claims "
|
|
229
|
+
"WHERE workspace_id = $1 AND run_id = $2 AND owner = $3",
|
|
230
|
+
self.workspace_id,
|
|
231
|
+
run_id,
|
|
232
|
+
owner,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# ------------------------------------------------------------------ approvals
|
|
236
|
+
|
|
237
|
+
async def save_approval(self, approval_id: str, payload: str, expires_at: float) -> None:
|
|
238
|
+
"""Persist an immutable approval independently of run checkpoint cleanup."""
|
|
239
|
+
pool = await self._get_pool()
|
|
240
|
+
async with pool.acquire() as conn, conn.transaction():
|
|
241
|
+
await conn.execute(
|
|
242
|
+
"INSERT INTO nexura_sdk_approvals (workspace_id, approval_id, payload, expires_at) "
|
|
243
|
+
"VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, approval_id) DO NOTHING",
|
|
244
|
+
self.workspace_id,
|
|
245
|
+
approval_id,
|
|
246
|
+
payload,
|
|
247
|
+
float(expires_at),
|
|
248
|
+
)
|
|
249
|
+
row = await conn.fetchrow(
|
|
250
|
+
"SELECT payload, expires_at FROM nexura_sdk_approvals "
|
|
251
|
+
"WHERE workspace_id = $1 AND approval_id = $2",
|
|
252
|
+
self.workspace_id,
|
|
253
|
+
approval_id,
|
|
254
|
+
)
|
|
255
|
+
if row is not None and (row["payload"], float(row["expires_at"])) != (
|
|
256
|
+
payload,
|
|
257
|
+
float(expires_at),
|
|
258
|
+
):
|
|
259
|
+
raise ApprovalDecisionError("Stored interruption differs from approval envelope.")
|
|
260
|
+
|
|
261
|
+
async def load_approval(self, approval_id: str) -> str | None:
|
|
262
|
+
"""Load the original approval payload, including after consumption."""
|
|
263
|
+
pool = await self._get_pool()
|
|
264
|
+
async with pool.acquire() as conn:
|
|
265
|
+
value = await conn.fetchval(
|
|
266
|
+
"SELECT payload FROM nexura_sdk_approvals "
|
|
267
|
+
"WHERE workspace_id = $1 AND approval_id = $2",
|
|
268
|
+
self.workspace_id,
|
|
269
|
+
approval_id,
|
|
270
|
+
)
|
|
271
|
+
return None if value is None else str(value)
|
|
272
|
+
|
|
273
|
+
async def consume_approval(self, approval_id: str, payload: str) -> bool:
|
|
274
|
+
"""Consume a matching unexpired approval once, atomically across hosts."""
|
|
275
|
+
pool = await self._get_pool()
|
|
276
|
+
async with pool.acquire() as conn:
|
|
277
|
+
result: str = await conn.execute(
|
|
278
|
+
"UPDATE nexura_sdk_approvals "
|
|
279
|
+
"SET consumed_at = EXTRACT(EPOCH FROM clock_timestamp()) "
|
|
280
|
+
"WHERE workspace_id = $1 AND approval_id = $2 AND payload = $3 "
|
|
281
|
+
"AND consumed_at IS NULL "
|
|
282
|
+
"AND expires_at > EXTRACT(EPOCH FROM clock_timestamp())",
|
|
283
|
+
self.workspace_id,
|
|
284
|
+
approval_id,
|
|
285
|
+
payload,
|
|
286
|
+
)
|
|
287
|
+
return result.rsplit(" ", 1)[-1] == "1"
|
|
288
|
+
|
|
289
|
+
async def close(self) -> None:
|
|
290
|
+
"""Close a pool this store created; a supplied pool stays open for its owner."""
|
|
291
|
+
if self._pool is not None and self._owns_pool:
|
|
292
|
+
await self._pool.close()
|
|
293
|
+
self._pool = None
|
|
294
|
+
self._ready = False
|
|
295
|
+
self._journal = None
|
|
296
|
+
self._journal_ready = False
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 Liceron
|
|
3
|
+
"""Postgres execution journal using asyncpg transactions and FOR UPDATE row locking.
|
|
4
|
+
|
|
5
|
+
Follows the repository.py transaction/CAS patterns without importing the runtime
|
|
6
|
+
or changing its older repository tables. Only the optional Postgres package needs
|
|
7
|
+
asyncpg; the shared journal logic and DDL remain in the stdlib-only SDK store.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import AsyncIterator, Mapping
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from nexura_agents.execution_store.models import ExecutionConflictError
|
|
19
|
+
from nexura_agents.execution_store.sql import _Connection, _SQLStore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _PostgresConnection:
|
|
23
|
+
def __init__(self, connection: Any) -> None:
|
|
24
|
+
self.connection = connection
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def _bind(sql: str, params: Mapping[str, Any]) -> tuple[str, list[Any]]:
|
|
28
|
+
names: list[str] = []
|
|
29
|
+
|
|
30
|
+
def bind(match: re.Match[str]) -> str:
|
|
31
|
+
name = match.group(1)
|
|
32
|
+
if name not in names:
|
|
33
|
+
names.append(name)
|
|
34
|
+
return f"${names.index(name) + 1}"
|
|
35
|
+
|
|
36
|
+
return re.sub(r":([a-z_][a-z_0-9]*)", bind, sql), [params[name] for name in names]
|
|
37
|
+
|
|
38
|
+
async def execute(self, sql: str, params: Mapping[str, Any]) -> int:
|
|
39
|
+
query, values = self._bind(sql, params)
|
|
40
|
+
result: str = await self.connection.execute(query, *values)
|
|
41
|
+
last = result.rsplit(" ", 1)[-1]
|
|
42
|
+
return int(last) if last.isdigit() else 0
|
|
43
|
+
|
|
44
|
+
async def fetch(self, sql: str, params: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
45
|
+
query, values = self._bind(sql, params)
|
|
46
|
+
return [dict(row) for row in await self.connection.fetch(query, *values)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PostgresExecutionStore(_SQLStore):
|
|
50
|
+
"""Optional durable multi-host journal, with database-clock leases and fenced writes.
|
|
51
|
+
|
|
52
|
+
Uses FOR UPDATE plus a token/owner/revision guarded UPDATE, following
|
|
53
|
+
PostgresRepository's CAS pattern. Expiry uses the server's clock_timestamp()
|
|
54
|
+
after locking; transaction-start now() could be stale after a row-lock wait.
|
|
55
|
+
Call init_db() to apply versioned migrations before first use. Supplied pools
|
|
56
|
+
remain caller-owned. DSNs must name an explicitly configured database.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
_lock_suffix = " FOR UPDATE"
|
|
60
|
+
_clock_sql = "SELECT EXTRACT(EPOCH FROM clock_timestamp()) AS now"
|
|
61
|
+
|
|
62
|
+
def __init__(self, dsn: str | None = None, *, pool: Any = None) -> None:
|
|
63
|
+
if dsn is None and pool is None:
|
|
64
|
+
raise ValueError("Provide a Postgres DSN or pool")
|
|
65
|
+
self._dsn = dsn
|
|
66
|
+
self._pool = pool
|
|
67
|
+
self._owns_pool = pool is None
|
|
68
|
+
self._pool_lock = asyncio.Lock()
|
|
69
|
+
|
|
70
|
+
async def _get_pool(self) -> Any:
|
|
71
|
+
async with self._pool_lock:
|
|
72
|
+
if self._pool is None:
|
|
73
|
+
import asyncpg # type: ignore[import-untyped]
|
|
74
|
+
|
|
75
|
+
self._pool = await asyncpg.create_pool(self._dsn, min_size=1, max_size=5)
|
|
76
|
+
return self._pool
|
|
77
|
+
|
|
78
|
+
@asynccontextmanager
|
|
79
|
+
async def _connection(self) -> AsyncIterator[_Connection]:
|
|
80
|
+
pool = await self._get_pool()
|
|
81
|
+
try:
|
|
82
|
+
async with pool.acquire() as connection, connection.transaction():
|
|
83
|
+
yield _PostgresConnection(connection)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
if getattr(exc, "sqlstate", None) == "23505":
|
|
86
|
+
raise ExecutionConflictError("Execution journal uniqueness conflict") from exc
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
async def _migration_lock(self, db: _Connection) -> None:
|
|
90
|
+
# Constant is journal-specific; lock works even before the schema exists.
|
|
91
|
+
await db.fetch("SELECT pg_advisory_xact_lock(734658101)", {})
|
|
92
|
+
|
|
93
|
+
async def close(self) -> None:
|
|
94
|
+
"""Close only a pool created by this store; supplied pools remain caller-owned."""
|
|
95
|
+
if self._pool is not None and self._owns_pool:
|
|
96
|
+
await self._pool.close()
|
|
97
|
+
self._pool = None
|