From b6dea080f1f735a6aa5210bc0bb1bb94c488d4b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:23:00 +0000 Subject: [PATCH 1/6] Initial plan From 306d9f24edd98f2cb146aec4aedfede1491f8522 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:30:06 +0000 Subject: [PATCH 2/6] Add locker-postgres module with PostgresLockService implementation Co-authored-by: JaredHatfield <208119+JaredHatfield@users.noreply.github.com> --- .github/workflows/docker-build-dev.yml | 2 + README.md | 7 + api/pom.xml | 11 + locker-postgres/README.md | 247 ++++++++++++++ locker-postgres/pom.xml | 107 ++++++ .../locker/postgres/LockerPostgresConfig.java | 53 +++ .../LockerPostgresDataSourceConfig.java | 107 ++++++ .../locker/postgres/PostgresLockService.java | 307 ++++++++++++++++++ .../postgres/PostgresLockServiceTest.java | 77 +++++ pom.xml | 7 + 10 files changed, 925 insertions(+) create mode 100644 locker-postgres/README.md create mode 100644 locker-postgres/pom.xml create mode 100644 locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/LockerPostgresConfig.java create mode 100644 locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/LockerPostgresDataSourceConfig.java create mode 100644 locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/PostgresLockService.java create mode 100644 locker-postgres/src/test/java/com/unitvectory/lockservicecentral/locker/postgres/PostgresLockServiceTest.java 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..a73f148 --- /dev/null +++ b/locker-postgres/README.md @@ -0,0 +1,247 @@ +# 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 ( + lock_id VARCHAR(512) PRIMARY KEY, + 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 +); + +-- 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 +CREATE TABLE IF NOT EXISTS locks ( + lock_id VARCHAR(512) PRIMARY KEY, + 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 +); + +-- 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: + +- `lock_id`: Primary key in format `{namespace}:{lockName}` +- `namespace`: The lock namespace +- `lock_name`: The lock name +- `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 + +### 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 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 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 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.5 + 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..f479586 --- /dev/null +++ b/locker-postgres/src/main/java/com/unitvectory/lockservicecentral/locker/postgres/PostgresLockService.java @@ -0,0 +1,307 @@ +/* + * 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 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 conditions that succeed only if the lock exists, + * is not expired, and matches the owner/instance
  • + *
  • Release: Single DELETE with 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 { + + 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); + } + }; + + /** + * 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 + */ + public PostgresLockService(DataSource dataSource, String tableName, + ObjectProvider canonicalLogContextProvider) { + 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 + */ + PostgresLockService(JdbcTemplate jdbcTemplate, String tableName, + ObjectProvider canonicalLogContextProvider) { + 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 + } + } + + /** + * Generates the database primary key for a lock based on namespace and lock name. + * + * @param namespace the namespace + * @param lockName the lock name + * @return the primary key value + */ + private String generateKey(String namespace, String lockName) { + return namespace + ":" + lockName; + } + + @Override + public Lock getLock(@NonNull String namespace, @NonNull String lockName) { + String key = generateKey(namespace, lockName); + + try { + String sql = "SELECT namespace, lock_name, owner, instance_id, lease_duration, expiry " + + "FROM " + tableName + " WHERE lock_id = ?"; + + List results = jdbcTemplate.query(sql, lockRowMapper, key); + + 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(); + String key = generateKey(lock.getNamespace(), lock.getLockName()); + + 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 + + " (lock_id, namespace, lock_name, owner, instance_id, lease_duration, expiry) " + + "VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (lock_id) 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 lock_id"; + + List result = jdbcTemplate.query(sql, + (rs, rowNum) -> rs.getString("lock_id"), + key, 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(); + String key = generateKey(lock.getNamespace(), lock.getLockName()); + + 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 lock_id = ? " + + "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(), + key, 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(); + String key = generateKey(lock.getNamespace(), lock.getLockName()); + + try { + // First, try to delete with owner/instance match + // This handles the normal release case + String deleteSql = "DELETE FROM " + tableName + " " + + "WHERE lock_id = ? " + + "AND owner = ? " + + "AND instance_id = ? " + + "RETURNING lock_id"; + + List deleteResult = jdbcTemplate.query(deleteSql, + (rs, rowNum) -> rs.getString("lock_id"), + key, 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 lock_id = ?"; + List existing = jdbcTemplate.query(checkSql, lockRowMapper, key); + + 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..13be3f0 --- /dev/null +++ b/locker-postgres/src/test/java/com/unitvectory/lockservicecentral/locker/postgres/PostgresLockServiceTest.java @@ -0,0 +1,77 @@ +/* + * 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.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 { + + @Test + @SuppressWarnings("unchecked") + public void getLockTest() { + JdbcTemplate mockJdbcTemplate = mock(JdbcTemplate.class); + // Use a no-op ObjectProvider for testing + ObjectProvider noOpProvider = 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(); + } + }; + PostgresLockService service = new PostgresLockService(mockJdbcTemplate, "locks", noOpProvider); + + // 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); + } +} 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 + + From 541f686db28c0fb30d91227b014530bb57e30663 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:37:52 +0000 Subject: [PATCH 3/6] Add table name validation to prevent SQL injection Co-authored-by: JaredHatfield <208119+JaredHatfield@users.noreply.github.com> --- .../locker/postgres/PostgresLockService.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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 index f479586..9311976 100644 --- 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 @@ -61,6 +61,13 @@ @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; @@ -82,15 +89,37 @@ public Lock mapRow(ResultSet rs, int rowNum) throws SQLException { } }; + /** + * 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; @@ -102,9 +131,11 @@ public PostgresLockService(DataSource dataSource, String tableName, * @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; From 0cab9394dabdfb5abec95630590a07e4acf52ae4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:39:23 +0000 Subject: [PATCH 4/6] Add table name validation tests for PostgresLockService Co-authored-by: JaredHatfield <208119+JaredHatfield@users.noreply.github.com> --- .../postgres/PostgresLockServiceTest.java | 63 ++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) 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 index 13be3f0..20bb123 100644 --- 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 @@ -14,6 +14,7 @@ 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; @@ -36,12 +37,8 @@ */ public class PostgresLockServiceTest { - @Test - @SuppressWarnings("unchecked") - public void getLockTest() { - JdbcTemplate mockJdbcTemplate = mock(JdbcTemplate.class); - // Use a no-op ObjectProvider for testing - ObjectProvider noOpProvider = new ObjectProvider<>() { + private ObjectProvider createNoOpProvider() { + return new ObjectProvider<>() { @Override public CanonicalLogContext getObject() { return new CanonicalLogContext(); @@ -62,7 +59,13 @@ public CanonicalLogContext getIfUnique() { return new CanonicalLogContext(); } }; - PostgresLockService service = new PostgresLockService(mockJdbcTemplate, "locks", noOpProvider); + } + + @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())) @@ -74,4 +77,50 @@ public CanonicalLogContext getIfUnique() { // 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()); + } } From 6c6efd9a7fa21375036df42d1621b9d08c0a89ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:43:20 +0000 Subject: [PATCH 5/6] Change Postgres schema to use composite primary key (namespace, lock_name) Co-authored-by: JaredHatfield <208119+JaredHatfield@users.noreply.github.com> --- locker-postgres/README.md | 27 ++++---- .../locker/postgres/PostgresLockService.java | 62 +++++++------------ 2 files changed, 38 insertions(+), 51 deletions(-) diff --git a/locker-postgres/README.md b/locker-postgres/README.md index a73f148..b629fb3 100644 --- a/locker-postgres/README.md +++ b/locker-postgres/README.md @@ -82,13 +82,13 @@ Create the locks table with the following schema: ```sql CREATE TABLE IF NOT EXISTS locks ( - lock_id VARCHAR(512) PRIMARY KEY, 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 + expiry BIGINT NOT NULL, + PRIMARY KEY (namespace, lock_name) ); -- Index for efficient expiry-based queries @@ -104,15 +104,15 @@ CREATE DATABASE lockservice; -- Connect to lockservice database \c lockservice --- Create table +-- Create table with composite primary key CREATE TABLE IF NOT EXISTS locks ( - lock_id VARCHAR(512) PRIMARY KEY, 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 + expiry BIGINT NOT NULL, + PRIMARY KEY (namespace, lock_name) ); -- Create index for expiry queries @@ -129,14 +129,15 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON locks TO lockuser; Lock items are stored with the following columns: -- `lock_id`: Primary key in format `{namespace}:{lockName}` -- `namespace`: The lock namespace -- `lock_name`: The lock name +- `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. @@ -162,17 +163,17 @@ DELETE FROM locks WHERE expiry < EXTRACT(EPOCH FROM now())::bigint; ### Behavior -- **Acquire**: Uses `INSERT ... ON CONFLICT DO UPDATE` with a compound condition: +- **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 that: +- **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 that: +- **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) @@ -218,13 +219,13 @@ sleep 5 # Create the locks table docker exec -i lockservice-postgres psql -U postgres -d lockservice <Atomicity Guarantees *
    - *
  • Acquire: Single INSERT ... ON CONFLICT 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 conditions that succeed only if the lock exists, - * is not expired, and matches the owner/instance
  • - *
  • Release: Single DELETE with conditions that succeed only if the lock - * matches the owner/instance
  • + *
  • 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

@@ -155,26 +156,13 @@ private void recordOutcome(String outcome) { } } - /** - * Generates the database primary key for a lock based on namespace and lock name. - * - * @param namespace the namespace - * @param lockName the lock name - * @return the primary key value - */ - private String generateKey(String namespace, String lockName) { - return namespace + ":" + lockName; - } - @Override public Lock getLock(@NonNull String namespace, @NonNull String lockName) { - String key = generateKey(namespace, lockName); - try { String sql = "SELECT namespace, lock_name, owner, instance_id, lease_duration, expiry " + - "FROM " + tableName + " WHERE lock_id = ?"; + "FROM " + tableName + " WHERE namespace = ? AND lock_name = ?"; - List results = jdbcTemplate.query(sql, lockRowMapper, key); + List results = jdbcTemplate.query(sql, lockRowMapper, namespace, lockName); if (results.isEmpty()) { return null; @@ -191,7 +179,6 @@ public Lock getLock(@NonNull String namespace, @NonNull String lockName) { @Override public Lock acquireLock(@NonNull Lock originalLock, long now) { Lock lock = originalLock.copy(); - String key = generateKey(lock.getNamespace(), lock.getLockName()); try { // Atomic INSERT ... ON CONFLICT DO UPDATE with conditions: @@ -200,20 +187,20 @@ public Lock acquireLock(@NonNull Lock originalLock, long now) { // The WHERE clause in DO UPDATE controls whether the update happens // Using Postgres now() for server-side time evaluation String sql = "INSERT INTO " + tableName + - " (lock_id, namespace, lock_name, owner, instance_id, lease_duration, expiry) " + - "VALUES (?, ?, ?, ?, ?, ?, ?) " + - "ON CONFLICT (lock_id) DO UPDATE SET " + + " (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 lock_id"; + "RETURNING namespace"; List result = jdbcTemplate.query(sql, - (rs, rowNum) -> rs.getString("lock_id"), - key, lock.getNamespace(), lock.getLockName(), lock.getOwner(), + (rs, rowNum) -> rs.getString("namespace"), + lock.getNamespace(), lock.getLockName(), lock.getOwner(), lock.getInstanceId(), lock.getLeaseDuration(), lock.getExpiry()); if (!result.isEmpty()) { @@ -238,7 +225,6 @@ public Lock acquireLock(@NonNull Lock originalLock, long now) { @Override public Lock renewLock(@NonNull Lock originalLock, long now) { Lock lock = originalLock.copy(); - String key = generateKey(lock.getNamespace(), lock.getLockName()); try { // Atomic UPDATE with conditions: @@ -249,7 +235,7 @@ public Lock renewLock(@NonNull Lock originalLock, long now) { String sql = "UPDATE " + tableName + " SET " + "lease_duration = lease_duration + ?, " + "expiry = expiry + ? " + - "WHERE lock_id = ? " + + "WHERE namespace = ? AND lock_name = ? " + "AND expiry >= EXTRACT(EPOCH FROM now())::bigint " + "AND owner = ? " + "AND instance_id = ? " + @@ -257,7 +243,7 @@ public Lock renewLock(@NonNull Lock originalLock, long now) { List results = jdbcTemplate.query(sql, lockRowMapper, lock.getLeaseDuration(), lock.getLeaseDuration(), - key, lock.getOwner(), lock.getInstanceId()); + lock.getNamespace(), lock.getLockName(), lock.getOwner(), lock.getInstanceId()); if (!results.isEmpty()) { // Lock was renewed successfully @@ -284,20 +270,19 @@ public Lock renewLock(@NonNull Lock originalLock, long now) { @Override public Lock releaseLock(@NonNull Lock originalLock, long now) { Lock lock = originalLock.copy(); - String key = generateKey(lock.getNamespace(), lock.getLockName()); try { // First, try to delete with owner/instance match // This handles the normal release case String deleteSql = "DELETE FROM " + tableName + " " + - "WHERE lock_id = ? " + + "WHERE namespace = ? AND lock_name = ? " + "AND owner = ? " + "AND instance_id = ? " + - "RETURNING lock_id"; + "RETURNING namespace"; List deleteResult = jdbcTemplate.query(deleteSql, - (rs, rowNum) -> rs.getString("lock_id"), - key, lock.getOwner(), lock.getInstanceId()); + (rs, rowNum) -> rs.getString("namespace"), + lock.getNamespace(), lock.getLockName(), lock.getOwner(), lock.getInstanceId()); if (!deleteResult.isEmpty()) { // Lock was deleted successfully @@ -306,8 +291,9 @@ public Lock releaseLock(@NonNull Lock originalLock, long now) { } 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 lock_id = ?"; - List existing = jdbcTemplate.query(checkSql, lockRowMapper, key); + "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) From 83405dec4492a570e09fb51db4227d075faf6690 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:45:18 +0000 Subject: [PATCH 6/6] Update PostgreSQL driver to 42.7.7 to fix security vulnerability Co-authored-by: JaredHatfield <208119+JaredHatfield@users.noreply.github.com> --- locker-postgres/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locker-postgres/pom.xml b/locker-postgres/pom.xml index 66c6dae..b52e065 100644 --- a/locker-postgres/pom.xml +++ b/locker-postgres/pom.xml @@ -13,7 +13,7 @@ locker-postgres - 42.7.5 + 42.7.7 6.2.1