Files
warmbly/internal/repository/pg_sequence.go
T
Matthew Meszaros f9c02bba6e fix(db): plug 4 tx leaks + bump pool from 4 → 25 — root cause of 10-min logout
Root cause for the 10-min auto-logout (confirmed via pg_stat_activity):
the postgres pool MaxConns was 4, and four repository functions opened
a tx without committing or rolling back. After four calls each leaked
a connection in "idle in transaction" state. Once all four were gone
the pool was permanently exhausted — every new request that needed a
connection blocked until the client gave up. The 10-min trigger is
because that's when the first /auth/refresh fires; refresh tries to
acquire a connection, hangs, eventually the browser aborts the request,
the frontend treats the failure as session expiry, kicks the user.

The four leaking sites:
  - emailRepository.Search        (drove the leak — Accounts page)
  - campaignRepository.Search
  - sequenceRepository.Create
  - contactRepository.BulkUpdate

Each now has `defer tx.Rollback(ctx)` immediately after Begin, matching
the pattern used in the non-leaky sites in the same files. Rollback is
a no-op after Commit, so this is safe for both read-only tx (Search)
and read-write tx (Create / BulkUpdate).

Additional hardening so a future leak can't silently brick the backend:
  - MaxConns 4 → 25. 4 was reckless even without leaks; one bursty
    admin page would saturate. 25 is still well under postgres'
    default max_connections=100.
  - MinConns 0 → 2. Keep a couple of warm connections at idle so the
    first request after a quiet period doesn't pay the connect cost.
  - idle_in_transaction_session_timeout=300000 (5 min) as a session
    RuntimeParam. If a code path forgets the defer, postgres aborts
    the leaked tx after 5 min and reclaims the connection.
  - statement_timeout=60000 (60 s) as a session RuntimeParam.
    Statement runaway can't pin a connection forever.

Verified after backend restart:
  SELECT count(*) FROM pg_stat_activity
    WHERE datname='warmbly_dev' AND state='idle in transaction';
  → 0
2026-05-23 04:41:40 +00:00

304 lines
6.7 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/encrypt"
)
type SequenceRepository interface {
Create(ctx context.Context, userID, campaignID string) (*models.Sequence, *errx.Error)
Get(ctx context.Context, userID, campaignID string) ([]models.Sequence, *errx.Error)
Update(ctx context.Context, userID, campaignID, sequenceID string, data *models.UpdateSequence) (*models.Sequence, *errx.Error)
Delete(ctx context.Context, userID, campaignID, sequenceID string) *errx.Error
}
type sequenceRepository struct {
DB *db.DB
Encrypt *encrypt.Encrypter
}
func NewSequenceRepostory(db *db.DB) SequenceRepository {
return &sequenceRepository{
DB: db,
}
}
var SequenceSelections []string = []string{
"id",
"name",
"subject",
"body_plain",
"body_html",
"body_sync",
"body_code",
"wait_after",
"position",
"updated_at",
"created_at",
}
func getSequenceSelect(join bool) string {
sel := SequenceSelections
if join {
for i := range sel {
sel[i] = "s." + sel[i]
}
}
return strings.Join(sel, ", ")
}
var (
SequenceSelect = getSequenceSelect(false)
SequenceSelectJoin = getSequenceSelect(true)
)
func GetSequence(row db.Scannable, seq *models.Sequence) error {
return row.Scan(
&seq.ID, &seq.Name, &seq.Subject, &seq.BodyPlain, &seq.BodyHTML, &seq.BodySync,
&seq.BodyCode, &seq.WaitAfter, &seq.Position, &seq.UpdatedAt, &seq.CreatedAt,
)
}
func (r *sequenceRepository) Get(ctx context.Context, userID string, campaignID string) ([]models.Sequence, *errx.Error) {
query := fmt.Sprintf(
`SELECT %s
FROM sequences s
JOIN campaigns c ON s.campaign_id = c.id
WHERE s.campaign_id = $1
AND c.user_id = $2
ORDER BY s.position ASC, s.created_at ASC`,
SequenceSelectJoin,
)
params := []any{
campaignID,
userID,
}
rows, err := r.DB.Query(
ctx,
query,
params...,
)
if err != nil {
db.CaptureError(err, query, params, "query")
return nil, errx.InternalError()
}
var sequences []models.Sequence = make([]models.Sequence, 0)
for rows.Next() {
var seq models.Sequence
err = GetSequence(rows, &seq)
if err != nil {
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
sequences = append(sequences, seq)
}
return sequences, nil
}
func (r *sequenceRepository) Create(ctx context.Context, userID string, campaignID string) (*models.Sequence, *errx.Error) {
tx, err := r.DB.Begin(ctx)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
}
defer tx.Rollback(ctx)
query := `
SELECT user_id
FROM campaigns WHERE id = $1
`
params := []any{
campaignID,
}
var ownerID string
err = tx.QueryRow(
ctx,
query,
params...,
).Scan(&ownerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errx.ErrNotFound
}
db.CaptureError(err, query, params, "queryrow")
return nil, errx.InternalError()
}
if ownerID != userID {
return nil, errx.ErrForbidden
}
// Get the next position for this campaign's sequences
var nextPos int
_ = tx.QueryRow(ctx, `SELECT COALESCE(MAX(position), 0) + 1 FROM sequences WHERE campaign_id = $1`, campaignID).Scan(&nextPos)
query = fmt.Sprintf(
`INSERT INTO sequences (
campaign_id, name, subject, body_plain, body_html, position
) VALUES (
$1, $2, $3, $4, $5, $6
) RETURNING %s`, SequenceSelect,
)
params = []any{
campaignID,
config.SequenceDefaultName,
"",
"",
"<div></div>",
nextPos,
}
row := tx.QueryRow(
ctx,
query,
params...,
)
var seq models.Sequence
err = GetSequence(row, &seq)
if err != nil {
db.CaptureError(err, query, params, "scan")
return nil, errx.InternalError()
}
if err := tx.Commit(ctx); err != nil {
db.CaptureError(err, "", nil, "commit")
return nil, errx.InternalError()
}
return &seq, nil
}
func (r *sequenceRepository) Update(ctx context.Context, userID, campaignID, sequenceID string, data *models.UpdateSequence) (*models.Sequence, *errx.Error) {
setClauses := []string{}
args := []any{userID, campaignID, sequenceID}
argPos := 4
if data.Name != nil {
if len(*data.Name) > 50 {
return nil, errx.ErrSequenceName
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "name", argPos))
args = append(args, *data.Name)
argPos++
}
if data.Subject != nil {
if len(*data.Subject) > 100 {
return nil, errx.ErrSequenceSubject
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "subject", argPos))
args = append(args, *data.Subject)
argPos++
}
if data.BodyPlain != nil {
if len(*data.BodyPlain) > config.SequenceBodyLimit {
return nil, errx.ErrSequenceBody
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "body_plain", argPos))
args = append(args, *data.BodyPlain)
argPos++
}
if data.BodyHTML != nil {
if len(*data.BodyHTML) > config.SequenceBodyLimit {
return nil, errx.ErrSequenceBody
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "body_html", argPos))
args = append(args, *data.BodyHTML)
argPos++
}
if data.BodySync != nil {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "body_sync", argPos))
args = append(args, *data.BodySync)
argPos++
}
if data.BodyCode != nil {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "body_code", argPos))
args = append(args, *data.BodyCode)
argPos++
}
if data.WaitAfter != nil {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "wait_after", argPos))
args = append(args, *data.WaitAfter)
argPos++
}
if argPos == 4 {
return nil, errx.ErrNotEnough
}
var seq models.Sequence
query := fmt.Sprintf(
`UPDATE sequences s
SET %s
FROM campaigns c
WHERE c.user_id = $1
AND c.id = $2
AND s.id = $3
RETURNING %s`,
strings.Join(setClauses, ", "),
SequenceSelectJoin,
)
row := r.DB.QueryRow(
ctx,
query,
args...,
)
err := GetSequence(row, &seq)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errx.ErrNotFound
}
db.CaptureError(err, query, args, "queryrow")
return nil, errx.InternalError()
}
return &seq, nil
}
func (r *sequenceRepository) Delete(ctx context.Context, userID, campaignID, sequenceID string) *errx.Error {
query := `
DELETE FROM sequences s
USING campaigns c
WHERE c.user_id = $1
AND c.id = $2
AND s.id = $3
`
params := []any{
userID,
campaignID,
sequenceID,
}
cmd, err := r.DB.Exec(
ctx,
query,
params...,
)
if err != nil {
db.CaptureError(err, query, params, "exec")
return errx.InternalError()
}
if cmd.RowsAffected() == 0 {
return errx.ErrNotFound
}
return nil
}