abmeter 0.2.2 → 0.3.0

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: 9942da1ef58cb41f5dd91e2bf4324394a393b9c5b8846c361389227c60b54c86
4
- data.tar.gz: 8be692e8ecf838466c7a1def6efbdae4b20af23105257b41b44b693fcf846dfe
3
+ metadata.gz: 2d4ea3bdc7addec399a8644a3d8d2ee8f0329b4d85b117a56f4df51c58e04816
4
+ data.tar.gz: b03e52a1fe306fe68b7dd63879ff067e59f358bdf885d4a8905e86d743ba282d
5
5
  SHA512:
6
- metadata.gz: 129b61ca52cee9e0d281d9be91813a2ecc96e7044124c3e1f0f21aeedd3b35a8ee089d03ad0a176cf94bee730d7beeb9c4ee3484c48bf30cfb3a1cedf6e4be19
7
- data.tar.gz: c0601c6055cb0767e812890d1830429667c143d8a8a6d21f3928832c6a8bb3a99c1063db6a20ca6c26d49b6d104922fa1539660cfd17ea7e15aec1a56e089e3e
6
+ metadata.gz: 18f1020d7926f3e7026441877010f730ef7ffc06078ad738a7be0121c9399465d3b056e39b6d0133f97d32fced489bec9a6f26c9d99f4f817e223b8927260eaf
7
+ data.tar.gz: 9952d1a56ed63530d25200b21d88b0aaf3aec868752b72c46029e5905af5ff20af1eb45c472323afa20251694949ba9df0ff611d13a1633d0a110546d1aa41a5
data/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  A simple A/B testing client library for Ruby applications.
4
4
 
5
+ > **Breaking change in 0.3.0:** `ABMeter.reset!` now discards queued data without any network I/O; use `ABMeter.reset` for the previous drain-on-exit behavior.
6
+
7
+ ## Supported Ruby versions
8
+
9
+ `abmeter` supports **Ruby 3.2 and newer**.
10
+
5
11
  ## Installation
6
12
 
7
13
  Add this line to your application's Gemfile:
@@ -34,6 +40,32 @@ user = ABMeter.user(id: current_user.id, email: current_user.email)
34
40
  ABMeter.event(`user_purchases_plan`, user, {plan: purchased_plan.name, price: purchased_plan.price})
35
41
  ```
36
42
 
43
+ ## Shutdown
44
+
45
+ ```ruby
46
+ # Graceful — bounded blocking, flushes pending exposures/events.
47
+ # Returns true on clean shutdown, false if the timeout elapsed.
48
+ ABMeter.reset(timeout: 5.0)
49
+
50
+ # Immediate — non-blocking, discards anything still queued.
51
+ # Use only when the process is exiting *right now* and you cannot afford I/O.
52
+ ABMeter.reset!
53
+ ```
54
+
55
+ ## Development
56
+
57
+ The gem uses [mise](https://mise.jdx.dev/) to pin Ruby (`mise.toml`). Pure-Ruby — no Postgres / Redis required.
58
+
59
+ ```bash
60
+ brew install mise # one-time
61
+ mise install # one-time: install pinned Ruby
62
+ bundle install
63
+ bundle exec rspec # tests
64
+ bundle exec rubocop # lint (if .rubocop.yml present)
65
+ ```
66
+
67
+ The gem is tested against Ruby 3.2, 3.3, 3.4, and 4.0.
68
+
37
69
  ## License
38
70
 
39
71
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -4,6 +4,7 @@ module ABMeter
4
4
  BATCH_SIZE = 100
5
5
  MAX_SUBMIT_ATTEMPTS = 3
6
6
  MAX_RETRY_QUEUE_SIZE = 1000
7
+ DEFAULT_SHUTDOWN_TIMEOUT = 5.0 # seconds — max time a graceful shutdown blocks before killing the worker
7
8
 
8
9
  @queue = Queue.new
9
10
  @retry_queue = []
@@ -12,6 +13,9 @@ module ABMeter
12
13
  @worker_thread = nil
13
14
  @flush_interval = DEFAULT_FLUSH_INTERVAL
14
15
  @logger = nil
16
+ @stopping = false
17
+ @stop_mutex = Mutex.new
18
+ @stop_signal = ConditionVariable.new
15
19
 
16
20
  class << self
17
21
  attr_reader :api_client, :flush_interval, :logger, :retry_queue
@@ -63,20 +67,47 @@ module ABMeter
63
67
  end
64
68
  end
65
69
 
66
- def shutdown
70
+ # Graceful, lossless, bounded blocking. Signals the worker to stop, which
71
+ # triggers a final flush of the main and retry queues, then waits up to
72
+ # `timeout` seconds for that flush to finish and the worker to exit. Kills
73
+ # the worker (dropping anything still unflushed) if the timeout elapses.
74
+ # Returns true on clean shutdown, false on timeout.
75
+ def shutdown(timeout: DEFAULT_SHUTDOWN_TIMEOUT)
76
+ worker = @worker_thread
77
+ return true unless worker
78
+
79
+ request_stop
80
+ joined = worker.join(timeout)
81
+ if joined.nil?
82
+ worker.kill
83
+ worker.join
84
+ log_error("Graceful shutdown timed out after #{timeout}s, killing worker (queued: #{@queue.size}, retry: #{@retry_queue.size})")
85
+ end
86
+ !joined.nil?
87
+ end
88
+
89
+ # Immediate, lossy, non-blocking. Kills the worker and discards queued
90
+ # items without any network I/O.
91
+ def shutdown!
67
92
  @worker_thread&.kill
68
- # Flush all remaining exposures
69
- flush until @queue.empty?
93
+ dropped_queue = @queue.size
94
+ dropped_retry = @retry_queue.size
95
+ if dropped_queue.positive? || dropped_retry.positive?
96
+ log_error("Immediate shutdown, dropping items (dropped_queue: #{dropped_queue}, dropped_retry: #{dropped_retry})")
97
+ end
98
+ true
99
+ end
100
+
101
+ def reset(timeout: DEFAULT_SHUTDOWN_TIMEOUT)
102
+ result = shutdown(timeout: timeout)
103
+ clear_state
104
+ result
70
105
  end
71
106
 
72
107
  def reset!
73
- shutdown
74
- @queue = Queue.new
75
- @retry_queue = []
76
- @api_client = nil
77
- @worker_thread = nil
78
- @flush_interval = DEFAULT_FLUSH_INTERVAL
79
- @logger = nil
108
+ shutdown!
109
+ clear_state
110
+ true
80
111
  end
81
112
 
82
113
  def worker_alive?
@@ -92,17 +123,53 @@ module ABMeter
92
123
  def start_worker
93
124
  return if worker_alive?
94
125
 
126
+ @stopping = false
95
127
  @worker_thread = Thread.new do
96
- loop do
97
- sleep @flush_interval
98
- flush
128
+ until @stopping
129
+ begin
130
+ wait_for_next_flush
131
+ flush unless @stopping
132
+ rescue StandardError => e
133
+ # Log error but keep worker running
134
+ log_error("Worker error: #{e.message}")
135
+ end
136
+ end
137
+ # Final drain on graceful stop — bounded by the caller's join timeout.
138
+ begin
139
+ flush until @queue.empty? && @retry_queue.empty?
99
140
  rescue StandardError => e
100
- # Log error but keep worker running
101
- log_error("Worker error: #{e.message}")
141
+ log_error("Final flush error: #{e.message}")
102
142
  end
103
143
  end
104
144
  end
105
145
 
146
+ # Sleeps for @flush_interval, waking immediately when request_stop
147
+ # signals. Re-checking @stopping under @stop_mutex closes the
148
+ # lost-wakeup window between the worker loop's flag check and the wait
149
+ # (a bare sleep + Thread#wakeup would drop a signal sent in that gap).
150
+ def wait_for_next_flush
151
+ @stop_mutex.synchronize do
152
+ @stop_signal.wait(@stop_mutex, @flush_interval) unless @stopping
153
+ end
154
+ end
155
+
156
+ def request_stop
157
+ @stop_mutex.synchronize do
158
+ @stopping = true
159
+ @stop_signal.broadcast
160
+ end
161
+ end
162
+
163
+ def clear_state
164
+ @queue = Queue.new
165
+ @retry_queue = []
166
+ @api_client = nil
167
+ @worker_thread = nil
168
+ @flush_interval = DEFAULT_FLUSH_INTERVAL
169
+ @logger = nil
170
+ @stopping = false
171
+ end
172
+
106
173
  def submit_exposures(exposures)
107
174
  submit_batch(:exposure, exposures) { |exposures| @api_client.submit_exposures(exposures) }
108
175
  end
@@ -8,7 +8,9 @@ module ABMeter
8
8
 
9
9
  attr_reader :id, :range, :audience_variants, :space_id, :salt, :space_salt
10
10
 
11
- def initialize(id:, range:, audience_variants:, space_id:, salt:, space_salt:)
11
+ # The six keyword args are this value object's assignment inputs; a
12
+ # params object would only add indirection for a plain data holder.
13
+ def initialize(id:, range:, audience_variants:, space_id:, salt:, space_salt:) # rubocop:disable Metrics/ParameterLists
12
14
  @id = id
13
15
  @range = range
14
16
  @audience_variants = audience_variants
@@ -41,6 +43,7 @@ module ABMeter
41
43
  {
42
44
  id: id,
43
45
  space_id: space_id,
46
+ salt: salt,
44
47
  range: [range.begin, range.end],
45
48
  audience_variants: audience_variants.map do |audience_variant|
46
49
  {
@@ -16,7 +16,7 @@ module ABMeter
16
16
  end
17
17
 
18
18
  def self.from_json(variant)
19
- parameter_values = variant[:parameter_values].map { |pv| [pv[:slug], pv[:value]] }.to_h
19
+ parameter_values = variant[:parameter_values].to_h { |pv| [pv[:slug], pv[:value]] }
20
20
  Variant.new(id: variant[:id], parameter_values: parameter_values)
21
21
  end
22
22
 
@@ -33,7 +33,7 @@ module ABMeter
33
33
  # Formula: (num * range) >> bits = (num * 100) >> 64
34
34
  # This maps [0, 2^64) uniformly to [0, 100)
35
35
  percentage = (num * 100) >> 64
36
-
36
+
37
37
  # Add 1 to get 1-100 range instead of 0-99
38
38
  percentage + 1
39
39
  end
data/lib/abmeter/core.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ostruct'
4
3
  require_relative 'version'
5
4
 
6
5
  # Utils
@@ -1,3 +1,3 @@
1
1
  module ABMeter
2
- VERSION = '0.2.2'.freeze
2
+ VERSION = '0.3.0'.freeze
3
3
  end
data/lib/abmeter.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  require 'active_support/core_ext/hash/indifferent_access'
2
2
  require 'digest'
3
- require 'ostruct'
4
3
 
5
4
  # Core (zero-dependency assignment logic)
6
5
  require_relative 'abmeter/core'
@@ -17,6 +16,7 @@ require_relative 'abmeter/error_safety'
17
16
  module ABMeter
18
17
  class << self
19
18
  include ErrorSafety
19
+
20
20
  def config
21
21
  raise 'ABMeter not configured. Call ABMeter.configure { |config| ... } first.' unless @config
22
22
 
@@ -64,12 +64,25 @@ module ABMeter
64
64
  end
65
65
  end
66
66
 
67
- def reset!
68
- AsyncSubmitter.shutdown
67
+ # Graceful, lossless, bounded blocking. Flushes pending exposures/events
68
+ # before clearing state. Returns true on clean shutdown, false if the
69
+ # timeout elapsed and the worker had to be killed.
70
+ def reset(timeout: AsyncSubmitter::DEFAULT_SHUTDOWN_TIMEOUT)
71
+ result = AsyncSubmitter.reset(timeout: timeout)
72
+ @config = nil
73
+ @client = nil
74
+ @resolver_provider = nil
75
+ result
76
+ end
69
77
 
78
+ # Immediate, lossy, non-blocking. Kills the worker thread and discards
79
+ # queued exposures/events without any network I/O.
80
+ def reset!
81
+ AsyncSubmitter.reset!
70
82
  @config = nil
71
83
  @client = nil
72
84
  @resolver_provider = nil
85
+ nil
73
86
  end
74
87
 
75
88
  def track_event(event_name, user_id, data)
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: abmeter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ABMeter
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: activesupport
@@ -77,6 +78,7 @@ metadata:
77
78
  homepage_uri: https://github.com/abmeter/abmeter-ruby
78
79
  source_code_uri: https://github.com/abmeter/abmeter-ruby
79
80
  rubygems_mfa_required: 'true'
81
+ post_install_message:
80
82
  rdoc_options: []
81
83
  require_paths:
82
84
  - lib
@@ -91,7 +93,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
91
93
  - !ruby/object:Gem::Version
92
94
  version: '0'
93
95
  requirements: []
94
- rubygems_version: 3.6.9
96
+ rubygems_version: 3.5.22
97
+ signing_key:
95
98
  specification_version: 4
96
99
  summary: ABMeter SDK for feature flags and A/B testing
97
100
  test_files: []