idempotency_lock 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/CHANGELOG.md +19 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +222 -0
- data/Rakefile +12 -0
- data/lib/generators/idempotency_lock/install_generator.rb +29 -0
- data/lib/generators/idempotency_lock/templates/create_idempotency_locks.rb.erb +19 -0
- data/lib/idempotency_lock/lock.rb +91 -0
- data/lib/idempotency_lock/railtie.rb +15 -0
- data/lib/idempotency_lock/result.rb +44 -0
- data/lib/idempotency_lock/version.rb +5 -0
- data/lib/idempotency_lock.rb +154 -0
- data/sig/idempotency_lock.rbs +4 -0
- metadata +88 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2433a11192a9d10ad05520f94d7b60854531c2c23ee0e2c674318c34aa77739c
|
|
4
|
+
data.tar.gz: 6b7e54105e42bee7122d20670a9bfc18192b4d0846e58b131939cf46274fa98f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 102949944e74ef10b4f9ba6d3d62221abb307aaf79da156b6e25277eea4fd11032a435a1ea726044e228635c30e00511d7df46a10fe5f66621bf24dac901721b
|
|
7
|
+
data.tar.gz: 110b05bb0e03d952e5a44051dc4dfbb51c728e06f63e58aa46d2379482ab34ee261a39410fed5d90db4c0abe800948cfb509b4f4a17ec03d0a2ffb8b5e2513e0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2025-12-06
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `IdempotencyLock.once` - Execute a block exactly once per lock name
|
|
8
|
+
- `Result` object with `executed?`, `skipped?`, `success?`, `error?`, and `value` methods
|
|
9
|
+
- TTL/expiration support via `ttl:` option (accepts ActiveSupport durations or seconds)
|
|
10
|
+
- Multiple error handling strategies via `on_error:` option:
|
|
11
|
+
- `:keep` (default) - keep lock on error
|
|
12
|
+
- `:unlock` - release lock to allow retry
|
|
13
|
+
- `:raise` - re-raise exception after releasing lock
|
|
14
|
+
- `Proc` - custom error handler
|
|
15
|
+
- `IdempotencyLock.locked?` - Check if an operation is locked
|
|
16
|
+
- `IdempotencyLock.release` - Manually release a lock
|
|
17
|
+
- `IdempotencyLock.cleanup_expired` - Remove all expired locks
|
|
18
|
+
- Rails generator: `rails generate idempotency_lock:install`
|
|
19
|
+
- `wrap` alias for `once` method
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
We as members, contributors, and leaders pledge to make participation in our
|
|
6
|
+
community a harassment-free experience for everyone, regardless of age, body
|
|
7
|
+
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
|
8
|
+
identity and expression, level of experience, education, socio-economic status,
|
|
9
|
+
nationality, personal appearance, race, caste, color, religion, or sexual
|
|
10
|
+
identity and orientation.
|
|
11
|
+
|
|
12
|
+
We pledge to act and interact in ways that contribute to an open, welcoming,
|
|
13
|
+
diverse, inclusive, and healthy community.
|
|
14
|
+
|
|
15
|
+
## Our Standards
|
|
16
|
+
|
|
17
|
+
Examples of behavior that contributes to a positive environment for our
|
|
18
|
+
community include:
|
|
19
|
+
|
|
20
|
+
* Demonstrating empathy and kindness toward other people
|
|
21
|
+
* Being respectful of differing opinions, viewpoints, and experiences
|
|
22
|
+
* Giving and gracefully accepting constructive feedback
|
|
23
|
+
* Accepting responsibility and apologizing to those affected by our mistakes,
|
|
24
|
+
and learning from the experience
|
|
25
|
+
* Focusing on what is best not just for us as individuals, but for the overall
|
|
26
|
+
community
|
|
27
|
+
|
|
28
|
+
Examples of unacceptable behavior include:
|
|
29
|
+
|
|
30
|
+
* The use of sexualized language or imagery, and sexual attention or advances of
|
|
31
|
+
any kind
|
|
32
|
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
|
33
|
+
* Public or private harassment
|
|
34
|
+
* Publishing others' private information, such as a physical or email address,
|
|
35
|
+
without their explicit permission
|
|
36
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
|
37
|
+
professional setting
|
|
38
|
+
|
|
39
|
+
## Enforcement Responsibilities
|
|
40
|
+
|
|
41
|
+
Community leaders are responsible for clarifying and enforcing our standards of
|
|
42
|
+
acceptable behavior and will take appropriate and fair corrective action in
|
|
43
|
+
response to any behavior that they deem inappropriate, threatening, offensive,
|
|
44
|
+
or harmful.
|
|
45
|
+
|
|
46
|
+
Community leaders have the right and responsibility to remove, edit, or reject
|
|
47
|
+
comments, commits, code, wiki edits, issues, and other contributions that are
|
|
48
|
+
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
|
49
|
+
decisions when appropriate.
|
|
50
|
+
|
|
51
|
+
## Scope
|
|
52
|
+
|
|
53
|
+
This Code of Conduct applies within all community spaces, and also applies when
|
|
54
|
+
an individual is officially representing the community in public spaces.
|
|
55
|
+
Examples of representing our community include using an official email address,
|
|
56
|
+
posting via an official social media account, or acting as an appointed
|
|
57
|
+
representative at an online or offline event.
|
|
58
|
+
|
|
59
|
+
## Enforcement
|
|
60
|
+
|
|
61
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
62
|
+
reported to the community leaders responsible for enforcement at
|
|
63
|
+
[INSERT CONTACT METHOD].
|
|
64
|
+
All complaints will be reviewed and investigated promptly and fairly.
|
|
65
|
+
|
|
66
|
+
All community leaders are obligated to respect the privacy and security of the
|
|
67
|
+
reporter of any incident.
|
|
68
|
+
|
|
69
|
+
## Enforcement Guidelines
|
|
70
|
+
|
|
71
|
+
Community leaders will follow these Community Impact Guidelines in determining
|
|
72
|
+
the consequences for any action they deem in violation of this Code of Conduct:
|
|
73
|
+
|
|
74
|
+
### 1. Correction
|
|
75
|
+
|
|
76
|
+
**Community Impact**: Use of inappropriate language or other behavior deemed
|
|
77
|
+
unprofessional or unwelcome in the community.
|
|
78
|
+
|
|
79
|
+
**Consequence**: A private, written warning from community leaders, providing
|
|
80
|
+
clarity around the nature of the violation and an explanation of why the
|
|
81
|
+
behavior was inappropriate. A public apology may be requested.
|
|
82
|
+
|
|
83
|
+
### 2. Warning
|
|
84
|
+
|
|
85
|
+
**Community Impact**: A violation through a single incident or series of
|
|
86
|
+
actions.
|
|
87
|
+
|
|
88
|
+
**Consequence**: A warning with consequences for continued behavior. No
|
|
89
|
+
interaction with the people involved, including unsolicited interaction with
|
|
90
|
+
those enforcing the Code of Conduct, for a specified period of time. This
|
|
91
|
+
includes avoiding interactions in community spaces as well as external channels
|
|
92
|
+
like social media. Violating these terms may lead to a temporary or permanent
|
|
93
|
+
ban.
|
|
94
|
+
|
|
95
|
+
### 3. Temporary Ban
|
|
96
|
+
|
|
97
|
+
**Community Impact**: A serious violation of community standards, including
|
|
98
|
+
sustained inappropriate behavior.
|
|
99
|
+
|
|
100
|
+
**Consequence**: A temporary ban from any sort of interaction or public
|
|
101
|
+
communication with the community for a specified period of time. No public or
|
|
102
|
+
private interaction with the people involved, including unsolicited interaction
|
|
103
|
+
with those enforcing the Code of Conduct, is allowed during this period.
|
|
104
|
+
Violating these terms may lead to a permanent ban.
|
|
105
|
+
|
|
106
|
+
### 4. Permanent Ban
|
|
107
|
+
|
|
108
|
+
**Community Impact**: Demonstrating a pattern of violation of community
|
|
109
|
+
standards, including sustained inappropriate behavior, harassment of an
|
|
110
|
+
individual, or aggression toward or disparagement of classes of individuals.
|
|
111
|
+
|
|
112
|
+
**Consequence**: A permanent ban from any sort of public interaction within the
|
|
113
|
+
community.
|
|
114
|
+
|
|
115
|
+
## Attribution
|
|
116
|
+
|
|
117
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
|
118
|
+
version 2.1, available at
|
|
119
|
+
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
|
120
|
+
|
|
121
|
+
Community Impact Guidelines were inspired by
|
|
122
|
+
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
|
123
|
+
|
|
124
|
+
For answers to common questions about this code of conduct, see the FAQ at
|
|
125
|
+
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
|
126
|
+
[https://www.contributor-covenant.org/translations][translations].
|
|
127
|
+
|
|
128
|
+
[homepage]: https://www.contributor-covenant.org
|
|
129
|
+
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
|
130
|
+
[Mozilla CoC]: https://github.com/mozilla/diversity
|
|
131
|
+
[FAQ]: https://www.contributor-covenant.org/faq
|
|
132
|
+
[translations]: https://www.contributor-covenant.org/translations
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 John Nagro
|
|
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,222 @@
|
|
|
1
|
+
# IdempotencyLock
|
|
2
|
+
|
|
3
|
+
A simple, robust idempotency solution for Ruby on Rails applications. Ensures operations run exactly once using database-backed locks with support for TTL expiration, multiple error handling strategies, and clean return value handling.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "idempotency_lock"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then execute:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Generate the migration:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
rails generate idempotency_lock:install
|
|
23
|
+
rails db:migrate
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
### Basic Usage
|
|
29
|
+
|
|
30
|
+
Wrap any operation that should only run once:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
result = IdempotencyLock.once("send-welcome-email-user-#{user.id}") do
|
|
34
|
+
UserMailer.welcome(user).deliver_now
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if result.executed?
|
|
38
|
+
puts "Email sent!"
|
|
39
|
+
else
|
|
40
|
+
puts "Email already sent (skipped)"
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The `once` method returns a `Result` object that tells you what happened:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
result.executed? # true if the block ran
|
|
48
|
+
result.skipped? # true if skipped due to existing lock
|
|
49
|
+
result.success? # true if executed without error
|
|
50
|
+
result.error? # true if an error occurred
|
|
51
|
+
result.value # the return value of the block
|
|
52
|
+
result.error # the exception if one was raised
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### TTL / Expiration
|
|
56
|
+
|
|
57
|
+
Sometimes you want "run once per hour" instead of "run once forever":
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# Run daily report once per day
|
|
61
|
+
IdempotencyLock.once("daily-report", ttl: 24.hours) do
|
|
62
|
+
ReportGenerator.daily_summary
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Run cleanup once per hour
|
|
66
|
+
IdempotencyLock.once("hourly-cleanup", ttl: 1.hour) do
|
|
67
|
+
TempFile.cleanup_old
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# TTL also accepts integer seconds
|
|
71
|
+
IdempotencyLock.once("periodic-task", ttl: 3600) do
|
|
72
|
+
some_periodic_work
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Error Handling
|
|
77
|
+
|
|
78
|
+
Control what happens when an error occurs inside the block:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# :keep (default) - Keep the lock, don't allow retry
|
|
82
|
+
IdempotencyLock.once("risky-op", on_error: :keep) do
|
|
83
|
+
might_fail
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# :unlock - Release the lock so another process can retry
|
|
87
|
+
IdempotencyLock.once("retriable-op", on_error: :unlock) do
|
|
88
|
+
external_api_call
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# :raise - Re-raise the exception after releasing the lock
|
|
92
|
+
IdempotencyLock.once("critical-op", on_error: :raise) do
|
|
93
|
+
must_succeed_or_alert
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Custom handler with a Proc
|
|
97
|
+
IdempotencyLock.once("custom-error", on_error: ->(e) { Bugsnag.notify(e) }) do
|
|
98
|
+
something_important
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Manual Lock Management
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# Check if an operation is locked
|
|
106
|
+
IdempotencyLock.locked?("my-operation") # => true/false
|
|
107
|
+
|
|
108
|
+
# Release a lock manually (useful for testing or manual intervention)
|
|
109
|
+
IdempotencyLock.release("my-operation")
|
|
110
|
+
|
|
111
|
+
# Clean up all expired locks (good for a periodic job)
|
|
112
|
+
IdempotencyLock.cleanup_expired
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Alias
|
|
116
|
+
|
|
117
|
+
The `wrap` method is available as an alias for `once`:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
IdempotencyLock.wrap("operation-name") { do_something }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Use Cases
|
|
124
|
+
|
|
125
|
+
### Preventing Duplicate Webhook Processing
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
class WebhooksController < ApplicationController
|
|
129
|
+
def stripe
|
|
130
|
+
event_id = params[:id]
|
|
131
|
+
|
|
132
|
+
result = IdempotencyLock.once("stripe-webhook-#{event_id}") do
|
|
133
|
+
StripeWebhookProcessor.process(params)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
head :ok
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Ensuring One-Time Migrations
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
IdempotencyLock.once("backfill-user-preferences-2024-01") do
|
|
145
|
+
User.find_each do |user|
|
|
146
|
+
user.create_preferences! unless user.preferences.present?
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Rate-Limited Operations
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
# Only send one reminder per user per day
|
|
155
|
+
IdempotencyLock.once("reminder-user-#{user.id}", ttl: 24.hours) do
|
|
156
|
+
ReminderMailer.daily_reminder(user).deliver_later
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Preventing Duplicate Sidekiq Jobs
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
class ImportantJob
|
|
164
|
+
include Sidekiq::Job
|
|
165
|
+
|
|
166
|
+
def perform(record_id)
|
|
167
|
+
IdempotencyLock.once("important-job-#{record_id}", on_error: :unlock) do
|
|
168
|
+
# If this fails, another job can retry
|
|
169
|
+
ImportantService.process(record_id)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Database Schema
|
|
176
|
+
|
|
177
|
+
The gem creates an `idempotency_locks` table with:
|
|
178
|
+
|
|
179
|
+
| Column | Type | Description |
|
|
180
|
+
|--------|------|-------------|
|
|
181
|
+
| `name` | string (max 255) | Unique identifier for the lock |
|
|
182
|
+
| `expires_at` | datetime | When the lock expires (null = never) |
|
|
183
|
+
| `executed_at` | datetime | When the operation was executed |
|
|
184
|
+
| `created_at` | datetime | Standard Rails timestamp |
|
|
185
|
+
| `updated_at` | datetime | Standard Rails timestamp |
|
|
186
|
+
|
|
187
|
+
Indexes:
|
|
188
|
+
- Unique index on `name` (provides atomicity)
|
|
189
|
+
- Partial index on `expires_at` where not null (for efficient TTL queries)
|
|
190
|
+
|
|
191
|
+
**Note on name length:** Lock names are limited to 255 characters to ensure compatibility with database unique index limits. MySQL users with `utf8mb4` encoding may need to reduce this to 191 characters by modifying the migration before running it.
|
|
192
|
+
|
|
193
|
+
## How It Works
|
|
194
|
+
|
|
195
|
+
1. **Lock Acquisition**: Attempts to create a record with the given name. The unique index ensures only one process succeeds.
|
|
196
|
+
|
|
197
|
+
2. **TTL Support**: If a TTL is provided, the lock includes an `expires_at` timestamp. Expired locks can be claimed by new operations via an atomic UPDATE.
|
|
198
|
+
|
|
199
|
+
3. **Error Handling**: Different strategies control whether the lock is released on error, allowing for retry semantics.
|
|
200
|
+
|
|
201
|
+
4. **Return Values**: The block's return value is captured and returned in the `Result` object.
|
|
202
|
+
|
|
203
|
+
## Thread Safety
|
|
204
|
+
|
|
205
|
+
The gem relies on database-level uniqueness constraints for atomicity. This means:
|
|
206
|
+
|
|
207
|
+
- ✅ Safe across multiple processes (web servers, job workers)
|
|
208
|
+
- ✅ Safe across multiple machines
|
|
209
|
+
- ✅ Safe in multi-threaded environments
|
|
210
|
+
- ✅ Survives application restarts
|
|
211
|
+
|
|
212
|
+
## Development
|
|
213
|
+
|
|
214
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
|
|
215
|
+
|
|
216
|
+
## Contributing
|
|
217
|
+
|
|
218
|
+
Bug reports and pull requests are welcome on GitHub.
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
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,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module IdempotencyLock
|
|
7
|
+
module Generators
|
|
8
|
+
# Generator to create the idempotency_locks table migration.
|
|
9
|
+
# Run with: rails generate idempotency_lock:install
|
|
10
|
+
class InstallGenerator < Rails::Generators::Base
|
|
11
|
+
include ActiveRecord::Generators::Migration
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Creates the idempotency_locks table migration"
|
|
16
|
+
|
|
17
|
+
def create_migration_file
|
|
18
|
+
migration_template(
|
|
19
|
+
"create_idempotency_locks.rb.erb",
|
|
20
|
+
"db/migrate/create_idempotency_locks.rb"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateIdempotencyLocks < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :idempotency_locks do |t|
|
|
6
|
+
# Limit ensures compatibility with unique index on most databases.
|
|
7
|
+
# MySQL with utf8mb4 supports max 191 chars; adjust if needed.
|
|
8
|
+
t.string :name, null: false, limit: 255
|
|
9
|
+
t.datetime :expires_at
|
|
10
|
+
t.datetime :executed_at
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :idempotency_locks, :name, unique: true
|
|
16
|
+
add_index :idempotency_locks, :expires_at, where: "expires_at IS NOT NULL"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IdempotencyLock
|
|
4
|
+
# ActiveRecord model for storing idempotency locks.
|
|
5
|
+
#
|
|
6
|
+
# This model expects a database table with the following schema:
|
|
7
|
+
# - name: string (unique, indexed, max 255 chars)
|
|
8
|
+
# - expires_at: datetime (nullable, indexed)
|
|
9
|
+
# - executed_at: datetime
|
|
10
|
+
# - created_at: datetime
|
|
11
|
+
# - updated_at: datetime
|
|
12
|
+
#
|
|
13
|
+
# Use the provided generator to create the migration:
|
|
14
|
+
# rails generate idempotency_lock:install
|
|
15
|
+
#
|
|
16
|
+
class Lock < ActiveRecord::Base
|
|
17
|
+
self.table_name = "idempotency_locks"
|
|
18
|
+
|
|
19
|
+
# Maximum length for lock names. Ensures compatibility with database
|
|
20
|
+
# unique index limits (MySQL utf8mb4 = 191, most others = 255+).
|
|
21
|
+
MAX_NAME_LENGTH = 255
|
|
22
|
+
|
|
23
|
+
validates :name, presence: true, uniqueness: true, length: { maximum: MAX_NAME_LENGTH }
|
|
24
|
+
|
|
25
|
+
# Check if this lock has expired
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def expired?
|
|
28
|
+
expires_at.present? && expires_at < Time.current
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Check if this lock is still valid (not expired)
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def valid_lock?
|
|
34
|
+
expires_at.nil? || expires_at >= Time.current
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Attempt to acquire a lock for the given name.
|
|
39
|
+
# Returns true if the lock was acquired, false otherwise.
|
|
40
|
+
#
|
|
41
|
+
# @param name [String] unique identifier for the operation
|
|
42
|
+
# @param expires_at [Time, nil] when the lock should expire (nil = never)
|
|
43
|
+
# @param now [Time] current time reference
|
|
44
|
+
# @return [Boolean] true if lock was acquired
|
|
45
|
+
def acquire(name, expires_at: nil, now: Time.current)
|
|
46
|
+
# Try to insert first (most common case for new keys)
|
|
47
|
+
begin
|
|
48
|
+
create!(name: name, expires_at: expires_at, executed_at: now)
|
|
49
|
+
return true
|
|
50
|
+
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
|
|
51
|
+
# Lock exists, check if it's expired
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Atomic update: claim the lock only if it's expired
|
|
55
|
+
# Returns number of rows updated (0 or 1)
|
|
56
|
+
updated = where(name: name)
|
|
57
|
+
.where("expires_at IS NOT NULL AND expires_at < ?", now)
|
|
58
|
+
.update_all(expires_at: expires_at, executed_at: now, updated_at: now)
|
|
59
|
+
|
|
60
|
+
updated.positive?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Release a lock by name (removes the record entirely)
|
|
64
|
+
#
|
|
65
|
+
# @param name [String] unique identifier for the operation
|
|
66
|
+
# @return [Boolean] true if a lock was removed
|
|
67
|
+
def release(name)
|
|
68
|
+
deleted = where(name: name).delete_all
|
|
69
|
+
deleted.positive?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Check if a valid (non-expired) lock exists for the given name
|
|
73
|
+
#
|
|
74
|
+
# @param name [String] unique identifier for the operation
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def locked?(name)
|
|
77
|
+
lock = find_by(name: name)
|
|
78
|
+
return false if lock.nil?
|
|
79
|
+
|
|
80
|
+
lock.valid_lock?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Clean up expired locks
|
|
84
|
+
#
|
|
85
|
+
# @return [Integer] number of locks removed
|
|
86
|
+
def cleanup_expired
|
|
87
|
+
where("expires_at IS NOT NULL AND expires_at < ?", Time.current).delete_all
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IdempotencyLock
|
|
4
|
+
# Rails integration for IdempotencyLock.
|
|
5
|
+
# Automatically configures the logger and loads generators.
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
generators do
|
|
8
|
+
require_relative "../generators/idempotency_lock/install_generator"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "idempotency_lock.configure" do
|
|
12
|
+
IdempotencyLock.logger = Rails.logger
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IdempotencyLock
|
|
4
|
+
# Represents the result of an idempotency operation.
|
|
5
|
+
#
|
|
6
|
+
# @example Checking if the block was executed
|
|
7
|
+
# result = IdempotencyLock.once("my-operation") { expensive_work }
|
|
8
|
+
# if result.executed?
|
|
9
|
+
# puts "Operation ran, got: #{result.value}"
|
|
10
|
+
# else
|
|
11
|
+
# puts "Operation was skipped (already ran)"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
class Result
|
|
15
|
+
attr_reader :value, :error
|
|
16
|
+
|
|
17
|
+
def initialize(executed:, value: nil, skipped: false, error: nil)
|
|
18
|
+
@executed = executed
|
|
19
|
+
@value = value
|
|
20
|
+
@skipped = skipped
|
|
21
|
+
@error = error
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Boolean] true if the block was executed
|
|
25
|
+
def executed?
|
|
26
|
+
@executed
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Boolean] true if the operation was skipped due to existing lock
|
|
30
|
+
def skipped?
|
|
31
|
+
@skipped
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] true if an error occurred during execution
|
|
35
|
+
def error?
|
|
36
|
+
!@error.nil?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] true if executed successfully without error
|
|
40
|
+
def success?
|
|
41
|
+
@executed && !error?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "idempotency_lock/version"
|
|
4
|
+
require_relative "idempotency_lock/result"
|
|
5
|
+
require_relative "idempotency_lock/lock"
|
|
6
|
+
|
|
7
|
+
# Database-backed idempotency locks for Ruby on Rails.
|
|
8
|
+
#
|
|
9
|
+
# Ensures operations run exactly once using database-backed locks with support
|
|
10
|
+
# for TTL expiration, multiple error handling strategies, and clean return
|
|
11
|
+
# value handling.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# result = IdempotencyLock.once("send-welcome-email-user-123") do
|
|
15
|
+
# UserMailer.welcome(user).deliver_now
|
|
16
|
+
# end
|
|
17
|
+
# puts "Email sent!" if result.executed?
|
|
18
|
+
#
|
|
19
|
+
# @example With TTL
|
|
20
|
+
# IdempotencyLock.once("daily-report", ttl: 24.hours) { generate_report }
|
|
21
|
+
#
|
|
22
|
+
module IdempotencyLock
|
|
23
|
+
class Error < StandardError; end
|
|
24
|
+
|
|
25
|
+
# Error handling strategies
|
|
26
|
+
ON_ERROR_UNLOCK = :unlock # Remove lock so operation can be retried
|
|
27
|
+
ON_ERROR_KEEP_LOCKED = :keep # Keep lock in place (default)
|
|
28
|
+
ON_ERROR_RAISE = :raise # Re-raise after cleanup/logging
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Execute a block exactly once for the given operation name.
|
|
32
|
+
#
|
|
33
|
+
# @param name [String] unique identifier for this operation
|
|
34
|
+
# @param ttl [ActiveSupport::Duration, Integer, nil] time-to-live for the lock
|
|
35
|
+
# @param on_error [Symbol, Proc] error handling strategy (:keep, :unlock, :raise, or Proc)
|
|
36
|
+
# @yield The block to execute (exactly once per lock name)
|
|
37
|
+
# @return [Result] containing execution status and return value
|
|
38
|
+
def once(name, ttl: nil, on_error: ON_ERROR_KEEP_LOCKED, &)
|
|
39
|
+
raise ArgumentError, "Block required" unless block_given?
|
|
40
|
+
|
|
41
|
+
now = Time.current
|
|
42
|
+
expires_at = calculate_expires_at(ttl, now)
|
|
43
|
+
acquired = Lock.acquire(name, expires_at: expires_at, now: now)
|
|
44
|
+
|
|
45
|
+
unless acquired
|
|
46
|
+
log_debug("Lock already exists for '#{name}', skipping execution")
|
|
47
|
+
return Result.new(executed: false, skipped: true)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
log_debug("Lock acquired for '#{name}', executing block")
|
|
51
|
+
execute_with_lock(name, on_error, &)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Convenience alias for `once`
|
|
55
|
+
alias wrap once
|
|
56
|
+
|
|
57
|
+
# Release a lock manually (useful for testing or manual intervention)
|
|
58
|
+
#
|
|
59
|
+
# @param name [String] the lock name to release
|
|
60
|
+
# @return [Boolean] true if a lock was released
|
|
61
|
+
def release(name)
|
|
62
|
+
Lock.release(name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check if an operation is currently locked
|
|
66
|
+
#
|
|
67
|
+
# @param name [String] the lock name to check
|
|
68
|
+
# @return [Boolean] true if locked and not expired
|
|
69
|
+
def locked?(name)
|
|
70
|
+
Lock.locked?(name)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Clean up all expired locks
|
|
74
|
+
#
|
|
75
|
+
# @return [Integer] number of locks cleaned up
|
|
76
|
+
def cleanup_expired
|
|
77
|
+
Lock.cleanup_expired
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Configure the logger (uses Rails.logger by default if available)
|
|
81
|
+
attr_writer :logger
|
|
82
|
+
|
|
83
|
+
def logger
|
|
84
|
+
@logger ||= defined?(Rails) ? Rails.logger : nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def calculate_expires_at(ttl, now)
|
|
90
|
+
return nil if ttl.nil?
|
|
91
|
+
|
|
92
|
+
if ttl.respond_to?(:from_now)
|
|
93
|
+
# ActiveSupport::Duration
|
|
94
|
+
now + ttl
|
|
95
|
+
else
|
|
96
|
+
# Assume seconds
|
|
97
|
+
now + ttl.to_i
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def execute_with_lock(name, on_error)
|
|
102
|
+
value = yield
|
|
103
|
+
Result.new(executed: true, value: value)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
handle_error(name, e, on_error)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def handle_error(name, exception, on_error)
|
|
109
|
+
log_error("Exception in idempotent block '#{name}': #{exception.class} - #{exception.message}")
|
|
110
|
+
|
|
111
|
+
case on_error
|
|
112
|
+
when :unlock then handle_unlock_error(name, exception)
|
|
113
|
+
when :raise then handle_raise_error(name, exception)
|
|
114
|
+
when :keep, ON_ERROR_KEEP_LOCKED then handle_keep_error(name, exception)
|
|
115
|
+
when Proc then handle_proc_error(name, exception, on_error)
|
|
116
|
+
else Result.new(executed: true, error: exception)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def handle_unlock_error(name, exception)
|
|
121
|
+
Lock.release(name)
|
|
122
|
+
log_debug("Lock released for '#{name}' due to error (on_error: :unlock)")
|
|
123
|
+
Result.new(executed: true, error: exception)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def handle_raise_error(name, exception)
|
|
127
|
+
Lock.release(name)
|
|
128
|
+
log_debug("Lock released for '#{name}', re-raising error (on_error: :raise)")
|
|
129
|
+
raise exception
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def handle_keep_error(name, exception)
|
|
133
|
+
log_debug("Lock kept for '#{name}' despite error (on_error: :keep)")
|
|
134
|
+
Result.new(executed: true, error: exception)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def handle_proc_error(name, exception, handler)
|
|
138
|
+
Lock.release(name)
|
|
139
|
+
handler.call(exception)
|
|
140
|
+
Result.new(executed: true, error: exception)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def log_debug(message)
|
|
144
|
+
logger&.debug("[IdempotencyLock] #{message}")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def log_error(message)
|
|
148
|
+
logger&.error("[IdempotencyLock] #{message}")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Load Rails integration if Rails is present
|
|
154
|
+
require_relative "idempotency_lock/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: idempotency_lock
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- John Nagro
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.0'
|
|
40
|
+
description: |
|
|
41
|
+
A simple, robust idempotency solution for Rails applications. Ensures operations
|
|
42
|
+
run exactly once using database-backed locks with support for TTL expiration,
|
|
43
|
+
multiple error handling strategies, and clean return value handling.
|
|
44
|
+
email:
|
|
45
|
+
- john@noreastergroup.com
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- CODE_OF_CONDUCT.md
|
|
52
|
+
- LICENSE.txt
|
|
53
|
+
- README.md
|
|
54
|
+
- Rakefile
|
|
55
|
+
- lib/generators/idempotency_lock/install_generator.rb
|
|
56
|
+
- lib/generators/idempotency_lock/templates/create_idempotency_locks.rb.erb
|
|
57
|
+
- lib/idempotency_lock.rb
|
|
58
|
+
- lib/idempotency_lock/lock.rb
|
|
59
|
+
- lib/idempotency_lock/railtie.rb
|
|
60
|
+
- lib/idempotency_lock/result.rb
|
|
61
|
+
- lib/idempotency_lock/version.rb
|
|
62
|
+
- sig/idempotency_lock.rbs
|
|
63
|
+
homepage: https://github.com/noreastergroup/idempotency_lock
|
|
64
|
+
licenses:
|
|
65
|
+
- MIT
|
|
66
|
+
metadata:
|
|
67
|
+
rubygems_mfa_required: 'true'
|
|
68
|
+
homepage_uri: https://github.com/noreastergroup/idempotency_lock
|
|
69
|
+
source_code_uri: https://github.com/noreastergroup/idempotency_lock
|
|
70
|
+
changelog_uri: https://github.com/noreastergroup/idempotency_lock/blob/main/CHANGELOG.md
|
|
71
|
+
rdoc_options: []
|
|
72
|
+
require_paths:
|
|
73
|
+
- lib
|
|
74
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: 3.2.0
|
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '0'
|
|
84
|
+
requirements: []
|
|
85
|
+
rubygems_version: 3.6.9
|
|
86
|
+
specification_version: 4
|
|
87
|
+
summary: Database-backed idempotency locks for Ruby on Rails
|
|
88
|
+
test_files: []
|