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.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/README.md +276 -0
- data/lib/flagnetic/backoff.rb +29 -0
- data/lib/flagnetic/changes.rb +88 -0
- data/lib/flagnetic/client.rb +609 -0
- data/lib/flagnetic/controller_helpers.rb +97 -0
- data/lib/flagnetic/detail.rb +73 -0
- data/lib/flagnetic/eval/evaluation.rb +382 -0
- data/lib/flagnetic/eval/instant.rb +71 -0
- data/lib/flagnetic/eval/murmur3.rb +70 -0
- data/lib/flagnetic/eval/operators.rb +225 -0
- data/lib/flagnetic/eval/regex.rb +647 -0
- data/lib/flagnetic/eval/ruleset.rb +267 -0
- data/lib/flagnetic/eval/semver.rb +80 -0
- data/lib/flagnetic/eval/types.rb +148 -0
- data/lib/flagnetic/eval/unicode_tables.rb +180 -0
- data/lib/flagnetic/eval.rb +38 -0
- data/lib/flagnetic/event_batch.rb +119 -0
- data/lib/flagnetic/exposures.rb +342 -0
- data/lib/flagnetic/fallback_chain.rb +230 -0
- data/lib/flagnetic/generations.rb +50 -0
- data/lib/flagnetic/global.rb +59 -0
- data/lib/flagnetic/local_overrides.rb +65 -0
- data/lib/flagnetic/logging.rb +62 -0
- data/lib/flagnetic/railtie.rb +146 -0
- data/lib/flagnetic/recorder.rb +135 -0
- data/lib/flagnetic/reporter.rb +94 -0
- data/lib/flagnetic/sse_parser.rb +94 -0
- data/lib/flagnetic/store.rb +34 -0
- data/lib/flagnetic/stream.rb +65 -0
- data/lib/flagnetic/summaries.rb +257 -0
- data/lib/flagnetic/testing.rb +160 -0
- data/lib/flagnetic/transport.rb +106 -0
- data/lib/flagnetic/uuid.rb +25 -0
- data/lib/flagnetic/version.rb +16 -0
- data/lib/flagnetic/worker.rb +270 -0
- data/lib/flagnetic.rb +34 -0
- metadata +79 -0
|
@@ -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: []
|