## 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.
83 lines
3.6 KiB
Go
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"
|
|
)
|