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
+
+