001// --------------------------------------------------------------------------------
002// Copyright 2002-2026 Echo Three, LLC
003//
004// Licensed under the Apache License, Version 2.0 (the "License");
005// you may not use this file except in compliance with the License.
006// You may obtain a copy of the License at
007//
008//     http://www.apache.org/licenses/LICENSE-2.0
009//
010// Unless required by applicable law or agreed to in writing, software
011// distributed under the License is distributed on an "AS IS" BASIS,
012// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013// See the License for the specific language governing permissions and
014// limitations under the License.
015// --------------------------------------------------------------------------------
016
017package com.echothree.util.server.persistence.mutex;
018
019import com.echothree.util.server.persistence.Session;
020import java.nio.ByteBuffer;
021import java.nio.charset.StandardCharsets;
022import java.util.Objects;
023import java.util.concurrent.TimeUnit;
024import javax.enterprise.context.ApplicationScoped;
025import javax.inject.Inject;
026import org.apache.commons.codec.digest.DigestUtils;
027import org.jooq.impl.DSL;
028
029/**
030 * Postgres session advisory locks keyed by the first 64 bits of SHA-256.
031 * Hash collisions cause otherwise distinct names to share a lock.
032 */
033@ApplicationScoped
034@DatabaseMutexFlavor(DatabaseMutexFlavor.Flavor.POSTGRES)
035public class DatabasePostgresMutex
036        implements DatabaseMutex {
037
038    private static final long POLL_INTERVAL_NANOS = TimeUnit.MILLISECONDS.toNanos(50);
039
040    @Inject
041    Session session;
042
043    private long lockKey(final String lockName) {
044        var bytes = Objects.requireNonNull(lockName, "lockName").getBytes(StandardCharsets.UTF_8);
045
046        // ByteBuffer reads in big-endian order; keep this mapping stable across callers.
047        return ByteBuffer.wrap(DigestUtils.sha256(bytes)).getLong();
048    }
049
050    @Override
051    public boolean acquireLock(final String lockName, final int timeoutSeconds) {
052        var key = lockKey(lockName);
053        var timeoutNanos = TimeUnit.SECONDS.toNanos(timeoutSeconds);
054        var started = System.nanoTime();
055        var acquired = false;
056
057        do {
058            if(Thread.currentThread().isInterrupted()) {
059                break;
060            }
061
062            var result = session.getDslContext()
063                    .select(DSL.function("pg_try_advisory_lock", Boolean.class, DSL.val(key)))
064                    .fetchOne(0, Boolean.class);
065
066            acquired = Boolean.TRUE.equals(result);
067
068            if(acquired || result == null) {
069                break;
070            }
071
072            var remaining = timeoutSeconds < 0 ? Long.MAX_VALUE : timeoutNanos - (System.nanoTime() - started);
073            if(remaining <= 0) {
074                break;
075            }
076
077            try {
078                TimeUnit.NANOSECONDS.sleep(Math.min(POLL_INTERVAL_NANOS, remaining));
079            } catch(InterruptedException e) {
080                Thread.currentThread().interrupt();
081                break;
082            }
083        } while(timeoutSeconds < 0 || System.nanoTime() - started < timeoutNanos);
084
085        return acquired;
086    }
087
088    @Override
089    public boolean releaseLock(final String lockName) {
090        var result = session.getDslContext()
091                .select(DSL.function("pg_advisory_unlock", Boolean.class, DSL.val(lockKey(lockName))))
092                .fetchOne(0, Boolean.class);
093
094        return Boolean.TRUE.equals(result);
095    }
096
097}