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.
@@ -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