community-agent-framework-postgres 0.1.1__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.
- agent_framework_community_postgres/__init__.py +77 -0
- agent_framework_community_postgres/__main__.py +3 -0
- agent_framework_community_postgres/_checkpoint_storage.py +218 -0
- agent_framework_community_postgres/_cli.py +97 -0
- agent_framework_community_postgres/_client.py +390 -0
- agent_framework_community_postgres/_entra.py +178 -0
- agent_framework_community_postgres/_framework.py +148 -0
- agent_framework_community_postgres/_history_provider.py +261 -0
- agent_framework_community_postgres/_json.py +34 -0
- agent_framework_community_postgres/_leases.py +196 -0
- agent_framework_community_postgres/_migrations.py +235 -0
- agent_framework_community_postgres/_persistence.py +184 -0
- agent_framework_community_postgres/_retention.py +159 -0
- agent_framework_community_postgres/_session_store.py +135 -0
- agent_framework_community_postgres/_store.py +66 -0
- agent_framework_community_postgres/_thread_snapshot_store.py +162 -0
- agent_framework_community_postgres/py.typed +0 -0
- community_agent_framework_postgres-0.1.1.dist-info/METADATA +367 -0
- community_agent_framework_postgres-0.1.1.dist-info/RECORD +21 -0
- community_agent_framework_postgres-0.1.1.dist-info/WHEEL +4 -0
- community_agent_framework_postgres-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Numbered schema migrations. The list index plus one is the version, as in LangGraph's checkpointer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
|
|
10
|
+
from psycopg import sql
|
|
11
|
+
|
|
12
|
+
from ._client import ClientHandle, PostgresStorageError, TableNames
|
|
13
|
+
|
|
14
|
+
Migration = Callable[[TableNames], Sequence[sql.Composed]]
|
|
15
|
+
|
|
16
|
+
LOCK_TIMEOUT = timedelta(seconds=30)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _lock_timeout_literal(lock_timeout: timedelta) -> sql.Literal:
|
|
20
|
+
milliseconds = lock_timeout // timedelta(milliseconds=1)
|
|
21
|
+
if milliseconds < 1:
|
|
22
|
+
# PostgreSQL reads a lock_timeout of 0 as "wait forever", the opposite of what a caller means.
|
|
23
|
+
raise ValueError("lock_timeout must be at least one millisecond.")
|
|
24
|
+
return sql.Literal(f"{milliseconds}ms")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _v1(names: TableNames) -> list[sql.Composed]:
|
|
28
|
+
t = names.table
|
|
29
|
+
i = names.index
|
|
30
|
+
return [
|
|
31
|
+
sql.SQL(
|
|
32
|
+
"CREATE TABLE {history} ("
|
|
33
|
+
" id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,"
|
|
34
|
+
" application_id text NOT NULL,"
|
|
35
|
+
" tenant_id text NOT NULL DEFAULT '',"
|
|
36
|
+
" agent_id text NOT NULL DEFAULT '',"
|
|
37
|
+
" source_id text NOT NULL,"
|
|
38
|
+
" session_id text NOT NULL,"
|
|
39
|
+
" message jsonb NOT NULL,"
|
|
40
|
+
" created_at timestamptz NOT NULL DEFAULT now(),"
|
|
41
|
+
" expires_at timestamptz)"
|
|
42
|
+
).format(history=t("history_messages")),
|
|
43
|
+
sql.SQL(
|
|
44
|
+
"CREATE INDEX {idx} ON {history} (application_id, tenant_id, agent_id, source_id, session_id, id)"
|
|
45
|
+
).format(idx=i("history_messages_session_idx"), history=t("history_messages")),
|
|
46
|
+
# Serves the purge's per-session "is any row still live" probe.
|
|
47
|
+
sql.SQL(
|
|
48
|
+
"CREATE INDEX {idx} ON {history} (application_id, tenant_id, agent_id, source_id, session_id, expires_at)"
|
|
49
|
+
).format(idx=i("history_messages_session_expiry_idx"), history=t("history_messages")),
|
|
50
|
+
sql.SQL("CREATE INDEX {idx} ON {history} (expires_at) WHERE expires_at IS NOT NULL").format(
|
|
51
|
+
idx=i("history_messages_expires_idx"), history=t("history_messages")
|
|
52
|
+
),
|
|
53
|
+
sql.SQL(
|
|
54
|
+
"CREATE TABLE {sessions} ("
|
|
55
|
+
" application_id text NOT NULL,"
|
|
56
|
+
" session_id text NOT NULL,"
|
|
57
|
+
" snapshot jsonb,"
|
|
58
|
+
" snapshot_version text NOT NULL,"
|
|
59
|
+
" created_at timestamptz NOT NULL DEFAULT now(),"
|
|
60
|
+
" updated_at timestamptz NOT NULL DEFAULT now(),"
|
|
61
|
+
" expires_at timestamptz,"
|
|
62
|
+
" purged_at timestamptz,"
|
|
63
|
+
" PRIMARY KEY (application_id, session_id))"
|
|
64
|
+
).format(sessions=t("sessions")),
|
|
65
|
+
sql.SQL("CREATE INDEX {idx} ON {sessions} (expires_at) WHERE expires_at IS NOT NULL").format(
|
|
66
|
+
idx=i("sessions_expires_idx"), sessions=t("sessions")
|
|
67
|
+
),
|
|
68
|
+
# "Latest" is write order within a scope (sequence), not the producer's clock; see docs/checkpoints.md.
|
|
69
|
+
sql.SQL(
|
|
70
|
+
"CREATE TABLE {checkpoints} ("
|
|
71
|
+
" application_id text NOT NULL,"
|
|
72
|
+
" tenant_id text NOT NULL DEFAULT '',"
|
|
73
|
+
" scope text NOT NULL DEFAULT '',"
|
|
74
|
+
" payload_format text NOT NULL DEFAULT 'agent-framework.python.checkpoint.v1',"
|
|
75
|
+
" workflow_name text NOT NULL,"
|
|
76
|
+
" checkpoint_id text NOT NULL,"
|
|
77
|
+
" previous_checkpoint_id text,"
|
|
78
|
+
" checkpoint_timestamp timestamptz NOT NULL,"
|
|
79
|
+
" sequence bigint GENERATED ALWAYS AS IDENTITY,"
|
|
80
|
+
" payload jsonb NOT NULL,"
|
|
81
|
+
" created_at timestamptz NOT NULL DEFAULT now(),"
|
|
82
|
+
" expires_at timestamptz,"
|
|
83
|
+
" PRIMARY KEY (application_id, tenant_id, scope, checkpoint_id))"
|
|
84
|
+
).format(checkpoints=t("checkpoints")),
|
|
85
|
+
sql.SQL(
|
|
86
|
+
"CREATE INDEX {idx} ON {checkpoints} (application_id, tenant_id, scope, workflow_name, sequence)"
|
|
87
|
+
).format(idx=i("checkpoints_scope_idx"), checkpoints=t("checkpoints")),
|
|
88
|
+
sql.SQL("CREATE INDEX {idx} ON {checkpoints} (expires_at) WHERE expires_at IS NOT NULL").format(
|
|
89
|
+
idx=i("checkpoints_expires_idx"), checkpoints=t("checkpoints")
|
|
90
|
+
),
|
|
91
|
+
sql.SQL(
|
|
92
|
+
"CREATE TABLE {snapshots} ("
|
|
93
|
+
" application_id text NOT NULL,"
|
|
94
|
+
" scope text NOT NULL,"
|
|
95
|
+
" thread_id text NOT NULL,"
|
|
96
|
+
" messages jsonb,"
|
|
97
|
+
" state jsonb,"
|
|
98
|
+
" interrupt jsonb,"
|
|
99
|
+
" session_state jsonb,"
|
|
100
|
+
" created_at timestamptz NOT NULL DEFAULT now(),"
|
|
101
|
+
" updated_at timestamptz NOT NULL DEFAULT now(),"
|
|
102
|
+
" expires_at timestamptz,"
|
|
103
|
+
" purged_at timestamptz,"
|
|
104
|
+
" PRIMARY KEY (application_id, scope, thread_id))"
|
|
105
|
+
).format(snapshots=t("thread_snapshots")),
|
|
106
|
+
sql.SQL("CREATE INDEX {idx} ON {snapshots} (expires_at) WHERE expires_at IS NOT NULL").format(
|
|
107
|
+
idx=i("thread_snapshots_expires_idx"), snapshots=t("thread_snapshots")
|
|
108
|
+
),
|
|
109
|
+
sql.SQL(
|
|
110
|
+
"CREATE TABLE {leases} ("
|
|
111
|
+
" application_id text NOT NULL,"
|
|
112
|
+
" resource text NOT NULL,"
|
|
113
|
+
" owner text NOT NULL,"
|
|
114
|
+
" token text NOT NULL,"
|
|
115
|
+
" expires_at timestamptz NOT NULL,"
|
|
116
|
+
" PRIMARY KEY (application_id, resource))"
|
|
117
|
+
).format(leases=t("leases")),
|
|
118
|
+
sql.SQL("CREATE INDEX {idx} ON {leases} (expires_at)").format(idx=i("leases_expires_idx"), leases=t("leases")),
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
MIGRATIONS: tuple[Migration, ...] = (_v1,)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class MigrationReport:
|
|
127
|
+
"""What ``migrate`` did: the versions it applied this run and the version now current."""
|
|
128
|
+
|
|
129
|
+
applied: tuple[int, ...]
|
|
130
|
+
current: int
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _version_table(names: TableNames) -> sql.Composed:
|
|
134
|
+
return sql.SQL(
|
|
135
|
+
"CREATE TABLE IF NOT EXISTS {migrations} ("
|
|
136
|
+
" version integer PRIMARY KEY,"
|
|
137
|
+
" applied_at timestamptz NOT NULL DEFAULT now())"
|
|
138
|
+
).format(migrations=names.table("migrations"))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def render(names: TableNames, versions: Sequence[int] | None = None) -> str:
|
|
142
|
+
"""The SQL ``migrate`` would run, as text, for review or manual application by a DBA.
|
|
143
|
+
|
|
144
|
+
Each version is one transaction (``BEGIN;`` ... ``COMMIT;``) under ``migrate``'s default
|
|
145
|
+
``lock_timeout``, as in ``migrate``. No versions render as an empty string.
|
|
146
|
+
"""
|
|
147
|
+
chosen = list(range(1, len(MIGRATIONS) + 1)) if versions is None else list(versions)
|
|
148
|
+
if any(not 1 <= version <= len(MIGRATIONS) for version in chosen):
|
|
149
|
+
raise ValueError(f"versions must be between 1 and {len(MIGRATIONS)}.")
|
|
150
|
+
if not chosen:
|
|
151
|
+
return ""
|
|
152
|
+
timeout = sql.SQL("SET LOCAL lock_timeout = {}").format(_lock_timeout_literal(LOCK_TIMEOUT)).as_string()
|
|
153
|
+
parts = [_version_table(names).as_string() + ";"]
|
|
154
|
+
for version in chosen:
|
|
155
|
+
parts.append(f"-- version {version}")
|
|
156
|
+
parts.append("BEGIN;")
|
|
157
|
+
parts.append(timeout + ";")
|
|
158
|
+
parts.extend(statement.as_string() + ";" for statement in MIGRATIONS[version - 1](names))
|
|
159
|
+
parts.append(
|
|
160
|
+
sql.SQL("INSERT INTO {migrations} (version) VALUES ({version})") # noqa: S608
|
|
161
|
+
.format(migrations=names.table("migrations"), version=sql.Literal(version))
|
|
162
|
+
.as_string()
|
|
163
|
+
+ ";"
|
|
164
|
+
)
|
|
165
|
+
parts.append("COMMIT;")
|
|
166
|
+
return "\n".join(parts) + "\n"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def current_version(client: ClientHandle, names: TableNames) -> int:
|
|
170
|
+
"""The highest applied version, or 0 when nothing has been applied."""
|
|
171
|
+
async with client.connection(transactional=False) as connection:
|
|
172
|
+
cursor = await connection.execute("SELECT to_regclass(%s)", (names.qualified("migrations"),))
|
|
173
|
+
found = await cursor.fetchone()
|
|
174
|
+
if found is None or found[0] is None:
|
|
175
|
+
return 0
|
|
176
|
+
cursor = await connection.execute(
|
|
177
|
+
sql.SQL("SELECT coalesce(max(version), 0) FROM {migrations}").format(migrations=names.table("migrations"))
|
|
178
|
+
)
|
|
179
|
+
row = await cursor.fetchone()
|
|
180
|
+
return int(row[0]) if row else 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def ensure_supported(current: int) -> None:
|
|
184
|
+
"""Raise ``PostgresStorageError`` when the database was migrated by a newer release of this package."""
|
|
185
|
+
if current > len(MIGRATIONS):
|
|
186
|
+
raise PostgresStorageError(
|
|
187
|
+
f"Database schema version {current} is newer than this package supports ({len(MIGRATIONS)});"
|
|
188
|
+
" upgrade the package."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def pending_versions(client: ClientHandle, names: TableNames) -> list[int]:
|
|
193
|
+
"""Versions ``migrate`` would apply now; raise when the database is newer than this package."""
|
|
194
|
+
current = await current_version(client, names)
|
|
195
|
+
ensure_supported(current)
|
|
196
|
+
return list(range(current + 1, len(MIGRATIONS) + 1))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def migrate(
|
|
200
|
+
client: ClientHandle, names: TableNames, *, lock_timeout: timedelta = LOCK_TIMEOUT
|
|
201
|
+
) -> MigrationReport:
|
|
202
|
+
"""Apply every pending migration, one transaction each, serialized across processes by an advisory lock.
|
|
203
|
+
|
|
204
|
+
A version's DDL runs under ``lock_timeout`` (``SET LOCAL``, after the migration lock is held), so a
|
|
205
|
+
migration never queues indefinitely behind application traffic; a timeout raises a retryable
|
|
206
|
+
``PostgresStorageError`` and a rerun resumes. Replicas waiting for another replica's migration
|
|
207
|
+
wait on the advisory lock without a timeout.
|
|
208
|
+
"""
|
|
209
|
+
lock_key = json.dumps(["agent-framework-postgres:migrations", names.schema, names.prefix])
|
|
210
|
+
timeout = _lock_timeout_literal(lock_timeout)
|
|
211
|
+
applied: list[int] = []
|
|
212
|
+
# READ COMMITTED in both blocks: a statement after the lock must see what another replica committed while
|
|
213
|
+
# this one waited, which a REPEATABLE READ snapshot taken by the lock statement itself would hide.
|
|
214
|
+
async with client.connection(read_committed=True) as connection:
|
|
215
|
+
# The lock also covers the CREATE TABLE IF NOT EXISTS, which races on pg_type when two processes start fresh.
|
|
216
|
+
await connection.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", (lock_key,))
|
|
217
|
+
await connection.execute(_version_table(names))
|
|
218
|
+
for version in await pending_versions(client, names):
|
|
219
|
+
async with client.connection(read_committed=True) as connection:
|
|
220
|
+
await connection.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", (lock_key,))
|
|
221
|
+
cursor = await connection.execute(
|
|
222
|
+
sql.SQL("SELECT 1 FROM {migrations} WHERE version = %s").format(migrations=names.table("migrations")),
|
|
223
|
+
(version,),
|
|
224
|
+
)
|
|
225
|
+
if await cursor.fetchone() is not None:
|
|
226
|
+
continue # another process applied it while we waited for the lock
|
|
227
|
+
await connection.execute(sql.SQL("SET LOCAL lock_timeout = {}").format(timeout))
|
|
228
|
+
for statement in MIGRATIONS[version - 1](names):
|
|
229
|
+
await connection.execute(statement)
|
|
230
|
+
await connection.execute(
|
|
231
|
+
sql.SQL("INSERT INTO {migrations} (version) VALUES (%s)").format(migrations=names.table("migrations")),
|
|
232
|
+
(version,),
|
|
233
|
+
)
|
|
234
|
+
applied.append(version)
|
|
235
|
+
return MigrationReport(applied=tuple(applied), current=await current_version(client, names))
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""One pool, one schema, every store: the entry point most applications want."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
8
|
+
|
|
9
|
+
from agent_framework import SecretString
|
|
10
|
+
from psycopg import sql
|
|
11
|
+
|
|
12
|
+
from ._checkpoint_storage import PostgresCheckpointStorage
|
|
13
|
+
from ._client import PostgresClient, TableNames, create_client, require_text
|
|
14
|
+
from ._entra import EntraCredential
|
|
15
|
+
from ._framework import log_untested_once
|
|
16
|
+
from ._history_provider import PostgresHistoryProvider, whole_session_expired
|
|
17
|
+
from ._leases import PostgresLeases
|
|
18
|
+
from ._migrations import LOCK_TIMEOUT, MigrationReport, current_version, migrate, pending_versions
|
|
19
|
+
from ._retention import PurgeReport, RetentionMode, RetentionPolicy, purge_rows
|
|
20
|
+
from ._session_store import PostgresSessionStore
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from ._thread_snapshot_store import PostgresAGUIThreadSnapshotStore
|
|
24
|
+
|
|
25
|
+
# Another application id, schema or prefix would escape the hub's migrations or purge; another client
|
|
26
|
+
# or credential would escape its pool and its close.
|
|
27
|
+
_HUB_OWNED = ("application_id", "client", "credential", "pool_kwargs", "schema", "table_prefix")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PostgresPersistence:
|
|
31
|
+
"""Own one connection pool and hand it to every store this package provides.
|
|
32
|
+
|
|
33
|
+
Enter it (``async with``) or call ``open()`` to open the pool up front; otherwise the
|
|
34
|
+
pool opens on first use. Stores it creates borrow the pool and never close it, and
|
|
35
|
+
once the hub is closed they refuse to run. With ``credential``, the pool signs in to Azure
|
|
36
|
+
Database for PostgreSQL with Microsoft Entra ID tokens.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
application_id: str,
|
|
43
|
+
connection_string: str | SecretString | None = None,
|
|
44
|
+
client: PostgresClient | None = None,
|
|
45
|
+
credential: EntraCredential | None = None,
|
|
46
|
+
env_file_path: str | None = None,
|
|
47
|
+
env_file_encoding: str | None = None,
|
|
48
|
+
schema: str = "public",
|
|
49
|
+
table_prefix: str = "af_",
|
|
50
|
+
retention: RetentionPolicy | None = None,
|
|
51
|
+
pool_kwargs: Mapping[str, Any] | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
log_untested_once()
|
|
54
|
+
self.application_id = require_text(application_id, "application_id")
|
|
55
|
+
self.names = TableNames(schema=schema, prefix=table_prefix)
|
|
56
|
+
self.retention = retention or RetentionPolicy()
|
|
57
|
+
self._client = create_client(
|
|
58
|
+
connection_string,
|
|
59
|
+
client=client,
|
|
60
|
+
env_file_path=env_file_path,
|
|
61
|
+
env_file_encoding=env_file_encoding,
|
|
62
|
+
credential=credential,
|
|
63
|
+
pool_kwargs=pool_kwargs,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def pool(self) -> PostgresClient:
|
|
68
|
+
"""The pool or connection every store created here shares."""
|
|
69
|
+
return self._client.client
|
|
70
|
+
|
|
71
|
+
async def open(self) -> None:
|
|
72
|
+
"""Open the owned pool (a no-op for a borrowed client)."""
|
|
73
|
+
await self._client.open()
|
|
74
|
+
|
|
75
|
+
async def close(self) -> None:
|
|
76
|
+
"""Close the owned pool."""
|
|
77
|
+
await self._client.close()
|
|
78
|
+
|
|
79
|
+
async def __aenter__(self) -> Self:
|
|
80
|
+
await self.open()
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
|
|
84
|
+
await self.close()
|
|
85
|
+
|
|
86
|
+
async def migrate(self, *, lock_timeout: timedelta = LOCK_TIMEOUT) -> MigrationReport:
|
|
87
|
+
"""Create or upgrade the schema; each version's DDL gives up after ``lock_timeout`` waiting on a lock."""
|
|
88
|
+
return await migrate(self._client, self.names, lock_timeout=lock_timeout)
|
|
89
|
+
|
|
90
|
+
async def pending_migrations(self) -> list[int]:
|
|
91
|
+
"""Versions ``migrate`` would apply."""
|
|
92
|
+
return await pending_versions(self._client, self.names)
|
|
93
|
+
|
|
94
|
+
async def current_version(self) -> int:
|
|
95
|
+
"""The schema version the database is at; 0 before the first migration."""
|
|
96
|
+
return await current_version(self._client, self.names)
|
|
97
|
+
|
|
98
|
+
def _common(self, overrides: dict[str, Any]) -> dict[str, Any]:
|
|
99
|
+
for key in _HUB_OWNED:
|
|
100
|
+
if key in overrides:
|
|
101
|
+
raise TypeError(f"{key} is set by the hub and cannot be overridden.")
|
|
102
|
+
base: dict[str, Any] = {
|
|
103
|
+
"application_id": self.application_id,
|
|
104
|
+
"client": self._client,
|
|
105
|
+
"schema": self.names.schema,
|
|
106
|
+
"table_prefix": self.names.prefix,
|
|
107
|
+
"retention": self.retention,
|
|
108
|
+
}
|
|
109
|
+
return base | overrides
|
|
110
|
+
|
|
111
|
+
def history_provider(
|
|
112
|
+
self, source_id: str = PostgresHistoryProvider.DEFAULT_SOURCE_ID, **kwargs: Any
|
|
113
|
+
) -> PostgresHistoryProvider:
|
|
114
|
+
"""A history provider on the shared pool; keyword arguments as on the class."""
|
|
115
|
+
return PostgresHistoryProvider(source_id, **self._common(kwargs))
|
|
116
|
+
|
|
117
|
+
def session_store(self, **kwargs: Any) -> PostgresSessionStore:
|
|
118
|
+
"""A session store on the shared pool."""
|
|
119
|
+
return PostgresSessionStore(**self._common(kwargs))
|
|
120
|
+
|
|
121
|
+
def checkpoint_storage(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
tenant_id: str | None = None,
|
|
125
|
+
scope: str | None = None,
|
|
126
|
+
allowed_checkpoint_types: list[str] | None = None,
|
|
127
|
+
**kwargs: Any,
|
|
128
|
+
) -> PostgresCheckpointStorage:
|
|
129
|
+
"""A checkpoint storage on the shared pool, optionally scoped to one tenant and one conversation."""
|
|
130
|
+
return PostgresCheckpointStorage(
|
|
131
|
+
tenant_id=tenant_id, scope=scope, allowed_checkpoint_types=allowed_checkpoint_types, **self._common(kwargs)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def thread_snapshot_store(self, **kwargs: Any) -> PostgresAGUIThreadSnapshotStore:
|
|
135
|
+
"""An AG-UI thread snapshot store on the shared pool (needs the ``ag-ui`` extra)."""
|
|
136
|
+
from ._thread_snapshot_store import PostgresAGUIThreadSnapshotStore
|
|
137
|
+
|
|
138
|
+
return PostgresAGUIThreadSnapshotStore(**self._common(kwargs))
|
|
139
|
+
|
|
140
|
+
def leases(self) -> PostgresLeases:
|
|
141
|
+
"""The lease table on the shared pool."""
|
|
142
|
+
return PostgresLeases(
|
|
143
|
+
application_id=self.application_id,
|
|
144
|
+
client=self._client,
|
|
145
|
+
schema=self.names.schema,
|
|
146
|
+
table_prefix=self.names.prefix,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
async def purge(self, mode: RetentionMode | None = None) -> PurgeReport:
|
|
150
|
+
"""Purge every expired row of this application in every table.
|
|
151
|
+
|
|
152
|
+
A row is expired when its ``expires_at``, stamped by whichever policy wrote it, has
|
|
153
|
+
passed; the hub needs no policy of its own. ``mode`` defaults to the hub policy's mode.
|
|
154
|
+
Each table is purged in batches, one transaction per batch. A history session is purged
|
|
155
|
+
only once every one of its messages has expired.
|
|
156
|
+
"""
|
|
157
|
+
chosen = RetentionPolicy(mode=mode).mode if mode is not None else self.retention.mode
|
|
158
|
+
where = sql.SQL("application_id = %(app)s")
|
|
159
|
+
params = {"app": self.application_id}
|
|
160
|
+
plan: list[tuple[str, sql.Composable, sql.Composable | None, bool]] = [
|
|
161
|
+
("history_messages", whole_session_expired(self.names.table("history_messages")), None, False),
|
|
162
|
+
("sessions", where, sql.SQL("snapshot = NULL"), True),
|
|
163
|
+
("checkpoints", where, None, False),
|
|
164
|
+
(
|
|
165
|
+
"thread_snapshots",
|
|
166
|
+
where,
|
|
167
|
+
sql.SQL("messages = NULL, state = NULL, interrupt = NULL, session_state = NULL"),
|
|
168
|
+
True,
|
|
169
|
+
),
|
|
170
|
+
("leases", where, None, False),
|
|
171
|
+
]
|
|
172
|
+
counts: dict[str, int] = {}
|
|
173
|
+
# One transaction per batch: row locks are never held across tables, so a long purge cannot
|
|
174
|
+
# deadlock with, say, a history trim on the same session.
|
|
175
|
+
for table, table_where, tombstone, tombstone_allowed in plan:
|
|
176
|
+
counts[f"{self.names.prefix}{table}"] = await purge_rows(
|
|
177
|
+
self._client,
|
|
178
|
+
table=self.names.table(table),
|
|
179
|
+
where=table_where,
|
|
180
|
+
params=params,
|
|
181
|
+
mode=chosen if tombstone_allowed else "delete",
|
|
182
|
+
tombstone=tombstone,
|
|
183
|
+
)
|
|
184
|
+
return PurgeReport(counts)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Retention: an optional time-to-live per store and the purge that acts on it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal, NoReturn, Self, cast
|
|
9
|
+
|
|
10
|
+
from psycopg import sql
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ._client import ClientHandle
|
|
14
|
+
|
|
15
|
+
RetentionMode = Literal["tombstone", "delete"]
|
|
16
|
+
|
|
17
|
+
EXPIRES_AT = sql.SQL("CASE WHEN %(ttl)s::interval IS NULL THEN NULL ELSE now() + %(ttl)s::interval END")
|
|
18
|
+
"""Value for an ``expires_at`` column on write; pass ``ttl`` (``timedelta | None``) in the parameters."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class RetentionPolicy:
|
|
23
|
+
"""How long rows live after their last write and what ``purge`` does to expired ones.
|
|
24
|
+
|
|
25
|
+
``ttl`` of ``None`` (the default) means nothing ever expires. ``tombstone`` keeps
|
|
26
|
+
the row with its ids and timestamps and drops the payload;
|
|
27
|
+
``delete`` removes the row. History messages and checkpoints are always deleted, and so are
|
|
28
|
+
expired leases, which carry no policy.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
ttl: timedelta | None = None
|
|
32
|
+
mode: RetentionMode = "tombstone"
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
if self.ttl is not None and self.ttl <= timedelta(0):
|
|
36
|
+
raise ValueError("ttl must be a positive timedelta or None.")
|
|
37
|
+
if self.mode not in ("tombstone", "delete"):
|
|
38
|
+
raise ValueError("mode must be 'tombstone' or 'delete'.")
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def enabled(self) -> bool:
|
|
42
|
+
"""Whether writes set ``expires_at``."""
|
|
43
|
+
return self.ttl is not None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _ReadOnlyCounts(Mapping[str, int]):
|
|
47
|
+
"""A read-only mapping over a private ``dict``; unlike a ``dict`` subclass, no ``dict`` method can change it.
|
|
48
|
+
|
|
49
|
+
It is built in ``__new__``, so calling ``__init__`` again changes nothing. ``deepcopy`` returns a plain
|
|
50
|
+
``dict``, which is what ``dataclasses.asdict`` puts under ``counts``; ``PurgeReport`` copies itself through
|
|
51
|
+
its own ``__reduce__``, so a copied report stays read-only.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
__slots__ = ("_data",)
|
|
55
|
+
_data: dict[str, int]
|
|
56
|
+
|
|
57
|
+
def __new__(cls, data: Mapping[str, int]) -> Self:
|
|
58
|
+
self = super().__new__(cls)
|
|
59
|
+
object.__setattr__(self, "_data", dict(data))
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __setattr__(self, name: str, value: object) -> NoReturn:
|
|
63
|
+
raise TypeError("PurgeReport.counts is read-only.")
|
|
64
|
+
|
|
65
|
+
def __getitem__(self, key: str) -> int:
|
|
66
|
+
return self._data[key]
|
|
67
|
+
|
|
68
|
+
def __iter__(self) -> Iterator[str]:
|
|
69
|
+
return iter(self._data)
|
|
70
|
+
|
|
71
|
+
def __len__(self) -> int:
|
|
72
|
+
return len(self._data)
|
|
73
|
+
|
|
74
|
+
def __repr__(self) -> str:
|
|
75
|
+
return repr(self._data)
|
|
76
|
+
|
|
77
|
+
def __eq__(self, other: object) -> bool:
|
|
78
|
+
if isinstance(other, Mapping):
|
|
79
|
+
return self._data == dict(cast("Mapping[object, object]", other))
|
|
80
|
+
return NotImplemented
|
|
81
|
+
|
|
82
|
+
__hash__ = None # type: ignore[assignment] # equal to a dict, so unhashable like one
|
|
83
|
+
|
|
84
|
+
def __reduce__(self) -> tuple[type[_ReadOnlyCounts], tuple[dict[str, int]]]:
|
|
85
|
+
return (_ReadOnlyCounts, (dict(self._data),))
|
|
86
|
+
|
|
87
|
+
def __deepcopy__(self, memo: dict[int, object]) -> dict[str, int]:
|
|
88
|
+
return dict(self._data)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class PurgeReport:
|
|
93
|
+
"""Rows affected per table by one purge; ``counts`` is a read-only copy. ``sum(reports)`` works."""
|
|
94
|
+
|
|
95
|
+
counts: Mapping[str, int] = field(default_factory=dict[str, int])
|
|
96
|
+
|
|
97
|
+
def __post_init__(self) -> None:
|
|
98
|
+
object.__setattr__(self, "counts", _ReadOnlyCounts(self.counts))
|
|
99
|
+
|
|
100
|
+
def __reduce__(self) -> tuple[type[PurgeReport], tuple[dict[str, int]]]:
|
|
101
|
+
# Pickle, copy and deepcopy rebuild the report from a plain dict, so the copy is read-only too.
|
|
102
|
+
return (PurgeReport, (dict(self.counts),))
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def total(self) -> int:
|
|
106
|
+
return sum(self.counts.values())
|
|
107
|
+
|
|
108
|
+
def __add__(self, other: PurgeReport) -> PurgeReport:
|
|
109
|
+
merged = dict(self.counts)
|
|
110
|
+
for table, count in other.counts.items():
|
|
111
|
+
merged[table] = merged.get(table, 0) + count
|
|
112
|
+
return PurgeReport(merged)
|
|
113
|
+
|
|
114
|
+
def __radd__(self, other: int) -> PurgeReport:
|
|
115
|
+
if other == 0: # the start value of sum()
|
|
116
|
+
return self
|
|
117
|
+
return NotImplemented
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def purge_rows(
|
|
121
|
+
client: ClientHandle,
|
|
122
|
+
*,
|
|
123
|
+
table: sql.Identifier,
|
|
124
|
+
where: sql.Composable,
|
|
125
|
+
params: Mapping[str, Any],
|
|
126
|
+
mode: RetentionMode,
|
|
127
|
+
tombstone: sql.Composable | None,
|
|
128
|
+
batch_size: int = 1000,
|
|
129
|
+
) -> int:
|
|
130
|
+
"""Tombstone or delete the expired rows matching ``where``, ``batch_size`` rows per transaction.
|
|
131
|
+
|
|
132
|
+
``where`` may qualify columns with ``c``, the candidate row's alias. Rows another purge holds
|
|
133
|
+
are skipped and taken by a later batch, so two purges never block each other.
|
|
134
|
+
"""
|
|
135
|
+
if batch_size < 1:
|
|
136
|
+
raise ValueError("batch_size must be at least 1.")
|
|
137
|
+
if mode == "tombstone" and tombstone is not None:
|
|
138
|
+
candidates = sql.SQL(
|
|
139
|
+
"SELECT c.ctid FROM {table} c WHERE ({where}) AND c.expires_at < now() AND c.purged_at IS NULL"
|
|
140
|
+
" LIMIT %(batch)s FOR UPDATE SKIP LOCKED"
|
|
141
|
+
)
|
|
142
|
+
statement = sql.SQL("UPDATE {table} SET {tombstone}, purged_at = now() WHERE ctid IN ({candidates})")
|
|
143
|
+
else:
|
|
144
|
+
candidates = sql.SQL(
|
|
145
|
+
"SELECT c.ctid FROM {table} c WHERE ({where}) AND c.expires_at < now()"
|
|
146
|
+
" LIMIT %(batch)s FOR UPDATE SKIP LOCKED"
|
|
147
|
+
)
|
|
148
|
+
statement = sql.SQL("DELETE FROM {table} WHERE ctid IN ({candidates})")
|
|
149
|
+
composed = statement.format(
|
|
150
|
+
table=table, tombstone=tombstone or sql.SQL(""), candidates=candidates.format(table=table, where=where)
|
|
151
|
+
)
|
|
152
|
+
total = 0
|
|
153
|
+
while True:
|
|
154
|
+
async with client.connection() as connection:
|
|
155
|
+
cursor = await connection.execute(composed, dict(params) | {"batch": batch_size})
|
|
156
|
+
count = cursor.rowcount
|
|
157
|
+
total += count
|
|
158
|
+
if count < batch_size:
|
|
159
|
+
return total
|