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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +228 -0
- data/Rakefile +8 -0
- data/docs/guide.md +377 -0
- data/lib/generators/kannuki/install_generator.rb +17 -0
- data/lib/generators/kannuki/templates/kannuki.rb +31 -0
- data/lib/kannuki/active_job_extension.rb +46 -0
- data/lib/kannuki/adapters/base.rb +51 -0
- data/lib/kannuki/adapters/mysql.rb +45 -0
- data/lib/kannuki/adapters/null.rb +44 -0
- data/lib/kannuki/adapters/postgresql.rb +82 -0
- data/lib/kannuki/adapters.rb +6 -0
- data/lib/kannuki/configuration.rb +46 -0
- data/lib/kannuki/errors.rb +36 -0
- data/lib/kannuki/instrumentation.rb +45 -0
- data/lib/kannuki/lock_key.rb +78 -0
- data/lib/kannuki/lock_manager.rb +93 -0
- data/lib/kannuki/model_extension.rb +41 -0
- data/lib/kannuki/railtie.rb +39 -0
- data/lib/kannuki/result.rb +35 -0
- data/lib/kannuki/strategies/base.rb +90 -0
- data/lib/kannuki/strategies/blocking.rb +12 -0
- data/lib/kannuki/strategies/non_blocking.rb +17 -0
- data/lib/kannuki/strategies/retry.rb +70 -0
- data/lib/kannuki/strategies.rb +6 -0
- data/lib/kannuki/testing.rb +96 -0
- data/lib/kannuki/version.rb +5 -0
- data/lib/kannuki.rb +25 -0
- metadata +102 -0
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
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
|