Explicit list of things ubgo/lock deliberately does NOT do, and
why. If you need any of these, this isn't the library for you —
follow the pointers below.
Same holder acquires the same name twice without deadlocking.
We follow Go's sync.Mutex stance: locks are non-reentrant by
design. Russ Cox's framing: "Locks should be associated with
invariants, not blocks of code." Reentrancy hides design
problems — when you have a function-calls-function-needs-same-lock
pattern, the right fix is to refactor into private xxxLocked()
helpers that document "caller must hold the lock", not to paper
over it with library magic.
Exception: pglock is reentrant by default because Postgres
advisory locks are natively reentrant at the protocol level. We
don't fight Postgres; we document the asymmetry instead.
If you need reentrancy across the family, this isn't the library.
Acquirewaits until the lock is free instead of returning ErrLocked.
Every backend's Acquire returns ErrLocked immediately on
contention. There is no WaitForLock variant. Reasoning:
- The dominant use case is "skip if already running" (cron singleton). Blocking is the wrong default.
- If you DO want a deadline, wrap with
context.WithTimeout— but a marker lock isn't the right tool for "serialize long work"; reach for a queue. - Wait-for-lock semantics turn correctness questions into timing questions, which is harder to reason about.
If you need blocking acquire with a wait queue, use
go.etcd.io/etcd/client/v3/concurrency.Mutex.Lock directly —
it's what etcdlock wraps with TryLock for the family API.
Multi-master Redis quorum locking (Redlock algorithm).
redislock targets a single Redis primary (Sentinel-friendly).
Reasons we don't ship Redlock:
- Operationally complex — N independent Redis primaries.
- Safety claims contested by Kleppmann. Clock-skew issues can produce two holders.
- For workloads that genuinely need quorum-correct locking,
etcdlockgives the same guarantees with simpler reasoning (Raft gives you total order on revisions; mod_revision is a globally monotonic fence).
If you need Redlock specifically, use go-redsync/redsync.
Detect cycles across multiple lock names held by different processes.
This is Redisson's MultiLock territory — graph-walking across
processes to detect A-waits-on-B-waits-on-A scenarios. Out of
scope for v1. The cron-singleton workloads we target use a single
lock name per work item.
If you genuinely need multi-resource distributed locking with deadlock detection, you're in territory where you should be asking whether a queue + idempotent processing is a better model.
A separate HTTP service that arbitrates locks for clients.
That's werf/lockgate's
pattern. Operationally adds a coordinator service — if you need
one, you're better off with etcdlock (etcd IS your coordinator;
no extra service to run).
The library starts a goroutine that auto-extends your lease.
We don't bake goroutines into the lock library. Reasons:
- Lifecycle questions: when does the goroutine stop? Tied to ctx? To the holder? To the process?
- Backpressure: what if the renewal blocks?
- Errors: how does the goroutine signal failure to your code?
Instead, Holder.Extend(ctx) is exposed and you choose when to
call it. See snippets.md §5
for the recommended pattern.
A lock that's coordinated across geo-distributed regions.
Not a distinct feature — the right tool depends on your existing distributed system:
- Redis with cross-region replication:
redislockworks (with caveats about replication lag). - Postgres with a single primary across regions:
pglockworks. - etcd cluster across regions:
etcdlockis the canonical fit; Raft handles the consistency.
We don't add a separate "geo lock" abstraction.
Locks visible from Go AND Python AND Node.
Each backend's lock IS visible to other languages because the mechanism is the underlying store, not the language wrapper:
redislockkeys are plain Redis keys readable from any client.pglockadvisory locks are visible inpg_locksfrom any client.etcdlockkeys are plain etcd keys.
But we don't ship Python/Node/Rust client libraries. If you want a Go-acquires + Python-respects flow, use the same key conventions in both.
Every Acquire bumps the TTL on the existing lock if you already hold it.
Locks are non-reentrant (see top of doc), so this case doesn't
arise — if you call Acquire while holding the lock, you get
ErrLocked. To keep a long-running holder's lock fresh, use
Holder.Extend(ctx) instead.
The marker stores arbitrary user-defined fields.
filelock markers have a fixed identity + debug schema; we don't
let users add arbitrary key-value pairs. Reasons:
- Forward compat: every reader (sweep, takeover, operator) has to know how to handle unknown fields.
- Most "metadata" desires are met by
WithTraceIDExtractor(puts the active OTel TraceID in the marker) or by encoding meaning into the lock NAME.
If your name needs structure (e.g. "tenant-42:job-export"), use a naming scheme.
A single recommended backend for all use cases.
There isn't one. The whole point of the family is that infra
constraints decide. Single-host with kernel-fenced crash safety →
flock. Multi-host on Postgres → pglock. Strong consistency
→ etcdlock. The decision tree in the root README
covers the choice in 30 seconds.
We picked lock/redislock (path) + package redislock (name)
deliberately rather than lock/redis + package redis because
the latter clashes with github.com/redis/go-redis/v9's
package redis. The suffix preserves the family pattern AND
avoids the collision. Same reasoning for pglock (which doesn't
strictly need the suffix; consistency wins).