kannuki 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 94eafe3bcefb3d5189fbd665a867370decc6c44e9c6b8cc55d58abcd6fed527e
4
+ data.tar.gz: b6500d08b69a33bbc2991dc3fcc02e86b2fc6cfa07bf34678eb68aded5b7625d
5
+ SHA512:
6
+ metadata.gz: ffc2742ba5608d63d1361756ed9641f0efb0971392fde02e4734082b47516533741623495b9f6b454446386d929d13fa469a1c285ec864addb3c0b512ac8a79d
7
+ data.tar.gz: 4da9124dc134f64e7da3d06d6cf13f30fd2553ab252b82fa7000a3ecdf1e456b6bb6bddeceee9a35970c217c821191b35e97e182ff09ab6a6c42b6aef27e1978
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,228 @@
1
+ <h1 align="center">Kannuki</h1>
2
+ <p align="center">
3
+ Advisory locking for ActiveRecord with modern Rails conventions.
4
+ </p>
5
+
6
+ <p align="center">
7
+ <a href="#installation">Installation</a> •
8
+ <a href="#basic-usage">Basic Usage</a> •
9
+ <a href="#model-extension">Model Extension</a> •
10
+ <a href="#activejob-integration">ActiveJob</a> •
11
+ <a href="#configuration">Configuration</a> •
12
+ <a href="docs/guide.md">User Guide</a>
13
+ </p>
14
+
15
+ Kannuki provides database-agnostic advisory locking for ActiveRecord with support for PostgreSQL and MySQL, offering blocking/non-blocking strategies, instrumentation, and ActiveJob integration.
16
+
17
+ ## Installation
18
+
19
+ Add to your Gemfile:
20
+
21
+ ```ruby
22
+ gem 'kannuki'
23
+ ```
24
+
25
+ Then run:
26
+
27
+ ```bash
28
+ bundle install
29
+ ```
30
+
31
+ Generate an initializer (optional):
32
+
33
+ ```bash
34
+ rails generate kannuki:install
35
+ ```
36
+
37
+ ## Basic Usage
38
+
39
+ ### Simple Lock
40
+
41
+ ```ruby
42
+ Kannuki.with_lock("my_critical_section") do
43
+ # Exclusive execution
44
+ end
45
+ ```
46
+
47
+ ### With Timeout
48
+
49
+ ```ruby
50
+ Kannuki.with_lock("process_order", timeout: 10) do
51
+ # Returns false if lock not acquired within 10 seconds
52
+ end
53
+ ```
54
+
55
+ ### Non-blocking (Try Lock)
56
+
57
+ ```ruby
58
+ result = Kannuki.try_lock("quick_check") do
59
+ perform_quick_operation
60
+ end
61
+ puts "Lock was not available" if result == false
62
+ ```
63
+
64
+ ### Raise on Failure
65
+
66
+ ```ruby
67
+ Kannuki.lock!("must_succeed") do
68
+ critical_operation
69
+ end
70
+ # => raises Kannuki::LockNotAcquiredError if lock unavailable
71
+ ```
72
+
73
+ ## Model Extension
74
+
75
+ ```ruby
76
+ class Order < ApplicationRecord
77
+ kannuki :number_generation, scope: :organization_id
78
+ end
79
+ ```
80
+
81
+ Usage:
82
+
83
+ ```ruby
84
+ order.with_number_generation_lock do
85
+ order.number = organization.orders.maximum(:number).to_i + 1
86
+ order.save!
87
+ end
88
+
89
+ # Non-blocking
90
+ order.try_number_generation_lock { ... }
91
+
92
+ # Raise on failure
93
+ order.number_generation_lock! { ... }
94
+
95
+ # Check if locked
96
+ order.number_generation_locked?
97
+ ```
98
+
99
+ Ad-hoc locking:
100
+
101
+ ```ruby
102
+ order.with_lock("custom_operation") do
103
+ # Lock key: "orders/123/custom_operation"
104
+ end
105
+ ```
106
+
107
+ ## ActiveJob Integration
108
+
109
+ ### Prevent Concurrent Execution
110
+
111
+ ```ruby
112
+ class HeavyImportJob < ApplicationJob
113
+ with_lock :import, key: -> { arguments.first }
114
+
115
+ def perform(import_id)
116
+ # Exclusive execution per import_id
117
+ end
118
+ end
119
+ ```
120
+
121
+ ### Skip Duplicate Jobs
122
+
123
+ ```ruby
124
+ class DataSyncJob < ApplicationJob
125
+ unique_by_lock on_conflict: :skip
126
+
127
+ def perform(resource_type, resource_id)
128
+ # Only one job with same arguments runs at a time
129
+ end
130
+ end
131
+ ```
132
+
133
+ ## Configuration
134
+
135
+ ```ruby
136
+ # config/initializers/kannuki.rb
137
+ Kannuki.configure do |config|
138
+ config.default_timeout = 30
139
+ config.default_strategy = :blocking
140
+ config.key_prefix = "myapp"
141
+ config.enable_instrumentation = Rails.env.production?
142
+ config.retry_attempts = 3
143
+ config.retry_interval = 0.5
144
+ config.retry_backoff = :exponential
145
+ end
146
+ ```
147
+
148
+ ### Strategies
149
+
150
+ | Strategy | Behavior |
151
+ |----------|----------|
152
+ | `:blocking` | Waits until lock available or timeout (default) |
153
+ | `:non_blocking` | Returns immediately if unavailable |
154
+ | `:retry` | Retries with configurable backoff |
155
+
156
+ ```ruby
157
+ Kannuki.with_lock("op", strategy: :retry, retry_attempts: 5) { ... }
158
+ ```
159
+
160
+ ## Instrumentation
161
+
162
+ Kannuki emits ActiveSupport::Notifications events:
163
+
164
+ - `acquired.kannuki`
165
+ - `released.kannuki`
166
+ - `failed.kannuki`
167
+ - `timeout.kannuki`
168
+ - `waiting.kannuki`
169
+
170
+ ```ruby
171
+ ActiveSupport::Notifications.subscribe(/\.kannuki$/) do |name, start, finish, id, payload|
172
+ duration = (finish - start) * 1000
173
+ Rails.logger.info "[Kannuki] #{name}: #{payload[:lock_key]} (#{duration.round(2)}ms)"
174
+ end
175
+ ```
176
+
177
+ ## Testing
178
+
179
+ ```ruby
180
+ RSpec.configure do |config|
181
+ config.before { Kannuki::Testing.enable! }
182
+ config.after { Kannuki::Testing.clear! }
183
+ end
184
+
185
+ # In tests
186
+ Kannuki::Testing.simulate_lock_held("my_lock")
187
+ result = Kannuki.try_lock("my_lock") { "success" }
188
+ expect(result).to be false
189
+ ```
190
+
191
+ ## Database Support
192
+
193
+ ### PostgreSQL
194
+
195
+ | Feature | Supported |
196
+ |---------|-----------|
197
+ | Session-level locks | Yes |
198
+ | Transaction-level locks | Yes |
199
+ | Shared locks | Yes |
200
+
201
+ ### MySQL
202
+
203
+ | Feature | Supported |
204
+ |---------|-----------|
205
+ | Session-level locks | Yes |
206
+ | Transaction-level locks | No |
207
+ | Shared locks | No |
208
+
209
+ ## Requirements
210
+
211
+ - Ruby >= 3.1
212
+ - Rails >= 7.0
213
+ - PostgreSQL >= 12 or MySQL >= 8.0
214
+
215
+ ## Development
216
+
217
+ ```bash
218
+ bundle install
219
+ bundle exec rspec
220
+ ```
221
+
222
+ ## Contributing
223
+
224
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ydah/kannuki.
225
+
226
+ ## License
227
+
228
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/docs/guide.md ADDED
@@ -0,0 +1,377 @@
1
+ # Kannuki User Guide
2
+
3
+ Kannuki is a gem that provides advisory locking for Rails applications. It enables exclusive control across multiple processes and servers using database-level locking.
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [Installation](#installation)
8
+ 2. [Basic Usage](#basic-usage)
9
+ 3. [Use Cases](#use-cases)
10
+ 4. [Configuration](#configuration)
11
+ 5. [Testing](#testing)
12
+ 6. [Troubleshooting](#troubleshooting)
13
+
14
+ ## Installation
15
+
16
+ Add to your Gemfile:
17
+
18
+ ```ruby
19
+ gem 'kannuki'
20
+ ```
21
+
22
+ Run:
23
+
24
+ ```bash
25
+ $ bundle install
26
+ ```
27
+
28
+ Generate initializer (optional):
29
+
30
+ ```bash
31
+ $ rails generate kannuki:install
32
+ ```
33
+
34
+ ## Basic Usage
35
+
36
+ ### Simple Lock
37
+
38
+ ```ruby
39
+ Kannuki.with_lock("my_operation") do
40
+ # This block runs exclusively
41
+ end
42
+ ```
43
+
44
+ ### Non-blocking Lock
45
+
46
+ ```ruby
47
+ result = Kannuki.try_lock("my_operation") do
48
+ # Runs only if lock is immediately available
49
+ "success"
50
+ end
51
+
52
+ if result == false
53
+ puts "Lock was not available"
54
+ end
55
+ ```
56
+
57
+ ### Lock with Exception on Failure
58
+
59
+ ```ruby
60
+ Kannuki.lock!("critical_operation") do
61
+ # Raises Kannuki::LockNotAcquiredError if lock unavailable
62
+ end
63
+ ```
64
+
65
+ ## Use Cases
66
+
67
+ ### 1. Preventing Duplicate Order Processing
68
+
69
+ Problem: Multiple workers might process the same order simultaneously.
70
+
71
+ Solution:
72
+
73
+ ```ruby
74
+ class OrderProcessor
75
+ def process(order_id)
76
+ Kannuki.with_lock("order_processing/#{order_id}") do
77
+ order = Order.find(order_id)
78
+ return if order.processed?
79
+
80
+ order.process!
81
+ order.update!(processed: true)
82
+ end
83
+ end
84
+ end
85
+ ```
86
+
87
+ ### 2. Sequential Number Generation
88
+
89
+ Problem: Auto-incrementing custom numbers (invoice numbers, ticket numbers) can collide.
90
+
91
+ Solution:
92
+
93
+ ```ruby
94
+ class Invoice < ApplicationRecord
95
+ kannuki :number_generation, scope: :company_id
96
+ end
97
+
98
+ class InvoiceService
99
+ def create_invoice(company, params)
100
+ invoice = company.invoices.build(params)
101
+
102
+ invoice.with_number_generation_lock do
103
+ last_number = company.invoices.maximum(:invoice_number) || 0
104
+ invoice.invoice_number = last_number + 1
105
+ invoice.save!
106
+ end
107
+
108
+ invoice
109
+ end
110
+ end
111
+ ```
112
+
113
+ ### 3. Preventing Duplicate Background Jobs
114
+
115
+ Problem: The same job might be enqueued multiple times and run concurrently.
116
+
117
+ Solution:
118
+
119
+ ```ruby
120
+ class DataSyncJob < ApplicationJob
121
+ unique_by_lock on_conflict: :skip
122
+
123
+ def perform(resource_type, resource_id)
124
+ # Only one job with same arguments runs at a time
125
+ # Duplicate jobs are silently skipped
126
+ end
127
+ end
128
+ ```
129
+
130
+ ### 4. Rate Limiting External API Calls
131
+
132
+ Problem: External API has rate limits; concurrent calls might exceed them.
133
+
134
+ Solution:
135
+
136
+ ```ruby
137
+ class ExternalApiClient
138
+ def fetch_data(endpoint)
139
+ Kannuki.with_lock("api/#{endpoint}", timeout: 30) do
140
+ # Only one request to this endpoint at a time
141
+ HTTP.get("https://api.example.com/#{endpoint}")
142
+ end
143
+ end
144
+ end
145
+ ```
146
+
147
+ ### 5. Exclusive Resource Access per User
148
+
149
+ Problem: User should only have one active session performing a specific operation.
150
+
151
+ Solution:
152
+
153
+ ```ruby
154
+ class ReportGenerator
155
+ def generate(user_id, report_type)
156
+ result = Kannuki.try_lock("reports/#{user_id}/#{report_type}") do
157
+ # Generate report
158
+ Report.create!(user_id: user_id, type: report_type, data: build_report)
159
+ end
160
+
161
+ if result == false
162
+ raise "Report generation already in progress"
163
+ end
164
+
165
+ result
166
+ end
167
+ end
168
+ ```
169
+
170
+ ### 6. Database Migration Safety
171
+
172
+ Problem: Running migrations on multiple servers simultaneously can cause issues.
173
+
174
+ Solution:
175
+
176
+ ```ruby
177
+ class SafeMigrationJob < ApplicationJob
178
+ with_lock :migration, key: -> { "db_migration" }
179
+
180
+ def perform(migration_name)
181
+ # Only one server runs migrations at a time
182
+ ActiveRecord::Migration.run(migration_name)
183
+ end
184
+ end
185
+ ```
186
+
187
+ ### 7. Singleton Scheduler
188
+
189
+ Problem: Scheduled task should only run on one server in a cluster.
190
+
191
+ Solution:
192
+
193
+ ```ruby
194
+ class SchedulerJob < ApplicationJob
195
+ def perform
196
+ result = Kannuki.try_lock("scheduler/hourly_tasks") do
197
+ HourlyTasks.each(&:run)
198
+ end
199
+
200
+ # If result is false, another server is handling it
201
+ Rails.logger.info("Scheduler skipped - another instance running") if result == false
202
+ end
203
+ end
204
+ ```
205
+
206
+ ### 8. Inventory Management
207
+
208
+ Problem: Concurrent purchases might oversell limited inventory.
209
+
210
+ Solution:
211
+
212
+ ```ruby
213
+ class PurchaseService
214
+ def purchase(product_id, quantity, user)
215
+ Kannuki.lock!("inventory/#{product_id}", timeout: 10) do
216
+ product = Product.find(product_id)
217
+
218
+ if product.stock >= quantity
219
+ product.decrement!(:stock, quantity)
220
+ Order.create!(product: product, quantity: quantity, user: user)
221
+ else
222
+ raise InsufficientStockError
223
+ end
224
+ end
225
+ rescue Kannuki::LockNotAcquiredError
226
+ raise "Product is currently being purchased by another user. Please try again."
227
+ end
228
+ end
229
+ ```
230
+
231
+ ## Configuration
232
+
233
+ ```ruby
234
+ # config/initializers/kannuki.rb
235
+ Kannuki.configure do |config|
236
+ # Default timeout in seconds (nil = wait indefinitely)
237
+ config.default_timeout = 30
238
+
239
+ # Default strategy (:blocking, :non_blocking, :retry)
240
+ config.default_strategy = :blocking
241
+
242
+ # Prefix for all lock keys (recommended for multi-app environments)
243
+ config.key_prefix = "myapp"
244
+
245
+ # Enable instrumentation for monitoring
246
+ config.enable_instrumentation = Rails.env.production?
247
+
248
+ # Retry strategy settings
249
+ config.retry_attempts = 3
250
+ config.retry_interval = 0.5
251
+ config.retry_backoff = :exponential # :exponential, :linear, :constant
252
+ end
253
+ ```
254
+
255
+ ### Lock Strategies
256
+
257
+ | Strategy | Behavior | Use When |
258
+ |----------|----------|----------|
259
+ | `:blocking` | Waits until lock available or timeout | Default, most operations |
260
+ | `:non_blocking` | Returns immediately if unavailable | Quick checks, optional operations |
261
+ | `:retry` | Retries with backoff | Transient contention expected |
262
+
263
+ ```ruby
264
+ # Blocking (default)
265
+ Kannuki.with_lock("op", strategy: :blocking, timeout: 30) { ... }
266
+
267
+ # Non-blocking
268
+ Kannuki.with_lock("op", strategy: :non_blocking) { ... }
269
+ # or
270
+ Kannuki.try_lock("op") { ... }
271
+
272
+ # Retry with custom settings
273
+ Kannuki.with_lock("op", strategy: :retry, retry_attempts: 5, retry_interval: 0.2) { ... }
274
+ ```
275
+
276
+ ## Testing
277
+
278
+ ### Setup
279
+
280
+ ```ruby
281
+ # spec/spec_helper.rb or spec/rails_helper.rb
282
+ RSpec.configure do |config|
283
+ config.before do
284
+ Kannuki::Testing.enable!
285
+ end
286
+
287
+ config.after do
288
+ Kannuki::Testing.clear!
289
+ end
290
+ end
291
+ ```
292
+
293
+ ### Simulating Held Locks
294
+
295
+ ```ruby
296
+ RSpec.describe OrderProcessor do
297
+ it "waits when order is being processed" do
298
+ # Simulate another process holding the lock
299
+ Kannuki::Testing.simulate_lock_held("order_processing/123")
300
+
301
+ result = Kannuki.try_lock("order_processing/123") { "processed" }
302
+
303
+ expect(result).to be false
304
+ end
305
+
306
+ it "processes order when lock is available" do
307
+ result = Kannuki.with_lock("order_processing/123") { "processed" }
308
+
309
+ expect(result).to eq "processed"
310
+ end
311
+ end
312
+ ```
313
+
314
+ ### Using RSpec Helpers
315
+
316
+ ```ruby
317
+ RSpec.describe MyService do
318
+ extend Kannuki::Testing::RSpecHelpers
319
+
320
+ with_kannuki_test_mode # Automatically enables/clears test mode
321
+
322
+ it "acquires lock" do
323
+ # Test mode is automatically enabled
324
+ end
325
+ end
326
+ ```
327
+
328
+ ## Troubleshooting
329
+
330
+ ### Lock Not Being Released
331
+
332
+ Symptom: Locks remain held after process crashes.
333
+
334
+ Cause: Process terminated without releasing session lock.
335
+
336
+ Solution: Session locks are automatically released when the database connection closes. Ensure your connection pool is properly configured. For critical operations, consider using transaction-scoped locks:
337
+
338
+ ```ruby
339
+ Kannuki.with_lock("op", transaction: true) do
340
+ # Lock released when transaction ends
341
+ end
342
+ ```
343
+
344
+ ### Timeout Errors
345
+
346
+ Symptom: `Kannuki::LockNotAcquiredError` or `false` returns frequently.
347
+
348
+ Cause: High contention on the same lock.
349
+
350
+ Solutions:
351
+ 1. Increase timeout: `timeout: 60`
352
+ 2. Use more granular lock keys: `"orders/#{order_id}"` instead of `"orders"`
353
+ 3. Use retry strategy: `strategy: :retry`
354
+
355
+ ### MySQL Nested Lock Warning
356
+
357
+ Symptom: Nested locks fail on MySQL.
358
+
359
+ Cause: MySQL's `GET_LOCK` only allows one lock per connection.
360
+
361
+ Solution: Restructure code to avoid nested locks, or use a single composite lock key.
362
+
363
+ ### Performance Concerns
364
+
365
+ Best Practices:
366
+ 1. Keep locked sections short
367
+ 2. Use specific lock keys to reduce contention
368
+ 3. Set appropriate timeouts
369
+ 4. Monitor lock metrics via instrumentation
370
+
371
+ ```ruby
372
+ # Monitor lock events
373
+ ActiveSupport::Notifications.subscribe(/\.kannuki$/) do |name, start, finish, id, payload|
374
+ duration_ms = (finish - start) * 1000
375
+ StatsD.timing("kannuki.#{name.sub('.kannuki', '')}", duration_ms)
376
+ end
377
+ ```
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Kannuki
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ desc 'Creates a Kannuki initializer file.'
11
+
12
+ def create_initializer_file
13
+ template 'kannuki.rb', 'config/initializers/kannuki.rb'
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ Kannuki.configure do |config|
4
+ # Default timeout in seconds for acquiring locks.
5
+ # nil means wait indefinitely.
6
+ # config.default_timeout = nil
7
+
8
+ # Default strategy for acquiring locks.
9
+ # Options: :blocking, :non_blocking, :retry
10
+ # config.default_strategy = :blocking
11
+
12
+ # Prefix for all lock keys (useful for multi-tenant applications).
13
+ # config.key_prefix = Rails.application.class.module_parent_name.underscore
14
+
15
+ # Enable ActiveSupport::Notifications instrumentation.
16
+ # config.enable_instrumentation = true
17
+
18
+ # Number of retry attempts when using the :retry strategy.
19
+ # config.retry_attempts = 3
20
+
21
+ # Interval between retries in seconds.
22
+ # config.retry_interval = 0.5
23
+
24
+ # Backoff strategy for retries.
25
+ # Options: :exponential, :linear, :constant
26
+ # config.retry_backoff = :exponential
27
+
28
+ # Default behavior when lock acquisition fails.
29
+ # Options: :return_false, :raise
30
+ # config.on_failure = :return_false
31
+ end