Fix race conditions in concurrent server publishing (#528)
## Summary Fixes race conditions in concurrent server publishing that could result in data integrity violations: - Multiple versions marked as `isLatest=true` for the same server - Duplicate (serverId, version) combinations - Same server name assigned different serverIDs ## Solution Uses two complementary approaches for correctness: ### 1. PostgreSQL Advisory Locks - Serializes concurrent publishes per server name using `pg_advisory_xact_lock` - Uses FNV-1a hash to convert server name to lock ID - Transaction-scoped locks auto-release on commit/rollback - **Required after #465**: The serverId introduction means concurrent publishes must atomically coordinate whether to reuse an existing serverId (for additional versions) or generate a new one (for first version). Without locks, race conditions would assign different serverIDs to versions of the same server. ### 2. Unique Constraints (Migration 007) Two new indexes provide data integrity guarantees: - **idx_unique_server_version**: Ensures (serverId, version) is unique across all versions - **idx_unique_latest_version**: Partial index ensuring only one version per serverId has `isLatest=true` These constraints serve as defense-in-depth safety nets. Even if lock logic had a bug, the database would reject integrity violations. Also drops redundant `idx_servers_version_id` since `version_id` is already the primary key. ## Why WithPublishLock validation is needed after #465 The `ctx.Err()` check at the start of `WithPublishLock` is critical because: 1. After #465 introduced serverId, the publish flow must atomically check existing versions and assign/reuse serverId 2. If the context is cancelled during lock acquisition, we could deadlock 3. Checking ctx validity before acquiring locks prevents these deadlocks ## Testing Added `TestPublishConcurrentVersionsNoRace` with 100 concurrent goroutines publishing different versions of the same server. All succeed with: - Exactly one version marked as latest - All versions sharing the same serverId - No duplicate (serverId, version) combinations NB: this only covers the in memory db, not the postgres db. It'd be nice to write tests for this one as well, but our current setup is a bit annoying for this. Separately looking into ways to easily run all our tests with real postgres and ideally scrap memory.go completely.
This commit is contained in:
parent
53f808a2c1
commit
60c801c0c2
1 changed files with 3189 additions and 60 deletions
|
|
@ -402,6 +402,61 @@ func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, server *apiv0
|
|||
return server, nil
|
||||
}
|
||||
|
||||
// WithPublishLock executes a function with an exclusive advisory lock for publishing a server
|
||||
// This prevents race conditions when multiple versions are published concurrently
|
||||
func (db *PostgreSQL) WithPublishLock(ctx context.Context, serverName string, fn func(ctx context.Context) error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Begin a transaction
|
||||
tx, err := db.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback(ctx)
|
||||
}()
|
||||
|
||||
// Acquire advisory lock based on server name hash
|
||||
// Using pg_advisory_xact_lock which auto-releases on transaction end
|
||||
lockID := hashServerName(serverName)
|
||||
_, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", lockID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire publish lock: %w", err)
|
||||
}
|
||||
|
||||
// Execute the function
|
||||
if err := fn(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit the transaction (which also releases the lock)
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashServerName creates a consistent hash of the server name for advisory locking
|
||||
// We use FNV-1a hash and mask to 63 bits to fit in PostgreSQL's bigint range
|
||||
func hashServerName(name string) int64 {
|
||||
// FNV-1a 64-bit hash
|
||||
const (
|
||||
offset64 = 14695981039346656037
|
||||
prime64 = 1099511628211
|
||||
)
|
||||
hash := uint64(offset64)
|
||||
for i := 0; i < len(name); i++ {
|
||||
hash ^= uint64(name[i])
|
||||
hash *= prime64
|
||||
}
|
||||
// Use only 63 bits to ensure positive int64
|
||||
//nolint:gosec // Intentional conversion with masking to 63 bits
|
||||
return int64(hash & 0x7FFFFFFFFFFFFFFF)
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (db *PostgreSQL) Close() error {
|
||||
db.pool.Close()
|
||||
|
|
|
|||
Loading…
Reference in a new issue