mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 00:01:14 +00:00
f9c02bba6e
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
304 lines
6.7 KiB
Go
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
|
|
}
|