idempo 1.1.0 → 1.2.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 +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.
|