Skip to content

schemaTemplateParam should mark schema replacement as Stable: true to enable Replacer cache #1241

@IgorDeo

Description

@IgorDeo

Hey folks! We've been tracking down a memory leak in our production Go service that uses River v0.33+ with riverpgxv5, and we traced it back to the sqlctemplate.Replacer cache never being used for schema replacements.

What's happening

The schemaTemplateParam function in riverpgxv5 passes Stable: false (the default) on the schema replacement:

// riverdriver/riverpgxv5/river_pgx_v5_driver.go
func schemaTemplateParam(ctx context.Context, schema string) context.Context {
    return sqlctemplate.WithReplacements(ctx, map[string]sqlctemplate.Replacement{
        "schema": {Value: schema},  // Stable defaults to false
    }, nil)
}

Because of this, replacerCacheKeyFrom always returns cacheEligible = false, and ReplaceAllStringFunc runs on every single query — even though the schema value never changes for the lifetime of a River client.

The caching mechanism from #802 exists and works correctly, it's just never activated because Stable isn't set.

Impact we observed

We run River in two deployments (an API that enqueues jobs and a worker that processes them). After profiling with go tool pprof in production:

  • Worker (runs river.Start() with continuous polling): regexp.ReplaceAllStringFunc grew +29MB in 16 minutes, accounting for 99% of all memory growth
  • API (insert-only client): regexp.ReplaceAllStringFunc grew +16.9MB in 20 minutes, accounting for 89% of all memory growth

The strings allocated by the regex are short-lived but under production throughput, the allocation rate outpaces GC and downstream consumers (like pgx tracers or OTel span attributes) can hold references long enough for the memory to accumulate.

We confirmed the fix by patching schemaTemplateParam locally via a go.mod replace directive — after deploying with Stable: true, memory graphs in our monitoring flatlined.

Suggested fix

One-line change in schemaTemplateParam:

"schema": {Value: schema, Stable: true},

The same applies to the ColumnExists method which also passes a schema replacement without Stable.

The schema value is derived from the client's config and is constant for the lifetime of the River client — it's the textbook case for Stable: true.

This wouldn't affect queries that use dynamic replacements like where_clause and order_by_clause (e.g., JobList, JobDeleteMany) — those correctly remain uncached since their replacements don't set Stable.

Environment

  • River: v0.33.0 (also confirmed on v0.35.1)
  • riverpgxv5
  • Go 1.26.2
  • pgx v5.9.2

Happy to open a PR if you'd like!

Screenshot of our pods in sandbox environment before and after the fix

Image

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions