idempotency_lock 0.1.0 → 0.1.2
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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 88b6cc80e37f3db005c446a96005520945b141ef45f1ff4d90863975de52ed45
|
|
4
|
+
data.tar.gz: 43adf42af7496edc68cf46590fc2d09d585214c44dcd57487d0c290161f4af72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 989d09faf1a493cf14ee9426c4ffa67275a761e5784072164a0b3e24f9f3202a0424308f9621cace94e2245707ae25d936b96349a8798dfd5bb2b38656529b7c
|
|
7
|
+
data.tar.gz: '084463272e767a8579dde4848d1a386645119404d65c608ee5d2960940963e877dbeaa3ed4c481679e6d5e14811cdeabeee2427a906571e2db9ea1366baf7344'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.1.2] - 2025-12-06
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Migration now works with MySQL (uses regular index instead of partial index,
|
|
8
|
+
which MySQL doesn't support)
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `Result#inspect` for easier debugging
|
|
13
|
+
|
|
14
|
+
## [0.1.1] - 2025-12-06
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Use optimistic locking when claiming expired locks to prevent race conditions
|
|
19
|
+
where two processes with clock skew could both claim the same lock
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `now` parameter is now injectable in `Lock.acquire`, `expired?`, and `valid_lock?` for testing
|
|
24
|
+
|
|
3
25
|
## [0.1.0] - 2025-12-06
|
|
4
26
|
|
|
5
27
|
### Added
|
data/README.md
CHANGED
|
@@ -186,7 +186,7 @@ The gem creates an `idempotency_locks` table with:
|
|
|
186
186
|
|
|
187
187
|
Indexes:
|
|
188
188
|
- Unique index on `name` (provides atomicity)
|
|
189
|
-
-
|
|
189
|
+
- Index on `expires_at` (partial index on PostgreSQL/SQLite, regular index on MySQL)
|
|
190
190
|
|
|
191
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
192
|
|
|
@@ -13,7 +13,14 @@ class CreateIdempotencyLocks < ActiveRecord::Migration<%= migration_version %>
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
add_index :idempotency_locks, :name, unique: true
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
# Partial indexes are more efficient but not supported by MySQL.
|
|
18
|
+
# Use a regular index for MySQL, partial index for PostgreSQL/SQLite.
|
|
19
|
+
if ActiveRecord::Base.connection.adapter_name.downcase.include?("mysql")
|
|
20
|
+
add_index :idempotency_locks, :expires_at
|
|
21
|
+
else
|
|
22
|
+
add_index :idempotency_locks, :expires_at, where: "expires_at IS NOT NULL"
|
|
23
|
+
end
|
|
17
24
|
end
|
|
18
25
|
end
|
|
19
26
|
|
|
@@ -24,14 +24,14 @@ module IdempotencyLock
|
|
|
24
24
|
|
|
25
25
|
# Check if this lock has expired
|
|
26
26
|
# @return [Boolean]
|
|
27
|
-
def expired?
|
|
28
|
-
expires_at.present? && expires_at <
|
|
27
|
+
def expired?(now: Time.current)
|
|
28
|
+
expires_at.present? && expires_at < now
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
# Check if this lock is still valid (not expired)
|
|
32
32
|
# @return [Boolean]
|
|
33
|
-
def valid_lock?
|
|
34
|
-
expires_at.nil? || expires_at >=
|
|
33
|
+
def valid_lock?(now: Time.current)
|
|
34
|
+
expires_at.nil? || expires_at >= now
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
class << self
|
|
@@ -40,7 +40,7 @@ module IdempotencyLock
|
|
|
40
40
|
#
|
|
41
41
|
# @param name [String] unique identifier for the operation
|
|
42
42
|
# @param expires_at [Time, nil] when the lock should expire (nil = never)
|
|
43
|
-
# @param now [Time] current time reference
|
|
43
|
+
# @param now [Time] current time reference (injectable for testing)
|
|
44
44
|
# @return [Boolean] true if lock was acquired
|
|
45
45
|
def acquire(name, expires_at: nil, now: Time.current)
|
|
46
46
|
# Try to insert first (most common case for new keys)
|
|
@@ -51,10 +51,17 @@ module IdempotencyLock
|
|
|
51
51
|
# Lock exists, check if it's expired
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
# Check if an existing lock is expired and can be claimed
|
|
55
|
+
existing = find_by(name: name)
|
|
56
|
+
return false if existing.nil?
|
|
57
|
+
return false unless existing.expired?(now: now)
|
|
58
|
+
|
|
59
|
+
# Optimistic locking: use the observed expires_at in the WHERE clause.
|
|
60
|
+
# This prevents a race where two processes both see an expired lock and
|
|
61
|
+
# both try to claim it. Only one UPDATE will match; the other returns 0.
|
|
62
|
+
# This is safer than `WHERE expires_at < now` which could allow a second
|
|
63
|
+
# process to steal a lock if the first used a short TTL.
|
|
64
|
+
updated = where(name: name, expires_at: existing.expires_at)
|
|
58
65
|
.update_all(expires_at: expires_at, executed_at: now, updated_at: now)
|
|
59
66
|
|
|
60
67
|
updated.positive?
|
|
@@ -40,5 +40,14 @@ module IdempotencyLock
|
|
|
40
40
|
def success?
|
|
41
41
|
@executed && !error?
|
|
42
42
|
end
|
|
43
|
+
|
|
44
|
+
# @return [String] human-readable representation for debugging
|
|
45
|
+
def inspect
|
|
46
|
+
parts = ["executed=#{@executed}"]
|
|
47
|
+
parts << "skipped=#{@skipped}" if @skipped
|
|
48
|
+
parts << "value=#{@value.inspect}" if @value
|
|
49
|
+
parts << "error=#{@error.class}" if @error
|
|
50
|
+
"#<IdempotencyLock::Result #{parts.join(" ")}>"
|
|
51
|
+
end
|
|
43
52
|
end
|
|
44
53
|
end
|