idempo 1.1.0 → 1.2.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -3
- data/CHANGELOG.md +17 -4
- data/README.md +6 -3
- data/idempo.gemspec +1 -2
- data/lib/idempo/active_record_backend.rb +20 -9
- data/lib/idempo/concurrent_request_error_app.rb +0 -2
- data/lib/idempo/malformed_key_error_app.rb +0 -2
- data/lib/idempo/memory_backend.rb +4 -20
- data/lib/idempo/memory_lock.rb +21 -0
- data/lib/idempo/version.rb +1 -1
- data/lib/idempo.rb +24 -8
- metadata +14 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 70c151d3823c956aac177dbf39ee4e5f216b9464a739b134c8a47822a01aa765
|
4
|
+
data.tar.gz: 4306e629c9d0f1bd59ad74bc6e61623e441e4713b88bccb8f69c08e8553dc251
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5e80a42f84a311e633697123f7213af21b0f79d501937d5963e4dd918eb5cabd5d01174d781b0528d4f51923fc9eb038af20afeb164c8cdd51d2cc1983ea1111
|
7
|
+
data.tar.gz: 4d2ee3d361d009aacc23c42ec38323c9b9373d874c9abbd70596882dbd48d6ed4edcf9eb5c32f44e366aa091e178dfcd71c919b45d6bc1b8c17804094b23629f
|
data/.github/workflows/ci.yml
CHANGED
@@ -15,7 +15,7 @@ jobs:
|
|
15
15
|
strategy:
|
16
16
|
matrix:
|
17
17
|
ruby:
|
18
|
-
-
|
18
|
+
- "2.7"
|
19
19
|
steps:
|
20
20
|
- name: Checkout
|
21
21
|
uses: actions/checkout@v4
|
@@ -37,11 +37,16 @@ jobs:
|
|
37
37
|
name: Specs
|
38
38
|
runs-on: ubuntu-22.04
|
39
39
|
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
|
40
|
+
env:
|
41
|
+
IDEMPO_RACK_VERSION: ${{ matrix.rack }}
|
40
42
|
strategy:
|
41
43
|
matrix:
|
42
44
|
ruby:
|
43
|
-
-
|
44
|
-
-
|
45
|
+
- "2.7"
|
46
|
+
- "3.2"
|
47
|
+
rack:
|
48
|
+
- "2.0"
|
49
|
+
- "3.0"
|
45
50
|
services:
|
46
51
|
mysql:
|
47
52
|
image: mysql:5.7
|
@@ -65,6 +70,7 @@ jobs:
|
|
65
70
|
steps:
|
66
71
|
- name: Checkout
|
67
72
|
uses: actions/checkout@v4
|
73
|
+
|
68
74
|
- name: Setup Ruby
|
69
75
|
uses: ruby/setup-ruby@v1
|
70
76
|
with:
|
data/CHANGELOG.md
CHANGED
@@ -1,4 +1,17 @@
|
|
1
|
-
##
|
1
|
+
## 1.2.2
|
2
|
+
|
3
|
+
- Support `#to_ary` on Rack response bodies on newer Rails/Rack versions
|
4
|
+
|
5
|
+
## 1.2.1
|
6
|
+
|
7
|
+
- Use autoloading for internal modules. A user using Redis does not have to load the ActiveRecord storage backend, for example
|
8
|
+
- Ensure that the original Rack response body receives a `close` when reading out for caching
|
9
|
+
|
10
|
+
## 1.2.0
|
11
|
+
|
12
|
+
- Use memory locking in addition to DB locking in `ActiveRecordBackend`
|
13
|
+
|
14
|
+
## 1.1.0
|
2
15
|
|
3
16
|
- Use modern ActiveRecord migration options for better Rails 7.x compatibility
|
4
17
|
- Ensure Github actions CI can run and uses Postgres appropriately
|
@@ -6,16 +19,16 @@
|
|
6
19
|
- Implement `#prune!` on storage backends
|
7
20
|
- Reformat all code using [standard](https://github.com/standardrb/standard) instead of wetransfer_style as it is both more relaxed and more modern
|
8
21
|
|
9
|
-
##
|
22
|
+
## 1.0.0
|
10
23
|
|
11
24
|
- Release 1.0 as the API can be considered stable and the gem has been in production for years
|
12
25
|
|
13
|
-
##
|
26
|
+
## 0.2.0
|
14
27
|
|
15
28
|
- Allow setting the global default TTL for the cached responses
|
16
29
|
- Allow customisation of the request key computation (so that the client can decide whether to include/exclude `Authorization` and the like)
|
17
30
|
- Extract the error response generating apps into separate modules, to make them easier to override
|
18
31
|
|
19
|
-
##
|
32
|
+
## 0.1.0
|
20
33
|
|
21
34
|
- Initial release
|
data/README.md
CHANGED
@@ -10,7 +10,8 @@ instead of calling your application.
|
|
10
10
|
Idempo supports a number of backends, we recommend using Redis if you have multiple application servers / dynos and MemoryBackend if you are only using one single Puma worker. To initialize with Redis as backend pass the `backend:` parameter when adding the middleware:
|
11
11
|
|
12
12
|
```ruby
|
13
|
-
|
13
|
+
be = Idempo::RedisBackend.new(Rails.application.config.redis_connection_pool)
|
14
|
+
use Idempo, backend: be
|
14
15
|
```
|
15
16
|
|
16
17
|
and to initialize with a memory store as backend:
|
@@ -65,7 +66,8 @@ end
|
|
65
66
|
Then configure Idempo to use the backend (in your `application.rb`):
|
66
67
|
|
67
68
|
```ruby
|
68
|
-
|
69
|
+
be = Idempo::ActiveRecordBackend.new
|
70
|
+
config.middleware.insert Idempo, backend: be
|
69
71
|
```
|
70
72
|
|
71
73
|
In your regular tasks (cron or Rake) you will want to add a call to delete old Idempo responses (there is an index on `expire_at`):
|
@@ -87,7 +89,8 @@ use Idempo, backend: Idempo::RedisBackend.new
|
|
87
89
|
If you have a configured Redis connection pool (and you should) - pass it to the initializer:
|
88
90
|
|
89
91
|
```ruby
|
90
|
-
|
92
|
+
be = Idempo::RedisBackend.new(config.redis_connection_pool)
|
93
|
+
config.middleware.insert Idempo, backend: be
|
91
94
|
```
|
92
95
|
|
93
96
|
All data stored in Redis will have TTLs and will expire automatically. Redis scripts ensure that updates to the stored idempotent responses and locking happen atomically.
|
data/idempo.gemspec
CHANGED
@@ -35,11 +35,10 @@ Gem::Specification.new do |spec|
|
|
35
35
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
36
36
|
spec.require_paths = ["lib"]
|
37
37
|
|
38
|
-
# Uncomment to register a new dependency of your gem
|
39
|
-
spec.add_dependency "rack"
|
40
38
|
spec.add_dependency "msgpack"
|
41
39
|
spec.add_dependency "measurometer", "~> 1.3"
|
42
40
|
|
41
|
+
spec.add_development_dependency "rack", "~> 3"
|
43
42
|
spec.add_development_dependency "rake", "~> 13.0"
|
44
43
|
spec.add_development_dependency "rspec", "~> 3.0"
|
45
44
|
spec.add_development_dependency "redis", "~> 4"
|
@@ -60,6 +60,7 @@ class Idempo::ActiveRecordBackend
|
|
60
60
|
|
61
61
|
def initialize
|
62
62
|
require "active_record"
|
63
|
+
@memory_lock = Idempo::MemoryLock.new
|
63
64
|
end
|
64
65
|
|
65
66
|
# Allows the model to be defined lazily without having to require active_record when this module gets loaded
|
@@ -70,15 +71,25 @@ class Idempo::ActiveRecordBackend
|
|
70
71
|
end
|
71
72
|
|
72
73
|
def with_idempotency_key(request_key)
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
74
|
+
# We need to use an in-memory lock because database advisory locks are
|
75
|
+
# reentrant. Both Postgres and MySQL allow multiple acquisitions of the
|
76
|
+
# same advisory lock within the same connection - in most Rails/Rack apps
|
77
|
+
# this translates to "within the same thread". This means that if one
|
78
|
+
# elects to use a non-threading webserver (like Falcon), or tests Idempo
|
79
|
+
# within the same thread (like we do), they won't get advisory locking
|
80
|
+
# for concurrent requests. Therefore a staged lock is required. First we apply
|
81
|
+
# the memory lock (for same thread on this process/multiple threads on this
|
82
|
+
# process) and then once we have that - the DB lock.
|
83
|
+
@memory_lock.with(request_key) do
|
84
|
+
db_safe_key = Digest::SHA1.base64digest(request_key)
|
85
|
+
database_lock = lock_implementation_for_connection(model.connection)
|
86
|
+
raise Idempo::ConcurrentRequest unless database_lock.acquire(model.connection, request_key)
|
87
|
+
|
88
|
+
begin
|
89
|
+
yield(Store.new(db_safe_key, model))
|
90
|
+
ensure
|
91
|
+
database_lock.release(model.connection, request_key)
|
92
|
+
end
|
82
93
|
end
|
83
94
|
end
|
84
95
|
|
@@ -1,12 +1,9 @@
|
|
1
1
|
class Idempo::MemoryBackend
|
2
2
|
def initialize
|
3
|
-
require "set"
|
4
3
|
require_relative "response_store"
|
5
|
-
|
6
|
-
@requests_in_flight_mutex = Mutex.new
|
7
|
-
@in_progress = Set.new
|
8
|
-
@store_mutex = Mutex.new
|
4
|
+
@lock = Idempo::MemoryLock.new
|
9
5
|
@response_store = Idempo::ResponseStore.new
|
6
|
+
@store_mutex = Mutex.new
|
10
7
|
end
|
11
8
|
|
12
9
|
class Store < Struct.new(:store_mutex, :response_store, :key, keyword_init: true)
|
@@ -24,22 +21,9 @@ class Idempo::MemoryBackend
|
|
24
21
|
end
|
25
22
|
|
26
23
|
def with_idempotency_key(request_key)
|
27
|
-
|
28
|
-
|
29
|
-
false
|
30
|
-
else
|
31
|
-
@in_progress << request_key
|
32
|
-
true
|
33
|
-
end
|
34
|
-
end
|
35
|
-
|
36
|
-
raise Idempo::ConcurrentRequest unless did_insert
|
37
|
-
|
38
|
-
store = Store.new(store_mutex: @store_mutex, response_store: @response_store, key: request_key)
|
39
|
-
begin
|
24
|
+
@lock.with(request_key) do
|
25
|
+
store = Store.new(store_mutex: @store_mutex, response_store: @response_store, key: request_key)
|
40
26
|
yield(store)
|
41
|
-
ensure
|
42
|
-
@requests_in_flight_mutex.synchronize { @in_progress.delete(request_key) }
|
43
27
|
end
|
44
28
|
end
|
45
29
|
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# A memory lock prevents multiple requests with the same request
|
2
|
+
# fingerprint from running concurrently
|
3
|
+
class Idempo::MemoryLock
|
4
|
+
def initialize
|
5
|
+
@requests_in_flight_mutex = Mutex.new
|
6
|
+
@in_progress = Set.new
|
7
|
+
end
|
8
|
+
|
9
|
+
def with(request_key)
|
10
|
+
@requests_in_flight_mutex.synchronize do
|
11
|
+
if @in_progress.include?(request_key)
|
12
|
+
raise Idempo::ConcurrentRequest
|
13
|
+
else
|
14
|
+
@in_progress << request_key
|
15
|
+
end
|
16
|
+
end
|
17
|
+
yield
|
18
|
+
ensure
|
19
|
+
@requests_in_flight_mutex.synchronize { @in_progress.delete(request_key) }
|
20
|
+
end
|
21
|
+
end
|
data/lib/idempo/version.rb
CHANGED
data/lib/idempo.rb
CHANGED
@@ -6,16 +6,20 @@ require "json"
|
|
6
6
|
require "measurometer"
|
7
7
|
require "msgpack"
|
8
8
|
require "zlib"
|
9
|
+
require "set"
|
10
|
+
require "rack"
|
9
11
|
|
10
|
-
|
11
|
-
require_relative "idempo/concurrent_request_error_app"
|
12
|
-
require_relative "idempo/malformed_key_error_app"
|
13
|
-
require_relative "idempo/memory_backend"
|
14
|
-
require_relative "idempo/redis_backend"
|
15
|
-
require_relative "idempo/request_fingerprint"
|
16
|
-
require_relative "idempo/version"
|
12
|
+
require "idempo/version"
|
17
13
|
|
18
14
|
class Idempo
|
15
|
+
autoload :ConcurrentRequestErrorApp, "idempo/concurrent_request_error_app"
|
16
|
+
autoload :MalformedKeyErrorApp, "idempo/malformed_key_error_app"
|
17
|
+
autoload :MemoryBackend, "idempo/memory_backend"
|
18
|
+
autoload :RedisBackend, "idempo/redis_backend"
|
19
|
+
autoload :ActiveRecordBackend, "idempo/active_record_backend"
|
20
|
+
autoload :RequestFingerprint, "idempo/request_fingerprint"
|
21
|
+
autoload :MemoryLock, "idempo/memory_lock"
|
22
|
+
|
19
23
|
DEFAULT_TTL_SECONDS = 30
|
20
24
|
SAVED_RESPONSE_BODY_SIZE_LIMIT = 4 * 1024 * 1024
|
21
25
|
|
@@ -54,6 +58,12 @@ class Idempo
|
|
54
58
|
status, headers, body = @app.call(env)
|
55
59
|
|
56
60
|
expires_in_seconds = (headers.delete("X-Idempo-Persist-For-Seconds") || @persist_for_seconds).to_i
|
61
|
+
|
62
|
+
# In some cases `body` could respond to to_ary. In this case, we don't need to call .close on body afterwards.
|
63
|
+
#
|
64
|
+
# @see https://github.com/rack/rack/blob/main/SPEC.rdoc#the-body-
|
65
|
+
body = body.to_ary if rack_v3? && body.respond_to?(:to_ary)
|
66
|
+
|
57
67
|
if response_may_be_persisted?(status, headers, body)
|
58
68
|
# Body is replaced with a cached version since a Rack response body is not rewindable
|
59
69
|
marshaled_response, body = serialize_response(status, headers, body)
|
@@ -73,6 +83,10 @@ class Idempo
|
|
73
83
|
|
74
84
|
private
|
75
85
|
|
86
|
+
def rack_v3?
|
87
|
+
Gem::Version.new(Rack.release) >= Gem::Version.new("3.0")
|
88
|
+
end
|
89
|
+
|
76
90
|
def from_persisted_response(marshaled_response)
|
77
91
|
if marshaled_response[-2..] != ":1"
|
78
92
|
raise Error, "Unknown serialization of the marshaled response"
|
@@ -85,7 +99,6 @@ class Idempo
|
|
85
99
|
# Buffer the Rack response body, we can only do that once (it is non-rewindable)
|
86
100
|
body_chunks = []
|
87
101
|
rack_response_body.each { |chunk| body_chunks << chunk.dup }
|
88
|
-
rack_response_body.close if rack_response_body.respond_to?(:close)
|
89
102
|
|
90
103
|
# Only keep headers which are strings
|
91
104
|
stringified_headers = headers.each_with_object({}) do |(header, value), filtered|
|
@@ -101,6 +114,9 @@ class Idempo
|
|
101
114
|
# (when we unserialize our response again) does a realloc, while slicing at the start
|
102
115
|
# does not
|
103
116
|
[deflated_message_packed_str, body_chunks]
|
117
|
+
ensure
|
118
|
+
# This will not be applied to response bodies of Array type.
|
119
|
+
rack_response_body.close if rack_response_body.respond_to?(:close)
|
104
120
|
end
|
105
121
|
|
106
122
|
def response_may_be_persisted?(status, headers, body)
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: idempo
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.2.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Julik Tarkhanov
|
@@ -9,10 +9,10 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: exe
|
11
11
|
cert_chain: []
|
12
|
-
date: 2024-
|
12
|
+
date: 2024-09-20 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
|
-
name:
|
15
|
+
name: msgpack
|
16
16
|
requirement: !ruby/object:Gem::Requirement
|
17
17
|
requirements:
|
18
18
|
- - ">="
|
@@ -26,33 +26,33 @@ dependencies:
|
|
26
26
|
- !ruby/object:Gem::Version
|
27
27
|
version: '0'
|
28
28
|
- !ruby/object:Gem::Dependency
|
29
|
-
name:
|
29
|
+
name: measurometer
|
30
30
|
requirement: !ruby/object:Gem::Requirement
|
31
31
|
requirements:
|
32
|
-
- - "
|
32
|
+
- - "~>"
|
33
33
|
- !ruby/object:Gem::Version
|
34
|
-
version: '
|
34
|
+
version: '1.3'
|
35
35
|
type: :runtime
|
36
36
|
prerelease: false
|
37
37
|
version_requirements: !ruby/object:Gem::Requirement
|
38
38
|
requirements:
|
39
|
-
- - "
|
39
|
+
- - "~>"
|
40
40
|
- !ruby/object:Gem::Version
|
41
|
-
version: '
|
41
|
+
version: '1.3'
|
42
42
|
- !ruby/object:Gem::Dependency
|
43
|
-
name:
|
43
|
+
name: rack
|
44
44
|
requirement: !ruby/object:Gem::Requirement
|
45
45
|
requirements:
|
46
46
|
- - "~>"
|
47
47
|
- !ruby/object:Gem::Version
|
48
|
-
version: '
|
49
|
-
type: :
|
48
|
+
version: '3'
|
49
|
+
type: :development
|
50
50
|
prerelease: false
|
51
51
|
version_requirements: !ruby/object:Gem::Requirement
|
52
52
|
requirements:
|
53
53
|
- - "~>"
|
54
54
|
- !ruby/object:Gem::Version
|
55
|
-
version: '
|
55
|
+
version: '3'
|
56
56
|
- !ruby/object:Gem::Dependency
|
57
57
|
name: rake
|
58
58
|
requirement: !ruby/object:Gem::Requirement
|
@@ -192,6 +192,7 @@ files:
|
|
192
192
|
- lib/idempo/concurrent_request_error_app.rb
|
193
193
|
- lib/idempo/malformed_key_error_app.rb
|
194
194
|
- lib/idempo/memory_backend.rb
|
195
|
+
- lib/idempo/memory_lock.rb
|
195
196
|
- lib/idempo/redis_backend.rb
|
196
197
|
- lib/idempo/request_fingerprint.rb
|
197
198
|
- lib/idempo/response_store.rb
|
@@ -219,7 +220,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
219
220
|
- !ruby/object:Gem::Version
|
220
221
|
version: '0'
|
221
222
|
requirements: []
|
222
|
-
rubygems_version: 3.
|
223
|
+
rubygems_version: 3.3.7
|
223
224
|
signing_key:
|
224
225
|
specification_version: 4
|
225
226
|
summary: Idempotency keys for all.
|