rails_soft_lock 0.1.2 → 0.2.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: a17245b0ec7da5d52139ebd9ccd5b237c6cf04511c53dd779ed9dcf234e8fc6f
4
- data.tar.gz: b4ad5fe08f57d7064181a020116107c18d345001f5ea6224f0fd4e196867d434
3
+ metadata.gz: 9f6443555d20169a04a8c16dbe970b56fca4f3befe6a840bea0f1385e6783524
4
+ data.tar.gz: '08382ac46d6a3fe067e4899721da2b217ccd74065bf76061886509694ab6c85a'
5
5
  SHA512:
6
- metadata.gz: cfe8b750d1ebcb0b22d2017ca0b2289d72a4673d22218ffc6af5ac0a4ff9d11a8a36fc536d8de74a6cd2182b6c88bb8962571a232d28c0e45d97382800494765
7
- data.tar.gz: 6e5774aaad0f9b7bc08002d19238597cffe14c67f2beca1e01ed280b041bb83eae25a88d18f3c340d30a4ba086caf3644f165ae459f16a280ac27583526d75dc
6
+ metadata.gz: a1bd7a8346ccfc3e17b12f632847627bd313ae27bb2cc641e437524f932d708cd93f2edcf9e7694a8e5b9df81efab8b1c76006d67fb7508fe5ad5e8df48797d6
7
+ data.tar.gz: 892296a9f41bf10c6c61eca479b42bd6f79892cb2dfbe9762f978080b634630b34f97178759259f0efa20db7bbed25ac059403a83e44cf3febcc3175932f6fd6
data/README.md CHANGED
@@ -1,21 +1,49 @@
1
- # RailsSoftLock
1
+ # RailsSoftLock – Group Locking for ApplicationRecord by Attribute
2
2
 
3
- This gem implements the ability to lock Rails Active Records using adapters for in-memory databases, such as redis, nats, etc.
4
- Locks can be done by using the active record attribute.
5
- it is possible to define the uniqueness scope of the attribute.
6
- The gem is under active development.
7
- Currently, an adapter to redis-compatible databases, such as redis, walkey, etc., has been implemented.
3
+ ## Overview
4
+
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
+
7
+ ### Key Features
8
+
9
+ Lightweight Group Locking:
10
+
11
+ Locks records sharing the same attribute value via an in-memory database (e.g., Redis, NATS).
12
+
13
+ Avoids expensive row-level locks in your primary database.
14
+
15
+ ActiveRecord Integration:
16
+
17
+ Extends Rails’ built-in locking mechanisms with adapters for in-memory stores.
18
+
19
+ Supports scoped uniqueness (e.g., lock groups by account_id + category).
20
+
21
+ Beyond Locking:
22
+
23
+ Can also mark/tag groups of records (e.g., flag all records with project_id=123 as "favorites").
24
+
25
+ Useful for batch operations or state management (e.g., "processing", "archived").
26
+
27
+ ### Current Status
28
+
29
+ Active Development: New features and optimizations in progress.
30
+
31
+ Available Adapters: Redis and Redis-compatible databases (e.g., Valkey).
8
32
 
9
33
  ## Installation
10
34
 
11
35
  Install the gem and add to the application's Gemfile:
12
36
 
13
37
  ```bash
38
+ # Stable version
39
+ gem "rails_soft_lock"
40
+ # Last version
14
41
  gem "rails_soft_lock", git: "https://github.com/sergey-arkhipov/rails_soft_lock.git"
15
42
 
16
43
  ```
17
44
 
18
- After run
45
+ When using Redis as an adapter and having REDIS_URL in config/redis.yml it is not necessary to install the initializer.
46
+ Only when need to replace some standard settings, User model, for example, or adapter.
19
47
 
20
48
  ```bash
21
49
  bundle install
@@ -57,6 +85,19 @@ Gem use ConnectionPool inside for safety connect to Redis adapter (now inplement
57
85
 
58
86
  Gem assumes that the User model is used to determine the user who sets the lock.
59
87
 
88
+ Another model for setting the attribute of the blocking user can be specified in the configuration.
89
+ The model ID is used for blocking.
90
+
91
+ ```ruby
92
+ RailsSoftLock.configure do |config|
93
+ ...
94
+ # (Optional) Model class for locked_by lookups
95
+ config.locked_by_class = "User"
96
+
97
+
98
+ end
99
+ ```
100
+
60
101
  Model < ApplicationRecord should include `RailsSoftLock::ModelExtensions`
61
102
  and `acts_as_locked_by` with `acts_as_locked_scope` should be set, for example
62
103
 
@@ -64,26 +105,42 @@ and `acts_as_locked_by` with `acts_as_locked_scope` should be set, for example
64
105
  class Article < ApplicationRecord
65
106
  include RailsSoftLock::ModelExtensions
66
107
 
67
- acts_as_locked_by(:attribyte)
68
- acts_as_locked_scope(proc { :scoped_attribute || "none" })
108
+ acts_as_locked_by: attribyte, scope: -> { 'scope_result'}
109
+
69
110
 
70
111
  ```
71
112
 
72
113
  See `spec/rails_soft_lock/model_extensions_spec.rb for implemented methods`
73
114
 
74
- ### Attention
115
+ ### Understanding the lock_or_find Method
116
+
117
+ The lock_or_find method returns a hash with the following structure:
118
+ ruby
119
+
120
+ { has_locked: false, locked_by: user.id }
121
+
122
+ Key Points:
123
+
124
+ has_locked: false indicates that the object was not locked prior to this operation.
125
+
126
+ Note: This does not refer to the success/failure of the lock attempt itself.
127
+
128
+ How locking works:
129
+
130
+ Since this is an in-memory database designed for fast access, the method:
131
+
132
+ Sets the lock (if no lock existed).
133
+
134
+ Reports (has_locked: false) that no prior lock was present.
135
+
136
+ Returns the ID of the user who now holds the lock.
75
137
 
76
- Pay attention how method `locak_or_find` work
138
+ If the object was already locked, it returns:
139
+ ruby
77
140
 
78
- Method return hash
79
- `has_locked: false, locked_by: user.id`
141
+ { has_locked: true, locked_by: <existing_lock_user_id> }
80
142
 
81
- `has_lock`: false implies that there was no lock on the passed object before this point.
82
- Not to be confused with the result of executing the lock itself.
83
- Since this is an in-memory base and the goal is quick and easy access, the method sets the lock,
84
- reports that there was no lock before, and returns the user of the lock.
85
- If there was a lock, true is returned and the user of this current lock.
86
- The lock itself is not changed.
143
+ In this case, the lock remains unchanged (no new lock is set).
87
144
 
88
145
  ## Development
89
146
 
@@ -7,21 +7,31 @@ module RailsSoftLock
7
7
  # List of supported adapters.
8
8
  VALID_ADAPTERS = %i[redis nats memcached].freeze
9
9
  attr_reader :adapter
10
- attr_accessor :adapter_options, :acts_as_locked_by, :acts_as_locked_scope
10
+ # :reek:Attribute
11
+ attr_accessor :adapter_options
12
+ # :reek:Attribute
13
+ attr_writer :locked_by_class
11
14
 
12
15
  def initialize
13
16
  @adapter = :redis # Default adapter
14
- @adapter_options = adapter_options || {} # Default adapter options
15
- @acts_as_locked_by = :none
16
- @acts_as_locked_scope = -> { "default" }
17
+ @adapter_options ||= RedisConfig.default_adapter_options # Default adapter options
18
+ @locked_by_class = locked_by_class || "User"
17
19
  end
18
20
 
19
- def [](key)
20
- send(key)
21
+ def acts_as_locked_by(attribute = :lock_attribute, scope: -> { "none" })
22
+ @acts_as_locked_options = { by: attribute, scope: scope }
21
23
  end
22
24
 
23
- def []=(key, value)
24
- send("#{key}=", value)
25
+ def acts_as_locked_attribute
26
+ @acts_as_locked_options&.[](:by) || :lock_attribute
27
+ end
28
+
29
+ def acts_as_locked_scope
30
+ @acts_as_locked_options&.[](:scope)&.call || "none"
31
+ end
32
+
33
+ def locked_by_class
34
+ @locked_by_class.is_a?(String) ? @locked_by_class.constantize : @locked_by_class
25
35
  end
26
36
 
27
37
  # Sets the adapter and validates it.
@@ -9,7 +9,7 @@ module RailsSoftLock
9
9
  # - object_key: The identifier of the lock instance, typically a unique database record ID
10
10
  # - object_value: The identifier of the locker that locked the record
11
11
  class LockObject
12
- # Подключаем адаптер на основе конфигурации
12
+ # Attach adapter based on config
13
13
  def self.adapter
14
14
  case RailsSoftLock.configuration.adapter
15
15
  when :redis
@@ -3,25 +3,13 @@
3
3
  # lib/rails_soft_lock/model_extensions.rb
4
4
 
5
5
  module RailsSoftLock
6
- # Определяет настройки блокировки модели
6
+ # Extend model and give methods from gem
7
7
  module ModelExtensions
8
8
  extend ActiveSupport::Concern
9
-
10
9
  class_methods do
11
- def acts_as_locked_by(attribute = :none)
12
- RailsSoftLock.configuration[:acts_as_locked_by] = attribute
13
- end
14
-
15
- def current_locked_attribute
16
- RailsSoftLock.configuration[:acts_as_locked_by]
17
- end
18
-
19
- def acts_as_locked_scope(attribute = nil)
20
- RailsSoftLock.configuration[:acts_as_locked_scope] = attribute || -> { "default" }
21
- end
22
-
23
- def current_locked_scope
24
- RailsSoftLock.configuration[:acts_as_locked_scope]
10
+ # :reek:UtilityFunction
11
+ def acts_as_locked_by(attribute = :lock_attribute, scope: -> { "none" })
12
+ RailsSoftLock.configuration.acts_as_locked_by(attribute, scope: scope)
25
13
  end
26
14
 
27
15
  def all_locks
@@ -33,8 +21,8 @@ module RailsSoftLock
33
21
  end
34
22
 
35
23
  def object_name
36
- scope = current_locked_scope&.call || "default"
37
- "#{name}-#{scope}"
24
+ scope = RailsSoftLock.configuration.acts_as_locked_scope
25
+ "#{name}::#{scope}"
38
26
  end
39
27
  end
40
28
 
@@ -55,11 +43,11 @@ module RailsSoftLock
55
43
 
56
44
  def locked_by
57
45
  user_id = base_lock_object.locked_by&.to_i
58
- user_id ? User.find_by(id: user_id) : nil
46
+ user_id ? RailsSoftLock.configuration.locked_by_class.find_by(id: user_id) : nil
59
47
  end
60
48
 
61
49
  def object_key
62
- attribute = RailsSoftLock.configuration[:acts_as_locked_by]
50
+ attribute = RailsSoftLock.configuration.acts_as_locked_attribute
63
51
  raise ArgumentError, "No locked attribute defined" if attribute == :none
64
52
 
65
53
  send(attribute)
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/rails_soft_lock/redis_config.rb
4
+ module RailsSoftLock
5
+ # Provides Redis configuration management for RailsSoftLock gem
6
+ # Handles:
7
+ # - Default Redis settings
8
+ # - Rails-specific configuration lookup
9
+ # - Safe fallbacks when Rails isn't available
10
+ module RedisConfig
11
+ module_function
12
+
13
+ # Returns the complete Redis adapter options hash
14
+ # @return [Hash] Options hash with :redis key containing configuration
15
+ # @example
16
+ # { redis: { host: 'localhost', port: 6379, db: 0, timeout: 5 } }
17
+ def default_adapter_options
18
+ { redis: config_with_defaults }
19
+ end
20
+
21
+ # Merges default settings with any Rails-specific configuration
22
+ # @return [Hash] Complete Redis configuration
23
+ # @note Will return just defaults if Rails isn't available
24
+ def config_with_defaults
25
+ base_config = rails_available? ? rails_config : {}
26
+ default_settings.merge(base_config)
27
+ end
28
+
29
+ # Checks if Rails environment is available and properly configured
30
+ # @return [Boolean]
31
+ def rails_available?
32
+ if defined?(Rails) &&
33
+ Rails.respond_to?(:application) &&
34
+ Rails.application
35
+ true
36
+ else
37
+ false
38
+ end
39
+ end
40
+
41
+ # Attempts to load Redis config from Rails application
42
+ # @return [Hash] Redis configuration from Rails or empty hash if unavailable
43
+ # @note Safely handles cases where config_for isn't available
44
+ def rails_config
45
+ return {} unless rails_config_available?
46
+
47
+ config = Rails.application.config_for(:redis)
48
+
49
+ config.is_a?(Hash) ? config.symbolize_keys : {}
50
+ rescue RuntimeError, ArgumentError
51
+ {}
52
+ end
53
+
54
+ # Checks if Rails provides config_for functionality
55
+ # @return [Boolean]
56
+ # @api private
57
+ def rails_config_available?
58
+ Rails.application.respond_to?(:config_for)
59
+ end
60
+
61
+ # The default Redis connection settings
62
+ # @return [Hash]
63
+ # @api private
64
+ def default_settings
65
+ { url: "redis://localhost:6379/0", timeout: 5 }
66
+ end
67
+ end
68
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsSoftLock
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -22,7 +22,7 @@ namespace :rails_soft_lock do
22
22
 
23
23
  # configuration for the redis adapter
24
24
  config.adapter_options = {
25
- redis: rails.application.config_for(:redis).merge(
25
+ redis: Rails.application.config_for(:redis).merge(
26
26
  timeout: 5
27
27
  )
28
28
  }
@@ -34,7 +34,7 @@ namespace :rails_soft_lock do
34
34
  # config.acts_as_locked_scope = -> { "default_scope" }
35
35
 
36
36
  # (Optional) Model class for locked_by lookups
37
- # config.locked_by_class = User
37
+ # config.locked_by_class = "User"
38
38
  end
39
39
  RUBY
40
40
  puts "Created RailsSoftLock configuration file at #{initializer_path}"
@@ -2,6 +2,8 @@
2
2
  module RailsSoftLock
3
3
  self.@lock_manager: untyped
4
4
 
5
+ self.@configuration: untyped
6
+
5
7
  # Error class for gem
6
8
  class Error < StandardError
7
9
  end
@@ -14,4 +16,17 @@ module RailsSoftLock
14
16
  def self.create: (untyped object_name, untyped object_key, untyped object_value) -> untyped
15
17
 
16
18
  def self.update: (untyped object_name, untyped object_key, untyped object_value) -> untyped
19
+
20
+ # Configures the RailsSoftLock gem with a block.
21
+ # @yield [config] Yields the configuration object to the block.
22
+ # @return [void]
23
+ def self.configure: () ?{ (untyped) -> untyped } -> untyped
24
+
25
+ # Returns the current configuration instance.
26
+ # @return [Configuration] The configuration object.
27
+ def self.configuration: () -> untyped
28
+
29
+ # Resets the configuration (useful for testing).
30
+ # @return [void]
31
+ def self.reset_configuration: () -> untyped
17
32
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_soft_lock
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergey Arkhipov
8
8
  - Vladimir Peskov
9
9
  bindir: lib
10
10
  cert_chain: []
11
- date: 2025-04-26 00:00:00.000000000 Z
11
+ date: 2025-04-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: connection_pool
@@ -40,7 +40,8 @@ dependencies:
40
40
  version: '2.7'
41
41
  description: Using In-Memory Databases to Work with Rails Active Record Locks
42
42
  email:
43
- - sergey-ARkhipov@ya.ru
43
+ - sergey-arkhipov@ya.ru
44
+ - v.peskov@mail.ru
44
45
  executables: []
45
46
  extensions: []
46
47
  extra_rdoc_files: []
@@ -59,15 +60,10 @@ files:
59
60
  - lib/rails_soft_lock/model_extensions.rb
60
61
  - lib/rails_soft_lock/railtie.rb
61
62
  - lib/rails_soft_lock/redis_adapter.rb
63
+ - lib/rails_soft_lock/redis_config.rb
62
64
  - lib/rails_soft_lock/version.rb
63
65
  - lib/tasks/rails_soft_lock.rake
64
66
  - sig/rails_soft_lock.rbs
65
- - sig/rails_soft_lock/configuration.rbs
66
- - sig/rails_soft_lock/lock_object.rbs
67
- - sig/rails_soft_lock/model_extensions.rbs
68
- - sig/rails_soft_lock/railtie.rbs
69
- - sig/rails_soft_lock/redis_adapter.rbs
70
- - sig/rails_soft_lock/version.rbs
71
67
  homepage: https://github.com/sergey-arkhipov/rails_soft_lock
72
68
  licenses:
73
69
  - MIT
@@ -1,50 +0,0 @@
1
- # lib/rails_soft_lock/configuration.rb
2
- module RailsSoftLock
3
- self.@configuration: untyped
4
-
5
- # Configuration class for RailsSoftLock gem.
6
- class Configuration
7
- @adapter: untyped
8
-
9
- @adapter_options: untyped
10
-
11
- @acts_as_locked_by: untyped
12
-
13
- @acts_as_locked_scope: untyped
14
-
15
- # List of supported adapters.
16
- VALID_ADAPTERS: ::Array[:redis | :nats | :memcached]
17
-
18
- attr_reader adapter: untyped
19
-
20
- attr_accessor adapter_options: untyped
21
-
22
- attr_accessor acts_as_locked_by: untyped
23
-
24
- attr_accessor acts_as_locked_scope: untyped
25
-
26
- def initialize: () -> void
27
-
28
- def []: (untyped key) -> untyped
29
-
30
- def []=: (untyped key, untyped value) -> untyped
31
-
32
- # Sets the adapter and validates it.
33
- # @param value [Symbol] The adapter to use.
34
- # @raise [ArgumentError] If the adapter is not supported.
35
- def adapter=: (untyped value) -> untyped
36
- end
37
-
38
- # Configures the RailsSoftLock gem with a block.
39
- # @yield [config] Yields the configuration object to the block.
40
- # @return [void]
41
- def self.configure: () ?{ (untyped) -> untyped } -> untyped
42
-
43
- # Returns the current configuration instance.
44
- # @return [Configuration] The configuration object.
45
- def self.configuration: () -> untyped
46
-
47
- # Resets the configuration (useful for testing).
48
- # @return [void]
49
- def self.reset_configuration: () -> untyped
50
- end
@@ -1,41 +0,0 @@
1
- module RailsSoftLock
2
- # Service for managing object locks
3
- # A lock object contains the following parameters:
4
- # - object_name: The name of the lock storage, typically the model name with an optional scope
5
- # - object_key: The identifier of the lock instance, typically a unique database record ID
6
- # - object_value: The identifier of the locker that locked the record
7
- class LockObject
8
- @object_name: untyped
9
-
10
- @object_key: untyped
11
-
12
- @object_value: untyped
13
-
14
- # Подключаем адаптер на основе конфигурации
15
- def self.adapter: () -> untyped
16
-
17
- attr_reader object_name: untyped
18
-
19
- attr_reader object_key: untyped
20
-
21
- attr_reader object_value: untyped
22
-
23
- def initialize: (object_name: untyped, ?object_key: untyped?, ?object_value: untyped?) -> void
24
-
25
- # Returns the ID of the locker who locked the object
26
- # @return [String, nil] The locker's ID or nil if not locked
27
- def locked_by: () -> untyped
28
-
29
- # Attempts to lock the object or returns the existing lock
30
- # @return [Hash] { has_locked: Boolean, locked_by: String or nil }
31
- def lock_or_find: () -> { has_locked: untyped, locked_by: untyped }
32
-
33
- # Unlocks the object
34
- # @return [Boolean] True if the lock was removed, false otherwise
35
- def unlock: () -> untyped
36
-
37
- # Returns all locks in the storage
38
- # @return [Hash] All key-value pairs in the storage
39
- def all_locks: () -> untyped
40
- end
41
- end
@@ -1,6 +0,0 @@
1
- module RailsSoftLock
2
- # Определяет настройки блокировки модели
3
- module ModelExtensions
4
- extend ActiveSupport::Concern
5
- end
6
- end
@@ -1,7 +0,0 @@
1
- module RailsSoftLock
2
- # Load rails_soft_lock:install
3
- # After load rails rails_soft_lock:install check config/initializers/rails_soft_lock.rb
4
- # and create initializer if do not exists
5
- class Railtie < Rails::Railtie
6
- end
7
- end
@@ -1,29 +0,0 @@
1
- module RailsSoftLock
2
- # Adapter for store lock in redis
3
- module RedisAdapter
4
- @redis_client: untyped
5
-
6
- # Initialize Redis client
7
- def redis_client: () -> untyped
8
-
9
- # Retrieves a value by key from the specified hash
10
- # @return [String, nil] The value associated with the key
11
- def get: () -> untyped
12
-
13
- # Creates a new key-value pair if the key does not exist
14
- # @return [Boolean] true if the key was created, false if it already existed
15
- def create: () -> untyped
16
-
17
- # Updates the value for an existing key or creates a new key-value pair
18
- # @return [Boolean] true if the key was updated, false if it was created
19
- def update: () -> untyped
20
-
21
- # Deletes a key from the specified hash
22
- # @return [Boolean] true if the key was deleted, false if it did not exist
23
- def delete: () -> untyped
24
-
25
- # Retrieves all key-value pairs in the specified hash
26
- # @return [Hash] The key-value pairs in the hash
27
- def all: () -> untyped
28
- end
29
- end
@@ -1,3 +0,0 @@
1
- module RailsSoftLock
2
- VERSION: "0.1.0"
3
- end