idempotency 0.1.2 → 0.1.4

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: 59512a5938bf9884bd46436ff18078a74c9ed2aafb45ca0c491d1f51fa2f42d7
4
- data.tar.gz: 4965a8b09bc2fa3d2627f79c90f4241ec192a05e7119f2003de8b55f67d394bf
3
+ metadata.gz: '00195248b99a69659167bae866e907aec9f44421d918bef44722ee79619692e7'
4
+ data.tar.gz: 17855572f61d3c2edca50a49b316dcd6a6dc5deaeb9067edeaf3005cb150b8bd
5
5
  SHA512:
6
- metadata.gz: 025e20c6912c741e0994d9119d76f67d261a39c4ea8864eb0840e78dc23baf9e0b6454c0dbac31e459f5530465e79fd4823352ee0a717228d3d09386e04d889f
7
- data.tar.gz: 55151ebed4cf89c7ac6e3444439d8c371d31b366e209fe6d17a87ac74ac1bad76921a0dc24f6502b7c5fa7be0e6679f8c22e67e836db5673249ffe7417675216
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@v3
13
- - name: Release Gem
14
- uses: cadwallion/publish-rubygems-action@master
15
- env:
16
- GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
17
- RUBYGEMS_API_KEY: ${{secrets.RUBYGEMS_API_KEY}}
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
@@ -9,6 +9,7 @@ gem 'mock_redis'
9
9
  gem 'rspec', '~> 3.0'
10
10
 
11
11
  gem 'connection_pool'
12
+ gem 'dry-monitor'
12
13
  gem 'hanami-controller', '~> 1.3'
13
14
  gem 'pry-byebug'
14
15
  gem 'rubocop', '~> 1.21'
data/Gemfile.lock CHANGED
@@ -1,9 +1,10 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- idempotency (0.1.2)
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
@@ -36,6 +36,7 @@ Gem::Specification.new do |spec|
36
36
 
37
37
  spec.add_dependency 'base64'
38
38
  spec.add_dependency 'dry-configurable'
39
+ spec.add_dependency 'dry-monitor'
39
40
  spec.add_dependency 'msgpack'
40
41
  spec.add_dependency 'redis'
41
42
  end
@@ -5,4 +5,12 @@ class Idempotency
5
5
  RACK_HEADER_KEY = 'HTTP_IDEMPOTENCY_KEY'
6
6
  HEADER_KEY = 'Idempotency-Key'
7
7
  end
8
+
9
+ module Events
10
+ ALL_EVENTS = [
11
+ CACHE_HIT = :cache_hit,
12
+ CACHE_MISS = :cache_miss,
13
+ LOCK_CONFLICT = :lock_conflict
14
+ ].freeze
15
+ end
8
16
  end
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Idempotency
4
- VERSION = '0.1.2'
4
+ VERSION = '0.1.4'
5
5
  end
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.2
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-07 00:00:00.000000000 Z
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: