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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28c11572b431bdc87a2f4771c63b6aef7665f619761c84949d0265b73f2976d1
4
- data.tar.gz: a81700f090a280d04f43d96dbac00603dc64fbd7912a7e22f69a76321792a57d
3
+ metadata.gz: 340ed740ba96e987fda3b66518a71a8693a3e1a99c5df1b9f0093acc89e5c36f
4
+ data.tar.gz: bdb1f86ad797cabf1468be9e9cf20bb1e89b7887ae420006b98fc436a2c0f51e
5
5
  SHA512:
6
- metadata.gz: 824828fb615e0e17866a4ebf266f493f3283876e6e83fb485afdfde89beae8a748ba9ab6e7afec273344572385b0853b2707943fc4f64692961174a8baae6c4e
7
- data.tar.gz: 1dcea30302c83b22c3782aeabf7307a3c931a7c6355b39f62726dd277630a472eb441200369426fa64a55dc04d124b65f18033a3a8524f24ebaf5ba5b65439e6
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
- ### Key Features
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
- result = redis_client.multi do |transaction|
30
- transaction.hsetnx(@object_name, @object_key, @object_value)
31
- transaction.hget(@object_name, @object_key)
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
- default_settings.merge(base_config)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsSoftLock
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -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
- # 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
- config.adapter_options = {
25
- redis: Rails.application.config_for(:redis).merge(
26
- timeout: 5
27
- )
28
- }
29
-
30
- # (Optional) Attribute used for locking
31
- # config.acts_as_locked_by = :lock_attribute
32
-
33
- # (Optional) Scope for separating locks
34
- # config.acts_as_locked_scope = -> { "default_scope" }
35
-
36
- # (Optional) Model class for locked_by lookups
37
- # config.locked_by_class = "User"
38
- end
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.3.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.7'
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.7'
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: '3.4'
87
+ version: '4.0'
88
88
  required_rubygems_version: !ruby/object:Gem::Requirement
89
89
  requirements:
90
90
  - - ">="