Skip to content

Add optional fencing token minted atomically on Obtain - #94

Merged
dim merged 3 commits into
bsm:mainfrom
nijatsdev:feat/fencing-token
Jun 27, 2026
Merged

Add optional fencing token minted atomically on Obtain#94
dim merged 3 commits into
bsm:mainfrom
nijatsdev:feat/fencing-token

Conversation

@nijatsdev

@nijatsdev nijatsdev commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Closes #93.

Adds an opt-in fencing token to Obtain: a strictly increasing value, incremented atomically inside obtain.lua on each new acquisition and returned by Lock.FenceToken. Callers stamp writes to the protected resource with the token and reject any write carrying an older one, fencing out a stale lock holder per Martin Kleppmann's How to do distributed locking.

API

lock, _ := locker.Obtain(ctx, "my-key", time.Second, &redislock.Options{FenceKey: "my-key:fence"})
if token := lock.FenceToken(); token != 0 {
    // stamp writes to the protected resource with `token`
}
  • Options.FenceKey string — the key the fence counter lives at; non-empty opts in (default off).
  • Lock.FenceToken() int64 — the token, or 0 when obtained without FenceKey. Tokens start at 1, so 0 is an unambiguous "unfenced" sentinel.

Behaviour

  • The token is minted only on a genuinely new acquisition (the msetnx path). A re-entrant override by the current holder returns the existing token without incrementing, so refreshing-via-obtain does not inflate it.
  • The fence key is caller-supplied rather than auto-derived, so you control its placement. It is passed in KEYS (the last entry when fencing is on, flagged via ARGV), so on Redis Cluster a fence key that does not share a slot with the lock key fails with CROSSSLOT up front rather than mid-execution.
  • The counter persists across Release, so it keeps increasing.
  • Backward compatible: without FenceKey, obtain.lua returns the original "OK" status and FenceToken returns 0. Existing tests pass unchanged.

Caveat (documented in the README)

The token is only as monotonic as the underlying Redis. On a single instance it is strict; on a Sentinel/Cluster failover that loses the INCR, it can regress. For strict cross-failover monotonicity, source the token from a linearizable store.

Tests

  • TestObtain_fence — token minted, monotonic across terms, absent without FenceKey.
  • TestObtain_fence_reentrant — re-entrant override does not advance the token.

Set Options.Fence to mint a fencing token with the lock: a strictly
increasing value, incremented atomically inside obtain.lua on each new
acquisition and returned by Lock.FenceToken. Stamp writes to the
protected resource with the token and reject any write carrying an
older one, fencing out a stale lock holder per Kleppmann's "How to do
distributed locking".

The token is minted only on a genuinely new acquisition; a re-entrant
override by the current holder returns the existing token without
incrementing. The counter is stored at "<key>:fence" and persists
across release so it keeps increasing. Without Options.Fence the script
returns the original "OK" status, preserving backward compatibility.

@dim dim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, only a minor comment, open to debate

Comment thread redislock.go Outdated

// FenceToken returns the lock's fencing token, or false if it was obtained
// without Options.Fence.
func (l *Lock) FenceToken() (int64, bool) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would simplify this and always return 0 if not fenced. you can mention this in the method documentation, seems the better choice

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — done. FenceToken now returns int64 with 0 meaning unfenced; since tokens start at 1 the sentinel is unambiguous, and I noted it on the method doc. Thanks!

Per review: FenceToken now returns a single int64 instead of
(int64, bool). Tokens start at 1, so 0 is an unambiguous "not fenced"
sentinel; documented on the method. Drops the now-redundant fenced
field.

@dim dim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, one more thing I thought of

Comment thread example_test.go Outdated
ctx := context.Background()

// Obtain a lock with a fencing token.
lock, err := locker.Obtain(ctx, "my-key", time.Second, &redislock.Options{Fence: true})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one little concern left: I don't like the fact that we are automatically generating key names for the fence key, I always prefer to delegate this to the user as there are sometimes non-trivial implications, e.g. redis cluster or other redis implementations where key names matter. I would therefore suggest:

lock, err := locker.Obtain(ctx, "my-key", time.Second, &redislock.Options{FenceKey: "my-key:fence"})

what do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — auto-generating the key name hides exactly the kind of Cluster/key-placement footgun you're describing, so letting the caller own it is the right call. Done: Options.FenceKey is now caller-supplied.

I also took it one step further for Cluster safety — the fence key now goes in KEYS (not ARGV), as the last entry when fencing is on, with an ARGV flag telling the script to treat it specially. That way Redis validates the slot up front and a misplaced fence key fails with CROSSSLOT before the script runs, rather than mid-execution.

Per review: replace the auto-derived fence key (keys[0]+":fence") with
a caller-supplied Options.FenceKey, so the caller controls its
placement — important on Redis Cluster, where the fence key must hash
to the same slot as the lock key(s).

The fence key is also passed in KEYS rather than ARGV (as the last
entry, flagged by ARGV[4]) so Redis validates the slot up front: a
misplaced fence key fails with CROSSSLOT before the script runs instead
of mid-execution.
@dim
dim merged commit 707d123 into bsm:main Jun 27, 2026
2 checks passed
@dim

dim commented Jun 27, 2026

Copy link
Copy Markdown
Member

Thanks!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: optional fencing token minted atomically on Obtain

2 participants