Skip to content

perf: turn pool_pre_ping off by default and rely on postgres_retry - #88

Merged
lesnik512 merged 5 commits into
mainfrom
perf/pre-ping-off-db-retry
Oct 16, 2026
Merged

lesnik512 merged 5 commits into
mainfrom
perf/pre-ping-off-db-retry

Conversation

@lesnik512

@lesnik512 lesnik512 commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

pool_pre_ping is now off by default. postgres_retry from db-retry handles a pooled connection that died while idle, so a connection checkout no longer costs an extra round trip. DB_POOL_PRE_PING=true still turns pre-ping back on.

This follows modern-python/chat-app#54.

Why this is safe now

  • db-retry 0.7.1 retries a DBAPIError that SQLAlchemy marks connection_invalidated. That covers asyncpg's InterfaceError: connection is closed, which is what a dead idle connection raises on first use.
  • db-retry 0.8.0 replaced tenacity with a plain loop, and the decorator now costs about 0.14 µs per call on success. In db-retry's one-row session.get benchmark, a request took 183 to 193 µs with pre-ping and 173 to 181 µs with postgres_retry and Transaction instead. The gap grows with network latency to Postgres.

What changes

  • db-retry>=0.8.0 is a dependency.
  • The database work behind each of the eight endpoints moved into a use case in app/use_cases/, following chat-app: a frozen dataclass with transaction: Transaction and a repository field, and a __call__ decorated with postgres_retry. Each runs its repository call inside async with self.transaction: and commits before leaving the block, reads included. Without the commit, __aexit__ rolls back, and the rollback expires the rows the handler is about to serialize.
  • Handlers only call the use case and convert the result to a schema. Handlers inject a use case with FromDI(UseCase).
  • app/ioc.py is split into Database (engines, session, transaction), Repositories and UseCases groups, collected in ALL_GROUPS.
  • Repositories use auto_commit=False, so the use case owns the whole transaction. With auto_commit=True, every write method commits on its own. That works while there is one call. A use case with two writes would commit the first, and a retry after a lost connection would apply it a second time.
  • app/use_cases/ is exempt from ruff's TC rules, as app/resources/ already was, because modern-di reads the dataclass field annotations at runtime.
  • AGENTS.md documents the use-case shape and its rules. It also fixes the claim that auto_commit commits at session close. advanced-alchemy commits at the end of each write method.

Tests

tests/test_settings.py checks the new default. The existing HTTP tests cover every use case.

There is no connection-loss test here. db-retry already tests this path against a real Postgres in test_a_connection_closed_while_pooled_is_retried_through_a_transaction: it terminates an idle pooled backend and checks that postgres_retry recovers through a Transaction. A template test would only run the same library code again. I did check it locally before dropping it. With the retry, a use case recovers both from a connection that was lost while the session held it and from one lost while idle in the pool. With DB_RETRY_RETRIES_NUMBER=1 both fail with InterfaceError: connection is closed.

Ruff and ty pass, and all 35 tests pass with 100% coverage against a local Postgres 14.

Trade-offs

  • Database work has to go through a use case with this shape. Code that queries outside one now returns a 500 on a dead idle connection, where pre-ping used to hide the problem.
  • If the connection drops during COMMIT, the write may already have landed, and the retry runs it again. create_deck and create_cards have no idempotency key, so a retry at that point can duplicate the write. AGENTS.md says so.

Not done

I didn't benchmark the template itself. The numbers above are db-retry's.

The handler tests terminate the backend a request session holds, the endpoint tests terminate an idle pooled connection on each engine. Both fail until the handlers retry inside a Transaction and pre-ping is off.
Every handler is decorated with postgres_retry and runs its queries inside a request-scoped db_retry.Transaction, committing before it leaves the block. Repositories switch to auto_commit=False so the handler owns the whole transaction. Requires db-retry 0.8.0.
app/use_cases/ holds one dataclass per operation, retried with postgres_retry and running inside its Transaction, as in chat-app. ioc splits into Database, Repositories and UseCases groups, and handlers only call a use case and convert the result.
Every use case has the same retry and Transaction shape, so ListDecksUseCase stands in for all of them. It runs against a connection lost while held by the session and one lost while idle in the pool. It only reads, so the committed fixture data and the truncation go away.
db-retry already tests a pooled connection closed while idle being retried through a Transaction against a real Postgres. The template test only ran the same library code through a trivial use case.
@lesnik512
lesnik512 merged commit b82495e into main Oct 16, 2026
2 checks passed
@lesnik512
lesnik512 deleted the perf/pre-ping-off-db-retry branch October 16, 2026 19:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant