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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9bfbbd03a37973e1309f1c9d3a0aa1032cbfdfaae73f78f275a7fd9800e5e9ed
4
- data.tar.gz: 54e1bb92e23d7003e7d4205e0964e94ab7288d342f2032fd4f7f2a280a2341f1
3
+ metadata.gz: 70c151d3823c956aac177dbf39ee4e5f216b9464a739b134c8a47822a01aa765
4
+ data.tar.gz: 4306e629c9d0f1bd59ad74bc6e61623e441e4713b88bccb8f69c08e8553dc251
5
5
  SHA512:
6
- metadata.gz: 9be3229d0844358a342b5fcc65a885e96f0ae7ae73ebcfe91b74046ed7ddc3e90d836d2fbe047f22434f9bd15beda36d5f0d0eb5bf4f8f597996fa7006a00101
7
- data.tar.gz: a9933869e79cd56229907bafe38b8c135357eb1e3b9ff8954e51f74f4a171b33dee8e5322a9178782c95e4686d96104d9ff4fbc4d801b5ff12066591cd68cd7b
6
+ metadata.gz: 5e80a42f84a311e633697123f7213af21b0f79d501937d5963e4dd918eb5cabd5d01174d781b0528d4f51923fc9eb038af20afeb164c8cdd51d2cc1983ea1111
7
+ data.tar.gz: 4d2ee3d361d009aacc23c42ec38323c9b9373d874c9abbd70596882dbd48d6ed4edcf9eb5c32f44e366aa091e178dfcd71c919b45d6bc1b8c17804094b23629f
@@ -15,7 +15,7 @@ jobs:
15
15
  strategy:
16
16
  matrix:
17
17
  ruby:
18
- - '2.7'
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
- - '2.7'
44
- - '3.2'
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.0] - 2024-02-22
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
- ## [1.0.0] - 2023-10-27
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
- ## [0.2.0] - 2022-04-08
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
- ## [0.1.0] - 2021-10-14
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
- use Idempo, backend: Idempo::RedisBackend.new(Rails.application.config.redis_connection_pool)
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
- config.middleware.insert Idempo, backend: Idempo::ActiveRecordBackend.new
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
- config.middleware.insert Idempo, backend: Idempo::RedisBackend.new(config.redis_connection_pool)
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
- db_safe_key = Digest::SHA1.base64digest(request_key)
74
- lock = lock_implementation_for_connection(model.connection)
75
-
76
- raise Idempo::ConcurrentRequest unless lock.acquire(model.connection, request_key)
77
-
78
- begin
79
- yield(Store.new(db_safe_key, model))
80
- ensure
81
- lock.release(model.connection, request_key)
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,5 +1,3 @@
1
- require "json"
2
-
3
1
  class Idempo::ConcurrentRequestErrorApp
4
2
  RETRY_AFTER_SECONDS = 2.to_s
5
3
 
@@ -1,5 +1,3 @@
1
- require "json"
2
-
3
1
  class Idempo::MalformedKeyErrorApp
4
2
  def self.call(env)
5
3
  res = {
@@ -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
- did_insert = @requests_in_flight_mutex.synchronize do
28
- if @in_progress.include?(request_key)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Idempo
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.2"
5
5
  end
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
- require_relative "idempo/active_record_backend"
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.1.0
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-02-22 00:00:00.000000000 Z
12
+ date: 2024-09-20 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
- name: rack
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: msgpack
29
+ name: measurometer
30
30
  requirement: !ruby/object:Gem::Requirement
31
31
  requirements:
32
- - - ">="
32
+ - - "~>"
33
33
  - !ruby/object:Gem::Version
34
- version: '0'
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: '0'
41
+ version: '1.3'
42
42
  - !ruby/object:Gem::Dependency
43
- name: measurometer
43
+ name: rack
44
44
  requirement: !ruby/object:Gem::Requirement
45
45
  requirements:
46
46
  - - "~>"
47
47
  - !ruby/object:Gem::Version
48
- version: '1.3'
49
- type: :runtime
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: '1.3'
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.1.6
223
+ rubygems_version: 3.3.7
223
224
  signing_key:
224
225
  specification_version: 4
225
226
  summary: Idempotency keys for all.