From f90c18ed6454ff4e07294e0c0144761bcc4a4aa9 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Thu, 2 Jul 2026 23:25:52 -0400 Subject: [PATCH] fix: close() nulls pool + connect() lock guards against concurrent races (psql-7, psql-8) - close() sets self._pool = None (in a finally, even on driver-error close) so a closed instance reports not-connected instead of masquerading as still-connected against a dead pool (the pool property now raises RuntimeError post-close as intended). - connect() is now guarded by an internal asyncio.Lock: concurrent connect() calls serialize instead of each racing to build + orphan its own live pool. Twin fix with mysql at the same Layer-1 signature level. - docstrings tightened (module header lifecycle/dsn notes, __init__ dsn note) with zero behavior change; re-verified against a real embedded postgres. verified against pixeltable-pgserver (test-only): 8 concurrent connect() calls now leave 0 orphaned pools + exactly 1 live pool (old: 7 orphaned, 8 live simultaneously); close() now flips the connected-check to False (old: stayed True against a dead pool). full layer-1/layer-2/transaction/concurrency regression suite still green. fresh-venv install confirms runtime purity (asyncpg only, no pixeltable-pgserver). bump v0.1.4 -> v0.1.5 Signed-off-by: disqualifier --- README.md | 6 ++--- pyproject.toml | 2 +- src/psql/psql.py | 68 +++++++++++++++++++++++++++--------------------- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 9534279..164b38d 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,18 @@ a sibling of the `mongo` lib. Class is **`PsqlDB`**. `requirements.txt`: ``` -psql @ git+ssh://git@git.rethinkstudios.io/rethink-public/psql.git@v0.1.4 +psql @ git+ssh://git@git.rethinkstudios.io/rethink-public/psql.git@v0.1.5 ``` Direct: ```bash -pip install "psql @ git+ssh://git@git.rethinkstudios.io/rethink-public/psql.git@v0.1.4" +pip install "psql @ git+ssh://git@git.rethinkstudios.io/rethink-public/psql.git@v0.1.5" ``` Pulls `asyncpg`. -Drop the `@v0.1.4` suffix from the line above to install the latest unpinned. +Drop the `@v0.1.5` suffix from the line above to install the latest unpinned. ## The two-layer API diff --git a/pyproject.toml b/pyproject.toml index 40d281c..9e8f32e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "psql" -version = "0.1.4" +version = "0.1.5" description = "async postgres wrapper over asyncpg: two-layer API (friendly verbs + raw escape hatch), fail-loud, config-free" requires-python = ">=3.10" dependencies = [ diff --git a/src/psql/psql.py b/src/psql/psql.py index 02eee33..edf5602 100644 --- a/src/psql/psql.py +++ b/src/psql/psql.py @@ -14,16 +14,17 @@ context manager: await db.execute("CREATE TABLE ...") lifecycle: - construction is sync and opens no socket. connect() builds the asyncpg pool and - validates it with `SELECT 1` so a bad host/credentials fails loud immediately rather - than on the first real op, and returns self. close() closes the pool. + construction is sync, opens no socket. connect() builds the asyncpg pool, validates it + with `SELECT 1` (fail loud on bad host/credentials immediately, not on the first real + op), returns self; concurrent connect() calls are lock-serialized so only one pool is + ever live. close() closes the pool and nulls the reference (a closed instance reports + not-connected). dsn: - host/port default to None, not "localhost"/5432 — asyncpg only reads a dsn's - embedded host/port when the host/port kwargs are falsy, so a hardcoded default would - silently shadow the dsn's server. pass dsn=... in pool_kwargs alone (no host/port) to - let the dsn's own host/port reach asyncpg; the no-dsn path still defaults to - localhost:5432. + host/port default to None, not "localhost"/5432 — asyncpg only reads a dsn's embedded + host/port when the host/port kwargs are falsy, so a hardcoded default would silently + shadow the dsn's server. pass dsn=... in pool_kwargs alone (no host/port) to let the + dsn's own host/port reach asyncpg; the no-dsn path still defaults to localhost:5432. two-layer API: LAYER 1 — friendly, portable verbs for simple single-table CRUD. these hide the @@ -53,6 +54,7 @@ errors (FAIL LOUD — unlike the mongo lib's swallow-and-default): not wrapped, use the raw `.pool` property (the asyncpg.Pool). """ +import asyncio import logging from typing import Any, Dict, List, Optional, Sequence @@ -99,12 +101,11 @@ class PsqlDB: the caller. extra pool_kwargs pass through to asyncpg.create_pool (ssl, server_ settings, dsn, etc). `host` may be a unix socket directory as well as a hostname. - host/port default to None here (not "localhost"/5432) because asyncpg only reads - a dsn's embedded host/port when the host/port kwargs are falsy — a hardcoded - default would silently shadow the dsn's server and connect you to the wrong one. - when no dsn is passed, host/port fall back to localhost:5432 (the common no-dsn - path is unchanged); when a dsn is passed, host/port stay None unless the caller - explicitly overrides them, letting the dsn's own host/port reach asyncpg. + host/port default to None here (not "localhost"/5432): asyncpg only reads a dsn's + embedded host/port when the host/port kwargs are falsy, so a hardcoded default + would silently shadow the dsn's server. no dsn -> host/port fall back to + localhost:5432; dsn passed -> host/port stay None (letting the dsn's own reach + asyncpg) unless the caller explicitly overrides them. """ if "dsn" not in pool_kwargs: if host is None: @@ -123,6 +124,7 @@ class PsqlDB: **pool_kwargs, ) self._pool: Optional[asyncpg.Pool] = None + self._connect_lock = asyncio.Lock() async def connect(self) -> "PsqlDB": """build the pool and validate it with SELECT 1; fail loud on bad config @@ -130,25 +132,29 @@ class PsqlDB: returns self so callers can write `db = await PsqlDB(...).connect()`. if called again on an already-connected instance the previous pool is closed first (no orphaned pool); if the SELECT-1 validation fails the freshly-built pool is torn - down before re-raising, so a failed connect() never leaks a live pool. + down before re-raising, so a failed connect() never leaks a live pool. guarded by + an internal lock so concurrent connect() calls build exactly one pool instead of + each racing to create + orphan their own. """ - if self._pool is not None: - await self.close() - pool = await asyncpg.create_pool(**self._config) - try: - await pool.fetchval("SELECT 1") - except _DRIVER_ERRORS: - log.exception("psql.connect() validation failed") - await pool.close() - raise - except BaseException: - await pool.close() - raise - self._pool = pool - return self + async with self._connect_lock: + if self._pool is not None: + await self.close() + pool = await asyncpg.create_pool(**self._config) + try: + await pool.fetchval("SELECT 1") + except _DRIVER_ERRORS: + log.exception("psql.connect() validation failed") + await pool.close() + raise + except BaseException: + await pool.close() + raise + self._pool = pool + return self async def close(self) -> None: - """close the pool on shutdown""" + """close the pool on shutdown and null the reference (so pool/connected checks + report not-connected against a dead pool)""" if self._pool is None: return try: @@ -156,6 +162,8 @@ class PsqlDB: except _DRIVER_ERRORS: log.exception("psql.close()") raise + finally: + self._pool = None async def __aenter__(self) -> "PsqlDB": return await self.connect()