Known flaws & limitations

May 4, 2026 · View on GitHub

The honest list of things ubgo/lock doesn't do well, can't do at all, or does with caveats. Read this before adopting — it's better to discover a hard limit before integrating than after.

Cross-cutting limitations

1. No backend has unconditional safety guarantees

Locks are coordination primitives — they only work when all participants follow the protocol. None of the backends prevent a process that doesn't call Acquire from clobbering the protected resource. This is true for every advisory lock library in any language; we mention it because operators often expect "kernel flock means kernel-protected access," which is wrong.

If you need mandatory access control, you need filesystem permissions, OS capabilities, or a higher-level abstraction (transactional database, object-versioning storage). A lock library is not the right tool.

2. Acquire is non-blocking — every backend, no exceptions

Wait-for-lock semantics are deliberately not implemented. If you need a deadline, wrap with context.WithTimeout and retry yourself. The library will never queue you. See non-goals.md for the reasoning.

3. The lock.Locker interface is a lowest-common-denominator

Backend-specific features (fencing tokens, semaphore mode, sweep, TraceID propagation) are NOT on the interface. Code written against lock.Locker can Acquire/Release only. To use fencing or semaphore, you need the concrete *<backend>.Factory. This is intentional — see the design rationale in comparison.md — but it means polymorphic consumers must give up the rich features.

4. The interface module's tag scheme is unusual

Each subpath has its own tag (filelock/v0.1.0, pglock/v0.1.0, …) and the root has plain v0.1.0. The Go module proxy can lag when indexing path-prefixed tags — fresh tags may take a few minutes before go get resolves them via the default proxy. Falling back to GOPROXY=direct always works.

filelock

Stale markers are visible until reclaimed

If a process crashes, its marker file stays on disk until either (a) the next Acquire for the same name runs through the PID-probe-and-takeover path, or (b) Sweep runs. Operators may see stale .lock files and think something's wrong. They aren't, but the noise is real. Mitigation: schedule Sweep periodically (see snippets.md §9).

NFS / shared filesystems have known races

O_CREATE|O_EXCL is atomic on local filesystems but historically non-atomic on NFSv2/v3 with certain server configurations. NFSv4 fixed this for most cases, but corner cases remain. We don't recommend filelock on shared filesystems — use a distributed backend instead. See design/races.md.

PID-reuse detection only works on Linux (currently)

processStartTime() is implemented on Linux via /proc/<pid>/stat. On macOS and Windows, it returns zero, which the probe layer interprets as "PID-reuse check unavailable, trust the alive answer." The result: on macOS/Windows, an unrelated process that happens to get the recycled PID of the original holder will be misidentified as alive, and the lock will appear held. This is rare but possible. Mitigation: set WithStaleAfter(d) so the time fallback eventually reclaims.

WithStaleAfter is easy to misuse

Beginners read it as "max runtime for this job." It's not — it's "how long after the holder appears dead can the next acquire take over." If you set it shorter than your job's healthy runtime under StaleStrategyTimeOnly, you get parallel execution. See the foot-gun walkthrough in MISSION §4.9.

Semaphore mode requires the n to agree across callers

If process A acquires with WithMaxConcurrent(3) and process B runs the same name with WithMaxConcurrent(2), B only probes slots 0–1 and may miss A's slot 2 — both think there's a free slot when really 4 are running. We don't persist n in metadata; deploy callers together with matching WithMaxConcurrent.

Fencing tokens are best-effort in semaphore mode

Multiple slots concurrently incrementing the per-name fence file can race; two holders may get the same token. Singleton mode (n=1) is strictly monotonic. Document this caveat for any downstream consumer that depends on strict monotonicity.

flock

NFS support is platform-dependent

flock(2) on Linux NFS is implemented in newer kernels (4.0+) but may silently fall back to local-only on older kernels or non-Linux NFS clients. Don't rely on flock for cross-host coordination. Use a distributed backend.

Linux flock is per-fd; BSD/macOS is per-process

This is a kernel-level difference, not a ubgo/flock issue, but it affects you: on Linux, two open file descriptors in the same process can independently lock the same file. On BSD/macOS, it's per-process. We don't paper over the difference — if you're locking from multiple goroutines in the same process, use sync.Mutex for that, then flock for cross-process.

Holder.Release races the kernel cleanup

If your process exits abruptly between Release()'s os.Remove and Close(), the marker file may remain on disk briefly. The kernel still releases the lock; the file is cosmetic. Sweep isn't a feature of flock because there's no real benefit (the lock isn't held; just the empty file lingers).

Windows LockFileEx semantics differ subtly

LockFileEx with EXCLUSIVE_LOCK is per-handle, not per-process. Two handles in the same Windows process to the same file CAN conflict (matches Linux flock behaviour, not BSD). Documented; not a bug.

redislock

TTL trade-off is unavoidable

Set TTL too short → healthy long-running jobs lose their lock to timeout, two holders run in parallel. Set TTL too long → crash recovery takes that long. There is no perfect setting. Mitigation patterns: use Holder.Extend(ctx) from the holder, OR size TTL based on p99 healthy runtime + safety margin.

Redis cluster failover can produce two holders

If the primary dies before replicating the SET-NX to the replica, and the replica is promoted, the new primary doesn't have your key — another caller can SET-NX successfully. Two holders. This is fundamental to AP-Redis and one reason the Redlock algorithm exists (and why it's controversial).

For workloads where this matters, use etcdlock instead. For cron-singleton and queue dedup workloads (where occasional double runs are acceptable, idempotent, or rare), redislock is fine.

Lua-guarded Release still has a tiny window

The Lua script runs GET key; if value matches, DEL key. Between Acquire and Release, the TTL could expire and a new holder could acquire a fresh lock with a NEW value. Our Lua guard prevents Release from deleting the new lock — it returns ErrLockLost instead. But the original holder believed it was holding a valid lock until Release; any work it did after TTL expiry was unprotected. Use fencing tokens to close this gap.

No Redlock multi-master support

By design — see non-goals.md. If you genuinely need quorum-correct distributed locking, use etcdlock.

pglock

Reentrancy is the family-wide exception

Postgres advisory locks are reentrant; same session can Acquire the same key twice and the lock counter increments. Other backends in the family are NOT reentrant. Code that depends on reentrancy will silently break when ported to flock/redislock/etcdlock.

If you write code against lock.Locker, you're in non-reentrant land — Acquire while holding returns ErrLocked on every backend EXCEPT pglock. Don't rely on reentrancy if you might swap backends.

One connection per held lock

Each Holder keeps a connection out of the pool for the lifetime of the lock. If you hold many concurrent locks, you need a correspondingly large pool. The math: MaxConns ≥ max simultaneous Holders + headroom for normal queries. Tune pgxpool.Config.MaxConns accordingly.

Postgres connection drops release the lock

This is a feature (crash safety), but it's also a footgun: a network hiccup that triggers a pool reconnect quietly drops the lock. Your code keeps running, thinking it holds the lock, until the next Holder.Release runs and (correctly) reports no-op. If you depend on the lock for correctness during the work, use fencing tokens (currently planned for pglock — txid_current() would work) or mod_revision-style protection from the consumer end.

String-name → int64 collision possible (but unlikely)

We hash names via FNV-1a into a 64-bit space. Two different names hashing to the same int64 would collide as the same Postgres advisory lock. For 1M lock names, the birthday probability is ~3e-8 — effectively zero, but technically possible.

No fencing token

pglock doesn't currently expose a fencing token. The natural fit is txid_current() on the holder's session, but it's not yet wired up. Use etcdlock if you need globally-monotonic fences.

No semaphore mode

pg_advisory_lock is binary — held or not. To get N-holders, you'd need a custom table with row-level locks. Not currently shipped.

etcdlock

Heavy dependency footprint

The etcd v3 client pulls grpc, protobuf, golang.org/x/* — about 50 transitive deps. By far the largest in the family. If your service doesn't already use etcd, this is a substantial addition.

TTL trade-off still applies

Same as Redis — TTL too short means healthy holders lose locks; too long means slow crash recovery. The auto-keepalive goroutine in concurrency.NewSession mitigates this for healthy holders, but if your holder is partitioned from etcd for longer than the TTL, the lock is reclaimed. Code defensively.

Network partitions can cause ErrLockLost-like situations

If your process is partitioned from the etcd cluster long enough for the lease to expire, etcd treats you as gone and deletes your key. The next Acquire elsewhere wins. When your network heals, your code still thinks it holds the lock — identical to the redislock case but with stronger guarantees during the partition (Raft prevents a flapping primary from losing your lock as long as you're connected).

Holder.Token() (mod_revision) is the defense. The downstream consumer rejects writes with token < highest seen — including writes from a partitioned-and-recovered holder.

FIFO fairness can cause head-of-line blocking

concurrency.Mutex queues waiters in FIFO order under Lock(ctx). We use TryLock to preserve non-blocking semantics, but if a downstream consumer of *etcdlock.Holder ever calls mutex.Lock(ctx) directly (we don't expose mutex publicly, so this isn't currently a hazard, but future API additions might), beware.

Lock keys persist on TTL-style cleanup

When a lease expires, etcd removes the lock key but the fence counter (mod_revision) is global to the cluster. Even after our key is deleted, the next acquire's mod_revision is strictly larger than ours. So fencing remains correct across crashes.

Etcd cluster operations matter

You're depending on etcd's availability for every Acquire. If the cluster is partitioned (no quorum), every Acquire fails. Operationally, etcd needs ≥3 nodes for safety; ≥5 for high availability. Adding etcd to your stack just for locking is rarely justified — use this when you're already running etcd for other reasons (Kubernetes, service discovery).

memlock

Per-process scope

By design — it's an in-memory map. Use only for unit tests. Do not deploy memlock in production, even for "just one process" cases — use flock or filelock instead, both of which guarantee mutual exclusion across processes (the actual threat model in production).

No persistence

If your test creates a Holder and the test binary crashes before Release, the in-memory state is gone — next test run sees a clean slate. That's usually what you want for tests, but it means memlock is genuinely useless if you wanted "survives process restart" semantics.

Token sequence resets per Factory

A new memlock.NewFactory() starts the fence counter at zero. Cross-test isolation is built in (each test gets a fresh factory), but it means memlock can't validate downstream consumers that rely on monotonicity across long sessions.

contrib/gocronlock

gocron's Locker interface is leaky

gocron's Locker.Lock / Lock.Unlock shape doesn't match lock.Locker exactly — gocron threads ctx through Unlock too. We adapt by ignoring Unlock's ctx (Release in our backends isn't ctx-aware in the locker.Holder interface). For local backends (filelock, flock, memlock) this is fine. For distributed backends (redislock, pglock, etcdlock) the release call IS doing network I/O, so a context with deadline would be useful. Currently the adapter uses context.Background() for cleanup. If you need cleanup-with-deadline, hold the concrete *<backend>.Factory and call WithLock directly.

gocron's "lock per job by name" pattern locks named jobs only

gocron uses the job's name (or function name if unnamed) as the lock key. Anonymous functions get autogenerated names that are NOT stable across builds. If your job naming has any non-determinism, two replicas might use different lock names and both run the job. Always pass gocron.WithName("...") when using gocronlock.

Subpackage adds gocron's heavy dep tree

Importing lock/contrib/gocronlock pulls gocron + cron parser

  • clockwork + uuid (4 transitive deps). That's why the adapter is an opt-in subpath, not in any core backend's go.mod.

Fundamental issue across distributed backends: clock skew

redislock and etcdlock rely on TTL for crash recovery. TTL is expressed in seconds the server measures. If the server's clock skews backward, locks expire later than expected; forward, earlier. Etcd uses Raft-leader-time, which is more robust than Redis's per-node time, but neither is bulletproof under pathological clock skew.

We don't ship clock-skew mitigation — that's a deployment concern. If your servers' clocks drift more than seconds, your problems are bigger than locking. Use NTP.

Fundamental issue across all backends: split brain

A network partition that splits a Raft cluster (etcd) or a Sentinel-managed Redis primary can produce scenarios where two processes both believe they hold a lock. Etcd is the most robust here because Raft requires quorum to grant a lease; Redis is most permissive (single-primary, replica eventually serves). Filelock + flock are immune since they're single-host.

If two-holders-during-partition is a correctness risk for your work, use fencing tokens AND consume them downstream. Without downstream cooperation, no lock library can prevent split-brain writes.

Documentation honesty

If you find a flaw in the implementation we haven't documented here, please file an issue. We'd rather expand this list than hide problems. Links: GitHub issues at github.com/ubgo/lock/issues.