Skip to content

feat: add distributed Locker client (rueidislock port)#373

Open
gtoxlili wants to merge 3 commits into
aembke:mainfrom
gtoxlili:feat/locker
Open

feat: add distributed Locker client (rueidislock port)#373
gtoxlili wants to merge 3 commits into
aembke:mainfrom
gtoxlili:feat/locker

Conversation

@gtoxlili
Copy link
Copy Markdown

@gtoxlili gtoxlili commented May 14, 2026

This PR adds a clients::Locker interface modelled on
Go's rueidislock.
It uses RESP3 client-side-cache invalidation pushes so a holder learns about
takeovers and evictions the moment they happen, plus a background extender that
bumps PEXPIREAT on a cadence (default validity / 2).

Public surface

let locker = Builder::default_centralized().build_locker(LockerConfig::new())?;
locker.client().init().await?;
locker.init().await?;

let guard = locker.acquire("cron:purge").await?;
tokio::select! {
    _ = do_work() => {}
    _ = guard.cancelled() => {} // someone else took the lock
}
guard.release().await;

What's in

  • Locker + LockGuard + LockerConfig behind a new locker feature flag
    (pulls in i-keys, i-scripts, i-tracking, sha-1).
  • Builder::build_locker for the standard entry point.
  • on_reconnect hook that re-arms CLIENT TRACKING — fred doesn't restore it
    automatically and without this every connection bounce would silently drop
    invalidation pushes for active holders.
  • Weak<LockerInner> in the dispatcher + reconnect handler so neither task
    keeps the locker alive after the last Locker clone drops; Drop for LockerInner aborts both for prompt cleanup.
  • Six Lua scripts mirroring rueidislock (acqat / acqms / fcqat / fcqms
    / extend / delkey), each with the trailing GET that arms tracking on
    the lock key.
  • Race-free Notify usage: Cancellation::cancelled pins the Notified and
    calls enable() before the second flag check; per-gate signals use
    notify_one (matching rueidislock's buffered chan size=1).
  • init() serializes concurrent callers via an async mutex and only publishes
    initialized = true after every fallible step succeeds, so a failed init is
    retryable and a race cannot spawn duplicate dispatchers.
  • register_outstanding and close are serialized through the same mutex, so
    an acquire racing with close either gets registered (and promptly cancelled)
    or refuses to register and runs a CAS-delete on the key it just wrote — no
    stranded keys.
  • Integration tests in both centralized and clustered harnesses: acquire /
    release, contention, in-process waiting, auto-extension past validity,
    force-takeover detection via push, tracking re-arm after force_reconnection,
    close-cancels-outstanding-guards.

What's not (out of scope, follow-ups)

  • Multi-key Redlock (KeyMajority > 1). The config shape leaves room for
    LockerConfig::key_majority(n) without breaking changes.
  • Connection-affinity guarantee equivalent to rueidis's PipelineMultiplex = -1. fred's start_tracking broadcasts and EVAL routes per-key, so for
    single-key locks this works in practice, but a multi-key variant would need
    extra care.
  • metrics / mocks integration — rueidislock doesn't ship those either.

Happy to split this into smaller pieces if that would help review.

gtoxlili added 3 commits May 14, 2026 11:38
A Rust port of Go's
[`rueidislock`](https://pkg.go.dev/github.com/redis/rueidis/rueidislock).
Each acquired lock spawns a background task that extends the TTL on a
cadence and also listens for RESP3 invalidation pushes on the lock key,
so the holder learns about takeovers and evictions without waiting for
the next extension tick. The returned `LockGuard` exposes a `cancelled()`
future for use in `tokio::select!` and a `release()` that runs a CAS
delete on the way out.

Correctness notes:

* `init()` serializes concurrent callers via an async mutex and only
  publishes `initialized = true` after every fallible step succeeds, so a
  failed init (e.g. RESP2 with caching enabled) is retryable and a race
  cannot spawn duplicate dispatchers.
* `init()` installs an `on_reconnect` handler that re-arms
  `CLIENT TRACKING` after every reconnect — fred doesn't restore
  tracking automatically, and without this hook the locker would
  silently stop receiving invalidations after the first connection
  bounce.
* The dispatcher and reconnect handler hold a `Weak<LockerInner>` so the
  background tasks don't form an `Arc` cycle with the locker they belong
  to. Dropping the last `Locker` clone aborts both via `Drop for
  LockerInner` and the tasks exit cleanly.
* `Cancellation::cancelled` uses `Notify::notify_waiters` with a pinned
  `Notified::enable()` so a `cancel` racing between the flag check and
  the future registration isn't lost. Per-gate signals use `notify_one`,
  matching `rueidislock`'s buffered-channel-of-1 semantics.
* `register_outstanding` and `close` are serialized through the same
  mutex, so an acquire racing with close either gets registered (and
  promptly cancelled) or refuses to register and runs a CAS-delete on
  the key it just wrote — no stranded keys.
* `release()` and `Drop` are partitioned: the explicit path awaits the
  extender's DEL and logs; `Drop` always runs `notify_gate` and
  `drop_gate` exactly once, so the gate's ref-count never wraps.
* `Drop` is sync — it signals cancellation but never spawns, since
  callers may drop guards outside of a tokio context.

Single-key only at this revision; the config shape leaves room for a
multi-key Redlock variant later (`LockerConfig::key_majority`) without
breaking the public API. Hidden behind a new `locker` feature that pulls
in `i-keys`, `i-scripts`, `i-tracking`, and `sha-1`.
Mirrors `build_pool` / `build_exclusive_pool` — pulls in the same
`Config` / `PerformanceConfig` / `ConnectionConfig` and hands back an
un-initialized `Locker` that the caller wires up with
`client.init().await` followed by `locker.init().await`.
Covers acquire / release, `try_acquire` on a contended lock, in-process
waiting for a release, auto-extension past validity, the
force-takeover path (verifies `cancelled()` fires within the
invalidation push window rather than the next extend tick), tracking
re-arm after a forced reconnection, and the close-cancels-outstanding-
guards behaviour. Tests are wired into both the centralized and
clustered harnesses; RESP2 runs no-op out since the locker defaults
require RESP3.
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.

1 participant