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 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.