Skip to content

Commit a51269a

Browse files
derekhigginsclaude
andauthored
perf(storage): use asyncpg connection pool in PostgreSQL kvstore (#5734)
## Summary - Replace per-operation `asyncpg.connect()`/`close()` with `asyncpg.create_pool()` to avoid TCP handshake and auth overhead on every get/set/delete call - Add retry-on-connection-error that catches `ConnectionDoesNotExistError`/`OSError`, expires stale pool connections, and retries once - Add `pool_size` and `max_overflow` config fields to `PostgresKVStoreConfig`, matching `PostgresSqlStoreConfig` naming conventions - Add integration test that restarts postgres mid-session to verify recovery - Add kvstore-related path triggers to the SqlStore CI workflow ## Test plan - [x] Unit tests pass: `uv run pytest tests/unit/utils/kvstore/test_postgres_kvstore.py -x --tb=short` (18 passed, 3 xfailed) - [x] Integration test `test_kvstore_recovers_after_postgres_restart` passes in CI - [x] CI green on fork Signed-off-by: Derek Higgins <derekh@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 67e038c commit a51269a

6 files changed

Lines changed: 233 additions & 68 deletions

File tree

.github/workflows/integration-sql-store-tests.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ on:
1313
- 'release-[0-9]+.[0-9]+.x'
1414
paths:
1515
- 'src/ogx/providers/utils/sqlstore/**'
16+
- 'src/ogx/core/storage/kvstore/postgres/**'
1617
- 'tests/integration/sqlstore/**'
18+
- 'tests/integration/providers/utils/kvstore/**'
1719
- 'uv.lock'
1820
- 'pyproject.toml'
1921
- 'requirements.txt'
@@ -70,6 +72,17 @@ jobs:
7072
run: |
7173
uv run pytest -sv tests/integration/providers/utils/sqlstore/
7274
75+
- name: Run KVStore Connection Resilience Test
76+
env:
77+
ENABLE_POSTGRES_TESTS: "true"
78+
POSTGRES_HOST: localhost
79+
POSTGRES_PORT: 5432
80+
POSTGRES_DB: ogx
81+
POSTGRES_USER: ogx
82+
POSTGRES_PASSWORD: ogx
83+
run: |
84+
uv run pytest -sv tests/integration/providers/utils/kvstore/
85+
7386
- name: Upload test logs
7487
if: ${{ always() }}
7588
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

src/ogx/core/storage/datatypes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ class PostgresKVStoreConfig(CommonConfig):
9292
ssl_mode: str | None = None
9393
ca_cert_path: str | None = None
9494
table_name: str = "ogx_kvstore"
95+
pool_size: int = Field(default=5, ge=1, description="Number of persistent connections in the pool")
96+
max_overflow: int = Field(default=10, ge=0, description="Max additional connections beyond pool_size")
9597

9698
@classmethod
9799
def sample_run_config(cls, table_name: str = "ogx_kvstore", **kwargs: object) -> dict[str, str]:

src/ogx/core/storage/kvstore/postgres/postgres.py

Lines changed: 73 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
# This source code is licensed under the terms described in the LICENSE file in
55
# the root directory of this source tree.
66

7+
import asyncio
8+
from collections.abc import Callable, Coroutine
79
from datetime import datetime
10+
from typing import TypeVar
811

912
import asyncpg # type: ignore[import-untyped]
1013

@@ -15,12 +18,16 @@
1518

1619
log = get_logger(name=__name__, category="providers::utils")
1720

21+
T = TypeVar("T")
22+
1823

1924
class PostgresKVStoreImpl(KVStore):
2025
"""PostgreSQL-backed key-value store implementation."""
2126

2227
def __init__(self, config: PostgresKVStoreConfig):
2328
self.config = config
29+
self._pool: asyncpg.Pool | None = None
30+
self._loop: asyncio.AbstractEventLoop | None = None
2431
self._table_created = False
2532

2633
async def initialize(self) -> None:
@@ -35,22 +42,34 @@ def _build_ssl(self) -> object:
3542
return self.config.ssl_mode
3643
return None
3744

38-
async def _connect(self) -> asyncpg.Connection:
39-
try:
40-
conn = await asyncpg.connect(
41-
host=self.config.host,
42-
port=int(self.config.port),
43-
database=self.config.db,
44-
user=self.config.user,
45-
password=self.config.password,
46-
ssl=self._build_ssl(),
47-
)
48-
except Exception as e:
49-
log.exception("Could not connect to PostgreSQL database server")
50-
raise RuntimeError("Could not connect to PostgreSQL database server") from e
45+
async def _acquire(self) -> asyncpg.Pool:
46+
loop = asyncio.get_running_loop()
47+
if self._pool is not None and self._loop is not loop:
48+
# Pool was created in a different event loop (e.g., during init in a
49+
# temporary asyncio.run() loop). Discard it -- the old connections are
50+
# already dead since that loop is closed.
51+
self._pool = None
52+
self._table_created = False
5153

52-
if not self._table_created:
54+
if self._pool is None:
5355
try:
56+
self._pool = await asyncpg.create_pool(
57+
host=self.config.host,
58+
port=int(self.config.port),
59+
database=self.config.db,
60+
user=self.config.user,
61+
password=self.config.password,
62+
ssl=self._build_ssl(),
63+
min_size=self.config.pool_size,
64+
max_size=self.config.pool_size + self.config.max_overflow,
65+
)
66+
self._loop = loop
67+
except Exception as e:
68+
log.exception("Could not connect to PostgreSQL database server")
69+
raise RuntimeError("Could not connect to PostgreSQL database server") from e
70+
71+
if not self._table_created:
72+
async with self._pool.acquire() as conn:
5473
await conn.execute(
5574
f"""
5675
CREATE TABLE IF NOT EXISTS {self.config.table_name} (
@@ -61,10 +80,25 @@ async def _connect(self) -> asyncpg.Connection:
6180
"""
6281
)
6382
self._table_created = True
64-
except Exception:
65-
await conn.close()
66-
raise
67-
return conn
83+
84+
return self._pool
85+
86+
async def _execute_with_retry(self, fn: Callable[[asyncpg.Connection], Coroutine[None, None, T]]) -> T:
87+
"""Execute fn with a pooled connection, retrying once on connection error."""
88+
pool = await self._acquire()
89+
try:
90+
async with pool.acquire() as conn:
91+
return await fn(conn)
92+
except (
93+
asyncpg.exceptions.ConnectionDoesNotExistError,
94+
asyncpg.exceptions.InterfaceError,
95+
OSError,
96+
RuntimeError,
97+
):
98+
log.warning("PostgreSQL connection lost, expiring pool connections")
99+
await pool.expire_connections()
100+
async with pool.acquire() as conn:
101+
return await fn(conn)
68102

69103
def _namespaced_key(self, key: str) -> str:
70104
if not self.config.namespace:
@@ -78,8 +112,8 @@ def _strip_namespace(self, key: str) -> str:
78112

79113
async def set(self, key: str, value: str, expiration: datetime | None = None) -> None:
80114
key = self._namespaced_key(key)
81-
conn = await self._connect()
82-
try:
115+
116+
async def _do(conn: asyncpg.Connection) -> None:
83117
await conn.execute(
84118
f"""
85119
INSERT INTO {self.config.table_name} (key, value, expiration)
@@ -91,13 +125,13 @@ async def set(self, key: str, value: str, expiration: datetime | None = None) ->
91125
value,
92126
expiration,
93127
)
94-
finally:
95-
await conn.close()
128+
129+
await self._execute_with_retry(_do)
96130

97131
async def get(self, key: str) -> str | None:
98132
key = self._namespaced_key(key)
99-
conn = await self._connect()
100-
try:
133+
134+
async def _do(conn: asyncpg.Connection) -> str | None:
101135
row = await conn.fetchrow(
102136
f"""
103137
SELECT value FROM {self.config.table_name}
@@ -107,26 +141,25 @@ async def get(self, key: str) -> str | None:
107141
key,
108142
)
109143
return row["value"] if row else None
110-
finally:
111-
await conn.close()
144+
145+
return await self._execute_with_retry(_do)
112146

113147
async def delete(self, key: str) -> None:
114148
key = self._namespaced_key(key)
115-
conn = await self._connect()
116-
try:
149+
150+
async def _do(conn: asyncpg.Connection) -> None:
117151
await conn.execute(
118152
f"DELETE FROM {self.config.table_name} WHERE key = $1",
119153
key,
120154
)
121-
finally:
122-
await conn.close()
155+
156+
await self._execute_with_retry(_do)
123157

124158
async def values_in_range(self, start_key: str, end_key: str) -> list[str]:
125159
start_key = self._namespaced_key(start_key)
126160
end_key = self._namespaced_key(end_key)
127161

128-
conn = await self._connect()
129-
try:
162+
async def _do(conn: asyncpg.Connection) -> list[str]:
130163
rows = await conn.fetch(
131164
f"""
132165
SELECT value FROM {self.config.table_name}
@@ -138,15 +171,14 @@ async def values_in_range(self, start_key: str, end_key: str) -> list[str]:
138171
end_key,
139172
)
140173
return [row["value"] for row in rows]
141-
finally:
142-
await conn.close()
174+
175+
return await self._execute_with_retry(_do)
143176

144177
async def keys_in_range(self, start_key: str, end_key: str) -> list[str]:
145178
start_key = self._namespaced_key(start_key)
146179
end_key = self._namespaced_key(end_key)
147180

148-
conn = await self._connect()
149-
try:
181+
async def _do(conn: asyncpg.Connection) -> list[str]:
150182
rows = await conn.fetch(
151183
f"""
152184
SELECT key FROM {self.config.table_name}
@@ -158,8 +190,10 @@ async def keys_in_range(self, start_key: str, end_key: str) -> list[str]:
158190
end_key,
159191
)
160192
return [self._strip_namespace(row["key"]) for row in rows]
161-
finally:
162-
await conn.close()
193+
194+
return await self._execute_with_retry(_do)
163195

164196
async def shutdown(self) -> None:
165-
pass
197+
if self._pool:
198+
await self._pool.close()
199+
self._pool = None
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copyright (c) The OGX Contributors.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Copyright (c) The OGX Contributors.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
import os
8+
import subprocess
9+
import time
10+
11+
import pytest
12+
13+
from ogx.core.storage.kvstore.config import PostgresKVStoreConfig
14+
from ogx.core.storage.kvstore.postgres.postgres import PostgresKVStoreImpl
15+
16+
POSTGRES_ENABLED = os.environ.get("ENABLE_POSTGRES_TESTS", "").lower() == "true"
17+
18+
pytestmark = pytest.mark.skipif(
19+
not POSTGRES_ENABLED,
20+
reason="PostgreSQL tests disabled (set ENABLE_POSTGRES_TESTS=true)",
21+
)
22+
23+
24+
def get_postgres_config():
25+
return PostgresKVStoreConfig(
26+
host=os.environ.get("POSTGRES_HOST", "localhost"),
27+
port=int(os.environ.get("POSTGRES_PORT", "5432")),
28+
db=os.environ.get("POSTGRES_DB", "ogx"),
29+
user=os.environ.get("POSTGRES_USER", "ogx"),
30+
password=os.environ.get("POSTGRES_PASSWORD", "ogx"),
31+
table_name="test_kvstore_resilience",
32+
)
33+
34+
35+
def _find_postgres_container():
36+
"""Find the running postgres container ID."""
37+
result = subprocess.run(
38+
["docker", "ps", "-q", "--filter", "ancestor=postgres:15"],
39+
capture_output=True,
40+
text=True,
41+
)
42+
container_id = result.stdout.strip()
43+
if not container_id:
44+
# Fallback: match any postgres container
45+
result = subprocess.run(
46+
["docker", "ps", "-q", "--filter", "name=postgres"],
47+
capture_output=True,
48+
text=True,
49+
)
50+
container_id = result.stdout.strip()
51+
if not container_id:
52+
pytest.skip("No postgres container found to restart")
53+
# Take first if multiple
54+
return container_id.split("\n")[0]
55+
56+
57+
def _restart_postgres(container_id: str, timeout: int = 30):
58+
"""Restart the postgres container and wait for it to accept connections."""
59+
subprocess.run(["docker", "restart", container_id], check=True)
60+
deadline = time.time() + timeout
61+
while time.time() < deadline:
62+
result = subprocess.run(
63+
[
64+
"docker",
65+
"exec",
66+
container_id,
67+
"pg_isready",
68+
"-U",
69+
"ogx",
70+
],
71+
capture_output=True,
72+
)
73+
if result.returncode == 0:
74+
return
75+
time.sleep(1)
76+
raise TimeoutError("PostgreSQL did not become ready after restart")
77+
78+
79+
async def test_kvstore_recovers_after_postgres_restart():
80+
config = get_postgres_config()
81+
store = PostgresKVStoreImpl(config)
82+
await store.initialize()
83+
84+
# Baseline: kvstore works
85+
await store.set("test_key", "before_restart")
86+
value = await store.get("test_key")
87+
assert value == "before_restart"
88+
89+
# Restart postgres
90+
container_id = _find_postgres_container()
91+
_restart_postgres(container_id)
92+
93+
# After restart, kvstore should still work.
94+
# Without reconnect logic this will raise InterfaceError.
95+
await store.set("test_key", "after_restart")
96+
value = await store.get("test_key")
97+
assert value == "after_restart"
98+
99+
await store.shutdown()

0 commit comments

Comments
 (0)