Skip to content

feat: add Redis lock backend - #101

Merged
rustatian merged 26 commits into
masterfrom
feature/redis
Sep 14, 2026
Merged

rustatian merged 26 commits into
masterfrom
feature/redis

Conversation

@rustatian

@rustatian rustatian commented Sep 9, 2026

Copy link
Copy Markdown
Member

The lock plugin gains a Redis backend that shares locks between RoadRunner instances through the existing RPC API, and the in-memory backend stays the default.

Changes

  • Redis backend on go-redis/v9: Lua scripts check ownership and change lock state atomically, and all waiting calls of one RoadRunner instance share one Pub/Sub connection.
  • New lock.config keys for addresses, authentication, database, Sentinel, pool size, timeouts, and TLS. An unknown driver, a negative database number, a non-zero database with a cluster client, a negative timeout, and a failed connection test stop plugin initialization.
  • Lock, LockRead, and UpdateTTL reject a ttl outside 0 to 9223372036854775 microseconds. All methods reject a wait outside that range. A rejected request returns an RPC error and changes no lock state.
  • ForceRelease now returns Ok: true only if it removed at least one lock on both backends.
  • Both backends now refuse a second read lock with the same ID on the same resource.
  • Lock and LockRead return Ok: false on contention until the wait expires. On Redis, a failed command or a deadline that occurs during a command returns an RPC error, because the lock state is then unknown.
  • New log records: the selected backend at the info level, a failed lock script and a failed Pub/Sub subscribe at the error level, and a Pub/Sub subscription that is established again at the warning level.
  • CI starts a Redis 7 service and runs both Go modules.

Configuration

lock:
  driver: redis
  config:
    addrs: ["127.0.0.1:6379"]
    username: ""
    password: ""
    db: 0
    master_name: "" # set to use Redis Sentinel; addrs then holds the Sentinel addresses
    sentinel_password: "" # password of the Sentinel nodes
    pool_size: 0 # 0 selects the go-redis default
    dial_timeout: 5s
    read_timeout: 5s
    write_timeout: 5s
    tls:
      root_ca: "" # empty selects the system root certificates
      cert: ""
      key: ""

A set master_name selects a failover client. Without master_name, one address selects a standalone client and two or more addresses select a cluster client. The db setting applies to a standalone client and to a failover client, and a cluster client uses database 0 only.

Omitted timeouts use the Redis client defaults. Write at least one key in the tls block, because the configuration reader drops an empty block and the connection then stays plaintext. Set cert and key together to send a client certificate.

The backend stores lock state under the fixed rr:lock: prefix, with the resource name as the namespace. Redis 7 or later is required.

closes: roadrunner-server/roadrunner#2070.

Copilot AI lite review requested due to automatic review settings September 9, 2026 19:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

There are confirmed correctness issues in the new concurrent writer test and Redis wait-loop timer handling that can cause nondeterministic behavior and busy looping, plus an empty-key cleanup issue in the Lua script that can lead to unbounded key buildup.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a Redis-backed implementation for the lock plugin so multiple RoadRunner instances can share exclusive/read locks via the existing RPC API, while keeping in-memory locks as the default when no lock config section is provided.

Changes:

  • Introduces a Redis backend using go-redis/v9 plus an embedded Lua script for atomic lock state transitions and TTL handling.
  • Implements wait/notification behavior using Redis Pub/Sub and client-side timers.
  • Expands documentation and adds Redis-focused integration tests; updates CI to run the full ./tests/... module with a Redis service.
File summaries
File Description
redis.go Implements Redis backend operations, waiting via Pub/Sub + timers.
redis.lua Atomic Lua script for lock/read/exists/release/ttl/force operations.
config.go Adds lock/Redis configuration structs with mapstructure tags.
plugin.go Selects memory vs Redis backend based on config; wires backend into plugin lifecycle.
memory.go Wraps existing in-memory locker behind the new backend interface (preserves default behavior).
rpc.go Routes RPC methods through the backend interface and propagates backend errors.
tests/redis_test.go Adds Redis integration tests covering exclusivity, readers, waits, expiry, and stop behavior.
tests/go.mod / tests/go.sum Adds go-redis/v9 and required indirect deps for the test module.
go.mod / go.sum Adds go-redis/v9 and required indirect deps for the plugin module.
.github/workflows/linux.yml Adds Redis service and runs go test ./... for the tests module.
README.md Documents backend selection, Redis config, behavior semantics, and how to run tests locally.
Review details
  • Files reviewed: 11/13 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread redis.go
Comment thread redis.lua
Comment thread tests/redis_test.go Outdated
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.48533% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.18%. Comparing base (d2614fc) to head (bf0a334).

Files with missing lines Patch % Lines
tls.go 52.38% 10 Missing ⚠️
redis.go 96.93% 9 Missing ⚠️
config.go 94.73% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #101      +/-   ##
==========================================
+ Coverage   78.82%   87.18%   +8.36%     
==========================================
  Files           4        9       +5     
  Lines         595     1007     +412     
==========================================
+ Hits          469      878     +409     
- Misses        126      129       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Signed-off-by: Valery Piashchynski <piashchynski.valery@gmail.com>
@rustatian rustatian self-assigned this Sep 11, 2026
@rustatian rustatian added the enhancement New feature or request label Sep 11, 2026
The command now targets ./..., so a Redis failure stopped the run before the memory-backend suite started.
The deadline was absolute and per connection, so it capped the total RPC span of a test at 5 seconds. The go test timeout bounds the run.
go mod tidy -diff now exits 0 in the root module and in tests.
The memory locker keeps a resource after its last lock ends, so ForceRelease reported a removal for a resource with no live lock. The Redis backend returns the DEL result and already follows the rule.
Determine the ForceRelease result while broadcasting to registered lock entries. Cleanup can zero counters after a reader registers, so counters do not prove that the resource is empty. Keep the existing nonblocking broadcasts and cover the reachable state with a deterministic regression test.
A repeated LockRead by the same ID granted a second reader on memory and refreshed the member on Redis. Both backends now refuse it, so one Release frees the resource. A waiting caller no longer retries at its own expiry.
Use a distinct Lua delay sentinel to stop duplicate read acquisitions before subscribing or retrying. Add an RPC regression proving that a notification after expiry cannot recreate the refused reader.
Reject a non-zero db with more than one address, because the cluster client uses database 0. Reject a negative dial_timeout, because every dial then fails at once. Reject negative read and write timeouts, because they remove the deadline from each command. Select the in-memory backend with driver: memory.
The Redis lock configuration now accepts master_name, sentinel_password, pool_size and a tls block with cert, key and root_ca. Validation rejects a negative pool_size and a tls block with only one of cert and key.
Every RPC method checks the ttl and wait values it forwards against the time.Duration limit. A negative or overflowing value returns an error and changes no lock state.
A deadline that fires during a Redis command returned Ok: false with no error. Redis can grant the lock after the caller stops waiting, which leaves a lock nobody releases. The command error now reaches the caller. Wait expiry with no command running still returns Ok: false.
Each waiting acquisition opened its own Pub/Sub connection, a receive goroutine and a health check. The backend keeps one connection, one dispatcher goroutine and a registry of the waiters of each channel.
Receive subscription replies directly so server-side failures reach waiting RPCs. Close failed subscriptions before replacement, preserve reconnect wakeups and idle health checks, and join the receiver during shutdown.
Send health pings independently of the receive loop and use bounded Receive calls that discard a failed stream. Keep partial notifications intact across health ticks and join the health worker with the dispatcher.
The wait field has one meaning for Lock and LockRead and another meaning for Release, ForceRelease, Exists and UpdateTTL. The two backends also apply a zero wait differently. Add a README section and a Redis regression test for a wait deadline during a Redis command.
Prevent ambiguous script replay through the cluster NoRetry hook while preserving MOVED, ASK, and NOSCRIPT handling.

Queue Pub/Sub I/O outside the waiter registry, preserve confirmation generations, and close backend-owned sockets on deadline-aware shutdown.

Reject negative databases before dialing, run root race regressions in CI, and correct the configuration documentation.
Reissue the pending idempotent UNSUBSCRIBE on receive, resubscription, or health reconnect signals while retaining its acknowledgment barrier.

Cover lost unsubscribe replies through real RPC and Redis, including unrelated surviving waiters, same-resource replacements, new resources, and last-channel recovery.
Notify the pending unsubscribe through the backend-owned dial hook whenever a physical connection is established. Preserve the matching acknowledgment barrier and fixed operation deadline.

Exercise successful MOVING/PONG handoff with an unread last-channel unsubscribe acknowledgment through real RPC and Redis, covering new and replacement waiters.
Name each case so a failure reports which client, reader, method or resource failed.
PLAN.md and plans/ hold working documents for a branch and leave the repository when the work is done.
@rustatian
rustatian merged commit 2b4e8d2 into master Sep 14, 2026
8 checks passed
@rustatian
rustatian deleted the feature/redis branch September 14, 2026 18:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[💡 FEATURE REQUEST]: Redis powered lock's

2 participants