rails_soft_lock 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +41 -1
- data/lib/rails_soft_lock/lock_object.rb +2 -1
- data/lib/rails_soft_lock/redis_adapter.rb +37 -5
- data/lib/rails_soft_lock/redis_config.rb +15 -1
- data/lib/rails_soft_lock/version.rb +1 -1
- data/lib/tasks/rails_soft_lock.rake +33 -25
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 340ed740ba96e987fda3b66518a71a8693a3e1a99c5df1b9f0093acc89e5c36f
|
|
4
|
+
data.tar.gz: bdb1f86ad797cabf1468be9e9cf20bb1e89b7887ae420006b98fc436a2c0f51e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b5741cf3a3aebd0219abeb477f855b63a58d86c25936efbcb2bb02efde3382af75f21523e516c303dbb0fe4033bcbea2f3c7f7dfc2ec1e2f2ad3099305272124
|
|
7
|
+
data.tar.gz: 10c6814dfa262802d16df2095cfa11c5cdbb55ecf66f8eec8bc26b1468e798fc68e89227d4acb4179deebfdb852863ba81091a920820661740005c6755db5e71
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
The RailsSoftLock gem provides group-level locking for Rails ApplicationRecord objects based on a shared attribute. Instead of individually locking each database record (which can be expensive and complex), it creates and manages a single in-memory lock for the entire group via the attribute. This reduces database contention while maintaining thread safety.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Key Features
|
|
8
8
|
|
|
9
9
|
Lightweight Group Locking:
|
|
10
10
|
|
|
@@ -24,6 +24,16 @@ The RailsSoftLock gem provides group-level locking for Rails ApplicationRecord o
|
|
|
24
24
|
|
|
25
25
|
Useful for batch operations or state management (e.g., "processing", "archived").
|
|
26
26
|
|
|
27
|
+
Lock Expiration (TTL) ⚠️ see "Lock TTL" section below — requires Redis >= 7.4:
|
|
28
|
+
|
|
29
|
+
Lock keys automatically expire after a configurable TTL, protecting against stuck/abandoned locks (e.g., a crashed process that never released one).
|
|
30
|
+
|
|
31
|
+
Default TTL is 12 hours (43200 seconds).
|
|
32
|
+
|
|
33
|
+
Configurable via the RAILS_SOFT_LOCK_TTL environment variable (in seconds), via config/redis.yml (ttl key), or per lock instance.
|
|
34
|
+
|
|
35
|
+
Set TTL to 0 to disable expiration entirely.
|
|
36
|
+
|
|
27
37
|
### Current Status
|
|
28
38
|
|
|
29
39
|
Active Development: New features and optimizations in progress.
|
|
@@ -142,6 +152,36 @@ Key Points:
|
|
|
142
152
|
|
|
143
153
|
In this case, the lock remains unchanged (no new lock is set).
|
|
144
154
|
|
|
155
|
+
## Lock TTL
|
|
156
|
+
|
|
157
|
+
**Attention!** This feature requires Redis/Valkey ~> 7.4 (uses `HEXPIRE`/`HTTL` under the hood, which set expiration on individual hash fields rather than the whole key).
|
|
158
|
+
|
|
159
|
+
By default, lock records expire automatically after 12 hours, protecting against
|
|
160
|
+
stuck/abandoned locks (e.g., a process crashing before releasing one). The TTL
|
|
161
|
+
applies **per lock field**, not to the whole group key — other locks sharing the
|
|
162
|
+
same group (`object_name`) are unaffected when one of them expires.
|
|
163
|
+
|
|
164
|
+
Override the default globally via the `RAILS_SOFT_LOCK_TTL` environment variable
|
|
165
|
+
(in seconds):
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
RAILS_SOFT_LOCK_TTL=3600 # 1 hour
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
or via `config/redis.yml`:
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
default: &default
|
|
175
|
+
url: redis://localhost:6379/0
|
|
176
|
+
ttl: 3600
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Set `RAILS_SOFT_LOCK_TTL=0` (or `ttl: 0` in `config/redis.yml`) to disable expiration
|
|
180
|
+
entirely — locks will never expire automatically.
|
|
181
|
+
|
|
182
|
+
If your Redis/Valkey server is older than 7.4, `HEXPIRE` will raise an error. Either
|
|
183
|
+
upgrade the server, or set TTL to `0` to disable this feature until you do.
|
|
184
|
+
|
|
145
185
|
## Development
|
|
146
186
|
|
|
147
187
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
@@ -30,10 +30,11 @@ module RailsSoftLock
|
|
|
30
30
|
|
|
31
31
|
attr_reader :object_name, :object_key, :object_value
|
|
32
32
|
|
|
33
|
-
def initialize(object_name:, object_key: nil, object_value: nil)
|
|
33
|
+
def initialize(object_name:, object_key: nil, object_value: nil, ttl: RailsSoftLock::RedisConfig.default_ttl)
|
|
34
34
|
@object_name = object_name
|
|
35
35
|
@object_key = object_key
|
|
36
36
|
@object_value = object_value.presence&.to_s # Convert to string for consistency
|
|
37
|
+
@ttl = ttl
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
# Returns the ID of the locker who locked the object
|
|
@@ -25,16 +25,27 @@ module RailsSoftLock
|
|
|
25
25
|
|
|
26
26
|
# Creates a new key-value pair if the key does not exist
|
|
27
27
|
# @return [Boolean] true if the key was created, false if it already existed
|
|
28
|
+
# Note: field creation and TTL application are two separate Redis calls
|
|
29
|
+
# (not a single atomic operation). In the rare case a process crashes
|
|
30
|
+
# between them, the lock field would persist without a TTL. This is an
|
|
31
|
+
# accepted trade-off: TTL here is a best-effort cleanup convenience, not
|
|
32
|
+
# a strict consistency guarantee — a Redis restart/redeploy will clear
|
|
33
|
+
# stale locks regardless. If atomicity becomes a hard requirement, see
|
|
34
|
+
# HSETEX (Redis >= 8.0) or wrap creation+TTL in a Lua script (EVAL).
|
|
35
|
+
|
|
28
36
|
def create
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
end
|
|
33
|
-
result.first # true on creation, false otherwise
|
|
37
|
+
created = create_field
|
|
38
|
+
apply_ttl if created
|
|
39
|
+
created # true on creation, false otherwise
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
# Updates the value for an existing key or creates a new key-value pair
|
|
37
43
|
# @return [Boolean] true if the key was updated, false if it was created
|
|
44
|
+
# @note TTL is intentionally NOT (re)applied here. This method is currently
|
|
45
|
+
# part of the adapter's generic interface but is not exposed through
|
|
46
|
+
# LockObject's public API (see LockObject#lock_or_find/#unlock/#all_locks).
|
|
47
|
+
# If it becomes user-facing, TTL handling should mirror #create — see the
|
|
48
|
+
# TTL trade-off note there before wiring it in.
|
|
38
49
|
def update # rubocop:disable Naming/PredicateMethod
|
|
39
50
|
result = redis_client.hset(@object_name, @object_key, @object_value)
|
|
40
51
|
result.zero?
|
|
@@ -52,5 +63,26 @@ module RailsSoftLock
|
|
|
52
63
|
def all
|
|
53
64
|
redis_client.hgetall(@object_name)
|
|
54
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def create_field
|
|
70
|
+
redis_client.multi do |transaction|
|
|
71
|
+
transaction.hsetnx(@object_name, @object_key, @object_value)
|
|
72
|
+
transaction.hget(@object_name, @object_key)
|
|
73
|
+
end.first
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Sets TTL on this specific hash field only, not on the whole group hash,
|
|
77
|
+
# since @object_name can hold multiple unrelated locks as separate fields
|
|
78
|
+
def apply_ttl
|
|
79
|
+
return unless @ttl.to_i.positive?
|
|
80
|
+
|
|
81
|
+
# HEXPIRE <key> <seconds> FIELDS <numfields> <field...>
|
|
82
|
+
# Sets TTL on a single hash field (not the whole hash key), so that
|
|
83
|
+
# unrelated locks sharing the same @object_name hash aren't affected.
|
|
84
|
+
# Requires Redis/Valkey >= 7.4.
|
|
85
|
+
redis_client.call("HEXPIRE", @object_name, @ttl.to_s, "FIELDS", "1", @object_key)
|
|
86
|
+
end
|
|
55
87
|
end
|
|
56
88
|
end
|
|
@@ -10,6 +10,10 @@ module RailsSoftLock
|
|
|
10
10
|
module RedisConfig
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
+
# Add possibility to set TTL for lock objects
|
|
14
|
+
# Set to 0 (via ENV or Rails config) to disable TTL entirely
|
|
15
|
+
DEFAULT_TTL = 43_200 # seconds, 12 hours
|
|
16
|
+
|
|
13
17
|
# Returns the complete Redis adapter options hash
|
|
14
18
|
# @return [Hash] Options hash with :redis key containing configuration
|
|
15
19
|
# @example
|
|
@@ -25,12 +29,22 @@ module RailsSoftLock
|
|
|
25
29
|
{ url: "redis://localhost:6379/0", timeout: 5 }
|
|
26
30
|
end
|
|
27
31
|
|
|
32
|
+
# TTL (in seconds) applied to lock keys as a safety net against stuck locks
|
|
33
|
+
# Priority: ENV var > Rails config/redis.yml (:ttl key) > DEFAULT_TTL
|
|
34
|
+
# A value of 0 means "no TTL" (locks never expire automatically)
|
|
35
|
+
# @return [Integer]
|
|
36
|
+
def default_ttl
|
|
37
|
+
rails_ttl = rails_available? ? rails_config[:ttl] : nil
|
|
38
|
+
Integer(ENV["RAILS_SOFT_LOCK_TTL"] || rails_ttl || DEFAULT_TTL)
|
|
39
|
+
end
|
|
40
|
+
|
|
28
41
|
# Merges default settings with any Rails-specific configuration
|
|
29
42
|
# @return [Hash] Complete Redis configuration
|
|
30
43
|
# @note Will return just defaults if Rails isn't available
|
|
31
44
|
def config_with_defaults
|
|
32
45
|
base_config = rails_available? ? rails_config : {}
|
|
33
|
-
|
|
46
|
+
# :ttl is a separate concept, not a Redis connection option, so exclude it here
|
|
47
|
+
default_settings.merge(base_config.except(:ttl))
|
|
34
48
|
end
|
|
35
49
|
|
|
36
50
|
# Checks if Rails environment is available and properly configured
|
|
@@ -11,31 +11,39 @@ namespace :rails_soft_lock do
|
|
|
11
11
|
puts "RailsSoftLock configuration file already exists at #{initializer_path}"
|
|
12
12
|
else
|
|
13
13
|
File.write(initializer_path, <<~RUBY)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
14
|
+
# frozen_string_literal: true
|
|
15
|
+
|
|
16
|
+
# Configuration for RailsSoftLock gem
|
|
17
|
+
# This file sets up the adapter and options for soft locking Active Record models
|
|
18
|
+
|
|
19
|
+
RailsSoftLock.configure do |config|
|
|
20
|
+
# Specify the adapter for storing locks
|
|
21
|
+
config.adapter = :redis
|
|
22
|
+
|
|
23
|
+
# Configuration for the redis adapter
|
|
24
|
+
# Note: :ttl is excluded here on purpose — it's not a Redis connection
|
|
25
|
+
# option and would break RedisClient.new if passed through. Lock TTL is
|
|
26
|
+
# configured separately, see the note below.
|
|
27
|
+
config.adapter_options = {
|
|
28
|
+
redis: Rails.application.config_for(:redis).to_h.except(:ttl).merge(
|
|
29
|
+
timeout: 5
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# (Optional) Attribute used for locking
|
|
34
|
+
# config.acts_as_locked_by = :lock_attribute
|
|
35
|
+
|
|
36
|
+
# (Optional) Scope for separating locks
|
|
37
|
+
# config.acts_as_locked_scope = -> { "default_scope" }
|
|
38
|
+
|
|
39
|
+
# (Optional) Model class for locked_by lookups
|
|
40
|
+
# config.locked_by_class = "User"
|
|
41
|
+
|
|
42
|
+
end
|
|
43
|
+
# (Optional) TTL (in seconds) for lock keys, protecting against stuck locks.
|
|
44
|
+
# Default is 12 hours. Set to 0 to disable expiration.
|
|
45
|
+
# Configure via the RAILS_SOFT_LOCK_TTL environment variable, or via the
|
|
46
|
+
# `ttl:` key in config/redis.yml — NOT via config.adapter_options.
|
|
39
47
|
RUBY
|
|
40
48
|
puts "Created RailsSoftLock configuration file at #{initializer_path}"
|
|
41
49
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_soft_lock
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergey Arkhipov
|
|
@@ -31,14 +31,14 @@ dependencies:
|
|
|
31
31
|
requirements:
|
|
32
32
|
- - "~>"
|
|
33
33
|
- !ruby/object:Gem::Version
|
|
34
|
-
version: '2.
|
|
34
|
+
version: '2.8'
|
|
35
35
|
type: :runtime
|
|
36
36
|
prerelease: false
|
|
37
37
|
version_requirements: !ruby/object:Gem::Requirement
|
|
38
38
|
requirements:
|
|
39
39
|
- - "~>"
|
|
40
40
|
- !ruby/object:Gem::Version
|
|
41
|
-
version: '2.
|
|
41
|
+
version: '2.8'
|
|
42
42
|
description: Using In-Memory Databases to Work with Rails Active Record Locks
|
|
43
43
|
email:
|
|
44
44
|
- sergey-arkhipov@ya.ru
|
|
@@ -84,7 +84,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
84
84
|
requirements:
|
|
85
85
|
- - ">="
|
|
86
86
|
- !ruby/object:Gem::Version
|
|
87
|
-
version: '
|
|
87
|
+
version: '4.0'
|
|
88
88
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
89
|
requirements:
|
|
90
90
|
- - ">="
|