From a46c4b13fc7b7752661dc0ac246c49cfb2305e73 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Wed, 1 Jul 2026 00:28:27 -0400 Subject: [PATCH] fix: connect() orphan (lockstep psql-3) + await pool.release() (mysql-1) - connect() closes an existing pool on re-connect and tears down the built pool on SELECT-1 validation failure (sibling of psql-3). - _Transaction now awaits pool.release() in both __aexit__ and the __aenter__ failure path, matching psql and aiomysql's idiom (no un-awaited _wakeup task per transaction). - README: insert-returns-lastrowid headline precision + upsert-rowcount convention note. verified vs real MariaDB (re-connect, 5-tx release, full suite). bump v0.1.1 -> v0.1.2 Signed-off-by: disqualifier --- README.md | 16 +++++++++++----- pyproject.toml | 2 +- src/mysql/mysql.py | 26 +++++++++++++++++++------- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c6c40cd..48244e2 100644 --- a/README.md +++ b/README.md @@ -11,24 +11,26 @@ wire-compatible and share the driver, so this covers both. `requirements.txt`: ``` -mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v0.1.1 +mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v0.1.2 ``` Direct: ```bash -pip install "mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v0.1.1" +pip install "mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v0.1.2" ``` Pulls `aiomysql` (which pulls `PyMySQL`). -Drop the `@v0.1.1` suffix from the line above to install the latest unpinned. +Drop the `@v0.1.2` suffix from the line above to install the latest unpinned. ## The two-layer API **Layer 1 — friendly verbs** for simple single-table CRUD. These hide the dialect and are -**identical to the `psql` lib** — same method names, signatures, return shapes — so you -swap psql↔mysql with zero call-site changes. +**signature-identical to the `psql` lib** — same method names, arguments, and return +*types* — so you swap psql↔mysql with zero call-site changes. One value-level difference: +`insert()` returns the new row id (`lastrowid`) here vs. the inserted rowcount in psql +(both `int`); everything else returns the same shape. **Layer 2 — raw escape hatch** for the complex ~20% (joins, aggregates, subqueries). You write the SQL with `%s` placeholders + a params sequence; the wrapper still gives pooling, @@ -111,6 +113,10 @@ Layer 1 is portable — you never see these. A Layer-2 raw-SQL author does: `fetchone` is the unified Layer-2 name in both libs (psql maps it onto asyncpg's `fetchrow` internally; here it's the native DBAPI verb). +`upsert()`'s returned rowcount follows MySQL's `ON DUPLICATE KEY UPDATE` convention (1 for +an insert, 2 for an update, 0 for a no-op) — a per-row count that differs from psql's; treat +it as "affected", not "rows matched". + ## Error contract — fail loud Unlike the `mongo` lib (which log-and-swallows), **this lib re-raises.** Every method diff --git a/pyproject.toml b/pyproject.toml index d57e484..8212afc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mysql" -version = "0.1.1" +version = "0.1.2" description = "async mysql/mariadb wrapper over aiomysql: two-layer API (friendly verbs + raw escape hatch), fail-loud, config-free" requires-python = ">=3.10" dependencies = [ diff --git a/src/mysql/mysql.py b/src/mysql/mysql.py index 5f3cd53..410d6f5 100644 --- a/src/mysql/mysql.py +++ b/src/mysql/mysql.py @@ -112,17 +112,25 @@ class MysqlDB: async def connect(self) -> "MysqlDB": """build the pool and validate it with SELECT 1; fail loud on bad config - returns self so callers can write `db = await MysqlDB(...).connect()`. + returns self so callers can write `db = await MysqlDB(...).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. """ + if self._pool is not None: + await self.close() + pool = await aiomysql.create_pool(**self._config) try: - self._pool = await aiomysql.create_pool(**self._config) - async with self._pool.acquire() as conn: + async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute("SELECT 1") await cur.fetchone() - except _DRIVER_ERRORS: - log.exception("mysql.connect() failed") + except BaseException: + log.exception("mysql.connect() validation failed") + pool.close() + await pool.wait_closed() raise + self._pool = pool return self async def close(self) -> None: @@ -379,7 +387,7 @@ class _Transaction: # begin() failing after acquire would otherwise leak the pooled connection — # __aexit__ is not called when __aenter__ raises. release it and reset so a # failed transaction start never burns a pool slot. - self._pool.release(self._conn) + await self._pool.release(self._conn) self._conn = None raise return self._conn @@ -391,7 +399,11 @@ class _Transaction: else: await self._conn.rollback() finally: - self._pool.release(self._conn) + # aiomysql's release() returns a task (it schedules a _wakeup); await it so we + # don't leave an un-awaited task each transaction — matches psql's awaited + # release and aiomysql's own idiom. the connection is returned synchronously + # inside release() regardless, so pool accounting is never at risk. + await self._pool.release(self._conn) def _where(conditions: Optional[Dict[str, Any]]) -> tuple: