Skip to content

database: add failover-safe pool defaults and pgxpool health checks for Postgres/AlloyDB#490

Open
stevemsmith wants to merge 4 commits into
moov-io:masterfrom
stevemsmith:fix/alloydb-failover-recovery
Open

database: add failover-safe pool defaults and pgxpool health checks for Postgres/AlloyDB#490
stevemsmith wants to merge 4 commits into
moov-io:masterfrom
stevemsmith:fix/alloydb-failover-recovery

Conversation

@stevemsmith

Copy link
Copy Markdown

Summary

  • Switch Postgres/AlloyDB connections from sql.Open("pgx") to pgxpool with stdlib.OpenDBFromPool() — enables HealthCheckPeriod (1s) to proactively evict dead connections after a failover, while keeping the *sql.DB return type so no downstream changes are needed
  • Add safe connection pool defaults (MaxLifetime=5m, MaxIdleTime=30s, MaxOpen=25, MaxIdle=5) that are applied automatically when services don't explicitly configure them
  • Add IsRetryablePostgresError() and RetryPostgres() utilities for services that want to retry transient connection errors on in-flight queries

Problem

During AlloyDB monthly maintenance switchovers (< 1s downtime), Go services using this library fail to recover because:

  1. No health checkingdatabase/sql with pgx as a driver has no background connection health checks. After a switchover, dead connections sit in the pool and get handed to the application, causing errors.
  2. No pool limits by defaultConnectionsConfig defaults to all zero values, meaning database/sql holds connections forever. Dead connections from before the switchover are never evicted.
  3. No retry logic — in-flight queries that fail due to the connection being severed have no retry path.

Changes

postgres.go

  • Replace sql.Open("pgx", connStr) with pgxpool.NewWithConfig() + stdlib.OpenDBFromPool()
  • Set HealthCheckPeriod = 1s — pgxpool pings idle connections every second and evicts dead ones before the app sees them
  • Refactor getAlloyDBConnectorConnStr()buildAlloyDBPoolConfig() to return *pgxpool.Config instead of a connection string
  • Add IsRetryablePostgresError() — classifies PG error codes (57P01 admin_shutdown, 57P03 cannot_connect_now, 08xxx connection class) and network errors (EOF, broken pipe, connection reset) as retryable
  • Add RetryPostgres() — opt-in retry wrapper with linear backoff for critical DB operations

model_config.go

  • Add DefaultPostgresConnectionsConfig() with failover-safe defaults

database.go

  • Add ApplyPostgresConnectionsConfig() that fills in defaults for zero-valued config fields
  • Postgres path in New() now uses this instead of the generic ApplyConnectionsConfig

Non-breaking

  • Return type of New() is still *sql.DB — no downstream changes needed
  • Services that explicitly set pool config values keep their values; defaults only fill in zeros
  • MySQL and Spanner paths are untouched
  • getPostgresConnStr() is unchanged (just moved in file)
  • No new external dependencies — pgxpool and stdlib are both part of pgx/v5

Test plan

  • Unit tests for IsRetryablePostgresError — all PG error codes, network errors, non-retryable cases
  • Unit tests for RetryPostgres — success, retry-then-succeed, non-retryable short-circuit, context cancellation, attempt exhaustion
  • Unit tests for DefaultPostgresConnectionsConfig and ApplyPostgresConnectionsConfig
  • go build ./database/... and go vet ./database/... pass
  • Integration test during next AlloyDB maintenance window

🤖 Generated with Claude Code

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly improves the robustness of database connections for Postgres and AlloyDB by implementing proactive health checks, establishing intelligent default connection pool settings, and providing utilities to automatically retry operations affected by transient connection issues. These changes aim to enhance service stability and recovery during database failovers or brief network disruptions, without requiring modifications to existing application code.

Highlights

  • Enhanced Postgres/AlloyDB Connection Resilience: Switched Postgres/AlloyDB connections from sql.Open("pgx") to pgxpool with stdlib.OpenDBFromPool(), enabling HealthCheckPeriod (1s) to proactively evict dead connections after a failover, while maintaining *sql.DB return type for compatibility.
  • Failover-Safe Connection Pool Defaults: Added safe connection pool defaults (MaxLifetime=5m, MaxIdleTime=30s, MaxOpen=25, MaxIdle=5) that are automatically applied when services do not explicitly configure them, preventing dead connections from lingering.
  • Transient Error Retry Utilities: Introduced IsRetryablePostgresError() to classify transient connection errors (e.g., admin_shutdown, cannot_connect_now, network errors) and RetryPostgres() for opt-in retry logic with linear backoff for critical database operations.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • database/database.go
    • Updated the New function to utilize ApplyPostgresConnectionsConfig for Postgres connections.
    • Added ApplyPostgresConnectionsConfig to apply default connection pool settings for Postgres/AlloyDB, filling in zero-valued configuration fields with predefined safe defaults.
  • database/database_test.go
    • Added TestApplyPostgresConnectionsConfig_Defaults to verify that default connection settings are correctly applied.
    • Added TestApplyPostgresConnectionsConfig_Overrides to confirm that explicitly set connection values override defaults.
    • Added TestDefaultPostgresConnectionsConfig to validate the properties of the default Postgres connection configuration.
  • database/model_config.go
    • Introduced DefaultPostgresConnectionsConfig function, providing failover-tuned default values for Postgres connection pool settings.
  • database/postgres.go
    • Refactored postgresConnection to leverage pgxpool.NewWithConfig and stdlib.OpenDBFromPool for enhanced connection management and health checking.
    • Configured pgxpool with a HealthCheckPeriod of 1 second to proactively ping and evict dead connections.
    • Renamed and refactored getAlloyDBConnectorConnStr to buildAlloyDBPoolConfig to return a *pgxpool.Config.
    • Introduced buildPgxPoolConfig to abstract the creation of pgxpool.Config for both standard Postgres and AlloyDB connections.
    • Added IsRetryablePostgresError function to identify transient PostgreSQL error codes and network errors that are safe to retry.
    • Implemented RetryPostgres utility for executing database operations with linear backoff on retryable errors.
  • database/postgres_test.go
    • Added comprehensive unit tests for IsRetryablePostgresError, covering various PostgreSQL error codes, network errors, and non-retryable scenarios.
    • Added unit tests for RetryPostgres to validate successful execution, retry behavior, non-retryable error handling, context cancellation, and attempt exhaustion.
Activity
  • No specific activity (comments, reviews, or progress updates) has been recorded for this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces robust failover handling for Postgres/AlloyDB connections by switching to pgxpool, which enables proactive health checks to evict dead connections. It also adds sensible default connection pool settings tuned for failover scenarios and an opt-in retry mechanism for transient connection errors. The changes are well-implemented, non-breaking for consumers of the library, and thoroughly tested. My feedback includes a couple of suggestions to improve code conciseness and strengthen test assertions.

Comment thread database/postgres.go
Comment thread database/postgres_test.go Outdated
adamdecaf
adamdecaf previously approved these changes Mar 17, 2026
@stevemsmith

Copy link
Copy Markdown
Author

CI is failing on a pre-existing vulnerability in golang.org/x/net@v0.50.0 (GO-2026-4559) — unrelated to the changes in this PR. Looks like #487 (Renovate) already bumps this dependency.

@adamdecaf

Copy link
Copy Markdown
Member

Can we try this in some apps before merging?

@adamdecaf

Copy link
Copy Markdown
Member

I'll work with gosec/golangci-lint on getting the linter fix released.

@adamdecaf adamdecaf self-requested a review March 27, 2026 19:07
@adamdecaf

Copy link
Copy Markdown
Member

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request enhances PostgreSQL and AlloyDB connection management by transitioning to pgxpool with background health checks and introducing tuned default pool settings for better failover recovery. It also adds a RetryPostgres utility and an IsRetryablePostgresError helper to handle transient connection-level errors. I have no feedback to provide.

@adamdecaf

Copy link
Copy Markdown
Member

@stevemsmith can you rebase off the latest origin/master ?

stevemsmith and others added 4 commits March 30, 2026 10:32
…oyDB

During AlloyDB maintenance switchovers (< 1s downtime), services using
database/sql with pgx fail to recover because:
1. No connection pool limits are set by default, so dead connections
   persist indefinitely
2. No retry logic exists for transient connection errors

This adds:
- DefaultPostgresConnectionsConfig() with MaxLifetime=5m, MaxIdleTime=30s
  to ensure dead connections are evicted quickly after failover
- ApplyPostgresConnectionsConfig() that fills in safe defaults when
  services don't explicitly configure pool settings
- IsRetryablePostgresError() to classify transient PG/network errors
- RetryPostgres() for services to wrap critical DB operations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Switch from sql.Open("pgx") to pgxpool.NewWithConfig() wrapped with
stdlib.OpenDBFromPool(). This gives us pgxpool's HealthCheckPeriod
(set to 5s) which proactively pings idle connections and evicts dead
ones — the most important fix for surviving AlloyDB maintenance
switchovers. The return type remains *sql.DB so no downstream changes
are needed.

Also cleans up the dialer TODO (dialer lifecycle is now tied to the
pool) and removes the unused pgx.ParseConfig import path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
For sub-second AlloyDB switchovers, 1s health checks detect and evict
dead connections faster with negligible overhead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Collapse switch cases in IsRetryablePostgresError for readability
- Make context cancellation test more specific (assert context.Canceled
  and exact call count)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@adamdecaf adamdecaf force-pushed the fix/alloydb-failover-recovery branch from 48cbb3b to 22a1b6f Compare March 30, 2026 16:15

@InfernoJJ InfernoJJ left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the retry stuff, otherwise looks good.

Comment thread database/postgres.go
Comment on lines +190 to +193
case "57P01", "57P02", "57P03": // admin_shutdown, crash_shutdown, cannot_connect_now
return true
case "08000", "08001", "08003", "08004", "08006": // connection_exception class
return true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to see documentation on these error cases as being valid to retry. Unsure I trust an AI here with possible data corruption if its incorrect. Or just remove the retry stuff and put it into a different PR.

@elovelan elovelan Jun 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both Gemini and Claude agree that at least some of these are unsafe to retry.

Interestingly, they both mentioned 40001 (serialization_failure) and 40P01 (deadlock_detected) are safe even though they're not included here. The guide for them mentions that there are a few similar codes 23505 (unique_violation), and 23P01 (exclusion_violation) that can be safe to retry, but might need to be handled on a case-by-case basis based on the way the calling code is written.

57P01 (admin_shutdown) is definitely safe based on the docs because the transaction is rolled back during a fast shutdown. I can't find a definitive source that says that 57P03 (cannot_connect_now) is safe, but the name for the code, as well as non-authoritative sources, including Claude and Gemini, indicate it's safe to retry. Two others they mention are safe are 53300 (too_many_connections), which is a connect-time error, and 57014 (query_canceled) because it indicates a rollback.

Interestingly, even though 08001 (sqlclient_unable_to_establish_sqlconnection) and 08004 (connection_rejected) are safe, 080xx codes are designed to be implemented by the client and pgx doesn't implement any, returning TCP errors and the like instead.

Comment thread database/postgres.go
return err
}
if attempt < maxAttempts-1 {
backoff := time.Duration(attempt+1) * 200 * time.Millisecond

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backoff is a bad idea in this case as you'll drastically increase the wait, along with it not being configurable. 200ms is an ETERNITY to a program, and is noticeable by a user. Theirs also no variance in this interface, so they will all slam at the same time.

Comment thread database/postgres.go
msg := err.Error()
if strings.Contains(msg, "connection reset by peer") ||
strings.Contains(msg, "broken pipe") ||
strings.Contains(msg, "connection refused") ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure this is the only safe one of this list (and even this we should confirm)

Comment thread database/postgres.go
// RetryPostgres executes fn up to maxAttempts times, retrying on transient
// connection errors. This is intended for use around individual database
// operations to survive brief outages like AlloyDB maintenance switchovers.
func RetryPostgres(ctx context.Context, maxAttempts int, fn func() error) error {

@elovelan elovelan Jun 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW both the thread on pgx that the author references whenever retries come up, with this comment that the author agrees should work and PGXTransactions recommend using an explicit transaction instead of an implicit one.

Even though both implicit transactions and the commit phase of an explicit can hit the timing issue that @InfernoJJ mentioned in one of our calls, where it successfully commits but the connection throws an error, there are fewer failure scenarios in the latter case. Using an explicit transaction would also allow us to go a step further and capture the pg_current_xact_id() before the commit so on failure we could call pg_xact_status() to see if the commit was succesful.

Note, neither PgBouncer nor Google's hosted version of it, Managed Connection Pooling solve this problem since a bouncer cannot safely make decisions without a clear commit status either. Google also explicitly calls out in the linked article that a bouncer is only recommended for short-lived tasks, of which we don't have many of, if any.

There are also some tuning options that we can do on the AlloyDB side that might be worth adding to make sure a connection is removed from the pool sooner than it is today. I just want to double check some of the suggestions I got before posting them in the ticket.

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.

4 participants