idempotency 0.1.2 → 0.1.4
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/release.yml +12 -6
- data/CHANGELOG.md +16 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +10 -1
- data/README.md +41 -1
- data/idempotency.gemspec +1 -0
- data/lib/idempotency/constants.rb +8 -0
- data/lib/idempotency/hanami.rb +2 -2
- data/lib/idempotency/instrumentation/statsd_listener.rb +43 -0
- data/lib/idempotency/rails.rb +2 -2
- data/lib/idempotency/testing/helpers.rb +29 -0
- data/lib/idempotency/version.rb +1 -1
- data/lib/idempotency.rb +47 -3
- metadata +18 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '00195248b99a69659167bae866e907aec9f44421d918bef44722ee79619692e7'
|
4
|
+
data.tar.gz: 17855572f61d3c2edca50a49b316dcd6a6dc5deaeb9067edeaf3005cb150b8bd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 96fd4a1711a56f0a1dd2e3397e35eadc3b205bec1fa6e1dc9bf07a51258c5e511d262832fa13f364759394e16f9eb40cf4a63f73ecc8b30f247cf4e30ff6b2e6
|
7
|
+
data.tar.gz: 4eb3cba2a688b36aae230a8a024d12626556829ad3959264fd19abd29b10cf7a181bdbcc3906b67e30938a470c633ab00ebb1d31b3eb114076f8771a43985fef
|
@@ -8,10 +8,16 @@ on:
|
|
8
8
|
jobs:
|
9
9
|
build:
|
10
10
|
runs-on: ubuntu-latest
|
11
|
+
|
12
|
+
permissions:
|
13
|
+
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
14
|
+
contents: write # IMPORTANT: this permission is required for `rake release` to push the release tag
|
15
|
+
|
11
16
|
steps:
|
12
|
-
- uses: actions/checkout@
|
13
|
-
- name:
|
14
|
-
uses:
|
15
|
-
|
16
|
-
|
17
|
-
|
17
|
+
- uses: actions/checkout@v4
|
18
|
+
- name: Set up Ruby
|
19
|
+
uses: ruby/setup-ruby@v1
|
20
|
+
with:
|
21
|
+
bundler-cache: true
|
22
|
+
ruby-version: '3.4'
|
23
|
+
- uses: rubygems/release-gem@v1
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,21 @@
|
|
1
1
|
## [Change Log]
|
2
2
|
|
3
|
+
## [0.1.4] - 2025-01-14
|
4
|
+
|
5
|
+
- Support metrics logging via StatsdListener
|
6
|
+
|
7
|
+
## [0.1.3] - 2025-01-14
|
8
|
+
|
9
|
+
- Support testing by mocking MockRedis#evalsha
|
10
|
+
|
11
|
+
## [0.1.2] - 2025-01-07
|
12
|
+
|
13
|
+
- Support auto-releasing gem
|
14
|
+
|
15
|
+
## [0.1.1] - 2025-01-07
|
16
|
+
|
17
|
+
- Allow configuring idempotent methods instead of hardcoding POST
|
18
|
+
|
3
19
|
## [0.1.0] - 2024-11-13
|
4
20
|
|
5
21
|
- Initial release
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,9 +1,10 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
idempotency (0.1.
|
4
|
+
idempotency (0.1.4)
|
5
5
|
base64
|
6
6
|
dry-configurable
|
7
|
+
dry-monitor
|
7
8
|
msgpack
|
8
9
|
redis
|
9
10
|
|
@@ -24,6 +25,13 @@ GEM
|
|
24
25
|
concurrent-ruby (~> 1.0)
|
25
26
|
logger
|
26
27
|
zeitwerk (~> 2.6)
|
28
|
+
dry-events (1.0.1)
|
29
|
+
concurrent-ruby (~> 1.0)
|
30
|
+
dry-core (~> 1.0, < 2)
|
31
|
+
dry-monitor (1.0.1)
|
32
|
+
dry-configurable (~> 1.0, < 2)
|
33
|
+
dry-core (~> 1.0, < 2)
|
34
|
+
dry-events (~> 1.0, < 2)
|
27
35
|
hanami-controller (1.3.3)
|
28
36
|
hanami-utils (~> 1.3)
|
29
37
|
rack (~> 2.0)
|
@@ -90,6 +98,7 @@ PLATFORMS
|
|
90
98
|
|
91
99
|
DEPENDENCIES
|
92
100
|
connection_pool
|
101
|
+
dry-monitor
|
93
102
|
hanami-controller (~> 1.3)
|
94
103
|
idempotency!
|
95
104
|
mock_redis
|
data/README.md
CHANGED
@@ -28,7 +28,14 @@ Idempotency.configure do |config|
|
|
28
28
|
}
|
29
29
|
|
30
30
|
config.idempotent_methods = %w[POST PUT PATCH]
|
31
|
-
config.idempotent_statuses = (200..299).to_a
|
31
|
+
config.idempotent_statuses = (200..299).to_a + (400..499).to_a
|
32
|
+
|
33
|
+
# Metrics configuration
|
34
|
+
config.metrics.statsd_client = statsd_client # Your StatsD client instance
|
35
|
+
config.metrics.namespace = 'my_service_name' # Optional namespace for metrics
|
36
|
+
|
37
|
+
# Custom instrumentation listeners (optional)
|
38
|
+
config.instrumentation_listeners = [my_custom_listener] # Array of custom listeners
|
32
39
|
end
|
33
40
|
```
|
34
41
|
|
@@ -89,3 +96,36 @@ end
|
|
89
96
|
|
90
97
|
# Render your response
|
91
98
|
```
|
99
|
+
|
100
|
+
### Testing
|
101
|
+
|
102
|
+
For those using `mock_redis` gem, some methods that `idempotency` gem uses are not implemented (e.g. eval, evalsha), and this could cause test cases to fail. To get around this, the gem has a monkeypatch over `mock_redis` gem to override the missing methods. To use it, simply add following lines to your `spec_helper.rb`:
|
103
|
+
|
104
|
+
```ruby
|
105
|
+
RSpec.configure do |config|
|
106
|
+
config.include Idempotency::Testing::Helpers
|
107
|
+
end
|
108
|
+
```
|
109
|
+
|
110
|
+
### Instrumentation
|
111
|
+
|
112
|
+
The gem supports instrumentation through StatsD out of the box. When you configure a StatsD client in the configuration, the StatsdListener will be automatically set up. It tracks the following metrics:
|
113
|
+
|
114
|
+
- `idempotency_cache_hit_count` - Incremented when a cached response is found
|
115
|
+
- `idempotency_cache_miss_count` - Incremented when no cached response exists
|
116
|
+
- `idempotency_lock_conflict_count` - Incremented when concurrent requests conflict
|
117
|
+
- `idempotency_cache_duration_seconds` - Histogram of operation duration
|
118
|
+
|
119
|
+
Each metric includes tags:
|
120
|
+
- `action` - Either the specified action name or `"{HTTP_METHOD}:{PATH}"`
|
121
|
+
- `namespace` - Your configured namespace (if provided)
|
122
|
+
- `metric` - The metric name (for duration histogram only)
|
123
|
+
|
124
|
+
To enable StatsD instrumentation, simply configure the metrics settings:
|
125
|
+
|
126
|
+
```ruby
|
127
|
+
Idempotency.configure do |config|
|
128
|
+
config.metrics.statsd_client = Datadog::Statsd.new
|
129
|
+
config.metrics.namespace = 'my_service_name'
|
130
|
+
end
|
131
|
+
```
|
data/idempotency.gemspec
CHANGED
data/lib/idempotency/hanami.rb
CHANGED
@@ -4,9 +4,9 @@ require_relative '../idempotency'
|
|
4
4
|
|
5
5
|
class Idempotency
|
6
6
|
module Hanami
|
7
|
-
def use_cache(request_identifiers = [], lock_duration: nil)
|
7
|
+
def use_cache(request_identifiers = [], lock_duration: nil, action: self.class.name)
|
8
8
|
response_status, response_headers, response_body = Idempotency.use_cache(
|
9
|
-
request, request_identifiers, lock_duration:
|
9
|
+
request, request_identifiers, lock_duration:, action:
|
10
10
|
) do
|
11
11
|
yield
|
12
12
|
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../idempotency'
|
4
|
+
|
5
|
+
class Idempotency
|
6
|
+
module Instrumentation
|
7
|
+
class StatsdListener
|
8
|
+
EVENT_NAME_TO_METRIC_MAPPINGS = {
|
9
|
+
Events::CACHE_HIT => 'idempotency_cache_hit_count',
|
10
|
+
Events::CACHE_MISS => 'idempotency_cache_miss_count',
|
11
|
+
Events::LOCK_CONFLICT => 'idempotency_lock_conflict_count'
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
def initialize(statsd_client, namespace = nil)
|
15
|
+
@statsd_client = statsd_client
|
16
|
+
@namespace = namespace
|
17
|
+
end
|
18
|
+
|
19
|
+
def setup_subscriptions
|
20
|
+
EVENT_NAME_TO_METRIC_MAPPINGS.each do |event_name, metric|
|
21
|
+
Idempotency.notifier.subscribe(event_name) do |event|
|
22
|
+
send_metric(metric, event.payload)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
attr_reader :namespace, :statsd_client
|
30
|
+
|
31
|
+
def send_metric(metric_name, event_data)
|
32
|
+
action = event_data[:action] || "#{event_data[:request].request_method}:#{event_data[:request].path}"
|
33
|
+
tags = ["action:#{action}"]
|
34
|
+
tags << "namespace:#{@namespace}" if @namespace
|
35
|
+
|
36
|
+
@statsd_client.increment(metric_name, tags:)
|
37
|
+
@statsd_client.histogram(
|
38
|
+
'idempotency_cache_duration_seconds', event_data[:duration], tags: tags + ["metric:#{metric_name}"]
|
39
|
+
)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
data/lib/idempotency/rails.rb
CHANGED
@@ -4,9 +4,9 @@ require_relative '../idempotency'
|
|
4
4
|
|
5
5
|
class Idempotency
|
6
6
|
module Rails
|
7
|
-
def use_cache(request_identifiers = [], lock_duration: nil)
|
7
|
+
def use_cache(request_identifiers = [], lock_duration: nil, action: "#{controller_name}##{action_name}")
|
8
8
|
response_status, response_headers, response_body = Idempotency.use_cache(
|
9
|
-
request, request_identifiers, lock_duration:
|
9
|
+
request, request_identifiers, lock_duration:, action:
|
10
10
|
) do
|
11
11
|
yield
|
12
12
|
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'idempotency/cache'
|
4
|
+
|
5
|
+
class Idempotency
|
6
|
+
module Testing
|
7
|
+
module Helpers
|
8
|
+
def self.included(_base)
|
9
|
+
return unless defined?(MockRedis)
|
10
|
+
|
11
|
+
MockRedis.class_eval do
|
12
|
+
def evalsha(sha, keys:, argv:)
|
13
|
+
return unless sha == Idempotency::Cache::COMPARE_AND_DEL_SCRIPT_SHA
|
14
|
+
|
15
|
+
value = argv[0]
|
16
|
+
cached_value = get(keys[0])
|
17
|
+
|
18
|
+
if value == cached_value
|
19
|
+
del(keys[0])
|
20
|
+
value
|
21
|
+
else
|
22
|
+
cached_value
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/idempotency/version.rb
CHANGED
data/lib/idempotency.rb
CHANGED
@@ -5,12 +5,29 @@ require 'json'
|
|
5
5
|
require 'base64'
|
6
6
|
require_relative 'idempotency/cache'
|
7
7
|
require_relative 'idempotency/constants'
|
8
|
+
require_relative 'idempotency/instrumentation/statsd_listener'
|
9
|
+
require 'dry-monitor'
|
8
10
|
|
9
11
|
class Idempotency
|
10
12
|
extend Dry::Configurable
|
13
|
+
@monitor = Monitor.new
|
14
|
+
|
15
|
+
def self.notifier
|
16
|
+
@monitor.synchronize do
|
17
|
+
@notifier ||= Dry::Monitor::Notifications.new(:idempotency_gem).tap do |n|
|
18
|
+
Events::ALL_EVENTS.each { |event| n.register_event(event) }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
11
22
|
|
12
23
|
setting :redis_pool
|
13
24
|
setting :logger
|
25
|
+
setting :instrumentation_listeners, default: []
|
26
|
+
setting :metrics do
|
27
|
+
setting :namespace
|
28
|
+
setting :statsd_client
|
29
|
+
end
|
30
|
+
|
14
31
|
setting :default_lock_expiry, default: 300 # 5 minutes
|
15
32
|
setting :idempotent_methods, default: %w[POST PUT PATCH DELETE]
|
16
33
|
setting :idempotent_statuses, default: (200..299).to_a + (400..499).to_a
|
@@ -21,16 +38,31 @@ class Idempotency
|
|
21
38
|
}.to_json
|
22
39
|
end
|
23
40
|
|
41
|
+
def self.configure
|
42
|
+
super
|
43
|
+
|
44
|
+
if config.metrics.statsd_client
|
45
|
+
config.instrumentation_listeners << Idempotency::Instrumentation::StatsdListener.new(
|
46
|
+
config.metrics.statsd_client,
|
47
|
+
config.metrics.namespace
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
config.instrumentation_listeners.each(&:setup_subscriptions)
|
52
|
+
end
|
53
|
+
|
24
54
|
def initialize(config: Idempotency.config, cache: Cache.new(config:))
|
25
55
|
@config = config
|
26
56
|
@cache = cache
|
27
57
|
end
|
28
58
|
|
29
|
-
def self.use_cache(request, request_identifiers, lock_duration: nil, &blk)
|
30
|
-
new.use_cache(request, request_identifiers, lock_duration:, &blk)
|
59
|
+
def self.use_cache(request, request_identifiers, lock_duration: nil, action: nil, &blk)
|
60
|
+
new.use_cache(request, request_identifiers, lock_duration:, action:, &blk)
|
31
61
|
end
|
32
62
|
|
33
|
-
def use_cache(request, request_identifiers, lock_duration:) # rubocop:disable Metrics/AbcSize
|
63
|
+
def use_cache(request, request_identifiers, lock_duration: nil, action: nil) # rubocop:disable Metrics/AbcSize
|
64
|
+
duration_start = Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
65
|
+
|
34
66
|
return yield unless cache_request?(request)
|
35
67
|
|
36
68
|
request_headers = request.env
|
@@ -42,6 +74,8 @@ class Idempotency
|
|
42
74
|
|
43
75
|
if (cached_status, cached_headers, cached_body = cached_response)
|
44
76
|
cached_headers.merge!(Constants::HEADER_KEY => idempotency_key)
|
77
|
+
instrument(Events::CACHE_HIT, request:, action:, duration: calculate_duration(duration_start))
|
78
|
+
|
45
79
|
return [cached_status, cached_headers, cached_body]
|
46
80
|
end
|
47
81
|
|
@@ -55,8 +89,10 @@ class Idempotency
|
|
55
89
|
response_headers.merge!({ Constants::HEADER_KEY => idempotency_key })
|
56
90
|
end
|
57
91
|
|
92
|
+
instrument(Events::CACHE_MISS, request:, action:, duration: calculate_duration(duration_start))
|
58
93
|
[response_status, response_headers, response_body]
|
59
94
|
rescue Idempotency::Cache::LockConflict
|
95
|
+
instrument(Events::LOCK_CONFLICT, request:, action:, duration: calculate_duration(duration_start))
|
60
96
|
[409, {}, config.response_body.concurrent_error]
|
61
97
|
end
|
62
98
|
|
@@ -64,6 +100,14 @@ class Idempotency
|
|
64
100
|
|
65
101
|
attr_reader :config, :cache
|
66
102
|
|
103
|
+
def instrument(event_name, **metadata)
|
104
|
+
Idempotency.notifier.instrument(event_name, **metadata)
|
105
|
+
end
|
106
|
+
|
107
|
+
def calculate_duration(start_time)
|
108
|
+
Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time
|
109
|
+
end
|
110
|
+
|
67
111
|
def calculate_fingerprint(request, idempotency_key, request_identifiers)
|
68
112
|
d = Digest::SHA256.new
|
69
113
|
d << idempotency_key
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: idempotency
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Vu Hoang
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-01-
|
11
|
+
date: 2025-01-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: base64
|
@@ -38,6 +38,20 @@ dependencies:
|
|
38
38
|
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: dry-monitor
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
41
55
|
- !ruby/object:Gem::Dependency
|
42
56
|
name: msgpack
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -90,7 +104,9 @@ files:
|
|
90
104
|
- lib/idempotency/cache.rb
|
91
105
|
- lib/idempotency/constants.rb
|
92
106
|
- lib/idempotency/hanami.rb
|
107
|
+
- lib/idempotency/instrumentation/statsd_listener.rb
|
93
108
|
- lib/idempotency/rails.rb
|
109
|
+
- lib/idempotency/testing/helpers.rb
|
94
110
|
- lib/idempotency/version.rb
|
95
111
|
homepage: https://www.ascenda.com
|
96
112
|
licenses:
|