flagnetic 0.1.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.
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flagnetic
6
+ # Worker is a client's background thread. It fetches the ruleset after
7
+ # every (re)connect of the change stream, on every event above the held
8
+ # version and on the poll interval, and retries with backoff; it flushes
9
+ # the exposure and alias queue and the evaluation summary window on
10
+ # their intervals. The stream
11
+ # itself is read by a Stream thread that posts what happens to the
12
+ # worker's inbox, which the worker waits on together with its next
13
+ # deadline. It never raises: a failure is a log event and a later attempt.
14
+ class Worker
15
+ def initialize(client, transport, timing, stream:)
16
+ @client = client
17
+ @transport = transport
18
+ @timing = timing
19
+ @stream_enabled = stream
20
+ @inbox = Queue.new
21
+ @stop = false
22
+ @stream_ids = 0
23
+ @thread = Thread.new { run }
24
+ @thread.name = "flagnetic"
25
+ @thread.report_on_exception = false
26
+ end
27
+
28
+ # stop ends the thread within timeout seconds, killing it when it is
29
+ # stuck in a fetch, and closes the stream. Called from the thread itself
30
+ # (a listener closing the client), it only asks the loop to end: the
31
+ # thread cannot wait for itself.
32
+ def stop(timeout)
33
+ @stop = true
34
+ wake
35
+ return if Thread.current == @thread
36
+
37
+ # A killed thread still runs its ensure, which closes the stream; it
38
+ # is not waited for, so close returns within timeout.
39
+ @thread.kill unless @thread.join([timeout, 0].max)
40
+ nil
41
+ end
42
+
43
+ # abandon forgets the threads of a parent process after a fork: they do
44
+ # not exist in the child, and the stream's socket is left to the
45
+ # garbage collector there, which closes the child's descriptor without
46
+ # shutting down the TLS session the parent still uses.
47
+ def abandon
48
+ @stop = true
49
+ @stream = nil
50
+ end
51
+
52
+ # wake interrupts the background thread's current wait. Safe to call
53
+ # from any thread, including after close.
54
+ def wake
55
+ @inbox << :wake
56
+ end
57
+
58
+ private
59
+
60
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+
62
+ def run
63
+ @fetch_backoff = Backoff.new(base: @timing.fetch_base, cap: @timing.fetch_cap)
64
+ @stream_backoff = Backoff.new(base: @timing.stream_base, cap: @timing.stream_cap)
65
+ @refused_backoff = Backoff.new(base: @timing.fetch_base, cap: @timing.unauthorized_cap)
66
+ @next_fetch = now
67
+ @stream_due = @stream_enabled ? now : nil
68
+ @connected = false
69
+ @want = nil
70
+ @event_retry = 0
71
+ step until @stop
72
+ ensure
73
+ @stream&.close
74
+ end
75
+
76
+ # step opens the stream when due, fetches when due, flushes the
77
+ # exposure and summary queues when either is due, then waits for the
78
+ # stream, a wake-up or the next deadline. The stream is opened before
79
+ # the first fetch, so no change is missed between the two.
80
+ def step
81
+ open_stream if @stream_due && now >= @stream_due
82
+ fetch if !@stop && now >= @next_fetch
83
+ drive(@client.__send__(:exposures), @timing.exposure_flush) unless @stop
84
+ drive(@client.__send__(:summaries), @timing.summary_flush) unless @stop
85
+ wait unless @stop
86
+ rescue StandardError => e
87
+ @client.__send__(:log, :error, :worker_failed, cause: "#{e.class}: #{e.message}")
88
+ sleep 1 unless @stop
89
+ end
90
+
91
+ # drive flushes queue once when it is due; a queue that is off is nil
92
+ # and this is a no-op. It never lets a flush failure stop the loop. The
93
+ # budget is the interval but at most one request timeout: this thread
94
+ # also fetches the ruleset and reads the stream's messages, so a flush
95
+ # retrying through an events outage for a whole minute would hold back
96
+ # every flag change for that minute. A flush cut short keeps what it
97
+ # did not deliver and is due again after its backoff.
98
+ def drive(queue, interval)
99
+ return unless queue&.due?(now)
100
+
101
+ queue.flush_once([interval, @timing.request_timeout].min)
102
+ rescue StandardError => e
103
+ @client.__send__(:log, :error, :worker_failed, cause: "#{e.class}: #{e.message}")
104
+ end
105
+
106
+ def open_stream
107
+ @stream_due = nil
108
+ @stream = Stream.new(@stream_ids += 1, @transport, @client.__send__(:held_version),
109
+ silence: @timing.silence, inbox: @inbox)
110
+ @head_pending = true
111
+ await_head
112
+ end
113
+
114
+ # await_head waits for the response head of the stream just opened, so
115
+ # that the fetch which follows knows whether the stream is up and a
116
+ # connect does not fetch a second time. Stream bounds its own requests
117
+ # with the request timeout, so the wait ends even if no message does.
118
+ def await_head
119
+ deadline = now + @timing.request_timeout + 1
120
+ while @head_pending && !@stop
121
+ message = @inbox.pop(timeout: [deadline - now, 0].max) or break
122
+ handle(message)
123
+ end
124
+ @head_pending = false
125
+ end
126
+
127
+ def wait
128
+ deadlines = [@next_fetch]
129
+ deadlines << @stream_due if @stream_due
130
+ deadlines << @client.__send__(:exposures)&.next_deadline
131
+ deadlines << @client.__send__(:summaries)&.next_deadline
132
+ deadlines.compact!
133
+ message = @inbox.pop(timeout: [deadlines.min - now, 0].max)
134
+ handle(message) if message
135
+ handle(@inbox.pop) until @inbox.empty?
136
+ end
137
+
138
+ def handle(message)
139
+ return if message == :wake
140
+
141
+ _, id, kind, *rest = message
142
+ return unless @stream&.id == id
143
+
144
+ case kind
145
+ when :head then head(*rest)
146
+ when :frame then frame(*rest)
147
+ when :ended then drop(rest[0], error: rest[1])
148
+ end
149
+ end
150
+
151
+ def head(status)
152
+ @head_pending = false
153
+ case status
154
+ when 200 then connected
155
+ when 401, 403 then refused(status)
156
+ else drop("HTTP #{status}", error: true)
157
+ end
158
+ end
159
+
160
+ # refused leaves a stream the key may not open closed, and tries again
161
+ # at the fetch loop's pace while the key is refused: backoff capped at
162
+ # the unauthorized cap, so a restored key gets its stream back.
163
+ def refused(status)
164
+ close_stream
165
+ @client.__send__(:key_revoked, status, "stream")
166
+ @stream_due = now + @refused_backoff.next_delay
167
+ end
168
+
169
+ def connected
170
+ @refused_backoff.reset
171
+ @connected = true
172
+ @connected_at = now
173
+ @client.__send__(:stream_connected)
174
+ @next_fetch = now # versions may have been missed while down
175
+ end
176
+
177
+ def close_stream
178
+ @head_pending = false
179
+ @stream&.close
180
+ @stream = nil
181
+ @connected = false
182
+ end
183
+
184
+ # drop ends the stream and schedules a reconnect: 0 to close_jitter
185
+ # seconds after a server-side close, else the backoff, reset once a
186
+ # connection stayed up stream_stable seconds.
187
+ def drop(cause, error:)
188
+ @stream_backoff.reset if @connected && now - @connected_at >= @timing.stream_stable
189
+ close_stream
190
+ @client.__send__(:log, error ? :warn : :info, :sse_dropped, cause: cause)
191
+ delay = error ? @stream_backoff.next_delay : rand * @timing.close_jitter
192
+ @stream_due = now + delay
193
+ @next_fetch = [@next_fetch, now + @timing.poll_down].min
194
+ end
195
+
196
+ def fetch
197
+ response = begin
198
+ @transport.fetch_ruleset(@client.__send__(:held_version))
199
+ rescue StandardError => e
200
+ e
201
+ end
202
+ schedule(@client.__send__(:handle_fetch, response))
203
+ end
204
+
205
+ def schedule(outcome)
206
+ case outcome
207
+ when :ok
208
+ @fetch_backoff.reset
209
+ @next_fetch = now + (@connected ? @timing.poll_connected : @timing.poll_down)
210
+ resume_stream
211
+ when :revoked
212
+ @next_fetch = now + @fetch_backoff.next_delay(cap: @timing.unauthorized_cap)
213
+ close_revoked_stream
214
+ else
215
+ @next_fetch = now + @fetch_backoff.next_delay
216
+ end
217
+ retry_event
218
+ end
219
+
220
+ # retry_event re-fetches after 1, 2 and 4 s while a fetch answers below
221
+ # the version an event announced: a replica may still be behind.
222
+ def retry_event
223
+ return unless @want
224
+
225
+ held = @client.__send__(:held_version)
226
+ if (held && held >= @want) || @event_retry >= @timing.event_retries.size
227
+ @want = nil
228
+ return
229
+ end
230
+ @next_fetch = [@next_fetch, now + @timing.event_retries[@event_retry]].min
231
+ @event_retry += 1
232
+ end
233
+
234
+ # A revoked key closes the stream; a later successful fetch, after an
235
+ # operator restores the key, opens it again.
236
+ def close_revoked_stream
237
+ return unless @stream
238
+
239
+ close_stream
240
+ @stream_revoked = true
241
+ end
242
+
243
+ def resume_stream
244
+ return unless @stream_revoked
245
+
246
+ @stream_revoked = false
247
+ @stream_due = now if @stream_enabled
248
+ end
249
+
250
+ def frame(kind, value)
251
+ return unless kind == :event && value.type == "ruleset"
252
+
253
+ version = begin
254
+ JSON.parse(value.data)["version"]
255
+ rescue JSON::ParserError, TypeError, NoMethodError
256
+ nil
257
+ end
258
+ version = Integer(value.id, exception: false) unless version.is_a?(Integer)
259
+ return unless version
260
+
261
+ held = @client.__send__(:held_version)
262
+ return if held && version <= held
263
+
264
+ @want = [@want || 0, version].max
265
+ @event_retry = 0
266
+ @next_fetch = now
267
+ end
268
+ end
269
+ private_constant :Worker
270
+ end
data/lib/flagnetic.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "flagnetic/version"
4
+ require_relative "flagnetic/eval"
5
+ require_relative "flagnetic/detail"
6
+ require_relative "flagnetic/logging"
7
+ require_relative "flagnetic/backoff"
8
+ require_relative "flagnetic/sse_parser"
9
+ require_relative "flagnetic/transport"
10
+ require_relative "flagnetic/stream"
11
+ require_relative "flagnetic/changes"
12
+ require_relative "flagnetic/recorder"
13
+ require_relative "flagnetic/store"
14
+ require_relative "flagnetic/local_overrides"
15
+ require_relative "flagnetic/uuid"
16
+ require_relative "flagnetic/generations"
17
+ require_relative "flagnetic/event_batch"
18
+ require_relative "flagnetic/reporter"
19
+ require_relative "flagnetic/exposures"
20
+ require_relative "flagnetic/summaries"
21
+ require_relative "flagnetic/worker"
22
+ require_relative "flagnetic/fallback_chain"
23
+ require_relative "flagnetic/client"
24
+ require_relative "flagnetic/global"
25
+ require_relative "flagnetic/railtie" if defined?(Rails::Railtie)
26
+
27
+ # Flagnetic is the Ruby SDK for Flagnetic feature flags and experiments:
28
+ # server mode, local evaluation from a ruleset held in memory. Start with
29
+ # Flagnetic::Client, or Flagnetic.client for the process-wide one; tests
30
+ # use Flagnetic::Testing (require "flagnetic/testing"). In a Rails app the
31
+ # railtie builds Flagnetic.client from configuration; the gem itself
32
+ # depends on nothing outside the standard library, Rails included.
33
+ module Flagnetic
34
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flagnetic
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Anatolii Zverev
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Evaluates Flagnetic rulesets locally with the same results as every other
13
+ Flagnetic evaluator.
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files: []
17
+ files:
18
+ - LICENSE
19
+ - README.md
20
+ - lib/flagnetic.rb
21
+ - lib/flagnetic/backoff.rb
22
+ - lib/flagnetic/changes.rb
23
+ - lib/flagnetic/client.rb
24
+ - lib/flagnetic/controller_helpers.rb
25
+ - lib/flagnetic/detail.rb
26
+ - lib/flagnetic/eval.rb
27
+ - lib/flagnetic/eval/evaluation.rb
28
+ - lib/flagnetic/eval/instant.rb
29
+ - lib/flagnetic/eval/murmur3.rb
30
+ - lib/flagnetic/eval/operators.rb
31
+ - lib/flagnetic/eval/regex.rb
32
+ - lib/flagnetic/eval/ruleset.rb
33
+ - lib/flagnetic/eval/semver.rb
34
+ - lib/flagnetic/eval/types.rb
35
+ - lib/flagnetic/eval/unicode_tables.rb
36
+ - lib/flagnetic/event_batch.rb
37
+ - lib/flagnetic/exposures.rb
38
+ - lib/flagnetic/fallback_chain.rb
39
+ - lib/flagnetic/generations.rb
40
+ - lib/flagnetic/global.rb
41
+ - lib/flagnetic/local_overrides.rb
42
+ - lib/flagnetic/logging.rb
43
+ - lib/flagnetic/railtie.rb
44
+ - lib/flagnetic/recorder.rb
45
+ - lib/flagnetic/reporter.rb
46
+ - lib/flagnetic/sse_parser.rb
47
+ - lib/flagnetic/store.rb
48
+ - lib/flagnetic/stream.rb
49
+ - lib/flagnetic/summaries.rb
50
+ - lib/flagnetic/testing.rb
51
+ - lib/flagnetic/transport.rb
52
+ - lib/flagnetic/uuid.rb
53
+ - lib/flagnetic/version.rb
54
+ - lib/flagnetic/worker.rb
55
+ homepage: https://github.com/azverinus/flagnetic
56
+ licenses:
57
+ - Apache-2.0
58
+ metadata:
59
+ source_code_uri: https://github.com/azverinus/flagnetic/tree/main/sdks/ruby
60
+ changelog_uri: https://github.com/azverinus/flagnetic/blob/main/sdks/ruby/CHANGELOG.md
61
+ rubygems_mfa_required: 'true'
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '3.3'
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 4.0.20
77
+ specification_version: 4
78
+ summary: 'Flagnetic feature flags and experiments: server-mode SDK for Ruby'
79
+ test_files: []