diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index a82c462..26144ec 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:9.6-alpine + image: postgres:16-alpine env: POSTGRES_DB: pglock POSTGRES_USER: test @@ -22,17 +22,19 @@ jobs: --health-timeout 5s --health-retries 5 steps: - - name: Set up Go 1.17 - uses: actions/setup-go@v1 + - name: Set up Go 1.24 + uses: actions/setup-go@v6 with: - go-version: 1.17 + go-version: 1.24 id: go - name: Check out code into the Go module directory - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Get dependencies run: go mod download - - name: Lint - run: make lint + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: latest - name: Test env: DATABASE_URL: 'postgres://test:test@localhost:5432/pglock?sslmode=disable' diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..df840b7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,13 @@ +version: "2" +linters: + exclusions: + paths: + - examples/ + default: standard + enable: + - gosec +formatters: + settings: + goimports: + local-prefixes: + - github.com/allisson/go-pglock diff --git a/Makefile b/Makefile index ebcb950..9fe955c 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,32 @@ lint: - if [ ! -f ./bin/golangci-lint ] ; \ - then \ - curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.47.2; \ - fi; - ./bin/golangci-lint run -e gosec + golangci-lint run -v test: go test -covermode=count -coverprofile=count.out -v ./... +test-race: + go test -race -covermode=atomic -coverprofile=count.out -v ./... + +test-coverage: + go test -covermode=count -coverprofile=count.out -v ./... + go tool cover -html=count.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +docker-up: + docker-compose up -d + @echo "Waiting for PostgreSQL to be ready..." + @sleep 3 + +docker-down: + docker-compose down -v + +test-local: docker-up + @echo "Running tests with local PostgreSQL..." + @DATABASE_URL='postgres://test:test@localhost:5432/pglock?sslmode=disable' go test -v ./... + @$(MAKE) docker-down + mock: @rm -rf mocks mockery --name Locker -.PHONY: lint test mock +.PHONY: lint test test-race test-coverage docker-up docker-down test-local mock diff --git a/QUICKREF.md b/QUICKREF.md new file mode 100644 index 0000000..b7add02 --- /dev/null +++ b/QUICKREF.md @@ -0,0 +1,232 @@ +# go-pglock Quick Reference + +## Installation + +```bash +go get github.com/allisson/go-pglock/v3 +``` + +## Basic Usage + +```go +import "github.com/allisson/go-pglock/v3" + +// Connect to database +db, _ := sql.Open("postgres", "postgres://user:pass@host/db?sslmode=disable") + +// Create lock +ctx := context.Background() +lock, err := pglock.NewLock(ctx, lockID, db) +defer lock.Close() + +// Acquire lock (non-blocking) +acquired, err := lock.Lock(ctx) + +// Wait for lock (blocking) +err := lock.WaitAndLock(ctx) + +// Release lock +err := lock.Unlock(ctx) +``` + +## API Quick Reference + +| Method | Behavior | Returns | Use Case | +|--------|----------|---------|----------| +| `NewLock(ctx, id, db)` | Create lock instance | `Lock, error` | Initialize lock | +| `Lock(ctx)` | Try acquire (non-blocking) | `bool, error` | Skip if busy | +| `WaitAndLock(ctx)` | Wait for lock (blocking) | `error` | Must execute | +| `Unlock(ctx)` | Release one lock level | `error` | After work | +| `Close()` | Release all & cleanup | `error` | Shutdown | + +## Common Patterns + +### Pattern: Try Lock + +```go +acquired, _ := lock.Lock(ctx) +if !acquired { + return // Skip work +} +defer lock.Unlock(ctx) +// Do work +``` + +### Pattern: Wait for Lock + +```go +if err := lock.WaitAndLock(ctx); err != nil { + return err +} +defer lock.Unlock(ctx) +// Do work +``` + +### Pattern: Lock with Timeout + +```go +ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +defer cancel() + +if err := lock.WaitAndLock(ctx); err != nil { + return err // Timeout or other error +} +defer lock.Unlock(context.Background()) +// Do work +``` + +### Pattern: Unique Lock per Resource + +```go +import "hash/fnv" + +func lockIDFromString(s string) int64 { + h := fnv.New64a() + h.Write([]byte(s)) + return int64(h.Sum64()) +} + +lockID := lockIDFromString("user-" + userID) +``` + +## Lock Behavior + +### Blocking vs Non-blocking + +| Method | Blocks? | Use When | +|--------|---------|----------| +| `Lock()` | No | Can skip if locked | +| `WaitAndLock()` | Yes | Must execute eventually | + +### Lock Stacking + +Locks **stack** within the same session: + +```go +lock.Lock(ctx) // Acquired (count: 1) +lock.Lock(ctx) // Acquired (count: 2) +lock.Unlock(ctx) // Released (count: 1) - still locked! +lock.Unlock(ctx) // Released (count: 0) - now free +``` + +### Lock Release + +Locks are released when: +- ✅ `Unlock()` called (decrements count) +- ✅ `Close()` called (releases all) +- ✅ Connection closes +- ✅ Database session ends + +## Error Handling + +```go +acquired, err := lock.Lock(ctx) +if err != nil { + // Database or connection error +} +if !acquired { + // Lock held by another process +} +``` + +```go +err := lock.WaitAndLock(ctx) +if errors.Is(err, context.DeadlineExceeded) { + // Timeout occurred +} +if errors.Is(err, context.Canceled) { + // Context was cancelled +} +``` + +## Best Practices + +### ✅ DO + +- Always `defer lock.Close()` +- Use context with timeouts for `WaitAndLock()` +- Match lock and unlock calls (stacking) +- Use deterministic lock IDs +- Check `acquired` return value + +### ❌ DON'T + +- Don't use random lock IDs +- Don't forget to unlock +- Don't acquire in inconsistent order (deadlock) +- Don't share Lock instances across goroutines +- Don't rely on lock after `Close()` + +## Testing + +```bash +# Start PostgreSQL +docker-compose up -d + +# Run tests +make test-local + +# With race detector +make test-race +``` + +## Use Cases at a Glance + +| Use Case | Pattern | Lock Type | +|----------|---------|-----------| +| Scheduled jobs | Try Lock | Non-blocking | +| Database migrations | Wait + Timeout | Blocking | +| Leader election | Try Lock | Non-blocking | +| Task processing | Try Lock | Non-blocking | +| Resource pools | Try each slot | Non-blocking | +| Critical sections | Wait Lock | Blocking | + +## Connection Management + +```go +// Configure pool for locks +db.SetMaxOpenConns(50) // Each lock uses one connection +db.SetMaxIdleConns(10) +db.SetConnMaxLifetime(time.Hour) +``` + +## Lock ID Strategies + +### Strategy 1: Sequential IDs + +```go +const ( + LockIDMigration = 1 + LockIDBackup = 2 + LockIDCleanup = 3 +) +``` + +### Strategy 2: Hash-based + +```go +lockID := hashToInt64("resource-name") +``` + +### Strategy 3: Composite + +```go +lockID := (resourceType << 32) | resourceID +``` + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| Lock never released | Add `defer lock.Close()` | +| Too many connections | Reduce pool size or close locks | +| Deadlocks | Acquire locks in consistent order | +| Timeouts | Increase timeout or investigate blocking | +| Tests skip | Set `DATABASE_URL` environment variable | + +## Resources + +- [Full Documentation](README.md) +- [Examples](examples/) +- [API Reference](https://pkg.go.dev/github.com/allisson/go-pglock/v3) +- [PostgreSQL Advisory Locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS) diff --git a/README.md b/README.md index 3283217..988e402 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,927 @@ # go-pglock + [![Build Status](https://github.com/allisson/go-pglock/workflows/tests/badge.svg)](https://github.com/allisson/go-pglock/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/allisson/go-pglock/v3)](https://goreportcard.com/report/github.com/allisson/go-pglock/v3) [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/allisson/go-pglock/v3) Distributed locks using PostgreSQL session level advisory locks. -## About PostgreSQL Advisory Locks +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [How It Works](#how-it-works) +- [API Reference](#api-reference) +- [Examples](#examples) + - [Basic Lock Usage](#basic-lock-usage) + - [Try Lock (Non-blocking)](#try-lock-non-blocking) + - [Wait and Lock (Blocking)](#wait-and-lock-blocking) + - [Lock with Timeout](#lock-with-timeout) + - [Concurrent Workers](#concurrent-workers) + - [Distributed Task Execution](#distributed-task-execution) + - [Leader Election](#leader-election) + - [Resource Pool Management](#resource-pool-management) + - [Database Migration Lock](#database-migration-lock) + - [Scheduled Job Coordination](#scheduled-job-coordination) +- [Best Practices](#best-practices) +- [Testing](#testing) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) +- [License](#license) + +## Overview + +`go-pglock` provides a simple and reliable way to implement distributed locks using PostgreSQL's advisory lock mechanism. This is useful when you need to coordinate access to shared resources across multiple processes or servers. + +### Key Features -From https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS: +- **Simple API**: Easy-to-use interface for acquiring and releasing locks +- **Non-blocking locks**: Try to acquire a lock without waiting +- **Blocking locks**: Wait until a lock becomes available +- **Context support**: Timeout and cancellation support for all operations +- **Lock stacking**: Same session can acquire the same lock multiple times +- **Automatic cleanup**: Locks are automatically released when connections close +- **No external dependencies**: Uses only PostgreSQL (no Redis, ZooKeeper, etc.) +- **Battle-tested**: Used in production environments -PostgreSQL provides a means for creating locks that have application-defined meanings. These are called advisory locks, because the system does not enforce their use — it is up to the application to use them correctly. Advisory locks can be useful for locking strategies that are an awkward fit for the MVCC model. For example, a common use of advisory locks is to emulate pessimistic locking strategies typical of so-called "flat file" data management systems. While a flag stored in a table could be used for the same purpose, advisory locks are faster, avoid table bloat, and are automatically cleaned up by the server at the end of the session. +### When to Use -There are two ways to acquire an advisory lock in PostgreSQL: at session level or at transaction level. Once acquired at session level, an advisory lock is held until explicitly released or the session ends. Unlike standard lock requests, session-level advisory lock requests do not honor transaction semantics: a lock acquired during a transaction that is later rolled back will still be held following the rollback, and likewise an unlock is effective even if the calling transaction fails later. A lock can be acquired multiple times by its owning process; for each completed lock request there must be a corresponding unlock request before the lock is actually released. Transaction-level lock requests, on the other hand, behave more like regular lock requests: they are automatically released at the end of the transaction, and there is no explicit unlock operation. This behavior is often more convenient than the session-level behavior for short-term usage of an advisory lock. Session-level and transaction-level lock requests for the same advisory lock identifier will block each other in the expected way. If a session already holds a given advisory lock, additional requests by it will always succeed, even if other sessions are awaiting the lock; this statement is true regardless of whether the existing lock hold and new request are at session level or transaction level. +Use `go-pglock` when you need to: + +- Prevent duplicate execution of scheduled jobs across multiple servers +- Coordinate access to shared resources +- Implement leader election +- Ensure only one instance processes a particular task +- Serialize access to critical sections in distributed systems +- Manage resource pools across multiple processes + +## Installation + +```bash +go get github.com/allisson/go-pglock/v3 +``` -## Example +Requirements: +- Go 1.17 or higher +- PostgreSQL 9.6 or higher -```golang +## Quick Start + +```go package main import ( - "context" - "database/sql" - "fmt" - "log" - "os" - - "github.com/allisson/go-pglock/v3" - _ "github.com/lib/pq" + "context" + "database/sql" + "fmt" + "log" + + "github.com/allisson/go-pglock/v3" + _ "github.com/lib/pq" ) -func newDB() (*sql.DB, error) { - // export DATABASE_URL='postgres://user:pass@localhost:5432/pglock?sslmode=disable' - dsn := os.Getenv("DATABASE_URL") - db, err := sql.Open("postgres", dsn) - if err != nil { - return nil, err - } - return db, db.Ping() +func main() { + // Connect to PostgreSQL + db, err := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable") + if err != nil { + log.Fatal(err) + } + defer db.Close() + + ctx := context.Background() + + // Create a lock with ID 1 + lock, err := pglock.NewLock(ctx, 1, db) + if err != nil { + log.Fatal(err) + } + defer lock.Close() + + // Try to acquire the lock + acquired, err := lock.Lock(ctx) + if err != nil { + log.Fatal(err) + } + + if acquired { + fmt.Println("Lock acquired! Doing work...") + // Do your work here + + // Release the lock + if err := lock.Unlock(ctx); err != nil { + log.Fatal(err) + } + fmt.Println("Lock released!") + } else { + fmt.Println("Could not acquire lock - another process has it") + } +} +``` + +## How It Works + +PostgreSQL advisory locks are a powerful feature for implementing distributed locking: + +- **Session-level locks**: Locks are held until explicitly released or the database connection closes +- **Application-defined**: You define the meaning of each lock using a numeric identifier (int64) +- **Fast and efficient**: No table bloat, faster than row-level locks +- **Automatic cleanup**: Server automatically releases locks when sessions end +- **Lock stacking**: A session can acquire the same lock multiple times (requires equal unlocks) + +From the [PostgreSQL documentation](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS): + +> PostgreSQL provides a means for creating locks that have application-defined meanings. These are called advisory locks, because the system does not enforce their use — it is up to the application to use them correctly. Advisory locks can be useful for locking strategies that are an awkward fit for the MVCC model. + +## API Reference + +### Types + +#### `Locker` Interface + +```go +type Locker interface { + Lock(ctx context.Context) (bool, error) + WaitAndLock(ctx context.Context) error + Unlock(ctx context.Context) error + Close() error +} +``` + +#### `Lock` Struct + +```go +type Lock struct { + // contains filtered or unexported fields +} +``` + +### Functions + +#### `NewLock(ctx context.Context, id int64, db *sql.DB) (Lock, error)` + +Creates a new Lock instance with a dedicated database connection. + +- `ctx`: Context for managing the connection acquisition +- `id`: The lock identifier (int64) +- `db`: Database connection pool +- Returns: Lock instance and error + +#### `Lock(ctx context.Context) (bool, error)` + +Attempts to acquire a lock without waiting. Returns immediately with true if acquired, false otherwise. + +#### `WaitAndLock(ctx context.Context) error` + +Blocks until the lock is acquired. Respects context cancellation and timeouts. + +#### `Unlock(ctx context.Context) error` + +Releases one level of lock ownership. Must be called equal to the number of Lock/WaitAndLock calls. + +#### `Close() error` + +Closes the database connection and releases all locks. + +## Examples + +### Basic Lock Usage + +```go +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + + "github.com/allisson/go-pglock/v3" + _ "github.com/lib/pq" +) + +func main() { + db, err := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable") + if err != nil { + log.Fatal(err) + } + defer db.Close() + + ctx := context.Background() + lockID := int64(100) + + // Create lock + lock, err := pglock.NewLock(ctx, lockID, db) + if err != nil { + log.Fatal(err) + } + defer lock.Close() + + // Acquire lock + acquired, err := lock.Lock(ctx) + if err != nil { + log.Fatal(err) + } + + if !acquired { + fmt.Println("Lock is held by another process") + return + } + + // Critical section + fmt.Println("Executing critical section...") + // Your code here + + // Release lock + if err := lock.Unlock(ctx); err != nil { + log.Fatal(err) + } +} +``` + +### Try Lock (Non-blocking) + +Perfect for scenarios where you want to skip work if another process is already doing it. + +```go +func processDataIfAvailable(db *sql.DB) error { + ctx := context.Background() + lockID := int64(200) + + lock, err := pglock.NewLock(ctx, lockID, db) + if err != nil { + return err + } + defer lock.Close() + + // Try to acquire without waiting + acquired, err := lock.Lock(ctx) + if err != nil { + return err + } + + if !acquired { + fmt.Println("Another process is already processing data, skipping...") + return nil + } + defer lock.Unlock(ctx) + + // Process data + fmt.Println("Processing data...") + // Your processing logic here + + return nil +} +``` + +### Wait and Lock (Blocking) + +Use when you must execute the task eventually, even if you have to wait. + +```go +func processDataAndWait(db *sql.DB) error { + ctx := context.Background() + lockID := int64(300) + + lock, err := pglock.NewLock(ctx, lockID, db) + if err != nil { + return err + } + defer lock.Close() + + fmt.Println("Waiting for lock...") + + // Wait until lock is available + if err := lock.WaitAndLock(ctx); err != nil { + return err + } + defer lock.Unlock(ctx) + + fmt.Println("Lock acquired, processing data...") + // Your processing logic here + + return nil +} +``` + +### Lock with Timeout + +Implement a timeout to avoid waiting indefinitely. + +```go +func processWithTimeout(db *sql.DB, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + lockID := int64(400) + + lock, err := pglock.NewLock(ctx, lockID, db) + if err != nil { + return err + } + defer lock.Close() + + // This will fail if lock is not acquired within timeout + if err := lock.WaitAndLock(ctx); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("could not acquire lock within %v", timeout) + } + return err + } + defer lock.Unlock(context.Background()) // Use background for cleanup + + fmt.Println("Lock acquired, processing...") + // Your processing logic here + + return nil +} +``` + +### Concurrent Workers + +Coordinate multiple workers accessing a shared resource. + +```go +func runWorker(workerID int, db *sql.DB, wg *sync.WaitGroup) { + defer wg.Done() + + ctx := context.Background() + lockID := int64(500) // Same lock ID for all workers + + lock, err := pglock.NewLock(ctx, lockID, db) + if err != nil { + log.Printf("Worker %d: failed to create lock: %v", workerID, err) + return + } + defer lock.Close() + + fmt.Printf("Worker %d: waiting for lock...\n", workerID) + + if err := lock.WaitAndLock(ctx); err != nil { + log.Printf("Worker %d: failed to acquire lock: %v", workerID, err) + return + } + + fmt.Printf("Worker %d: acquired lock, processing...\n", workerID) + + // Simulate work + time.Sleep(1 * time.Second) + + fmt.Printf("Worker %d: releasing lock\n", workerID) + lock.Unlock(ctx) +} + +func main() { + db, _ := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable") + defer db.Close() + + var wg sync.WaitGroup + for i := 1; i <= 5; i++ { + wg.Add(1) + go runWorker(i, db, &wg) + } + wg.Wait() +} +``` + +### Distributed Task Execution + +Ensure a task runs only once across multiple servers. + +```go +type TaskProcessor struct { + db *sql.DB +} + +func (tp *TaskProcessor) ProcessTask(taskID string) error { + ctx := context.Background() + + // Use hash of task ID as lock ID + lockID := hashToInt64(taskID) + + lock, err := pglock.NewLock(ctx, lockID, tp.db) + if err != nil { + return err + } + defer lock.Close() + + // Try to acquire lock + acquired, err := lock.Lock(ctx) + if err != nil { + return err + } + + if !acquired { + return fmt.Errorf("task %s is already being processed", taskID) + } + defer lock.Unlock(ctx) + + fmt.Printf("Processing task %s...\n", taskID) + + // Execute task + if err := tp.executeTask(taskID); err != nil { + return fmt.Errorf("failed to execute task: %w", err) + } + + fmt.Printf("Task %s completed\n", taskID) + return nil +} + +func (tp *TaskProcessor) executeTask(taskID string) error { + // Your task execution logic + time.Sleep(2 * time.Second) + return nil +} + +func hashToInt64(s string) int64 { + h := fnv.New64a() + h.Write([]byte(s)) + return int64(h.Sum64()) +} +``` + +### Leader Election + +Implement leader election in a cluster of services. + +```go +type LeaderElector struct { + db *sql.DB + lockID int64 + isLeader bool + mu sync.RWMutex +} + +func NewLeaderElector(db *sql.DB, clusterName string) *LeaderElector { + return &LeaderElector{ + db: db, + lockID: hashToInt64(clusterName), + } +} + +func (le *LeaderElector) RunElection(ctx context.Context) error { + lock, err := pglock.NewLock(ctx, le.lockID, le.db) + if err != nil { + return err + } + defer lock.Close() + + // Try to become leader + acquired, err := lock.Lock(ctx) + if err != nil { + return err + } + + if acquired { + le.mu.Lock() + le.isLeader = true + le.mu.Unlock() + + fmt.Println("✓ Became leader") + defer func() { + le.mu.Lock() + le.isLeader = false + le.mu.Unlock() + lock.Unlock(context.Background()) + fmt.Println("✗ Lost leadership") + }() + + // Perform leader duties + le.performLeaderDuties(ctx) + } else { + fmt.Println("Another instance is the leader") + } + + return nil +} + +func (le *LeaderElector) IsLeader() bool { + le.mu.RLock() + defer le.mu.RUnlock() + return le.isLeader +} + +func (le *LeaderElector) performLeaderDuties(ctx context.Context) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + fmt.Println("Leader performing periodic task...") + // Do leader work + } + } } +``` + +### Resource Pool Management -func closeDB(db *sql.DB) { - if err := db.Close(); err != nil { - log.Fatal(err) - } +Manage a limited pool of resources across multiple processes. + +```go +type ResourcePool struct { + db *sql.DB + poolSize int +} + +func NewResourcePool(db *sql.DB, poolSize int) *ResourcePool { + return &ResourcePool{ + db: db, + poolSize: poolSize, + } +} + +// AcquireResource tries to acquire one resource from the pool +func (rp *ResourcePool) AcquireResource(ctx context.Context) (resourceID int, release func(), err error) { + // Try each resource slot + for i := 1; i <= rp.poolSize; i++ { + lockID := int64(10000 + i) // Base offset + slot number + + lock, err := pglock.NewLock(ctx, lockID, rp.db) + if err != nil { + continue + } + + // Try to acquire this slot (non-blocking) + acquired, err := lock.Lock(ctx) + if err != nil { + lock.Close() + continue + } + + if acquired { + // Successfully acquired this resource slot + release := func() { + lock.Unlock(context.Background()) + lock.Close() + } + return i, release, nil + } + + lock.Close() + } + + return 0, nil, fmt.Errorf("no resources available in pool") } func main() { - // Create two postgresql sessions - db1, err := newDB() - if err != nil { - log.Fatal(err) - } - defer closeDB(db1) - db2, err := newDB() - if err != nil { - log.Fatal(err) - } - defer closeDB(db2) - - // Set id and create locks - ctx := context.Background() - id := int64(1) - lock1, err := pglock.NewLock(ctx, id, db1) - if err != nil { - log.Fatal(err) - } - lock2, err := pglock.NewLock(ctx, id, db2) - if err != nil { - log.Fatal(err) - } - - // lock1 get the lock - ok, err := lock1.Lock(ctx) - if err != nil { - log.Fatal(err) - } - fmt.Printf("lock1.Lock()==%v\n", ok) - - // lock2 try to get the lock - ok, err = lock2.Lock(ctx) - if err != nil { - log.Fatal(err) - } - fmt.Printf("lock2.Lock()==%v\n", ok) - - // lock1 release the lock - if err := lock1.Unlock(ctx); err != nil { - log.Fatal(err) - } - - // lock2 try to get the lock again - ok, err = lock2.Lock(ctx) - if err != nil { - log.Fatal(err) - } - fmt.Printf("lock2.Lock()==%v\n", ok) - - // lock2 release the lock - if err := lock2.Unlock(ctx); err != nil { - log.Fatal(err) - } -} -``` - -```go run main.go -lock1.Lock()==true -lock2.Lock()==false -lock2.Lock()==true + db, _ := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable") + defer db.Close() + + pool := NewResourcePool(db, 3) // Pool of 3 resources + + ctx := context.Background() + resourceID, release, err := pool.AcquireResource(ctx) + if err != nil { + log.Fatal(err) + } + defer release() + + fmt.Printf("Acquired resource %d\n", resourceID) + + // Use the resource + time.Sleep(2 * time.Second) + + fmt.Printf("Releasing resource %d\n", resourceID) +} ``` + +### Database Migration Lock + +Ensure database migrations run only once in multi-instance deployments. + +```go +type MigrationRunner struct { + db *sql.DB +} + +func (mr *MigrationRunner) RunMigrations(ctx context.Context) error { + const migrationLockID = int64(999999) + + lock, err := pglock.NewLock(ctx, migrationLockID, mr.db) + if err != nil { + return fmt.Errorf("failed to create migration lock: %w", err) + } + defer lock.Close() + + fmt.Println("Attempting to acquire migration lock...") + + // Use a timeout to avoid waiting too long + lockCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + if err := lock.WaitAndLock(lockCtx); err != nil { + return fmt.Errorf("failed to acquire migration lock: %w", err) + } + defer lock.Unlock(context.Background()) + + fmt.Println("Migration lock acquired, checking migration status...") + + // Check if migrations are needed + needsMigration, err := mr.checkMigrationStatus() + if err != nil { + return err + } + + if !needsMigration { + fmt.Println("Database is up to date") + return nil + } + + // Run migrations + fmt.Println("Running migrations...") + if err := mr.executeMigrations(); err != nil { + return fmt.Errorf("migration failed: %w", err) + } + + fmt.Println("Migrations completed successfully") + return nil +} + +func (mr *MigrationRunner) checkMigrationStatus() (bool, error) { + // Check if migrations are needed + // This is application-specific logic + return true, nil +} + +func (mr *MigrationRunner) executeMigrations() error { + // Execute your migrations + time.Sleep(2 * time.Second) // Simulate migration work + return nil +} +``` + +### Scheduled Job Coordination + +Coordinate scheduled jobs across multiple instances to prevent duplicate execution. + +```go +type ScheduledJob struct { + db *sql.DB + jobID string + lockID int64 +} + +func NewScheduledJob(db *sql.DB, jobID string) *ScheduledJob { + return &ScheduledJob{ + db: db, + jobID: jobID, + lockID: hashToInt64(jobID), + } +} + +func (sj *ScheduledJob) Execute(ctx context.Context) error { + lock, err := pglock.NewLock(ctx, sj.lockID, sj.db) + if err != nil { + return fmt.Errorf("failed to create lock: %w", err) + } + defer lock.Close() + + // Try to acquire lock (non-blocking) + acquired, err := lock.Lock(ctx) + if err != nil { + return fmt.Errorf("failed to acquire lock: %w", err) + } + + if !acquired { + fmt.Printf("Job %s is already running on another instance\n", sj.jobID) + return nil + } + defer lock.Unlock(ctx) + + fmt.Printf("Executing job %s...\n", sj.jobID) + + // Execute the actual job + if err := sj.run(ctx); err != nil { + return fmt.Errorf("job execution failed: %w", err) + } + + fmt.Printf("Job %s completed\n", sj.jobID) + return nil +} + +func (sj *ScheduledJob) run(ctx context.Context) error { + // Your job logic here + select { + case <-time.After(5 * time.Second): + // Job completed + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func main() { + db, _ := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable") + defer db.Close() + + // Simulate a cron job running every minute + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + ctx := context.Background() + job := NewScheduledJob(db, "cleanup-task") + + for { + select { + case <-ticker.C: + if err := job.Execute(ctx); err != nil { + log.Printf("Job execution error: %v", err) + } + } + } +} +``` + +## Best Practices + +### 1. Always Close Locks + +Use `defer` to ensure locks are closed even if errors occur: + +```go +lock, err := pglock.NewLock(ctx, lockID, db) +if err != nil { + return err +} +defer lock.Close() // Always close to release the connection +``` + +### 2. Match Lock and Unlock Calls + +Locks stack, so ensure you unlock as many times as you lock: + +```go +// Acquired twice +lock.Lock(ctx) +lock.Lock(ctx) + +// Must unlock twice +lock.Unlock(ctx) +lock.Unlock(ctx) +``` + +### 3. Use Context Timeouts + +Prevent indefinite waiting with context timeouts: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +defer cancel() + +if err := lock.WaitAndLock(ctx); err != nil { + // Handle timeout +} +``` + +### 4. Choose Appropriate Lock IDs + +- Use meaningful, deterministic IDs based on resource names +- Use hash functions for string-based identifiers +- Document your lock ID allocation strategy + +```go +// Good: Deterministic based on resource +lockID := hashToInt64("user-" + userID) + +// Avoid: Random or non-deterministic IDs +lockID := rand.Int63() // Bad! +``` + +### 5. Handle Lock Acquisition Failures + +Always check if lock acquisition succeeded: + +```go +acquired, err := lock.Lock(ctx) +if err != nil { + // Handle error +} +if !acquired { + // Handle case where lock is held by another process +} +``` + +### 6. Use Connection Pooling Wisely + +Each lock holds a dedicated connection. Consider your connection pool size: + +```go +// Configure appropriate pool size +db.SetMaxOpenConns(50) // Ensure enough connections for locks + queries +``` + +### 7. Consider Lock Granularity + +- Fine-grained locks: Better concurrency, more complex +- Coarse-grained locks: Simpler, but may reduce throughput + +### 8. Testing with Locks + +When testing code that uses locks, consider using different lock IDs per test: + +```go +func TestMyFunction(t *testing.T) { + lockID := int64(time.Now().UnixNano()) // Unique per test run + // ... test code +} +``` + +## Testing + +### Running Tests Locally + +The project includes a Docker Compose setup for easy local testing: + +```bash +# Start PostgreSQL and run tests +make test-local + +# Run tests with race detector +make test-race + +# Generate coverage report +make test-coverage +``` + +### Manual Testing + +```bash +# Start PostgreSQL +docker-compose up -d + +# Set DATABASE_URL +export DATABASE_URL='postgres://test:test@localhost:5432/pglock?sslmode=disable' + +# Run tests +go test -v ./... + +# Clean up +docker-compose down +``` + +## Troubleshooting + +### "pq: database \"pglock\" does not exist" + +Create the database: + +```sql +CREATE DATABASE pglock; +``` + +### "too many connections" + +Increase PostgreSQL's `max_connections` or reduce your application's connection pool size: + +```go +db.SetMaxOpenConns(25) // Reduce if hitting connection limits +``` + +### Deadlocks + +Advisory locks can deadlock if acquired in different orders. Always acquire locks in a consistent order: + +```go +// Good: Consistent order +lockA := getLock(1) +lockB := getLock(2) + +// Bad: Inconsistent order can cause deadlocks +if someCondition { + lockA, then lockB +} else { + lockB, then lockA +} +``` + +### Lock Not Released + +Locks are automatically released when: +- `Unlock()` is called +- `Close()` is called +- Database connection closes +- Database session ends + +If locks aren't releasing, check for: +- Missing `Unlock()` calls +- Connection leaks +- Application crashes before cleanup + +### Context Deadline Exceeded + +If you see context deadline errors, either: +- Increase the timeout +- Investigate why locks are held for so long +- Use non-blocking `Lock()` instead of `WaitAndLock()` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5554f89 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: pglock + POSTGRES_USER: test + POSTGRES_PASSWORD: test + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U test"] + interval: 5s + timeout: 5s + retries: 5 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..f3b0cab --- /dev/null +++ b/examples/README.md @@ -0,0 +1,234 @@ +# go-pglock Examples + +This directory contains practical examples demonstrating various use cases for `go-pglock`. + +## Prerequisites + +1. PostgreSQL running and accessible +2. Database created (default: `pglock`) +3. Set `DATABASE_URL` environment variable (optional) + +```bash +# Using docker-compose from the project root +docker-compose up -d + +# Or set custom database URL +export DATABASE_URL='postgres://user:pass@localhost:5432/dbname?sslmode=disable' +``` + +## Running Examples + +### Basic Lock Usage + +Demonstrates the fundamental lock acquire and release pattern. + +```bash +cd examples/basic +go run main.go +``` + +**What it demonstrates:** +- Creating a lock +- Acquiring a lock with `Lock()` (non-blocking) +- Performing work in a critical section +- Releasing the lock + +**Try it:** +Run two instances in different terminals to see one acquire the lock while the other is blocked. + +### Concurrent Workers + +Shows how multiple workers coordinate access to a shared resource. + +```bash +cd examples/workers +go run main.go +``` + +**What it demonstrates:** +- Multiple goroutines competing for the same lock +- `WaitAndLock()` blocking behavior +- Sequential execution enforced by the lock +- Proper cleanup in concurrent scenarios + +**Expected output:** +- Workers start and wait for the lock +- Workers execute one at a time (serialized) +- Each worker holds the lock for ~1 second + +### Leader Election + +Implements a simple leader election mechanism. + +```bash +cd examples/leader-election +go run main.go +``` + +**What it demonstrates:** +- Leader election pattern +- Only one instance becomes the leader +- Leader performing periodic tasks +- Followers waiting/skipping work + +**Use cases:** +- Distributed systems where only one instance should perform certain tasks +- Active-passive failover scenarios +- Coordinating cluster-wide operations + +### Timeout Handling + +Shows how to use context timeouts with lock operations. + +```bash +cd examples/timeout +go run main.go +``` + +**What it demonstrates:** +- Creating locks with timeout contexts +- Handling `context.DeadlineExceeded` errors +- Different timeout values and their effects +- Proper cleanup when timeouts occur + +**Expected output:** +- First attempt fails due to short timeout +- Second attempt succeeds with longer timeout + +### Task Processing + +Demonstrates distributed task processing with lock-based coordination. + +```bash +cd examples/task-processing +go run main.go +``` + +**What it demonstrates:** +- Using task IDs to generate lock IDs +- Preventing duplicate task execution +- Hash-based lock ID generation +- Handling already-processing scenarios + +**Use cases:** +- Job queues in distributed systems +- Idempotent task processing +- Preventing race conditions in task execution + +## Running Multiple Instances + +To see the distributed locking in action, run the same example in multiple terminal windows: + +```bash +# Terminal 1 +cd examples/basic +go run main.go + +# Terminal 2 (run simultaneously) +cd examples/basic +go run main.go +``` + +You'll see that only one instance acquires the lock while others wait or skip. + +## Customizing Examples + +All examples use the same database connection pattern: + +```go +dsn := os.Getenv("DATABASE_URL") +if dsn == "" { + dsn = "postgres://test:test@localhost:5432/pglock?sslmode=disable" +} +``` + +You can customize the connection by: +1. Setting the `DATABASE_URL` environment variable +2. Modifying the default DSN in the code +3. Using connection parameters in the DSN string + +## Common Patterns + +### Pattern 1: Try Lock (Non-blocking) + +Use when you want to skip work if another instance is already doing it: + +```go +acquired, err := lock.Lock(ctx) +if !acquired { + return // Skip work +} +// Do work +``` + +### Pattern 2: Wait for Lock (Blocking) + +Use when work must be done eventually: + +```go +if err := lock.WaitAndLock(ctx); err != nil { + return err +} +// Do work +``` + +### Pattern 3: Lock with Timeout + +Use when you want to wait but not indefinitely: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +defer cancel() + +if err := lock.WaitAndLock(ctx); err != nil { + // Handle timeout or other errors +} +// Do work +``` + +## Building Examples + +To build all examples: + +```bash +# From the examples directory +for dir in */; do + echo "Building ${dir%/}..." + (cd "$dir" && go build -o "../../bin/${dir%/}" .) +done +``` + +This creates binaries in the `bin/` directory. + +## Troubleshooting + +### "DATABASE_URL environment variable not set" + +Either set the environment variable or use the default connection string in the code. + +### "pq: database does not exist" + +Create the database: +```sql +CREATE DATABASE pglock; +``` + +### "connection refused" + +Make sure PostgreSQL is running: +```bash +docker-compose up -d +``` + +### Lock Never Released + +Check that: +- `defer lock.Close()` is present +- Program doesn't panic before cleanup +- Context isn't cancelled prematurely + +## Further Reading + +- [PostgreSQL Advisory Locks Documentation](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS) +- [Main README](../README.md) for more examples and patterns +- [API Documentation](https://pkg.go.dev/github.com/allisson/go-pglock/v3) diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..4d4db11 --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + + "github.com/allisson/go-pglock/v3" + _ "github.com/lib/pq" +) + +func main() { + // Get database URL from environment + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "postgres://test:test@localhost:5432/pglock?sslmode=disable" + } + + // Connect to PostgreSQL + db, err := sql.Open("postgres", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if err := db.Ping(); err != nil { + log.Fatal(err) + } + + ctx := context.Background() + lockID := int64(100) + + // Create lock + lock, err := pglock.NewLock(ctx, lockID, db) + if err != nil { + log.Fatal(err) + } + defer lock.Close() + + fmt.Println("Attempting to acquire lock...") + + // Acquire lock + acquired, err := lock.Lock(ctx) + if err != nil { + log.Fatal(err) + } + + if !acquired { + fmt.Println("Lock is held by another process") + return + } + + // Critical section + fmt.Println("Lock acquired! Executing critical section...") + fmt.Println("Doing important work...") + + // Release lock + if err := lock.Unlock(ctx); err != nil { + log.Fatal(err) + } + + fmt.Println("Lock released successfully!") +} diff --git a/examples/leader-election/main.go b/examples/leader-election/main.go new file mode 100644 index 0000000..23d7bd8 --- /dev/null +++ b/examples/leader-election/main.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "hash/fnv" + "log" + "os" + "sync" + "time" + + "github.com/allisson/go-pglock/v3" + _ "github.com/lib/pq" +) + +type LeaderElector struct { + db *sql.DB + lockID int64 + isLeader bool + mu sync.RWMutex + instanceID string +} + +func NewLeaderElector(db *sql.DB, clusterName, instanceID string) *LeaderElector { + return &LeaderElector{ + db: db, + lockID: hashToInt64(clusterName), + instanceID: instanceID, + } +} + +func (le *LeaderElector) RunElection(ctx context.Context) error { + lock, err := pglock.NewLock(ctx, le.lockID, le.db) + if err != nil { + return err + } + defer lock.Close() + + // Try to become leader + acquired, err := lock.Lock(ctx) + if err != nil { + return err + } + + if acquired { + le.mu.Lock() + le.isLeader = true + le.mu.Unlock() + + fmt.Printf("✓ Instance %s became leader\n", le.instanceID) + defer func() { + le.mu.Lock() + le.isLeader = false + le.mu.Unlock() + lock.Unlock(context.Background()) + fmt.Printf("✗ Instance %s lost leadership\n", le.instanceID) + }() + + // Perform leader duties + le.performLeaderDuties(ctx) + } else { + fmt.Printf("Instance %s is a follower (another instance is leader)\n", le.instanceID) + } + + return nil +} + +func (le *LeaderElector) IsLeader() bool { + le.mu.RLock() + defer le.mu.RUnlock() + return le.isLeader +} + +func (le *LeaderElector) performLeaderDuties(ctx context.Context) { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + count := 0 + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + count++ + fmt.Printf(" [Leader %s] Performing periodic task #%d...\n", le.instanceID, count) + if count >= 5 { + return // Exit after 5 iterations for demo + } + } + } +} + +func hashToInt64(s string) int64 { + h := fnv.New64a() + h.Write([]byte(s)) + return int64(h.Sum64()) +} + +func main() { + // Get database URL from environment + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "postgres://test:test@localhost:5432/pglock?sslmode=disable" + } + + db, err := sql.Open("postgres", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if err := db.Ping(); err != nil { + log.Fatal(err) + } + + // Simulate multiple instances trying to become leader + fmt.Println("Starting leader election simulation...") + fmt.Println("Simulating 3 instances competing for leadership") + fmt.Println() + + ctx := context.Background() + var wg sync.WaitGroup + + for i := 1; i <= 3; i++ { + wg.Add(1) + instanceID := fmt.Sprintf("instance-%d", i) + + go func(id string) { + defer wg.Done() + + elector := NewLeaderElector(db, "my-cluster", id) + if err := elector.RunElection(ctx); err != nil { + log.Printf("Instance %s error: %v", id, err) + } + }(instanceID) + + // Stagger the starts slightly + time.Sleep(100 * time.Millisecond) + } + + wg.Wait() + fmt.Println("\nLeader election simulation completed!") +} diff --git a/examples/task-processing/main.go b/examples/task-processing/main.go new file mode 100644 index 0000000..a3367c7 --- /dev/null +++ b/examples/task-processing/main.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "hash/fnv" + "log" + "os" + "time" + + "github.com/allisson/go-pglock/v3" + _ "github.com/lib/pq" +) + +type TaskProcessor struct { + db *sql.DB +} + +func NewTaskProcessor(db *sql.DB) *TaskProcessor { + return &TaskProcessor{db: db} +} + +func (tp *TaskProcessor) ProcessTask(taskID string) error { + ctx := context.Background() + + // Use hash of task ID as lock ID + lockID := hashToInt64(taskID) + + lock, err := pglock.NewLock(ctx, lockID, tp.db) + if err != nil { + return err + } + defer lock.Close() + + // Try to acquire lock + acquired, err := lock.Lock(ctx) + if err != nil { + return err + } + + if !acquired { + return fmt.Errorf("task %s is already being processed by another instance", taskID) + } + defer lock.Unlock(ctx) + + fmt.Printf("✓ Processing task %s...\n", taskID) + + // Execute task + if err := tp.executeTask(taskID); err != nil { + return fmt.Errorf("failed to execute task: %w", err) + } + + fmt.Printf("✓ Task %s completed successfully\n", taskID) + return nil +} + +func (tp *TaskProcessor) executeTask(taskID string) error { + // Simulate task execution + time.Sleep(2 * time.Second) + return nil +} + +func hashToInt64(s string) int64 { + h := fnv.New64a() + h.Write([]byte(s)) + return int64(h.Sum64()) +} + +func main() { + // Get database URL from environment + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "postgres://test:test@localhost:5432/pglock?sslmode=disable" + } + + db, err := sql.Open("postgres", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if err := db.Ping(); err != nil { + log.Fatal(err) + } + + processor := NewTaskProcessor(db) + + // Simulate tasks + tasks := []string{ + "send-email-123", + "process-payment-456", + "generate-report-789", + } + + fmt.Println("Processing tasks...") + fmt.Println("Each task will only run once even if multiple instances try to process it.") + fmt.Println() + + for _, taskID := range tasks { + if err := processor.ProcessTask(taskID); err != nil { + fmt.Printf("✗ Error processing task %s: %v\n", taskID, err) + } + fmt.Println() + } + + // Try to process the same task again (should be rejected or complete) + fmt.Println("Attempting to process a task again immediately...") + if err := processor.ProcessTask(tasks[0]); err != nil { + fmt.Printf("✗ Expected behavior: %v\n", err) + } + + fmt.Println("\nTask processing example completed!") +} diff --git a/examples/timeout/main.go b/examples/timeout/main.go new file mode 100644 index 0000000..d0f8e4b --- /dev/null +++ b/examples/timeout/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log" + "os" + "time" + + "github.com/allisson/go-pglock/v3" + _ "github.com/lib/pq" +) + +func processWithTimeout(db *sql.DB, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + lockID := int64(400) + + lock, err := pglock.NewLock(ctx, lockID, db) + if err != nil { + return err + } + defer lock.Close() + + fmt.Printf("Attempting to acquire lock with %v timeout...\n", timeout) + + // This will fail if lock is not acquired within timeout + if err := lock.WaitAndLock(ctx); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("could not acquire lock within %v", timeout) + } + return err + } + defer lock.Unlock(context.Background()) // Use background for cleanup + + fmt.Println("✓ Lock acquired, processing...") + time.Sleep(2 * time.Second) + fmt.Println("✓ Processing complete") + + return nil +} + +func main() { + // Get database URL from environment + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "postgres://test:test@localhost:5432/pglock?sslmode=disable" + } + + db, err := sql.Open("postgres", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if err := db.Ping(); err != nil { + log.Fatal(err) + } + + // First, acquire a lock and hold it + ctx := context.Background() + lockID := int64(400) + + lock, err := pglock.NewLock(ctx, lockID, db) + if err != nil { + log.Fatal(err) + } + + if err := lock.WaitAndLock(ctx); err != nil { + log.Fatal(err) + } + fmt.Println("Background lock acquired (will be held for 10 seconds)") + + // Spawn a goroutine to release after delay + go func() { + time.Sleep(10 * time.Second) + lock.Unlock(ctx) + lock.Close() + fmt.Println("\nBackground lock released") + }() + + // Give it a moment to ensure the lock is held + time.Sleep(500 * time.Millisecond) + + // Now try with a short timeout (should fail) + fmt.Println("\nTest 1: Attempting with 3 second timeout (should fail)...") + if err := processWithTimeout(db, 3*time.Second); err != nil { + fmt.Printf("✗ Expected failure: %v\n", err) + } + + // Try again with longer timeout (should succeed) + fmt.Println("\nTest 2: Attempting with 15 second timeout (should succeed)...") + if err := processWithTimeout(db, 15*time.Second); err != nil { + fmt.Printf("✗ Unexpected error: %v\n", err) + } else { + fmt.Println("✓ Successfully acquired lock with timeout") + } + + fmt.Println("\nTimeout example completed!") +} diff --git a/examples/workers/main.go b/examples/workers/main.go new file mode 100644 index 0000000..3588a82 --- /dev/null +++ b/examples/workers/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "sync" + "time" + + "github.com/allisson/go-pglock/v3" + _ "github.com/lib/pq" +) + +func runWorker(workerID int, db *sql.DB, wg *sync.WaitGroup) { + defer wg.Done() + + ctx := context.Background() + lockID := int64(500) // Same lock ID for all workers + + lock, err := pglock.NewLock(ctx, lockID, db) + if err != nil { + log.Printf("Worker %d: failed to create lock: %v", workerID, err) + return + } + defer lock.Close() + + fmt.Printf("Worker %d: waiting for lock...\n", workerID) + + if err := lock.WaitAndLock(ctx); err != nil { + log.Printf("Worker %d: failed to acquire lock: %v", workerID, err) + return + } + + fmt.Printf("Worker %d: ✓ acquired lock, processing...\n", workerID) + + // Simulate work + time.Sleep(1 * time.Second) + + fmt.Printf("Worker %d: releasing lock\n", workerID) + if err := lock.Unlock(ctx); err != nil { + log.Printf("Worker %d: failed to release lock: %v", workerID, err) + } +} + +func main() { + // Get database URL from environment + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "postgres://test:test@localhost:5432/pglock?sslmode=disable" + } + + db, err := sql.Open("postgres", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if err := db.Ping(); err != nil { + log.Fatal(err) + } + + fmt.Println("Starting 5 concurrent workers...") + fmt.Println("They will compete for the same lock and execute sequentially.") + fmt.Println() + + var wg sync.WaitGroup + for i := 1; i <= 5; i++ { + wg.Add(1) + go runWorker(i, db, &wg) + } + + wg.Wait() + fmt.Println("\nAll workers completed!") +} diff --git a/go.mod b/go.mod index 0deb66f..d8a2325 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,15 @@ module github.com/allisson/go-pglock/v3 -go 1.17 +go 1.24 require ( - github.com/lib/pq v1.10.6 - github.com/stretchr/testify v1.8.0 + github.com/lib/pq v1.10.9 + github.com/stretchr/testify v1.11.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.4.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ea8940f..286c42d 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,14 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs= -github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lock.go b/lock.go index b6494d5..5548359 100644 --- a/lock.go +++ b/lock.go @@ -1,3 +1,14 @@ +// Package pglock provides distributed locks using PostgreSQL session level advisory locks. +// +// PostgreSQL advisory locks are application-defined locks that can be useful for locking +// strategies that are an awkward fit for the MVCC model. They are faster than table-based +// locking mechanisms, avoid table bloat, and are automatically cleaned up by the server +// at the end of the session. +// +// This package uses session-level advisory locks, which are held until explicitly released +// or the session ends. Unlike transaction-level locks, session-level advisory locks do not +// honor transaction semantics: a lock acquired during a transaction that is later rolled +// back will still be held following the rollback. package pglock import ( @@ -5,7 +16,12 @@ import ( "database/sql" ) -// Locker is an interface for postgresql advisory locks. +// Locker is an interface for PostgreSQL advisory locks. +// +// All methods use PostgreSQL session-level advisory locks, which persist until +// explicitly released or the database connection is closed. These locks are based +// on a numeric identifier and can be used to coordinate access to shared resources +// across multiple database connections. type Locker interface { Lock(ctx context.Context) (bool, error) WaitAndLock(ctx context.Context) error @@ -13,15 +29,28 @@ type Locker interface { Close() error } -// Lock implements the Locker interface. +// Lock implements the Locker interface using PostgreSQL advisory locks. +// +// A Lock holds a dedicated database connection and a lock identifier. +// The connection is obtained from a connection pool and is held for the +// lifetime of the Lock instance to maintain the session-level advisory lock. type Lock struct { id int64 conn *sql.Conn } -// Lock obtains exclusive session level advisory lock if available. -// It’s similar to WaitAndLock, except it will not wait for the lock to become available. -// It will either obtain the lock and return true, or return false if the lock cannot be acquired immediately. +// Lock attempts to obtain an exclusive session level advisory lock without waiting. +// +// This method uses PostgreSQL's pg_try_advisory_lock function, which is non-blocking. +// It will either obtain the lock immediately and return true, or return false if the +// lock is already held by another session. This is similar to WaitAndLock, except it +// will not wait for the lock to become available. +// +// Multiple lock requests stack within the same session, meaning if a resource is +// locked three times, it must be unlocked three times to be fully released. +// +// Returns true if the lock was successfully acquired, false if it's already held +// by another session, and an error if the database operation fails. func (l *Lock) Lock(ctx context.Context) (bool, error) { result := false sqlQuery := "SELECT pg_try_advisory_lock($1)" @@ -29,28 +58,72 @@ func (l *Lock) Lock(ctx context.Context) (bool, error) { return result, err } -// WaitAndLock obtains exclusive session level advisory lock. -// If another session already holds a lock on the same resource identifier, this function will wait until the resource becomes available. -// Multiple lock requests stack, so that if the resource is locked three times it must then be unlocked three times. +// WaitAndLock obtains an exclusive session level advisory lock, waiting if necessary. +// +// This method uses PostgreSQL's pg_advisory_lock function, which will block until +// the lock becomes available. If another session already holds a lock on the same +// resource identifier, this function will wait until the resource becomes available. +// +// Multiple lock requests stack within the same session, meaning if a resource is +// locked three times, it must be unlocked three times to be fully released. If the +// session already holds the given advisory lock, additional requests will always +// succeed immediately. +// +// The lock persists until explicitly released via Unlock or until the session ends. +// Returns an error if the database operation fails or if the context is cancelled +// while waiting for the lock. func (l *Lock) WaitAndLock(ctx context.Context) error { sqlQuery := "SELECT pg_advisory_lock($1)" _, err := l.conn.ExecContext(ctx, sqlQuery, l.id) return err } -// Unlock releases the lock. +// Unlock releases a previously acquired advisory lock. +// +// This method uses PostgreSQL's pg_advisory_unlock function to release one level +// of lock ownership. Because lock requests stack within a session, each Unlock call +// only decrements the lock count by one. If the same lock was acquired multiple times, +// it must be unlocked the same number of times to be fully released. +// +// Note that unlocking a lock that is not currently held will not return an error, +// but may have unexpected consequences in PostgreSQL. It's the caller's responsibility +// to ensure locks and unlocks are properly paired. +// +// Returns an error if the database operation fails. func (l *Lock) Unlock(ctx context.Context) error { sqlQuery := "SELECT pg_advisory_unlock($1)" _, err := l.conn.ExecContext(ctx, sqlQuery, l.id) return err } -// Close closes the DB connection, consequently releasing all locks. +// Close closes the database connection, releasing all advisory locks held by this Lock. +// +// Since advisory locks are automatically cleaned up when a database session ends, +// closing the connection will release all locks held on this connection, regardless +// of how many times they were acquired. This provides a reliable way to ensure all +// locks are released when the Lock instance is no longer needed. +// +// After calling Close, the Lock instance should not be used for any further operations. +// Returns an error if closing the connection fails. func (l *Lock) Close() error { return l.conn.Close() } -// NewLock returns a Lock with *sql.Conn +// NewLock creates a new Lock instance with a dedicated database connection. +// +// This function obtains a connection from the provided database connection pool +// and stores it for use in lock and unlock operations. The connection is held for +// the lifetime of the Lock instance to maintain session-level advisory locks. +// +// Parameters: +// - ctx: Context for managing the connection acquisition +// - id: The lock identifier (a 64-bit integer used as the PostgreSQL advisory lock key) +// - db: A database connection pool from which to obtain a dedicated connection +// +// The caller is responsible for calling Close on the returned Lock to release +// the connection back to the pool and clean up any held advisory locks. +// +// Returns a Lock instance and an error if the connection cannot be obtained. func NewLock(ctx context.Context, id int64, db *sql.DB) (Lock, error) { // Obtain a connection from the DB connection pool and store it and use it for lock and unlock operations conn, err := db.Conn(ctx) diff --git a/lock_test.go b/lock_test.go index b071f56..cb23036 100644 --- a/lock_test.go +++ b/lock_test.go @@ -3,118 +3,465 @@ package pglock import ( "context" "database/sql" - "log" "os" + "strings" "sync" + "sync/atomic" "testing" "time" _ "github.com/lib/pq" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func newDB() (*sql.DB, error) { +// newDB creates a new database connection for testing. +func newDB(t *testing.T) *sql.DB { + t.Helper() dsn := os.Getenv("DATABASE_URL") - db, err := sql.Open("postgres", dsn) - if err != nil { - return nil, err + if dsn == "" { + t.Skip("DATABASE_URL environment variable not set") } - return db, db.Ping() + db, err := sql.Open("postgres", dsn) + require.NoError(t, err, "failed to open database connection") + require.NoError(t, db.Ping(), "failed to ping database") + return db } -func closeDB(db *sql.DB) { +// closeDB closes the database connection and reports any errors. +func closeDB(t *testing.T, db *sql.DB) { + t.Helper() if err := db.Close(); err != nil { - log.Fatal(err) + t.Errorf("failed to close database: %v", err) } } -func waitAndLock(id int64, l *Lock, wg *sync.WaitGroup) { - ctx := context.Background() - if err := l.WaitAndLock(ctx); err != nil { - log.Fatal(err) - } - time.Sleep(time.Duration(500) * time.Millisecond) - if err := l.Unlock(ctx); err != nil { - log.Fatal(err) - } - wg.Done() -} - +// TestNewLock verifies that NewLock creates a valid Lock instance. func TestNewLock(t *testing.T) { - db, err := newDB() - assert.Equal(t, nil, err) - defer closeDB(db) + db := newDB(t) + defer closeDB(t, db) + id := int64(10) lock, err := NewLock(context.Background(), id, db) - assert.Nil(t, err) - defer assert.Nil(t, lock.Close()) + require.NoError(t, err) + defer func() { + assert.NoError(t, lock.Close()) + }() + assert.Equal(t, id, lock.id) assert.NotNil(t, lock.conn) } -func TestLockUnlock(t *testing.T) { - db1, err := newDB() - assert.Nil(t, err) - defer closeDB(db1) - db2, err := newDB() - assert.Nil(t, err) - defer closeDB(db2) +// TestNewLock_ContextCancelled tests NewLock with a cancelled context. +func TestNewLock_ContextCancelled(t *testing.T) { + db := newDB(t) + defer closeDB(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := NewLock(ctx, 1, db) + assert.Error(t, err, "NewLock should fail with cancelled context") +} + +// TestLock_BasicAcquisitionAndRelease tests basic lock acquisition and release. +func TestLock_BasicAcquisitionAndRelease(t *testing.T) { + db1 := newDB(t) + defer closeDB(t, db1) + db2 := newDB(t) + defer closeDB(t, db2) ctx := context.Background() id := int64(1) + + lock1, err := NewLock(ctx, id, db1) + require.NoError(t, err) + defer lock1.Close() //nolint:errcheck + + lock2, err := NewLock(ctx, id, db2) + require.NoError(t, err) + defer lock2.Close() //nolint:errcheck + + // lock1 should acquire the lock + ok, err := lock1.Lock(ctx) + require.NoError(t, err) + assert.True(t, ok, "lock1 should acquire the lock") + + // lock2 should fail to acquire the lock + ok, err = lock2.Lock(ctx) + require.NoError(t, err) + assert.False(t, ok, "lock2 should not acquire the lock") + + // Release lock1 + err = lock1.Unlock(ctx) + require.NoError(t, err) + + // lock2 should now acquire the lock + ok, err = lock2.Lock(ctx) + require.NoError(t, err) + assert.True(t, ok, "lock2 should acquire the lock after lock1 releases") + + // Clean up + err = lock2.Unlock(ctx) + require.NoError(t, err) +} + +// TestLock_SameSessionMultipleAcquisitions tests that the same session can acquire a lock multiple times. +func TestLock_SameSessionMultipleAcquisitions(t *testing.T) { + db := newDB(t) + defer closeDB(t, db) + + ctx := context.Background() + id := int64(2) + + lock, err := NewLock(ctx, id, db) + require.NoError(t, err) + defer lock.Close() //nolint:errcheck + + // Acquire lock multiple times + ok, err := lock.Lock(ctx) + require.NoError(t, err) + assert.True(t, ok) + + ok, err = lock.Lock(ctx) + require.NoError(t, err) + assert.True(t, ok, "same session should be able to acquire lock multiple times") + + ok, err = lock.Lock(ctx) + require.NoError(t, err) + assert.True(t, ok, "same session should be able to acquire lock multiple times") + + // Unlock the same number of times + require.NoError(t, lock.Unlock(ctx)) + require.NoError(t, lock.Unlock(ctx)) + require.NoError(t, lock.Unlock(ctx)) +} + +// TestLock_Stacking verifies that locks stack and require equal unlocks. +func TestLock_Stacking(t *testing.T) { + db1 := newDB(t) + defer closeDB(t, db1) + db2 := newDB(t) + defer closeDB(t, db2) + + ctx := context.Background() + id := int64(3) + lock1, err := NewLock(ctx, id, db1) - assert.Nil(t, err) - defer lock1.Close() + require.NoError(t, err) + defer lock1.Close() //nolint:errcheck + lock2, err := NewLock(ctx, id, db2) - assert.Nil(t, err) - defer lock2.Close() + require.NoError(t, err) + defer lock2.Close() //nolint:errcheck + // Acquire lock twice with lock1 ok, err := lock1.Lock(ctx) + require.NoError(t, err) assert.True(t, ok) - assert.Nil(t, err) + ok, err = lock1.Lock(ctx) + require.NoError(t, err) + assert.True(t, ok) + + // lock2 should not be able to acquire ok, err = lock2.Lock(ctx) + require.NoError(t, err) assert.False(t, ok) - assert.Nil(t, err) - err = lock1.Unlock(ctx) - assert.Nil(t, err) + // Unlock once + require.NoError(t, lock1.Unlock(ctx)) + + // lock2 should still not be able to acquire (lock is still held once) + ok, err = lock2.Lock(ctx) + require.NoError(t, err) + assert.False(t, ok, "lock should still be held after one unlock") + + // Unlock second time + require.NoError(t, lock1.Unlock(ctx)) + // Now lock2 should be able to acquire ok, err = lock2.Lock(ctx) + require.NoError(t, err) + assert.True(t, ok, "lock2 should acquire after all unlocks") + + require.NoError(t, lock2.Unlock(ctx)) +} + +// TestWaitAndLock_BlockingBehavior tests that WaitAndLock blocks until lock is available. +func TestWaitAndLock_BlockingBehavior(t *testing.T) { + db1 := newDB(t) + defer closeDB(t, db1) + db2 := newDB(t) + defer closeDB(t, db2) + + ctx := context.Background() + id := int64(4) + + lock1, err := NewLock(ctx, id, db1) + require.NoError(t, err) + defer lock1.Close() //nolint:errcheck + + lock2, err := NewLock(ctx, id, db2) + require.NoError(t, err) + defer lock2.Close() //nolint:errcheck + + // lock1 acquires the lock + require.NoError(t, lock1.WaitAndLock(ctx)) + + var lock2Acquired atomic.Bool + var wg sync.WaitGroup + wg.Add(1) + + // lock2 attempts to acquire in goroutine + go func() { + defer wg.Done() + err := lock2.WaitAndLock(ctx) + if err == nil { + lock2Acquired.Store(true) + lock2.Unlock(ctx) //nolint:errcheck,gosec + } + }() + + // Give lock2 time to start waiting + time.Sleep(100 * time.Millisecond) + assert.False(t, lock2Acquired.Load(), "lock2 should be waiting") + + // Release lock1 + require.NoError(t, lock1.Unlock(ctx)) + + // Wait for lock2 to acquire + wg.Wait() + assert.True(t, lock2Acquired.Load(), "lock2 should have acquired the lock") +} + +// TestWaitAndLock_ContextCancellation tests that WaitAndLock respects context cancellation. +func TestWaitAndLock_ContextCancellation(t *testing.T) { + db1 := newDB(t) + defer closeDB(t, db1) + db2 := newDB(t) + defer closeDB(t, db2) + + bgCtx := context.Background() + id := int64(5) + + lock1, err := NewLock(bgCtx, id, db1) + require.NoError(t, err) + defer lock1.Close() //nolint:errcheck + + lock2, err := NewLock(bgCtx, id, db2) + require.NoError(t, err) + defer lock2.Close() //nolint:errcheck + + // lock1 acquires the lock + require.NoError(t, lock1.WaitAndLock(bgCtx)) + defer lock1.Unlock(bgCtx) //nolint:errcheck + + // Create a context with timeout for lock2 + ctx, cancel := context.WithTimeout(bgCtx, 200*time.Millisecond) + defer cancel() + + // lock2 should fail due to context timeout + err = lock2.WaitAndLock(ctx) + assert.Error(t, err, "WaitAndLock should fail with context timeout") + // PostgreSQL can return different error messages for context cancellation + errMsg := err.Error() + assert.True(t, + strings.Contains(errMsg, "context deadline exceeded") || + strings.Contains(errMsg, "canceling statement due to user request"), + "error should indicate context cancellation, got: %s", errMsg) +} + +// TestWaitAndLock_Concurrent tests multiple concurrent lock attempts. +func TestWaitAndLock_Concurrent(t *testing.T) { + // Check if DATABASE_URL is set before spawning goroutines + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + t.Skip("DATABASE_URL environment variable not set") + } + + const numGoroutines = 10 + id := int64(6) + + var wg sync.WaitGroup + var counter int64 + ctx := context.Background() + + // Create multiple locks that will compete for the same resource + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + db := newDB(t) + defer closeDB(t, db) + + lock, err := NewLock(ctx, id, db) + if err != nil { + t.Errorf("failed to create lock: %v", err) + return + } + defer lock.Close() //nolint:errcheck + + // Acquire lock + if err := lock.WaitAndLock(ctx); err != nil { + t.Errorf("failed to acquire lock: %v", err) + return + } + + // Critical section: increment counter + current := atomic.LoadInt64(&counter) + time.Sleep(10 * time.Millisecond) // Simulate work + atomic.StoreInt64(&counter, current+1) + + // Release lock + if err := lock.Unlock(ctx); err != nil { + t.Errorf("failed to release lock: %v", err) + } + }() + } + + wg.Wait() + assert.Equal(t, int64(numGoroutines), atomic.LoadInt64(&counter), + "counter should equal number of goroutines") +} + +// TestClose_ReleasesLocks verifies that Close() works correctly. +// Note: PostgreSQL advisory locks are session-level and should be released when +// the session ends, but the exact timing depends on how the database driver +// manages connection lifecycle. +func TestClose_ReleasesLocks(t *testing.T) { + db := newDB(t) + defer closeDB(t, db) + + ctx := context.Background() + id := int64(7) + + // Create and use a lock + lock1, err := NewLock(ctx, id, db) + require.NoError(t, err) + + // Acquire lock multiple times + ok, err := lock1.Lock(ctx) + require.NoError(t, err) assert.True(t, ok) - assert.Nil(t, err) - err = lock2.Unlock(ctx) - assert.Nil(t, err) + ok, err = lock1.Lock(ctx) + require.NoError(t, err) + assert.True(t, ok) - err = lock2.Unlock(ctx) - assert.Nil(t, err) + // Close should not error even with stacked locks + require.NoError(t, lock1.Close(), "Close should succeed") + + // We should be able to create a new lock with the same ID + lock2, err := NewLock(ctx, id, db) + require.NoError(t, err) + defer lock2.Close() //nolint:errcheck + + // Acquire with the new lock + ok, err = lock2.Lock(ctx) + require.NoError(t, err) + assert.True(t, ok, "should be able to acquire lock with new Lock instance") + + require.NoError(t, lock2.Unlock(ctx)) } -func TestWaitAndLock(t *testing.T) { - db1, err := newDB() - assert.Nil(t, err) - defer closeDB(db1) - db2, err := newDB() - assert.Nil(t, err) - defer closeDB(db2) +// TestUnlock_ExtraUnlockDoesNotError verifies that extra unlocks don't cause errors. +func TestUnlock_ExtraUnlockDoesNotError(t *testing.T) { + db := newDB(t) + defer closeDB(t, db) + + ctx := context.Background() + id := int64(8) + + lock, err := NewLock(ctx, id, db) + require.NoError(t, err) + defer lock.Close() //nolint:errcheck + + // Acquire and release normally + ok, err := lock.Lock(ctx) + require.NoError(t, err) + assert.True(t, ok) + + require.NoError(t, lock.Unlock(ctx)) + + // Extra unlock should not error (PostgreSQL behavior) + err = lock.Unlock(ctx) + assert.NoError(t, err, "extra unlock should not cause error") +} + +// TestLock_DifferentIDs tests that different lock IDs don't interfere. +func TestLock_DifferentIDs(t *testing.T) { + db1 := newDB(t) + defer closeDB(t, db1) + db2 := newDB(t) + defer closeDB(t, db2) + + ctx := context.Background() + + lock1, err := NewLock(ctx, 100, db1) + require.NoError(t, err) + defer lock1.Close() //nolint:errcheck + + lock2, err := NewLock(ctx, 200, db2) + require.NoError(t, err) + defer lock2.Close() //nolint:errcheck + + // Both locks should be able to acquire (different IDs) + ok, err := lock1.Lock(ctx) + require.NoError(t, err) + assert.True(t, ok) + + ok, err = lock2.Lock(ctx) + require.NoError(t, err) + assert.True(t, ok, "different lock IDs should not interfere") + + require.NoError(t, lock1.Unlock(ctx)) + require.NoError(t, lock2.Unlock(ctx)) +} + +// TestWaitAndLock_Sequential tests sequential lock acquisitions with timing. +func TestWaitAndLock_Sequential(t *testing.T) { + db1 := newDB(t) + defer closeDB(t, db1) + db2 := newDB(t) + defer closeDB(t, db2) ctx := context.Background() wg := sync.WaitGroup{} - id := int64(1) + id := int64(9) + lock1, err := NewLock(ctx, id, db1) - assert.Nil(t, err) - defer lock1.Close() + require.NoError(t, err) + defer lock1.Close() //nolint:errcheck + lock2, err := NewLock(ctx, id, db2) - assert.Nil(t, err) - defer lock2.Close() + require.NoError(t, err) + defer lock2.Close() //nolint:errcheck start := time.Now() - wg.Add(1) - go waitAndLock(id, &lock1, &wg) // wait for 500 milliseconds - wg.Add(1) - go waitAndLock(id, &lock2, &wg) // wait for 500 milliseconds + + // Helper function that holds lock for duration + holdLock := func(lock *Lock, duration time.Duration) { + defer wg.Done() + if err := lock.WaitAndLock(ctx); err != nil { + t.Errorf("failed to acquire lock: %v", err) + return + } + time.Sleep(duration) + if err := lock.Unlock(ctx); err != nil { + t.Errorf("failed to release lock: %v", err) + } + } + + wg.Add(2) + go holdLock(&lock1, 300*time.Millisecond) + go holdLock(&lock2, 300*time.Millisecond) + wg.Wait() - stop := time.Since(start) - assert.True(t, stop.Milliseconds() >= 1000) + elapsed := time.Since(start) + + // Should take at least 600ms (sequential) + assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(600), + "sequential lock acquisitions should take cumulative time") }