Skip to content

Scalable outbox: dedicated connection pool, keyset pagination, dedup - #25

Merged
strobus merged 9 commits into
mainfrom
feat/scalable-outbox-pagination
Mar 16, 2026
Merged

Scalable outbox: dedicated connection pool, keyset pagination, dedup#25
strobus merged 9 commits into
mainfrom
feat/scalable-outbox-pagination

Conversation

@strobus

@strobus strobus commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

Problem

At scale, the outbox consumer was experiencing context deadline exceeded errors and blocking behavior. Root cause analysis identified three compounding issues:

  1. Connection pool starvation — The outbox consumer shared a *sql.DB pool with the API resolvers. Long-running mutations (e.g. createServiceableAddresses) would exhaust the pool, starving the outbox consumer's SELECT and FOR UPDATE queries until the mutation completed or timed out.

  2. Unbounded table scanSELECT id FROM outbox loaded every row into memory in a single query, causing context deadline timeouts as the table grew.

Changes

Dedicated consumer connection pool (store/pg/pg.go)

  • Creates a separate *sql.DB from connStr with configurable MaxOpenConns (default 8)
  • All consumer operations (queryPage, ProcessTx, GetWithLock, Delete) run on this isolated pool
  • The caller's db parameter is accepted for backward compatibility but no longer stored
  • Added Close() method and done channel for clean shutdown and goroutine lifecycle
  • Added WithMaxConsumerConns(n) option

Keyset pagination (store/pg/pg.go)

  • Replaced SELECT id FROM outbox with SELECT id FROM outbox WHERE id > $1 ORDER BY id LIMIT N
  • O(1) per page via primary key index, stable under concurrent inserts/deletes
  • Split into queryPage (DB-scoped context) and fetchPage (channel sends) so backpressure cannot trigger context timeouts
  • Added WithPageSize(n) and WithChannelBufferSize(n) options
  • Defaults tuned to chanBufferSize=5, pageSize=6 — producer blocks on the 6th item, keeping the consumer saturated while minimizing duplicate-read windows across pods

In-flight deduplication (outbox.go)

  • Added sync.Map to track IDs currently being processed
  • Duplicate IDs from overlapping scans are skipped (LoadOrStore / Delete around process)

ProcessTx rollback safety (store/pg/pg.go)

  • Added defer tx.Rollback() to handle context cancellation gracefully (no-op after commit)
  • Eliminated noisy sql: transaction has already been committed or rolled back errors

Listener lifecycle (store/pg/pg.go)

  • Added defer l.Close() in Listen goroutine
  • Moved l.Ping() back into the time.After select case (only needed when idle)
  • Removed redundant pre-check select on done channel
  • Added rows.Err() check after row iteration

Tests (store/pg/pg_test.go, store/pg/pg_suite_test.go)

  • Added tests for dedicated consumer pool creation, WithMaxConsumerConns, and Close()
  • Added defer subject.Close() to all test cases
  • Fixed connStr initialization in test suite setup

Test plan

  • go test ./store/pg/... passes
  • Verified under load — context deadline errors resolved
  • Confirmed outbox processes messages concurrently with long-running API mutations
  • Keyset pagination handles concurrent inserts/deletes correctly

🤖 Generated with Claude Code

strobus and others added 2 commits March 14, 2026 09:08
This log fires for every outbox record that was already processed by
another pod or goroutine, which is expected behavior in multi-replica
deployments. At INFO level it produces thousands of noisy log lines
that obscure meaningful output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The getRecordIDs loop used defer inside a for-loop, accumulating unclosed
rows and uncancelled contexts across pages — exhausting the connection pool
and causing context deadline exceeded errors. This extracts a fetchPage
method so defer scopes correctly per page.

Also replaces LIMIT/OFFSET pagination with keyset pagination (WHERE id > $1
ORDER BY id) which is O(1) per page and stable under concurrent modifications.
Increases the channel buffer from 1 to 100 to reduce backpressure blocking.

Adds configurable WithPageSize and WithChannelBufferSize store options.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@strobus strobus added the Type: Enhancement New feature or request label Mar 15, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@strobus strobus changed the title Fix connection leak and add keyset pagination for scalability Fix context deadline issues with keyset pagination for scalability Mar 15, 2026
strobus and others added 7 commits March 15, 2026 20:04
Split fetchPage into queryPage (DB-scoped) and fetchPage (channel send)
so that channel backpressure cannot trigger the 15s context timeout.
This was causing "pq: canceling statement due to user request" errors
every 15s under load when the consumer couldn't drain the channel fast
enough.

Reduce defaults to chanBufferSize=5, pageSize=6 so the producer blocks
on the 6th item of each page. This keeps the consumer saturated (5 items
of runway while the next page is fetched) while minimizing the window
where another pod could read the same buffered-but-unprocessed items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add sync.Map-based in-flight tracking in dispatch() to skip IDs already
being processed. Without this, getRecordIDs rescans return records still
being processed, sending duplicate IDs to the channel. Each duplicate
spawns a goroutine that acquires a pool connection via BeginTx, saturating
the pool and causing 30s context timeouts across the system — including
spilling into the sites-api connection pool.

Use defer tx.Rollback() in ProcessTx (idiomatic Go pattern) so that when
context cancellation auto-rolls back the tx, the deferred Rollback is a
silent no-op instead of returning "sql: transaction has already been
committed or rolled back".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…pressure

The outbox consumer (queryPage, ProcessTx) shared the same *sql.DB pool
as the API resolvers. Long-running GraphQL mutations exhausted the pool,
starving the consumer and triggering 15s context timeouts.

Open a dedicated consumer pool from the existing connStr with a small
MaxOpenConns (default 8). queryPage and ProcessTx.BeginTx now use this
isolated pool, so API traffic cannot block outbox processing.

Also adds:
- WithMaxConsumerConns option to configure the pool size
- Close() method with done channel for graceful shutdown of the Listen
  goroutine and consumer pool
- Cancellable channel sends via done channel to prevent goroutine leaks

Fixes test connStr to point at the correct database (outbox_test).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Set s.db = consumerDB on the parent store so init(), Delete, and Update
bypass the caller's shared pool entirely. The db parameter in NewStore
is kept for backward compatibility but no longer used.

Also removes redundant pre-check select on done channel in Listen loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@strobus strobus changed the title Fix context deadline issues with keyset pagination for scalability Scalable outbox: dedicated connection pool, keyset pagination, dedup Mar 16, 2026
@strobus
strobus requested a review from gjarmstrong March 16, 2026 16:04
@strobus
strobus merged commit 2c6c233 into main Mar 16, 2026
1 of 3 checks passed
@strobus
strobus deleted the feat/scalable-outbox-pagination branch March 16, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Type: Enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants