From f9d92602748e496b1533e48ad0c3ad2cf74b7d8d Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Sat, 21 Feb 2026 00:29:09 +0100 Subject: [PATCH] fix(channelserver): configure DB pool and add transactions for guild ops sqlx.Open was called with no pool configuration, risking PostgreSQL connection exhaustion under load. Set max open/idle conns and lifetimes. CreatePost INSERT + soft-delete UPDATE were two separate queries with no transaction, risking inconsistent state on partial failure. CollectAdventure used SELECT then UPDATE without a lock, allowing concurrent guild members to double-collect. Now uses SELECT FOR UPDATE within a transaction. --- main.go | 7 +++++++ server/channelserver/repo_guild.go | 32 +++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index 82becbddf..a778c2e0e 100644 --- a/main.go +++ b/main.go @@ -136,6 +136,13 @@ func main() { if err != nil { preventClose(config, fmt.Sprintf("Database: Failed to ping, %s", err.Error())) } + + // Configure connection pool to avoid exhausting PostgreSQL under load. + db.SetMaxOpenConns(50) + db.SetMaxIdleConns(10) + db.SetConnMaxLifetime(5 * time.Minute) + db.SetConnMaxIdleTime(2 * time.Minute) + logger.Info("Database: Started successfully") // Pre-compute all server IDs this instance will own, so we only diff --git a/server/channelserver/repo_guild.go b/server/channelserver/repo_guild.go index 6ed3ff7a7..b735e7000 100644 --- a/server/channelserver/repo_guild.go +++ b/server/channelserver/repo_guild.go @@ -1,6 +1,7 @@ package channelserver import ( + "context" "database/sql" "errors" "fmt" @@ -498,16 +499,24 @@ func (r *GuildRepository) ListPosts(guildID uint32, postType int) ([]*MessageBoa // CreatePost inserts a new guild post and soft-deletes excess posts beyond maxPosts. func (r *GuildRepository) CreatePost(guildID, authorID, stampID uint32, postType int, title, body string, maxPosts int) error { - if _, err := r.db.Exec( + tx, err := r.db.BeginTxx(context.Background(), nil) + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec( `INSERT INTO guild_posts (guild_id, author_id, stamp_id, post_type, title, body) VALUES ($1, $2, $3, $4, $5, $6)`, guildID, authorID, stampID, postType, title, body); err != nil { return err } - _, err := r.db.Exec(`UPDATE guild_posts SET deleted = true WHERE id IN ( + if _, err := tx.Exec(`UPDATE guild_posts SET deleted = true WHERE id IN ( SELECT id FROM guild_posts WHERE guild_id = $1 AND post_type = $2 AND deleted = false ORDER BY created_at DESC OFFSET $3 - )`, guildID, postType, maxPosts) - return err + )`, guildID, postType, maxPosts); err != nil { + return err + } + return tx.Commit() } // DeletePost soft-deletes a guild post by ID. @@ -698,15 +707,24 @@ func (r *GuildRepository) CreateAdventureWithCharge(guildID, destination, charge } // CollectAdventure marks an adventure as collected by the given character (CSV append). +// Uses SELECT FOR UPDATE to prevent concurrent double-collect. func (r *GuildRepository) CollectAdventure(adventureID uint32, charID uint32) error { + tx, err := r.db.BeginTxx(context.Background(), nil) + if err != nil { + return err + } + defer tx.Rollback() + var collectedBy string - err := r.db.QueryRow("SELECT collected_by FROM guild_adventures WHERE id = $1", adventureID).Scan(&collectedBy) + err = tx.QueryRow("SELECT collected_by FROM guild_adventures WHERE id = $1 FOR UPDATE", adventureID).Scan(&collectedBy) if err != nil { return err } collectedBy = stringsupport.CSVAdd(collectedBy, int(charID)) - _, err = r.db.Exec("UPDATE guild_adventures SET collected_by = $1 WHERE id = $2", collectedBy, adventureID) - return err + if _, err = tx.Exec("UPDATE guild_adventures SET collected_by = $1 WHERE id = $2", collectedBy, adventureID); err != nil { + return err + } + return tx.Commit() } // ChargeAdventure adds charge to a guild adventure.