abmeter 0.2.4 → 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: 3de305080a86006a94e25c375c97e3f00130132ff44e4f1c51d7fb8719d0a18f
4
- data.tar.gz: 2c318d9008614bb7b7ad561c7587cecbf009fa8e68331d6fc35974b8b1cd1ccd
3
+ metadata.gz: 2d4ea3bdc7addec399a8644a3d8d2ee8f0329b4d85b117a56f4df51c58e04816
4
+ data.tar.gz: b03e52a1fe306fe68b7dd63879ff067e59f358bdf885d4a8905e86d743ba282d
5
5
  SHA512:
6
- metadata.gz: dc3b45f859392cb95ca923aa89f813b876ddb6d086852da649c8a7d7c53ab2f7c0564761d0eefb19525578395882af92d4af101e5de8e1210a2f26decbd12188
7
- data.tar.gz: b4ac66c59f3d601e3f7b88bcdcbc0b64ac8daf7a48c0d96ed8d76dad0a5804644f53bbef3f848de6676f57ee18caa626bd0efd9035ba9db634bcc506091f727f
6
+ metadata.gz: 18f1020d7926f3e7026441877010f730ef7ffc06078ad738a7be0121c9399465d3b056e39b6d0133f97d32fced489bec9a6f26c9d99f4f817e223b8927260eaf
7
+ data.tar.gz: 9952d1a56ed63530d25200b21d88b0aaf3aec868752b72c46029e5905af5ff20af1eb45c472323afa20251694949ba9df0ff611d13a1633d0a110546d1aa41a5
data/README.md CHANGED
@@ -2,6 +2,8 @@
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
+
5
7
  ## Supported Ruby versions
6
8
 
7
9
  `abmeter` supports **Ruby 3.2 and newer**.
@@ -38,6 +40,18 @@ user = ABMeter.user(id: current_user.id, email: current_user.email)
38
40
  ABMeter.event(`user_purchases_plan`, user, {plan: purchased_plan.name, price: purchased_plan.price})
39
41
  ```
40
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
+
41
55
  ## Development
42
56
 
43
57
  The gem uses [mise](https://mise.jdx.dev/) to pin Ruby (`mise.toml`). Pure-Ruby — no Postgres / Redis required.
@@ -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
@@ -43,6 +43,7 @@ module ABMeter
43
43
  {
44
44
  id: id,
45
45
  space_id: space_id,
46
+ salt: salt,
46
47
  range: [range.begin, range.end],
47
48
  audience_variants: audience_variants.map do |audience_variant|
48
49
  {
@@ -1,3 +1,3 @@
1
1
  module ABMeter
2
- VERSION = '0.2.4'.freeze
2
+ VERSION = '0.3.0'.freeze
3
3
  end
data/lib/abmeter.rb CHANGED
@@ -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,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: abmeter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ABMeter
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-13 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport