Distributed scheduler lock built on Redis SET NX PX: instances call dibs on a job; the first one wins, everyone else skips.
When the same scheduled job runs on N instances of a service, exactly one instance should execute it per tick. This namespace provides that guarantee as a higher-order function: the caller hands us a closure and we run it if — and only if — this process wins the distributed lock. This deliberately avoids the annotation + AOP approach because annotations cannot express runtime-computed lock keys, cannot return the job's result to the caller, and silently fail to apply on self-invocation since Spring proxies only intercept external calls.
The namespace is AOT-compiled (:gen-class) into dibs.Dibs
so that Kotlin/Java callers see a plain static-method API with zero Clojure
in the call site:
execute(conn, lockName, atMostSec, atLeastSec, Runnable) -> boolean
executeWithResult(conn, lockName, atMostSec, atLeastSec, Supplier) -> Optional<T>
execute returns whether the lock was won (and therefore whether the task
ran). executeWithResult returns the task's value wrapped in Optional, or
Optional.empty() when this instance was skipped. Note the ambiguity this
implies: a Supplier that legitimately returns null is indistinguishable
from a skip. Callers that care must use execute or return a sentinel.
A lock is a single Redis key whose value is a UUID token unique to the holder. Three rules make it safe:
SET key token NX PX ttl — one atomic command, so two
instances can never both believe they won.conn is either a Redis URI string ("redis://host:6379"), a Carmine
conn map, or a java.util.Map treated as the Carmine :spec map (the shape a
Kotlin caller naturally produces: mapOf("uri" to "redis://...")).
Distributed scheduler lock built on Redis SET NX PX: instances call dibs
on a job; the first one wins, everyone else skips.
## Purpose
When the same scheduled job runs on N instances of a service, exactly one
instance should execute it per tick. This namespace provides that guarantee
as a higher-order function: the caller hands us a closure and we run it
if — and only if — this process wins the distributed lock. This deliberately
avoids the annotation + AOP approach because annotations cannot express
runtime-computed lock keys, cannot return the job's result to the caller,
and silently fail to apply on self-invocation since Spring proxies
only intercept external calls.
## Java surface
The namespace is AOT-compiled (`:gen-class`) into `dibs.Dibs`
so that Kotlin/Java callers see a plain static-method API with zero Clojure
in the call site:
execute(conn, lockName, atMostSec, atLeastSec, Runnable) -> boolean
executeWithResult(conn, lockName, atMostSec, atLeastSec, Supplier) -> Optional<T>
`execute` returns whether the lock was won (and therefore whether the task
ran). `executeWithResult` returns the task's value wrapped in Optional, or
Optional.empty() when this instance was skipped. Note the ambiguity this
implies: a Supplier that legitimately returns null is indistinguishable
from a skip. Callers that care must use `execute` or return a sentinel.
## Locking model
A lock is a single Redis key whose value is a UUID token unique to the
holder. Three rules make it safe:
1. Acquisition is `SET key token NX PX ttl` — one atomic command, so two
instances can never both believe they won.
2. The TTL (lockAtMostFor) means a crashed holder cannot wedge the lock
forever; it self-heals when the TTL lapses.
3. Release is token-checked in a Lua script, so a holder that outlived its
own TTL cannot delete a lock that now belongs to someone else.
`conn` is either a Redis URI string ("redis://host:6379"), a Carmine
conn map, or a java.util.Map treated as the Carmine :spec map (the shape a
Kotlin caller naturally produces: mapOf("uri" to "redis://...")).(-execute conn lock-name at-most-sec at-least-sec task)(-executeWithResult conn lock-name at-most-sec at-least-sec task)(locked-call conn lock-name at-most-sec at-least-sec f)Run thunk f under the distributed lock lock-name. Returns
[acquired? result]: [true (f)] if this call won the lock, [false nil] if
another holder has it and f was skipped entirely.
This is the one place the acquire/execute/release lifecycle lives, so the ordering rules are worth spelling out:
Durations are validated before touching Redis. atLeastFor > atMostFor is a contradiction (the lock would be required to outlive its own expiry), and catching it here turns a subtle production timing bug into an immediate IllegalArgumentException at the call site.
The token is minted per call, not per process. Two ticks of the same job on the same instance are distinct holders; reusing a process-wide token would let tick N+1 release a lock that tick N still holds.
Release lives in finally so a throwing task cannot leak the lock
until its TTL. The exception itself propagates to the caller untouched
— swallowing it would make failing jobs look like successful ones to
the scheduler.
Elapsed time is measured in milliseconds as longs. remaining-ms is clamped at zero because a task that ran longer than atLeastFor has already satisfied the minimum-hold requirement by simply existing.
Run thunk `f` under the distributed lock `lock-name`. Returns [acquired? result]: [true (f)] if this call won the lock, [false nil] if another holder has it and `f` was skipped entirely. This is the one place the acquire/execute/release lifecycle lives, so the ordering rules are worth spelling out: - Durations are validated before touching Redis. atLeastFor > atMostFor is a contradiction (the lock would be required to outlive its own expiry), and catching it here turns a subtle production timing bug into an immediate IllegalArgumentException at the call site. - The token is minted per *call*, not per process. Two ticks of the same job on the same instance are distinct holders; reusing a process-wide token would let tick N+1 release a lock that tick N still holds. - Release lives in `finally` so a throwing task cannot leak the lock until its TTL. The exception itself propagates to the caller untouched — swallowing it would make failing jobs look like successful ones to the scheduler. - Elapsed time is measured in milliseconds as longs. remaining-ms is clamped at zero because a task that ran longer than atLeastFor has already satisfied the minimum-hold requirement by simply existing.
(release-lock conn lock-key token remaining-ms)Release lock-key, but only if token still owns it. When
remaining-ms is positive, the key is retained that long instead of
deleted (the lockAtLeastFor window — see release-script for why).
Returns true iff we still owned the lock; false means our TTL expired
while the task ran and the key is either gone or someone else's now, in
which case we deliberately touch nothing.
Release `lock-key`, but only if `token` still owns it. When `remaining-ms` is positive, the key is retained that long instead of deleted (the lockAtLeastFor window — see release-script for why). Returns true iff we still owned the lock; false means our TTL expired while the task ran and the key is either gone or someone else's now, in which case we deliberately touch nothing.
(try-acquire-lock conn lock-key token ttl-ms)Attempt to acquire lock-key on behalf of token, expiring after
ttl-ms. Returns true iff this call acquired the lock.
This is a single SET key token NX PX ttl — the NX makes acquisition
atomic (no get-then-set race window), and attaching the TTL in the same
command means there is no instant where the key exists without an expiry.
A two-step SETNX + EXPIRE would leave exactly that gap: crash between the
two commands and the lock is held forever.
The token matters as much as the TTL. Every acquisition writes a value unique to the acquiring call (a random UUID), which is what later lets release-lock distinguish "my lock" from "a lock someone else took after mine expired". Without it, this would be a lock that any process can release, which is barely a lock at all.
Attempt to acquire `lock-key` on behalf of `token`, expiring after `ttl-ms`. Returns true iff this call acquired the lock. This is a single `SET key token NX PX ttl` — the NX makes acquisition atomic (no get-then-set race window), and attaching the TTL in the same command means there is no instant where the key exists without an expiry. A two-step SETNX + EXPIRE would leave exactly that gap: crash between the two commands and the lock is held forever. The token matters as much as the TTL. Every acquisition writes a value unique to the acquiring call (a random UUID), which is what later lets release-lock distinguish "my lock" from "a lock someone else took after mine expired". Without it, this would be a lock that any process can release, which is barely a lock at all.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |