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