registry/internal/database/database.go
adam jones 60c801c0c2
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.
2025-09-22 12:16:05 +01:00

83 lines
3.6 KiB
Go

package database
import (
"context"
"errors"
"time"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
)
// Common database errors
var (
ErrNotFound = errors.New("record not found")
ErrAlreadyExists = errors.New("record already exists")
ErrInvalidInput = errors.New("invalid input")
ErrDatabase = errors.New("database error")
ErrInvalidVersion = errors.New("invalid version: cannot publish duplicate version")
ErrMaxServersReached = errors.New("maximum number of versions for this server reached (10000): please reach out at https://github.com/modelcontextprotocol/registry to explain your use case")
)
// ServerFilter defines filtering options for server queries
type ServerFilter struct {
Name *string // for finding versions of same server
RemoteURL *string // for duplicate URL detection
UpdatedSince *time.Time // for incremental sync filtering
SubstringName *string // for substring search on name
Version *string // for exact version matching
IsLatest *bool // for filtering latest versions only
}
// Database defines the interface for database operations
type Database interface {
// Retrieve server entries with optional filtering
List(ctx context.Context, filter *ServerFilter, cursor string, limit int) ([]*apiv0.ServerJSON, string, error)
// Retrieve a single server by its version ID
GetByVersionID(ctx context.Context, versionID string) (*apiv0.ServerJSON, error)
// Retrieve latest version of a server by server ID
GetByServerID(ctx context.Context, serverID string) (*apiv0.ServerJSON, error)
// Retrieve specific version of a server by server ID and version
GetByServerIDAndVersion(ctx context.Context, serverID string, version string) (*apiv0.ServerJSON, error)
// Retrieve all versions of a server by server ID
GetAllVersionsByServerID(ctx context.Context, serverID string) ([]*apiv0.ServerJSON, error)
// CreateServer adds a new server to the database
CreateServer(ctx context.Context, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error)
// UpdateServer updates an existing server record
UpdateServer(ctx context.Context, id string, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error)
// WithPublishLock executes a function with an exclusive lock for publishing a server
// This prevents race conditions when multiple versions are published concurrently
WithPublishLock(ctx context.Context, serverName string, fn func(ctx context.Context) error) error
// Close closes the database connection
Close() error
}
// WithPublishLockT is a generic helper that wraps WithPublishLock for functions returning a value
// This exists because Go does not support generic methods on interfaces - only the Database interface
// method WithPublishLock (without generics) can exist, so we provide this generic wrapper function.
// This is a common pattern in Go for working around this language limitation.
func WithPublishLockT[T any](ctx context.Context, db Database, serverName string, fn func(ctx context.Context) (T, error)) (T, error) {
var result T
var fnErr error
err := db.WithPublishLock(ctx, serverName, func(lockCtx context.Context) error {
result, fnErr = fn(lockCtx)
return fnErr
})
if err != nil {
var zero T
return zero, err
}
return result, nil
}
// ConnectionType represents the type of database connection
type ConnectionType string
const (
// ConnectionTypeMemory represents an in-memory database connection
ConnectionTypeMemory ConnectionType = "memory"
// ConnectionTypePostgreSQL represents a PostgreSQL database connection
ConnectionTypePostgreSQL ConnectionType = "postgresql"
)