diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml index 372489e..72a29cf 100644 --- a/.github/workflows/docker-build-dev.yml +++ b/.github/workflows/docker-build-dev.yml @@ -25,6 +25,8 @@ jobs: tag: etcd - profile: dynamodb tag: dynamodb + - profile: postgres + tag: postgres steps: - name: Checkout code diff --git a/README.md b/README.md index 65e7c8d..fa661e4 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ The API documentation is available via Swagger UI at the root of the running ser | Firestore | `locker-firestore` | Google Cloud Firestore for distributed deployments on GCP | | etcd | `locker-etcd` | etcd for distributed deployments using Kubernetes or other etcd-based infrastructure | | DynamoDB | `locker-dynamodb` | AWS DynamoDB for distributed deployments on AWS | +| PostgreSQL | `locker-postgres` | PostgreSQL for distributed deployments using relational databases | ## Building @@ -48,6 +49,9 @@ mvn clean package -P etcd # DynamoDB backend mvn clean package -P dynamodb +# PostgreSQL backend +mvn clean package -P postgres + # Build all backends for testing mvn clean package -P everything ``` @@ -68,4 +72,7 @@ docker build --build-arg LOCKER=etcd -t lockservicecentral-etcd . # DynamoDB backend docker build --build-arg LOCKER=dynamodb -t lockservicecentral-dynamodb . + +# PostgreSQL backend +docker build --build-arg LOCKER=postgres -t lockservicecentral-postgres . ``` diff --git a/api/pom.xml b/api/pom.xml index 015c6d1..30489a4 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -132,6 +132,17 @@ + + postgres + + + com.unitvectory.lockservicecentral + locker-postgres + ${project.version} + runtime + + + diff --git a/locker-postgres/README.md b/locker-postgres/README.md new file mode 100644 index 0000000..b629fb3 --- /dev/null +++ b/locker-postgres/README.md @@ -0,0 +1,248 @@ +# locker-postgres + +PostgreSQL backend implementation for LockServiceCentral. + +## Quick Start + +Build and run with the Postgres backend: + +```bash +mvn clean package -DskipTests -Ppostgres -ntp +SPRING_PROFILES_ACTIVE=postgres LOCKER_POSTGRES_HOST=localhost LOCKER_POSTGRES_PASSWORD=yourpassword AUTHENTICATION_DISABLED=true java -jar ./api/target/api-0.0.1-SNAPSHOT.jar +``` + +Or with Docker: + +```bash +docker build --build-arg LOCKER=postgres -t lockservicecentral-postgres . +docker run -p 8080:8080 \ + -e SPRING_PROFILES_ACTIVE=postgres \ + -e LOCKER_POSTGRES_HOST=host.docker.internal \ + -e LOCKER_POSTGRES_PASSWORD=yourpassword \ + -e AUTHENTICATION_DISABLED=true \ + lockservicecentral-postgres +``` + +## Overview + +This module provides a distributed lock implementation backed by [PostgreSQL](https://www.postgresql.org/). It uses PostgreSQL's atomic operations (INSERT ... ON CONFLICT, UPDATE, DELETE with RETURNING) to ensure atomic lock operations across distributed instances. + +## Configuration + +All configuration properties are prefixed with `locker.postgres.*`. + +### Connection Properties + +| Property | Default | Description | +|----------|---------|-------------| +| `locker.postgres.host` | `localhost` | PostgreSQL server hostname | +| `locker.postgres.port` | `5432` | PostgreSQL server port | +| `locker.postgres.database` | `lockservice` | Database name | +| `locker.postgres.schema` | | Database schema (optional) | +| `locker.postgres.username` | `postgres` | Database username | +| `locker.postgres.password` | | Database password | +| `locker.postgres.ssl` | `false` | Enable SSL connection | +| `locker.postgres.connectionPoolSize` | `10` | Maximum connection pool size | +| `locker.postgres.tableName` | `locks` | Table name for storing locks | + +### Environment Variables + +All properties can be configured via environment variables using the standard Spring convention (uppercase with underscores): + +```bash +LOCKER_POSTGRES_HOST=mydb.example.com +LOCKER_POSTGRES_PORT=5432 +LOCKER_POSTGRES_DATABASE=lockservice +LOCKER_POSTGRES_USERNAME=lockuser +LOCKER_POSTGRES_PASSWORD=secretpassword +LOCKER_POSTGRES_SSL=true +LOCKER_POSTGRES_TABLENAME=my_locks +``` + +## Building + +Build the Postgres-enabled API jar: + +```bash +mvn clean package -DskipTests -Ppostgres -ntp +java -jar ./api/target/api-0.0.1-SNAPSHOT.jar +``` + +Build Docker image: + +```bash +docker build --build-arg LOCKER=postgres -t lockservicecentral-postgres . +``` + +## PostgreSQL Setup + +### Required Table Schema + +Create the locks table with the following schema: + +```sql +CREATE TABLE IF NOT EXISTS locks ( + namespace VARCHAR(255) NOT NULL, + lock_name VARCHAR(255) NOT NULL, + owner VARCHAR(255) NOT NULL, + instance_id VARCHAR(255) NOT NULL, + lease_duration BIGINT NOT NULL, + expiry BIGINT NOT NULL, + PRIMARY KEY (namespace, lock_name) +); + +-- Index for efficient expiry-based queries +CREATE INDEX IF NOT EXISTS idx_locks_expiry ON locks (expiry); +``` + +### Complete Setup Script + +```sql +-- Create database (run as superuser) +CREATE DATABASE lockservice; + +-- Connect to lockservice database +\c lockservice + +-- Create table with composite primary key +CREATE TABLE IF NOT EXISTS locks ( + namespace VARCHAR(255) NOT NULL, + lock_name VARCHAR(255) NOT NULL, + owner VARCHAR(255) NOT NULL, + instance_id VARCHAR(255) NOT NULL, + lease_duration BIGINT NOT NULL, + expiry BIGINT NOT NULL, + PRIMARY KEY (namespace, lock_name) +); + +-- Create index for expiry queries +CREATE INDEX IF NOT EXISTS idx_locks_expiry ON locks (expiry); + +-- Optional: Create a dedicated user +CREATE USER lockuser WITH PASSWORD 'yourpassword'; +GRANT SELECT, INSERT, UPDATE, DELETE ON locks TO lockuser; +``` + +## Implementation Details + +### Item Structure + +Lock items are stored with the following columns: + +- `namespace`: The lock namespace (part of composite primary key) +- `lock_name`: The lock name (part of composite primary key) +- `owner`: The lock owner +- `instance_id`: The client instance ID +- `lease_duration`: The total accumulated lease duration in seconds +- `expiry`: The expiry timestamp in epoch seconds + +The composite primary key `(namespace, lock_name)` uniquely identifies each lock. + +### Atomicity + +All lock operations use PostgreSQL's atomic SQL statements to ensure fully atomic lock semantics. Each operation performs all condition checks and the mutation in a single SQL statement, eliminating race conditions that would occur with read-then-write patterns. + +**Key atomicity guarantees:** + +- **Single-statement mutations**: Acquire, renew, and release each complete in a single SQL statement with conditions evaluated atomically by PostgreSQL. +- **Expiry checks in SQL**: Lock expiration is evaluated within SQL WHERE clauses using `EXTRACT(EPOCH FROM now())`, ensuring no time-of-check to time-of-use (TOCTOU) vulnerabilities. +- **RETURNING clause**: All mutations use `RETURNING` to get the result of the operation without a separate query. + +### Lock Expiry + +Lock expiry is represented as a Unix epoch timestamp (seconds since 1970-01-01 00:00:00 UTC) in the `expiry` column. The expiry is calculated as `now + leaseDuration` when acquiring a lock. + +Expiry checks are performed server-side using PostgreSQL's `now()` function to ensure consistent time evaluation across distributed clients. + +**Note:** Unlike some other backends, PostgreSQL does not automatically delete expired rows. Consider running a periodic cleanup job if you need to remove stale lock entries: + +```sql +-- Optional: Delete expired locks (run periodically) +DELETE FROM locks WHERE expiry < EXTRACT(EPOCH FROM now())::bigint; +``` + +### Behavior + +- **Acquire**: Uses `INSERT ... ON CONFLICT (namespace, lock_name) DO UPDATE` with a compound condition: + - Lock doesn't exist (INSERT succeeds), OR + - Lock is expired (`expiry < now()`), OR + - Lock belongs to the same owner/instance (`owner = :owner AND instance_id = :instanceId`) + +- **Renew**: Uses a single `UPDATE` statement with `WHERE namespace = ? AND lock_name = ?` that: + - Validates the lock exists, is not expired, and matches owner/instance + - Atomically adds the requested duration to both `lease_duration` and `expiry` + - Returns the updated values via `RETURNING` + +- **Release**: Uses a single `DELETE` statement with `WHERE namespace = ? AND lock_name = ?` that: + - Validates ownership (`owner` and `instance_id` must match) + - Uses `RETURNING` to confirm deletion + - Treats "lock not found" as success (already released) + - Treats releasing an expired lock (owned by another) as failure + +## Example Configuration + +```properties +# PostgreSQL connection +locker.postgres.host=mydb.example.com +locker.postgres.port=5432 +locker.postgres.database=lockservice +locker.postgres.schema=public +locker.postgres.username=lockuser +locker.postgres.password=secretpassword + +# Enable SSL +locker.postgres.ssl=true + +# Connection pool +locker.postgres.connectionPoolSize=20 + +# Table name +locker.postgres.tableName=my_locks +``` + +## Local Development with Docker + +For local development, you can use Docker to run PostgreSQL: + +```bash +# Run PostgreSQL with Docker +docker run -d \ + --name lockservice-postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=lockservice \ + -p 5432:5432 \ + postgres:16 + +# Wait for PostgreSQL to start +sleep 5 + +# Create the locks table +docker exec -i lockservice-postgres psql -U postgres -d lockservice < + + 4.0.0 + + com.unitvectory + lockservicecentral + 0.0.1-SNAPSHOT + + + com.unitvectory.lockservicecentral + locker-postgres + + + 42.7.7 + 6.2.1 + + + + + com.unitvectory.lockservicecentral + locker + ${project.version} + + + com.unitvectory.lockservicecentral + logging + ${project.version} + + + org.springframework + spring-context + + + org.springframework + spring-jdbc + + + org.postgresql + postgresql + ${postgresql.version} + + + com.zaxxer + HikariCP + ${hikaricp.version} + + + org.slf4j + slf4j-api + + + com.unitvectory.lockservicecentral + locker-tests + ${project.version} + test + + + org.mockito + mockito-core + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.projectlombok + lombok + provided + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.springframework.boot + spring-boot-maven-plugin + + + true + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + diff --git a/locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/LockerPostgresConfig.java b/locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/LockerPostgresConfig.java new file mode 100644 index 0000000..032d2b0 --- /dev/null +++ b/locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/LockerPostgresConfig.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.unitvectory.lockservicecentral.locker.postgres; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +import com.unitvectory.lockservicecentral.locker.LockService; +import com.unitvectory.lockservicecentral.logging.CanonicalLogContext; + +/** + * The Configuration for the Postgres LockService. + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Configuration +@Profile("postgres") +public class LockerPostgresConfig { + + @Autowired + private DataSource dataSource; + + @Value("${locker.postgres.tableName:locks}") + private String tableName; + + /** + * Creates the LockService bean. + * + * @param canonicalLogContextProvider the canonical log context provider + * @return the LockService instance + */ + @Bean + public LockService lockService(ObjectProvider canonicalLogContextProvider) { + return new PostgresLockService(this.dataSource, this.tableName, canonicalLogContextProvider); + } +} diff --git a/locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/LockerPostgresDataSourceConfig.java b/locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/LockerPostgresDataSourceConfig.java new file mode 100644 index 0000000..1d9d50b --- /dev/null +++ b/locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/LockerPostgresDataSourceConfig.java @@ -0,0 +1,107 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.unitvectory.lockservicecentral.locker.postgres; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +/** + * The Configuration for the Postgres DataSource. + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Configuration +@Profile("postgres") +public class LockerPostgresDataSourceConfig { + + @Value("${locker.postgres.host:localhost}") + private String host; + + @Value("${locker.postgres.port:5432}") + private int port; + + @Value("${locker.postgres.database:lockservice}") + private String database; + + @Value("${locker.postgres.schema:#{null}}") + private String schema; + + @Value("${locker.postgres.username:postgres}") + private String username; + + @Value("${locker.postgres.password:#{null}}") + private String password; + + @Value("${locker.postgres.ssl:false}") + private boolean ssl; + + @Value("${locker.postgres.connectionPoolSize:10}") + private int connectionPoolSize; + + /** + * Creates the DataSource bean for Postgres connections. + * + * @return the DataSource instance + */ + @Bean + public DataSource dataSource() { + HikariConfig config = new HikariConfig(); + + // Build JDBC URL + StringBuilder jdbcUrl = new StringBuilder(); + jdbcUrl.append("jdbc:postgresql://"); + jdbcUrl.append(host); + jdbcUrl.append(":"); + jdbcUrl.append(port); + jdbcUrl.append("/"); + jdbcUrl.append(database); + + if (ssl) { + jdbcUrl.append("?sslmode=require"); + } + + config.setJdbcUrl(jdbcUrl.toString()); + config.setUsername(username); + + if (password != null && !password.isEmpty()) { + config.setPassword(password); + } + + // Set schema if provided + if (schema != null && !schema.isEmpty()) { + config.setSchema(schema); + } + + // Connection pool settings + config.setMaximumPoolSize(connectionPoolSize); + config.setMinimumIdle(1); + config.setIdleTimeout(60000); + config.setConnectionTimeout(30000); + config.setPoolName("lockservice-postgres-pool"); + + // PostgreSQL specific settings + config.addDataSourceProperty("cachePrepStmts", "true"); + config.addDataSourceProperty("prepStmtCacheSize", "25"); + config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + + return new HikariDataSource(config); + } +} diff --git a/locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/PostgresLockService.java b/locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/PostgresLockService.java new file mode 100644 index 0000000..687bf5a --- /dev/null +++ b/locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/PostgresLockService.java @@ -0,0 +1,324 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.unitvectory.lockservicecentral.locker.postgres; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import com.unitvectory.lockservicecentral.locker.Lock; +import com.unitvectory.lockservicecentral.locker.LockService; +import com.unitvectory.lockservicecentral.logging.CanonicalLogContext; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +/** + * PostgreSQL implementation of {@link LockService} providing distributed lock functionality. + * + *

This implementation uses PostgreSQL's atomic operations (INSERT ... ON CONFLICT, UPDATE, + * DELETE with RETURNING) to ensure atomic lock operations across distributed instances. + * All lock mutations (acquire, renew, release) are performed using single atomic SQL statements + * with all conditions evaluated server-side, eliminating race conditions that would occur + * with read-then-write patterns.

+ * + *

Atomicity Guarantees

+ *
    + *
  • Acquire: Single INSERT ... ON CONFLICT (namespace, lock_name) DO UPDATE with + * conditions that succeed only if the lock doesn't exist, is expired, or belongs to + * the same owner/instance
  • + *
  • Renew: Single UPDATE with WHERE namespace=? AND lock_name=? plus conditions + * that succeed only if the lock exists, is not expired, and matches the owner/instance
  • + *
  • Release: Single DELETE with WHERE namespace=? AND lock_name=? plus conditions + * that succeed only if the lock matches the owner/instance
  • + *
+ * + *

Lock Expiry Handling

+ *

Lock expiry is checked atomically within SQL statements using Postgres's now() function + * via extract(epoch from now()). This ensures that expiry checks are evaluated at the database + * level and cannot be affected by clock skew between read and write operations.

+ * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Slf4j +public class PostgresLockService implements LockService { + + /** + * Pattern for valid PostgreSQL table names. + * Allows letters, numbers, and underscores, starting with a letter or underscore. + */ + private static final java.util.regex.Pattern VALID_TABLE_NAME_PATTERN = + java.util.regex.Pattern.compile("^[a-zA-Z_][a-zA-Z0-9_]*$"); + + private final JdbcTemplate jdbcTemplate; + private final String tableName; + private final ObjectProvider canonicalLogContextProvider; + + /** + * RowMapper for converting result sets to Lock objects. + */ + private final RowMapper lockRowMapper = new RowMapper<>() { + @Override + public Lock mapRow(ResultSet rs, int rowNum) throws SQLException { + Map map = new HashMap<>(); + map.put("namespace", rs.getString("namespace")); + map.put("lockName", rs.getString("lock_name")); + map.put("owner", rs.getString("owner")); + map.put("instanceId", rs.getString("instance_id")); + map.put("leaseDuration", rs.getLong("lease_duration")); + map.put("expiry", rs.getLong("expiry")); + return new Lock(map); + } + }; + + /** + * Validates that the table name is a valid PostgreSQL identifier. + * + * @param tableName the table name to validate + * @throws IllegalArgumentException if the table name is invalid + */ + private static void validateTableName(String tableName) { + if (tableName == null || tableName.isEmpty()) { + throw new IllegalArgumentException("Table name cannot be null or empty"); + } + if (!VALID_TABLE_NAME_PATTERN.matcher(tableName).matches()) { + throw new IllegalArgumentException( + "Invalid table name. Table name must start with a letter or underscore " + + "and contain only letters, numbers, and underscores."); + } + if (tableName.length() > 63) { + throw new IllegalArgumentException("Table name cannot exceed 63 characters"); + } + } + + /** + * Constructs a new PostgresLockService. + * + * @param dataSource the DataSource for Postgres connections + * @param tableName the Postgres table name for locks + * @param canonicalLogContextProvider provider for the canonical log context + * @throws IllegalArgumentException if the table name is invalid + */ + public PostgresLockService(DataSource dataSource, String tableName, + ObjectProvider canonicalLogContextProvider) { + validateTableName(tableName); + this.jdbcTemplate = new JdbcTemplate(dataSource); + this.tableName = tableName; + this.canonicalLogContextProvider = canonicalLogContextProvider; + } + + /** + * Constructs a new PostgresLockService with a pre-configured JdbcTemplate. + * + * @param jdbcTemplate the JdbcTemplate for database operations + * @param tableName the Postgres table name for locks + * @param canonicalLogContextProvider provider for the canonical log context + * @throws IllegalArgumentException if the table name is invalid + */ + PostgresLockService(JdbcTemplate jdbcTemplate, String tableName, + ObjectProvider canonicalLogContextProvider) { + validateTableName(tableName); + this.jdbcTemplate = jdbcTemplate; + this.tableName = tableName; + this.canonicalLogContextProvider = canonicalLogContextProvider; + } + + /** + * Records the lock service outcome to the canonical log context. + * + * @param outcome the screaming snake case outcome + */ + private void recordOutcome(String outcome) { + try { + CanonicalLogContext context = canonicalLogContextProvider.getObject(); + context.put("lock_service_outcome", outcome); + } catch (Exception e) { + // Don't break lock operations if logging fails + } + } + + @Override + public Lock getLock(@NonNull String namespace, @NonNull String lockName) { + try { + String sql = "SELECT namespace, lock_name, owner, instance_id, lease_duration, expiry " + + "FROM " + tableName + " WHERE namespace = ? AND lock_name = ?"; + + List results = jdbcTemplate.query(sql, lockRowMapper, namespace, lockName); + + if (results.isEmpty()) { + return null; + } + + return results.get(0); + + } catch (Exception e) { + log.error("Error getting lock: {} {}", namespace, lockName, e); + return null; + } + } + + @Override + public Lock acquireLock(@NonNull Lock originalLock, long now) { + Lock lock = originalLock.copy(); + + try { + // Atomic INSERT ... ON CONFLICT DO UPDATE with conditions: + // - Insert if row doesn't exist + // - Update if row exists AND (expired OR same owner/instance) + // The WHERE clause in DO UPDATE controls whether the update happens + // Using Postgres now() for server-side time evaluation + String sql = "INSERT INTO " + tableName + + " (namespace, lock_name, owner, instance_id, lease_duration, expiry) " + + "VALUES (?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (namespace, lock_name) DO UPDATE SET " + + "owner = EXCLUDED.owner, " + + "instance_id = EXCLUDED.instance_id, " + + "lease_duration = EXCLUDED.lease_duration, " + + "expiry = EXCLUDED.expiry " + + "WHERE " + tableName + ".expiry < EXTRACT(EPOCH FROM now())::bigint " + + "OR (" + tableName + ".owner = EXCLUDED.owner AND " + tableName + ".instance_id = EXCLUDED.instance_id) " + + "RETURNING namespace"; + + List result = jdbcTemplate.query(sql, + (rs, rowNum) -> rs.getString("namespace"), + lock.getNamespace(), lock.getLockName(), lock.getOwner(), + lock.getInstanceId(), lock.getLeaseDuration(), lock.getExpiry()); + + if (!result.isEmpty()) { + // Lock was acquired (insert or update succeeded) + lock.setSuccess(); + recordOutcome("ACQUIRED"); + } else { + // Conflict: lock exists, is not expired, and belongs to different owner + lock.setFailed(); + recordOutcome("ACQUIRE_CONFLICT"); + } + + } catch (Exception e) { + log.error("Error acquiring lock: {}", lock, e); + lock.setFailed(); + recordOutcome("ACQUIRE_ERROR"); + } + + return lock; + } + + @Override + public Lock renewLock(@NonNull Lock originalLock, long now) { + Lock lock = originalLock.copy(); + + try { + // Atomic UPDATE with conditions: + // - Lock must exist (implicit in UPDATE) + // - Lock must not be expired (using Postgres now()) + // - Lock must match owner and instance_id + // Adds leaseDuration to both lease_duration and expiry + String sql = "UPDATE " + tableName + " SET " + + "lease_duration = lease_duration + ?, " + + "expiry = expiry + ? " + + "WHERE namespace = ? AND lock_name = ? " + + "AND expiry >= EXTRACT(EPOCH FROM now())::bigint " + + "AND owner = ? " + + "AND instance_id = ? " + + "RETURNING namespace, lock_name, owner, instance_id, lease_duration, expiry"; + + List results = jdbcTemplate.query(sql, lockRowMapper, + lock.getLeaseDuration(), lock.getLeaseDuration(), + lock.getNamespace(), lock.getLockName(), lock.getOwner(), lock.getInstanceId()); + + if (!results.isEmpty()) { + // Lock was renewed successfully + Lock updatedLock = results.get(0); + lock.setLeaseDuration(updatedLock.getLeaseDuration()); + lock.setExpiry(updatedLock.getExpiry()); + lock.setSuccess(); + recordOutcome("RENEWED"); + } else { + // Condition failed: lock doesn't exist, is expired, or belongs to different owner + lock.setFailed(); + recordOutcome("RENEW_CONFLICT"); + } + + } catch (Exception e) { + log.error("Error renewing lock: {}", lock, e); + lock.setFailed(); + recordOutcome("RENEW_ERROR"); + } + + return lock; + } + + @Override + public Lock releaseLock(@NonNull Lock originalLock, long now) { + Lock lock = originalLock.copy(); + + try { + // First, try to delete with owner/instance match + // This handles the normal release case + String deleteSql = "DELETE FROM " + tableName + " " + + "WHERE namespace = ? AND lock_name = ? " + + "AND owner = ? " + + "AND instance_id = ? " + + "RETURNING namespace"; + + List deleteResult = jdbcTemplate.query(deleteSql, + (rs, rowNum) -> rs.getString("namespace"), + lock.getNamespace(), lock.getLockName(), lock.getOwner(), lock.getInstanceId()); + + if (!deleteResult.isEmpty()) { + // Lock was deleted successfully + lock.setCleared(); + recordOutcome("RELEASED"); + } else { + // Delete didn't match - check if lock exists and why + String checkSql = "SELECT namespace, lock_name, owner, instance_id, lease_duration, expiry " + + "FROM " + tableName + " WHERE namespace = ? AND lock_name = ?"; + List existing = jdbcTemplate.query(checkSql, lockRowMapper, + lock.getNamespace(), lock.getLockName()); + + if (existing.isEmpty()) { + // Lock doesn't exist - treat as success (already released) + lock.setCleared(); + recordOutcome("RELEASED_NOT_FOUND"); + } else { + Lock existingLock = existing.get(0); + if (existingLock.getExpiry() < now) { + // Lock is expired - treat as effectively released + lock.setCleared(); + recordOutcome("RELEASED_EXPIRED"); + } else { + // Lock exists and belongs to different owner, and is not expired + lock.setFailed(); + recordOutcome("RELEASE_CONFLICT"); + } + } + } + + } catch (Exception e) { + log.error("Error releasing lock: {}", lock, e); + lock.setFailed(); + recordOutcome("RELEASE_ERROR"); + } + + return lock; + } +} diff --git a/locker-postgres/src/test/java/com/unitvectory/lockservicecentral/locker/postgres/PostgresLockServiceTest.java b/locker-postgres/src/test/java/com/unitvectory/lockservicecentral/locker/postgres/PostgresLockServiceTest.java new file mode 100644 index 0000000..20bb123 --- /dev/null +++ b/locker-postgres/src/test/java/com/unitvectory/lockservicecentral/locker/postgres/PostgresLockServiceTest.java @@ -0,0 +1,126 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.unitvectory.lockservicecentral.locker.postgres; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import com.unitvectory.lockservicecentral.locker.Lock; +import com.unitvectory.lockservicecentral.logging.CanonicalLogContext; + +/** + * The PostgresLockService test. + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +public class PostgresLockServiceTest { + + private ObjectProvider createNoOpProvider() { + return new ObjectProvider<>() { + @Override + public CanonicalLogContext getObject() { + return new CanonicalLogContext(); + } + + @Override + public CanonicalLogContext getObject(Object... args) { + return new CanonicalLogContext(); + } + + @Override + public CanonicalLogContext getIfAvailable() { + return new CanonicalLogContext(); + } + + @Override + public CanonicalLogContext getIfUnique() { + return new CanonicalLogContext(); + } + }; + } + + @Test + @SuppressWarnings("unchecked") + public void getLockTest() { + JdbcTemplate mockJdbcTemplate = mock(JdbcTemplate.class); + PostgresLockService service = new PostgresLockService(mockJdbcTemplate, "locks", createNoOpProvider()); + + // Mock empty result + when(mockJdbcTemplate.query(anyString(), any(RowMapper.class), any())) + .thenReturn(Collections.emptyList()); + + // Call the method under test + Lock lock = service.getLock("foo", "bar"); + + // Verify the result + assertNull(lock); + } + + @Test + public void invalidTableNameNullTest() { + JdbcTemplate mockJdbcTemplate = mock(JdbcTemplate.class); + assertThrows(IllegalArgumentException.class, () -> { + new PostgresLockService(mockJdbcTemplate, null, createNoOpProvider()); + }); + } + + @Test + public void invalidTableNameEmptyTest() { + JdbcTemplate mockJdbcTemplate = mock(JdbcTemplate.class); + assertThrows(IllegalArgumentException.class, () -> { + new PostgresLockService(mockJdbcTemplate, "", createNoOpProvider()); + }); + } + + @Test + public void invalidTableNameSqlInjectionTest() { + JdbcTemplate mockJdbcTemplate = mock(JdbcTemplate.class); + assertThrows(IllegalArgumentException.class, () -> { + new PostgresLockService(mockJdbcTemplate, "locks; DROP TABLE users;", createNoOpProvider()); + }); + } + + @Test + public void invalidTableNameSpecialCharsTest() { + JdbcTemplate mockJdbcTemplate = mock(JdbcTemplate.class); + assertThrows(IllegalArgumentException.class, () -> { + new PostgresLockService(mockJdbcTemplate, "my-locks", createNoOpProvider()); + }); + } + + @Test + public void validTableNameWithUnderscoreTest() { + JdbcTemplate mockJdbcTemplate = mock(JdbcTemplate.class); + // Should not throw + new PostgresLockService(mockJdbcTemplate, "my_locks", createNoOpProvider()); + } + + @Test + public void validTableNameStartingWithUnderscoreTest() { + JdbcTemplate mockJdbcTemplate = mock(JdbcTemplate.class); + // Should not throw + new PostgresLockService(mockJdbcTemplate, "_locks", createNoOpProvider()); + } +} diff --git a/pom.xml b/pom.xml index 689f85e..b850de8 100644 --- a/pom.xml +++ b/pom.xml @@ -32,6 +32,7 @@ locker-firestore locker-etcd locker-dynamodb + locker-postgres @@ -56,6 +57,12 @@ locker-dynamodb + + postgres + + locker-postgres + +