Locks, semaphores & latches

The JDK concurrency trio — ReentrantLock, Semaphore, CountDownLatch — but shared by every pod on the backbone. One bean, lease-based, crash-safe.

On this page

    Concept

    Feature

    Inside one JVM you reach for java.util.concurrent; across pods there was nothing — so "only one pod may run this" ended up as ad-hoc database flags and "at most N at a time" was not enforced at all. DacCoordination gives the same three primitives cluster scope, built on the Redis the cache already uses:

    DacLockMutual exclusionOne holder across the cluster; re-entrant for the holding thread, like ReentrantLock. "Only one pod runs the nightly export."
    DacSemaphoreBounded concurrencyAt most N permits held cluster-wide, whichever pods hold them. "At most four report exports at a time against the rate-limited API."
    DacCountDownLatchWait for N eventsCounting and waiting on different pods. "Release when all five partitions report done."

    Everything is a lease. Whatever a pod holds — a lock, a permit — carries a 30-second expiry that the library renews in the background at a third of the lease while the pod is alive. A pod that crashes mid-hold stops renewing, the lease runs out, and the cluster unblocks on its own. No coordination primitive here can deadlock the cluster on a dead holder.

    DacLock — the re-entrant cluster lock

    Exclusive across all pods, re-entrant for the thread that holds it — take it again and release the matching number of times, exactly like ReentrantLock. Ownership is per thread per pod, so even two threads on the same pod exclude each other.

    private final DacCoordination coordination;
    
    public void runNightlyExport() throws InterruptedException {
        DacLock lock = coordination.lock("nightly-export");
        if (!lock.tryLock(Duration.ofSeconds(5))) {
            return;                       // another pod is already exporting
        }
        try {
            export();
        } finally {
            lock.unlock();
        }
    }
    methodbehaviour
    tryLock()Take the lock without waiting; the holding thread always succeeds again (re-entrant).
    tryLock(wait)Poll for the lock until acquired or wait runs out.
    unlock()Leave once; the lock frees for others when the thread has left as often as it entered. Throws IllegalMonitorStateException when the thread does not hold it.
    isHeldByCurrentThread()Whether the calling thread holds the lock.
    Important

    This is a coordination lock, not a leader election. For "one pod owns this role continuously and re-elects on failure" the engine's LeaderElector is the right tool — DacLock is for bounded critical sections: jobs, migrations, one-off admin actions.

    DacSemaphore — the global semaphore

    At most permits concurrent holders across the whole cluster. Every pod declares the same limit in code; each acquired permit is an AutoCloseable handle:

    DacSemaphore exports = coordination.semaphore("report-export", 4);
    
    Optional<DacSemaphore.Permit> permit = exports.tryAcquire(Duration.ofSeconds(10));
    if (permit.isEmpty()) {
        throw new TooBusyException("all 4 export slots are taken across the cluster");
    }
    try (var p = permit.get()) {
        exportReport();
    }                                     // released here — or by lease expiry if this pod dies
    methodbehaviour
    tryAcquire()Take a permit without waiting; empty when all permits are held.
    tryAcquire(wait)Poll until a permit frees up (released or expired) or wait runs out.
    Permit.release() / close()Give the permit back; releasing twice is harmless.
    held()Permits currently held across the cluster, expired holders excluded.

    DacCountDownLatch — wait for N events

    The first caller creates the latch at its count; later calls from any pod join it (their count argument is ignored). Workers count down wherever they run; whoever assembles the result awaits:

    // the pod kicking off the work
    DacCountDownLatch done = coordination.latch("nightly-partitions", 5, Duration.ofHours(1));
    publishPartitionJobs();
    if (!done.await(Duration.ofMinutes(30))) {
        log.warn("only {} partitions finished in time", 5 - done.count());
    }
    
    // each worker pod, when its partition completes
    coordination.latch("nightly-partitions", 5, Duration.ofHours(1)).countDown();
    methodbehaviour
    countDown()Count down once, from any pod; counting below zero does nothing.
    await(timeout)Block until the latch reaches zero; false when the timeout ran out first.
    count()What is left; 0 when released — or when the latch expired or never existed.
    Note

    The TTL is the latch's whole lifetime, not a hold lease: pick it longer than the slowest realistic run. An expired latch reads as released — the fail-open choice, so a forgotten latch can never block a future run forever.

    Mechanics and guarantees

    aspectbehaviour
    scopeAll pods sharing one keyspace root, like the broadcast bus. Keys live under {root}:coord:lock:*, :coord:sem:*, :coord:latch:*.
    atomicityEvery state change is one Lua script on Redis — acquire, release, renew and count-down are race-free even under contention from many pods.
    crash safetyLocks and permits are 30-second leases renewed at 10-second intervals by a background thread. A dead pod's holds expire; nothing must be cleaned up by hand.
    waitingtryLock(wait), tryAcquire(wait) and await(timeout) poll every 100 ms — coordination-grade latency, deliberately not a messaging feature.
    fairnessNone — waiters race on each poll, like tryLock loops in one JVM. Do not build ordering on top of it.
    Redis downAcquire fails, so guarded work does not run — coordination fails closed, unlike reads, which fall back to their source. Decide per call site whether to skip or to proceed unguarded.
    testingInMemoryRedisStore implements all of it: build a DacCoordination over one shared store in a plain unit test — two instances behave like two pods.
    what it is notNot a fencing token for storage systems and not a transaction. Where absolute correctness under partitions matters more than liveness, use the database's own locking.
    More information

    The bean is auto-configured — inject DacCoordination wherever the cache starter is on the classpath. For asking all pods a question rather than coordinating work between them, see Cluster broadcast & health.