Skip to content

feat: support PostgreSQL connection pool configuration - #5478

Open
mvanhorn wants to merge 5 commits into
thomaspoignant:mainfrom
mvanhorn:feat/4835-postgresql-pool-config
Open

feat: support PostgreSQL connection pool configuration#5478
mvanhorn wants to merge 5 commits into
thomaspoignant:mainfrom
mvanhorn:feat/4835-postgresql-pool-config

Conversation

@mvanhorn

Copy link
Copy Markdown
Contributor

Description

The PostgreSQL retriever previously exposed only uri, table, and custom
column names, leaving the connection pool entirely at the driver defaults.
Production workloads on managed Postgres need control over pool sizing and
connection lifetimes for performance and resource safety.

This PR adds optional, per-retriever connection pool settings, following the
design the maintainer selected in #4835:

  • maxOpenConns -> pgxpool MaxConns
  • maxIdleConns -> pgxpool MinConns (the pool floor; pgxpool has no separate
    idle ceiling, idle connections are bounded by MaxConns)
  • connMaxLifetime -> pgxpool MaxConnLifetime
  • connMaxIdleTime -> pgxpool MaxConnIdleTime

How it works:

  • A new PoolConfig is threaded through the pgxpool pool builder at the
    existing GetPool / poolMap seam in postgres.go. When no pool block is
    configured, the pool is built exactly as before (pgxpool.New), so existing
    configurations are byte-for-byte unaffected. When a pool block is present, the
    URI is parsed with pgxpool.ParseConfig and only the explicitly set fields are
    overridden.
  • The cached poolMap is now keyed by URI and the pool config, so two
    retrievers that share a URI but request different pool settings no longer
    silently reuse the first pool created.
  • The same fields are surfaced on RetrieverConf (relay proxy / config files)
    with validation in validatePostgreSQLRetriever() (negative values,
    maxIdleConns greater than maxOpenConns, and unparseable durations are
    rejected with a clear error) and wired through createPostgreSQLRetriever.
  • Documentation for the new block is added to the PostgreSQL retriever docs.

How to test: see the new unit tests in
retriever/postgresqlretriever/postgres_pool_test.go,
cmdhelpers/retrieverconf/retriever_conf_test.go, and
cmdhelpers/retrieverconf/init/retriever_init_test.go. Building a pool does not
open a connection, so the pool-config tests run without a live database; the
existing testcontainers-backed pool test continues to cover end-to-end behavior.

No breaking change to configuration behavior: every new field is optional and
defaults preserve the current behavior.

Closes issue(s)

Resolve #4835

Checklist

  • I have tested this code
  • I have added unit test to cover this code
  • I have updated the documentation (README.md and /website/docs)
  • I have followed the contributing guide

Fixes #4835

@netlify

netlify Bot commented Jun 21, 2026

Copy link
Copy Markdown

Deploy Preview for go-feature-flag-doc-preview canceled.

Name Link
🔨 Latest commit 9767ecb
🔍 Latest deploy log https://app.netlify.com/projects/go-feature-flag-doc-preview/deploys/6a4fbd2a5c86880008e26ada

@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds optional connection pool configuration (MaxConns, MinConns, MaxConnLifetime, MaxConnIdleTime) to the PostgreSQL retriever, threaded through pgxpool via a new PoolConfig struct. Existing configurations are fully unaffected — the zero-value fast path preserves the previous pgxpool.New call, and the pool cache is now keyed by URI plus pool settings so different pool configurations on the same URI get independent pools.

  • PoolConfig is introduced in postgres.go with IsZero() and cacheKey() helpers; GetPool/ReleasePool accept the new struct and newPool applies only non-zero fields over the pgxpool defaults.
  • Validation is added to validatePostgreSQLRetriever() (negative values, minConns > maxConns, unparseable durations) and defensive bounds checks are repeated in createPostgreSQLRetriever().
  • Documentation and Go module usage examples are updated in the MDX file.

Confidence Score: 5/5

Safe to merge; all new fields are optional with zero-value backward compatibility and the pool cache correctly segregates configurations by URI+settings.

The change is well-tested, backward-compatible, and the pool management logic is sound. The only gap is that validatePostgreSQLRetriever does not check the int32 upper bound for MaxConns/MinConns, meaning a theoretically out-of-range value passes IsValid() but fails at factory time — a minor validation inconsistency with no realistic production impact.

cmdhelpers/retrieverconf/retriever_conf.go — the validatePostgreSQLRetriever function should mirror the int32 overflow check that already exists in createPostgreSQLRetriever.

Important Files Changed

Filename Overview
retriever/postgresqlretriever/postgres.go Adds PoolConfig type, newPool helper, and updates GetPool/ReleasePool signatures; cache key now uses named parameters to prevent URI/config collisions; backward-compatible zero-value fast path preserved.
retriever/postgresqlretriever/retriever.go Adds Pool PoolConfig field to Retriever struct and threads it through Init and Shutdown; clean and minimal change.
cmdhelpers/retrieverconf/retriever_conf.go Adds MaxConns/MinConns/MaxConnLifetime/MaxConnIdleTime fields and validatePostgreSQLRetriever guards; missing int32 upper-bound check means IsValid() can pass for values that fail at factory time.
cmdhelpers/retrieverconf/init/retriever_init.go createPostgreSQLRetriever now converts RetrieverConf pool fields to PoolConfig with int32 range checks and duration parsing; correctly wires through to the Retriever struct.
retriever/postgresqlretriever/postgres_pool_test.go New test file covering IsZero, newPool config application, partial config, invalid URI, and cache key stability/distinctness; runs without a live database.
website/docs/integrations/store-flags-configuration/postgresql.mdx Adds pool configuration documentation with YAML and Go module examples; docs table entries and Go struct fields look accurate.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Cfg as RetrieverConf
    participant Init as retriever_init.go
    participant R as Retriever
    participant GP as GetPool
    participant NP as newPool
    participant Cache as poolMap

    Cfg->>Init: createPostgreSQLRetriever(c)
    Init->>Init: bounds-check MaxConns/MinConns (int32 range)
    Init->>Init: parse MaxConnLifetime / MaxConnIdleTime
    Init->>R: "&Retriever{URI, Table, Columns, Pool: PoolConfig{}}"

    R->>GP: GetPool(ctx, uri, poolCfg)
    GP->>GP: "key = cacheKey(uri, poolCfg)"
    alt key in poolMap
        GP-->>R: cached pool (refCount++)
    else key not found
        GP->>NP: newPool(ctx, uri, poolCfg)
        alt poolCfg.IsZero()
            NP->>NP: pgxpool.New(ctx, uri)
        else pool settings configured
            NP->>NP: pgxpool.ParseConfig(uri)
            NP->>NP: override MaxConns/MinConns/Lifetimes
            NP->>NP: "MinConns > MaxConns? error"
            NP->>NP: pgxpool.NewWithConfig(config)
        end
        NP-->>GP: pool
        GP->>GP: pool.Ping(ctx)
        GP->>Cache: "poolMap[key] = {pool, refCount:1}"
        GP-->>R: pool
    end

    R->>GP: ReleasePool(ctx, uri, poolCfg)
    GP->>Cache: refCount-- to 0? Close and delete
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Cfg as RetrieverConf
    participant Init as retriever_init.go
    participant R as Retriever
    participant GP as GetPool
    participant NP as newPool
    participant Cache as poolMap

    Cfg->>Init: createPostgreSQLRetriever(c)
    Init->>Init: bounds-check MaxConns/MinConns (int32 range)
    Init->>Init: parse MaxConnLifetime / MaxConnIdleTime
    Init->>R: "&Retriever{URI, Table, Columns, Pool: PoolConfig{}}"

    R->>GP: GetPool(ctx, uri, poolCfg)
    GP->>GP: "key = cacheKey(uri, poolCfg)"
    alt key in poolMap
        GP-->>R: cached pool (refCount++)
    else key not found
        GP->>NP: newPool(ctx, uri, poolCfg)
        alt poolCfg.IsZero()
            NP->>NP: pgxpool.New(ctx, uri)
        else pool settings configured
            NP->>NP: pgxpool.ParseConfig(uri)
            NP->>NP: override MaxConns/MinConns/Lifetimes
            NP->>NP: "MinConns > MaxConns? error"
            NP->>NP: pgxpool.NewWithConfig(config)
        end
        NP-->>GP: pool
        GP->>GP: pool.Ping(ctx)
        GP->>Cache: "poolMap[key] = {pool, refCount:1}"
        GP-->>R: pool
    end

    R->>GP: ReleasePool(ctx, uri, poolCfg)
    GP->>Cache: refCount-- to 0? Close and delete
Loading

Reviews (5): Last reviewed commit: "chore: bump Go toolchain to 1.26.5 for G..." | Re-trigger Greptile

Comment thread cmdhelpers/retrieverconf/retriever_conf.go Outdated
Comment thread retriever/postgresqlretriever/postgres.go
Comment thread website/docs/integrations/store-flags-configuration/postgresql.mdx Outdated
@codecov

codecov Bot commented Jun 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.04%. Comparing base (1c847f2) to head (9767ecb).
⚠️ Report is 128 commits behind head on main.

Files with missing lines Patch % Lines
cmdhelpers/retrieverconf/init/retriever_init.go 76.19% 4 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5478      +/-   ##
==========================================
+ Coverage   86.58%   87.04%   +0.45%     
==========================================
  Files         160      160              
  Lines        6956     7257     +301     
==========================================
+ Hits         6023     6317     +294     
- Misses        694      697       +3     
- Partials      239      243       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Comment on lines +137 to +148
if c.ConnMaxLifetime != "" {
if _, errParse := time.ParseDuration(c.ConnMaxLifetime); errParse != nil {
return fmt.Errorf("invalid retriever configuration, "+
"\"connMaxLifetime\" is not a valid duration for kind \"%s\": %w", c.Kind, errParse)
}
}
if c.ConnMaxIdleTime != "" {
if _, errParse := time.ParseDuration(c.ConnMaxIdleTime); errParse != nil {
return fmt.Errorf("invalid retriever configuration, "+
"\"connMaxIdleTime\" is not a valid duration for kind \"%s\": %w", c.Kind, errParse)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Negative duration strings bypass validation but are silently ignored at runtime

time.ParseDuration accepts negative values like "-1h" without error, so the validation here passes. The value then flows into PoolConfig.ConnMaxLifetime or PoolConfig.ConnMaxIdleTime. In newPool, both fields have a > 0 guard — meaning a negative duration is silently discarded and the driver default is kept instead. The user receives no error and no indication that their setting was not applied.

Suggested change
if c.ConnMaxLifetime != "" {
if _, errParse := time.ParseDuration(c.ConnMaxLifetime); errParse != nil {
return fmt.Errorf("invalid retriever configuration, "+
"\"connMaxLifetime\" is not a valid duration for kind \"%s\": %w", c.Kind, errParse)
}
}
if c.ConnMaxIdleTime != "" {
if _, errParse := time.ParseDuration(c.ConnMaxIdleTime); errParse != nil {
return fmt.Errorf("invalid retriever configuration, "+
"\"connMaxIdleTime\" is not a valid duration for kind \"%s\": %w", c.Kind, errParse)
}
}
if c.ConnMaxLifetime != "" {
d, errParse := time.ParseDuration(c.ConnMaxLifetime)
if errParse != nil {
return fmt.Errorf("invalid retriever configuration, "+
"\"connMaxLifetime\" is not a valid duration for kind \"%s\": %w", c.Kind, errParse)
}
if d <= 0 {
return fmt.Errorf("invalid retriever configuration, "+
"\"connMaxLifetime\" must be a positive duration for kind \"%s\"", c.Kind)
}
}
if c.ConnMaxIdleTime != "" {
d, errParse := time.ParseDuration(c.ConnMaxIdleTime)
if errParse != nil {
return fmt.Errorf("invalid retriever configuration, "+
"\"connMaxIdleTime\" is not a valid duration for kind \"%s\": %w", c.Kind, errParse)
}
if d <= 0 {
return fmt.Errorf("invalid retriever configuration, "+
"\"connMaxIdleTime\" must be a positive duration for kind \"%s\"", c.Kind)
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: cmdhelpers/retrieverconf/retriever_conf.go
Line: 137-148

Comment:
**Negative duration strings bypass validation but are silently ignored at runtime**

`time.ParseDuration` accepts negative values like `"-1h"` without error, so the validation here passes. The value then flows into `PoolConfig.ConnMaxLifetime` or `PoolConfig.ConnMaxIdleTime`. In `newPool`, both fields have a `> 0` guard — meaning a negative duration is silently discarded and the driver default is kept instead. The user receives no error and no indication that their setting was not applied.

```suggestion
	if c.ConnMaxLifetime != "" {
		d, errParse := time.ParseDuration(c.ConnMaxLifetime)
		if errParse != nil {
			return fmt.Errorf("invalid retriever configuration, "+
				"\"connMaxLifetime\" is not a valid duration for kind \"%s\": %w", c.Kind, errParse)
		}
		if d <= 0 {
			return fmt.Errorf("invalid retriever configuration, "+
				"\"connMaxLifetime\" must be a positive duration for kind \"%s\"", c.Kind)
		}
	}
	if c.ConnMaxIdleTime != "" {
		d, errParse := time.ParseDuration(c.ConnMaxIdleTime)
		if errParse != nil {
			return fmt.Errorf("invalid retriever configuration, "+
				"\"connMaxIdleTime\" is not a valid duration for kind \"%s\": %w", c.Kind, errParse)
		}
		if d <= 0 {
			return fmt.Errorf("invalid retriever configuration, "+
				"\"connMaxIdleTime\" must be a positive duration for kind \"%s\"", c.Kind)
		}
	}
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Cursor

Address CI failures on thomaspoignant#5478:
- gosec G115: bounds-check MaxOpenConns/MaxIdleConns before narrowing int to
  int32, returning an error for out-of-range values.
- check-mode golden tests: add json:",omitempty" to the optional pool fields so
  they no longer serialize zero values into every retriever's config output.
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Fixed both CI failures:

  • Lint (gosec G115): the int -> int32 conversions for MaxOpenConns/MaxIdleConns now have a bounds check that returns an error for out-of-range values before narrowing, so the overflow warning is resolved.
  • Test (check-mode goldens): the four optional pool fields were serializing zero values into every retriever's config output, breaking the check-* golden comparisons. Added json:",omitempty" so they only appear when actually configured. All TestCmdEvaluate check-mode tests pass locally and gosec reports no G115 issues.

@thomaspoignant thomaspoignant left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Hey @mvanhorn I've done a first path of reviews (not complete yet), but I have addressed some small issues here and there.

I will perform a more in depth review as soon as possible.

Comment on lines +115 to +117
if poolCfg.MaxIdleConns > 0 {
config.MinConns = poolCfg.MaxIdleConns
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

🔨 issue: ‏why do you put MaxIdleConns inside the MinConns pgxpool config here ?

Should it me

config.MaxIdleConns = poolCfg.MaxIdleConns

config.MaxConnIdleTime = poolCfg.ConnMaxIdleTime
}

return pgxpool.NewWithConfig(ctx, config)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

It would be great here to have a check that we don't have MinConns > MaxConns

Something like this would work:

Suggested change
return pgxpool.NewWithConfig(ctx, config)
if config.MinConns > config.MaxConns {
return nil, fmt.Errorf(
"invalid pool configuration: maxIdleConns (%d) must not exceed maxOp
enConns (%d)",
config.MinConns, config.MaxConns)
}
return pgxpool.NewWithConfig(ctx, config)

if poolCfg.MaxIdleConns > 0 {
config.MinConns = poolCfg.MaxIdleConns
}
if poolCfg.ConnMaxLifetime > 0 {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

🤓 nitpick: ‏If we want to test fully.

Suggested change
if poolCfg.ConnMaxLifetime > 0 {
if c.MaxOpenConns < 0 || c.MaxOpenConns > math.MaxInt32 {

if poolCfg.ConnMaxLifetime > 0 {
config.MaxConnLifetime = poolCfg.ConnMaxLifetime
}
if poolCfg.ConnMaxIdleTime > 0 {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

🤓 nitpick: ‏very very nit pick

Suggested change
if poolCfg.ConnMaxIdleTime > 0 {
if poolCfg.ConnMaxIdleTime > 0 || c.ConnMaxIdleTime > math.MaxInt32 {

Comment on lines +112 to +113
if poolCfg.MaxOpenConns > 0 {
config.MaxConns = poolCfg.MaxOpenConns

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

🔨 issue: ‏why do you put MaxOpenConns inside the MaxConns pgxpool config here ?

Should it be?

config.MaxOpenConns = poolCfg.MaxOpenConns

Per review: the config borrowed database/sql names (MaxOpenConns/MaxIdleConns)
but MaxIdleConns (a maximum) was mapped onto pgxpool's MinConns (a minimum
floor), which is misleading. Renamed the pool config fields and yaml/json keys
to pgxpool's own model (maxConns/minConns) so the mapping is 1:1 and
self-documenting. Adds a MinConns > MaxConns validation and corrects the int32
bounds checks (< 0 || > MaxInt32) for the narrowing conversions.
@mvanhorn

mvanhorn commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Good catch on the mapping - you're right that borrowing database/sql's MaxOpenConns/MaxIdleConns names was misleading, since MaxIdleConns (a max) was landing on pgxpool's MinConns (a minimum floor). Renamed the pool config to pgxpool's own model: maxConns/minConns, mapped 1:1 (config.MaxConns = poolCfg.MaxConns, config.MinConns = poolCfg.MinConns). Updated the yaml/json keys, docs, and tests to match.

Also applied your suggestions: a MinConns > MaxConns validation (both at the retriever config layer and in newPool), and fixed the int32 bounds checks to < 0 || > math.MaxInt32 for both fields.

Verified: the pool config tests pass locally (IsZero, AppliesConfig, the MinConns>MaxConns guard, partial config, invalid URI) and go vet is clean. The one failing test, TestGetPool_MultipleURIsAndReuse, spins up a real Postgres via testcontainers and needs Docker running, which I don't have here - it's unaffected by the rename.

@mvanhorn

mvanhorn commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

The Govulncheck failure is GO-2026-5856, a Go stdlib (crypto/tls) vulnerability fixed in go1.26.5 - it's unrelated to this PR's diff and will affect any branch on go1.26.4. Bumped the go directive to 1.26.5 and re-ran make's vuln-check target locally: 0 affecting vulnerabilities now. If you'd rather take the bump on main separately, happy to drop the commit from this PR.

@sonarqubecloud

sonarqubecloud Bot commented Jul 9, 2026

Copy link
Copy Markdown

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.

(change) PostgreSQL Connection Pool Configuration

2 participants