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,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flagnetic
6
+ # LocalOverrides substitutes flag values inside this process, on a
7
+ # developer's machine or in a non-production build. A substituted value
8
+ # never reaches the server. An overridden flag reads reason
9
+ # local_override, with no experiment block and the stage unchanged.
10
+ #
11
+ # Local overrides exist only on a client constructed with
12
+ # allow_overrides: true, and are refused when the held ruleset belongs to
13
+ # a protected environment: set raises LocalOverrideRefused and logs
14
+ # override_refused, and a protected ruleset arriving later drops every
15
+ # override. Setting or removing one fires on_change.
16
+ #
17
+ # client.local_overrides.set("new_checkout", true)
18
+ class LocalOverrides
19
+ def initialize(client)
20
+ @client = client
21
+ end
22
+
23
+ # set substitutes value for the flag key. The value must be of the
24
+ # flag's type: true or false, a String, a Numeric, or for a JSON flag
25
+ # anything JSON can encode. A key the ruleset lacks takes the type of
26
+ # the value. Raises LocalOverridesNotAllowed, LocalOverrideRefused, or
27
+ # ArgumentError for a value of the wrong type.
28
+ def set(key, value)
29
+ @client.__send__(:set_local_override, key.to_s, value)
30
+ nil
31
+ end
32
+
33
+ # remove drops the local override of key, firing on_change when there
34
+ # was one.
35
+ def remove(key)
36
+ @client.__send__(:remove_local_overrides, [key.to_s])
37
+ nil
38
+ end
39
+
40
+ # clear drops every local override, firing on_change with their keys.
41
+ def clear
42
+ @client.__send__(:remove_local_overrides, nil)
43
+ nil
44
+ end
45
+
46
+ # list returns the local overrides by flag key.
47
+ def list = @client.__send__(:local_override_values)
48
+
49
+ # normalize checks value against a flag type and returns the frozen
50
+ # value to serve.
51
+ def self.normalize(type, value)
52
+ case type
53
+ when "boolean" then return value if [true, false].include?(value)
54
+ when "string" then return value.dup.freeze if value.is_a?(String)
55
+ when "number" then return value if value.is_a?(Numeric) && value.real? && value.to_f.finite?
56
+ when "json" then return JSON.parse(JSON.generate(value), freeze: true)
57
+ else raise ArgumentError, "flag type #{type.inspect} is unknown"
58
+ end
59
+ raise ArgumentError, "a #{value.class} is not a value of a #{type} flag"
60
+ rescue JSON::GeneratorError, JSON::ParserError => e
61
+ raise ArgumentError, "a #{value.class} is not a JSON value: #{e.message}"
62
+ end
63
+ private_class_method :normalize
64
+ end
65
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ # EVENTS are the stable names of log events: alert on them. The fields
5
+ # each carries are listed in the README.
6
+ EVENTS = %i[
7
+ ruleset_loaded fetch_failed sse_connected sse_dropped stage_changed key_revoked schema_unsupported
8
+ rule_unsupported override_set override_refused local_overrides_allowed type_mismatch evaluation_failed
9
+ listener_failed store_failed worker_failed queue_dropped exposure_skipped ingest_failed exposures_disabled
10
+ client_not_configured
11
+ ].freeze
12
+
13
+ # LEVELS are the log levels, lowest first.
14
+ LEVELS = %i[debug info warn error].freeze
15
+
16
+ # Logging adapts the logger option: a callable receiving
17
+ # (level, event, fields), or a standard library ::Logger (anything
18
+ # responding to add), which receives every level and applies its own.
19
+ module Logging
20
+ module_function
21
+
22
+ # SEVERITIES are the standard library Logger's severities by level.
23
+ SEVERITIES = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
24
+
25
+ # standard is the default logger: warn and above to $stderr, through
26
+ # the standard library's Logger when the application has loaded it (a
27
+ # Rails application has), else as the same lines through Kernel#warn.
28
+ # The gem never requires logger itself: from Ruby 3.5 it is a bundled
29
+ # gem, which would be a runtime dependency.
30
+ def standard(level: :warn)
31
+ logger = defined?(::Logger) ? ::Logger.new($stderr, progname: "flagnetic") : nil
32
+ min = LEVELS.index(level)
33
+ lambda do |lvl, event, fields|
34
+ next if LEVELS.index(lvl) < min
35
+
36
+ line = format_line(event, fields)
37
+ logger ? logger.add(SEVERITIES[lvl], line) : Kernel.warn("flagnetic #{lvl.upcase}: #{line}")
38
+ end
39
+ end
40
+
41
+ # adapt turns the logger option into a callable.
42
+ def adapt(logger)
43
+ return standard if logger.nil?
44
+ return logger if logger.respond_to?(:call)
45
+
46
+ if logger.respond_to?(:add)
47
+ return lambda { |lvl, event, fields|
48
+ logger.add(SEVERITIES[lvl], format_line(event, fields))
49
+ }
50
+ end
51
+
52
+ raise ArgumentError, "logger must respond to call(level, event, fields) or be a Logger, got #{logger.class}"
53
+ end
54
+
55
+ def format_line(event, fields)
56
+ ([event.to_s] + fields.map { |k, v| "#{k}=#{v.is_a?(String) ? v.inspect : v}" }).join(" ")
57
+ end
58
+
59
+ private_constant :SEVERITIES
60
+ private_class_method :standard, :format_line
61
+ end
62
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require_relative "../flagnetic"
5
+ require_relative "controller_helpers"
6
+
7
+ module Flagnetic
8
+ # Railtie builds Flagnetic.client from the Rails configuration. The gem
9
+ # loads it only when Rails is loaded; nothing else in the gem knows Rails.
10
+ #
11
+ # Configuration, all under config.flagnetic, with the SDK key and base
12
+ # URL taken from the first of config, the FLAGNETIC_SDK_KEY and
13
+ # FLAGNETIC_BASE_URL environment variables, and credentials
14
+ # (flagnetic.sdk_key, flagnetic.base_url):
15
+ #
16
+ # - sdk_key, base_url: see above. Without both the client serves
17
+ # built-in defaults and logs client_not_configured.
18
+ # - offline_file: a ruleset document to serve instead; no network.
19
+ # - start: nil (default) starts the client at boot in a server or job
20
+ # process and on first use in rails console, rails runner and rake;
21
+ # true starts it at boot everywhere; false only on first use.
22
+ # - ready_timeout: seconds a boot waits for the ruleset (default 5).
23
+ # - close_timeout: seconds the exit flush may take (default 5).
24
+ # - allow_overrides: false disables local overrides, which are otherwise
25
+ # allowed in every Rails environment but production, where no option
26
+ # enables them.
27
+ # - logger: the logger option of Client.new (default Rails.logger).
28
+ # - client_options: any other Client.new option, as a Hash.
29
+ # - platform, app_version: predeclared attributes of every request
30
+ # context, and the client's platform and app_version options, so
31
+ # User-Agent, evaluation summaries and exposures outside a request
32
+ # carry them too.
33
+ # - user_unit_type, user_id, anonymous_unit_type, anonymous_cookie,
34
+ # attributes: how the controller helper builds a request's context;
35
+ # see ControllerHelpers.
36
+ #
37
+ # The client is started per process: a Puma or Unicorn worker starts its
38
+ # own background thread the moment it is forked, and every process
39
+ # closes its client at exit, which flushes what it has queued.
40
+ class Railtie < ::Rails::Railtie
41
+ config.flagnetic = ActiveSupport::OrderedOptions.new
42
+ config.flagnetic.start = nil
43
+ config.flagnetic.ready_timeout = 5
44
+ config.flagnetic.close_timeout = 5
45
+ config.flagnetic.client_options = {}
46
+ config.flagnetic.anonymous_cookie = "flagnetic_anonymous_id"
47
+ config.flagnetic.user_id = ControllerHelpers::SIGNED_IN_USER_ID
48
+
49
+ # EMPTY is the ruleset an unconfigured client serves: no flags, so every
50
+ # getter returns its built-in default.
51
+ EMPTY = { "schema_version" => 1, "version" => 0, "scope" => "server", "flags" => [] }.freeze
52
+ private_constant :EMPTY
53
+
54
+ # A Rakefile loads the application's tasks before any task boots the
55
+ # environment, so this runs first: rake is a one-off process.
56
+ rake_tasks { Railtie.one_off = true }
57
+
58
+ initializer "flagnetic.controller_helpers" do
59
+ ActiveSupport.on_load(:action_controller) { include Flagnetic::ControllerHelpers }
60
+ end
61
+
62
+ config.after_initialize { |app| Railtie.boot(app) }
63
+
64
+ class << self
65
+ attr_writer :one_off
66
+
67
+ # boot installs the builder of Flagnetic.client and, in a server or
68
+ # job process, builds the client now and waits for its ruleset.
69
+ def boot(app)
70
+ config = app.config.flagnetic
71
+ Flagnetic.client_builder = -> { build(app) }
72
+ Process.singleton_class.prepend(ForkHook) unless Process.singleton_class < ForkHook
73
+ start = config.start.nil? ? !one_off? : config.start
74
+ Flagnetic.client if start
75
+ end
76
+
77
+ # build constructs the client from the configuration and registers
78
+ # its close at exit.
79
+ def build(app)
80
+ config = app.config.flagnetic
81
+ client = new_client(app)
82
+ at_exit { client.close(timeout: config.close_timeout) }
83
+ client
84
+ end
85
+
86
+ # after_fork starts the background thread of a forked child: a Puma
87
+ # or Unicorn worker then follows the change stream before its first
88
+ # request. A client not built yet is left to be built on first use.
89
+ def after_fork
90
+ Flagnetic.client.after_fork if Flagnetic.client?
91
+ end
92
+
93
+ private
94
+
95
+ # new_client constructs the client the configuration describes.
96
+ # Local overrides stay off in production whatever client_options say.
97
+ def new_client(app)
98
+ config = app.config.flagnetic
99
+ log = Logging.adapt(config.logger || ::Rails.logger)
100
+ sdk_key = setting(app, :sdk_key)
101
+ base_url = setting(app, :base_url)
102
+ allow_overrides = config.allow_overrides != false && !::Rails.env.production?
103
+ options = { **config.client_options, logger: log, allow_overrides: allow_overrides }
104
+ options[:platform] = config.platform if config.platform
105
+ options[:app_version] = config.app_version if config.app_version
106
+ if config.offline_file
107
+ Client.new(offline_file: config.offline_file, **options)
108
+ elsif sdk_key && base_url
109
+ Client.new(sdk_key, base_url: base_url, **options).tap { |c| c.ready(timeout: config.ready_timeout) }
110
+ else
111
+ log.call(:warn, :client_not_configured, { rails_env: ::Rails.env.to_s })
112
+ Client.new(offline: EMPTY, **options, send_summaries: false)
113
+ end
114
+ end
115
+
116
+ # setting reads name from config, the environment variable, then
117
+ # credentials; blank is unset.
118
+ def setting(app, name)
119
+ [app.config.flagnetic[name], ENV.fetch("FLAGNETIC_#{name.upcase}", nil), credential(app, name)].find(&:present?)
120
+ end
121
+
122
+ def credential(app, name)
123
+ app.credentials.dig(:flagnetic, name)
124
+ rescue StandardError
125
+ nil
126
+ end
127
+
128
+ # one_off tells whether this process is rails console, rails runner or
129
+ # rake: a process the client must not start in unless it is asked
130
+ # for.
131
+ def one_off?
132
+ @one_off || defined?(::Rails::Console) || defined?(::Rails::Command::RunnerCommand) ? true : false
133
+ end
134
+ end
135
+
136
+ # ForkHook runs Railtie.after_fork in every forked child (Ruby 3.1's
137
+ # Process._fork, which every fork goes through).
138
+ module ForkHook
139
+ def _fork
140
+ pid = super
141
+ Railtie.after_fork if pid.zero?
142
+ pid
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Flagnetic
6
+ # Recorder holds in memory what an offline client would report, so a
7
+ # test can assert on it: the running evaluation counts, every evaluation
8
+ # summary a flush or close would have sent, and every exposure and alias
9
+ # the client recorded. It is thread-safe.
10
+ class Recorder
11
+ # SummaryCount is one row of an evaluation summary: how often a flag
12
+ # served a variation for an evaluation reason. It carries no unit IDs
13
+ # and no values; variation is nil for local_override and fallback.
14
+ # count is the wire name; Struct#count is not used on it.
15
+ SummaryCount = Struct.new(:flag, :variation, :reason, :count, keyword_init: true) # rubocop:disable Lint/StructNewOverride
16
+
17
+ # EvaluationSummary is one evaluation summary an offline client would
18
+ # have sent, as its own event: a deduplication key, the interval and
19
+ # ruleset version it reports, the app_version option, and its rows.
20
+ EvaluationSummary = Struct.new(:event_id, :interval_start, :interval_end, :ruleset_version, :app_version, :counts,
21
+ keyword_init: true) do
22
+ def initialize(**fields)
23
+ super
24
+ freeze
25
+ end
26
+ end
27
+
28
+ # Exposure is one exposure an offline client would have sent.
29
+ Exposure = Struct.new(:event_id, :experiment_key, :flag_key, :variation_key, :unit_type, :unit_id, :platform,
30
+ :app_version, :reason, :override, :ruleset_version, :occurred_at, keyword_init: true) do
31
+ def initialize(**fields)
32
+ super
33
+ freeze
34
+ end
35
+ end
36
+
37
+ # Alias is one alias an offline client would have sent.
38
+ Alias = Struct.new(:event_id, :from_unit_type, :from_unit_id, :to_unit_type, :to_unit_id, :occurred_at,
39
+ keyword_init: true) do
40
+ def initialize(**fields)
41
+ super
42
+ freeze
43
+ end
44
+ end
45
+
46
+ def initialize
47
+ @mutex = Mutex.new
48
+ @counts = Hash.new(0)
49
+ @summaries = []
50
+ @exposures = []
51
+ @aliases = []
52
+ end
53
+
54
+ # summary returns the running evaluation counts since construction or
55
+ # the last reset, ordered by flag, variation and reason. Closing a
56
+ # window (flush, close) does not touch it.
57
+ def summary
58
+ rows = @mutex.synchronize { @counts.dup }
59
+ rows.map { |(flag, variation, reason), n| SummaryCount.new(flag:, variation:, reason:, count: n).freeze }
60
+ .sort_by { |r| [r.flag, r.variation.to_s, r.reason.to_s] }
61
+ end
62
+
63
+ # reset forgets everything recorded.
64
+ def reset
65
+ @mutex.synchronize do
66
+ @counts.clear
67
+ @summaries.clear
68
+ @exposures.clear
69
+ @aliases.clear
70
+ end
71
+ end
72
+
73
+ # count records one evaluation into the running total.
74
+ def count(flag, detail)
75
+ key = [flag, detail.variation, detail.reason]
76
+ @mutex.synchronize { @counts[key] += 1 }
77
+ end
78
+
79
+ # record keeps an evaluation summary the client would have sent once a
80
+ # window closed.
81
+ def record(summary)
82
+ @mutex.synchronize { @summaries << summary }
83
+ end
84
+
85
+ # evaluation_summaries returns the evaluation summaries the client
86
+ # would have sent since construction or the last reset, in the order
87
+ # they were reported.
88
+ def evaluation_summaries
89
+ @mutex.synchronize { @summaries.dup }
90
+ end
91
+
92
+ # record_event keeps an exposure or an alias the client would have
93
+ # sent, from the wire-shaped Hash Exposures builds. An offline client
94
+ # records it at the call itself, so a test asserts on it without
95
+ # flushing.
96
+ def record_event(event)
97
+ case event["type"]
98
+ when "exposure"
99
+ @mutex.synchronize do
100
+ @exposures << Exposure.new(event_id: event["event_id"], experiment_key: event["experiment_key"],
101
+ flag_key: event["flag_key"], variation_key: event["variation_key"],
102
+ unit_type: event["unit_type"], unit_id: event["unit_id"],
103
+ platform: event["platform"], app_version: event["app_version"],
104
+ reason: event["evaluation_reason"].to_sym, override: event["override"],
105
+ ruleset_version: event["ruleset_version"],
106
+ occurred_at: Time.parse(event["occurred_at"]))
107
+ end
108
+ when "alias"
109
+ @mutex.synchronize do
110
+ @aliases << Alias.new(event_id: event["event_id"], from_unit_type: event["from_unit_type"],
111
+ from_unit_id: event["from_unit_id"], to_unit_type: event["to_unit_type"],
112
+ to_unit_id: event["to_unit_id"], occurred_at: Time.parse(event["occurred_at"]))
113
+ end
114
+ end
115
+ end
116
+
117
+ # exposures returns the exposures the client would have sent since
118
+ # construction or the last reset, in the order they were recorded.
119
+ # Repeats per experiment, unit and variation are suppressed before they
120
+ # reach the recorder, exactly as they are online.
121
+ def exposures
122
+ @mutex.synchronize { @exposures.dup }
123
+ end
124
+
125
+ # aliases returns the aliases the client would have sent since
126
+ # construction or the last reset, in the order they were recorded.
127
+ def aliases
128
+ @mutex.synchronize { @aliases.dup }
129
+ end
130
+
131
+ # The client hands the recorder what it counts and reports; a test only
132
+ # reads it.
133
+ private :count, :record, :record_event
134
+ end
135
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "event_batch"
4
+
5
+ module Flagnetic
6
+ # Reporter is the send-with-backoff machinery shared by Exposures and
7
+ # Summaries: both post batches through EventBatch under the same retry
8
+ # policy (429, 5xx and network errors retried with backoff bounded by
9
+ # the flush's own budget; 401/403 backed off to the unauthorized cap
10
+ # and logged once) and the same bounded wait for another flush already
11
+ # in progress. schedule_retry is the one place they differ (the protocol
12
+ # allows honouring Retry-After or backing off on its own schedule); each
13
+ # includer defines its own. The includer supplies @transport, @timing,
14
+ # @log, @backoff, @not_before, @revoked_logged, @send_mutex and a
15
+ # private #kind.
16
+ module Reporter
17
+ # MIN_ATTEMPT_TIME is the least budget left an attempt needs to be
18
+ # worth making at all, so a very short flush_interval (tests) still
19
+ # gets one real attempt instead of giving up before trying.
20
+ MIN_ATTEMPT_TIME = 0.01
21
+
22
+ private
23
+
24
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+
26
+ # wait_for_send_lock waits for @send_mutex, up to deadline, so a flush
27
+ # bounded by its own budget never blocks on another flush already in
28
+ # progress for longer than that budget. Returns whether it was
29
+ # acquired; the caller must unlock it.
30
+ def wait_for_send_lock(deadline)
31
+ until @send_mutex.try_lock
32
+ return false if now >= deadline
33
+
34
+ sleep([deadline - now, 0.01].min)
35
+ end
36
+ true
37
+ end
38
+
39
+ # attempt sends group, retrying it whole (same events, same event IDs)
40
+ # on 429, 5xx and network errors, until the platform answers it, the
41
+ # deadline is up or the key is refused. Each call is itself bounded by
42
+ # whatever is left of the deadline, so a hung platform never holds a
43
+ # flush past its budget.
44
+ def attempt(group, deadline)
45
+ loop do
46
+ remaining = deadline - now
47
+ if remaining <= MIN_ATTEMPT_TIME
48
+ result = EventBatch::Outcome.new(kind: :retry, error: "flush budget exhausted")
49
+ # schedule_retry's own doing: without it @not_before never
50
+ # advances, and the next due? is immediately true again.
51
+ schedule_retry(group, result)
52
+ return result
53
+ end
54
+
55
+ result = EventBatch.send_batch(@transport, group, [remaining, @timing.request_timeout].min)
56
+ case result.kind
57
+ when :retry then wait = schedule_retry(group, result)
58
+ when :unauthorized then (schedule_unauthorized(result)
59
+ return result)
60
+ else return result
61
+ end
62
+ return result if now + wait > deadline
63
+
64
+ sleep(wait)
65
+ end
66
+ end
67
+
68
+ def schedule_unauthorized(result)
69
+ wait = @backoff.next_delay(cap: @timing.unauthorized_cap)
70
+ @not_before = now + wait
71
+ return if @revoked_logged
72
+
73
+ @revoked_logged = true
74
+ log(:error, :key_revoked, status: result.status, source: "events")
75
+ end
76
+
77
+ def log_ingest_failed(group, result, wait)
78
+ log(:warn, :ingest_failed, kind: kind, events: group.size, retry_in: wait.round(3), status: result.status,
79
+ error: result.error)
80
+ end
81
+
82
+ def report_dropped(group, result)
83
+ log(:warn, :queue_dropped, kind: kind, cause: "client_error",
84
+ events: group.map { |e| e["event_id"] }, status: result.status, error: result.error)
85
+ end
86
+
87
+ def log(level, event, **fields)
88
+ @log.call(level, event, fields.compact)
89
+ rescue StandardError
90
+ nil
91
+ end
92
+ end
93
+ private_constant :Reporter
94
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ # SSEParser reads the text/event-stream format of the change stream by the
5
+ # WHATWG rules: CRLF, LF or CR end a line, even when split between reads;
6
+ # a blank line dispatches the event gathered so far; lines starting with
7
+ # a colon are comments; data lines join with newlines; the last event ID
8
+ # carries over between events. The server only ever writes `retry`, a
9
+ # `ruleset` event and `: hb` heartbeats, but a proxy may reframe them.
10
+ class SSEParser
11
+ # Event is one dispatched event. type defaults to "message"; id is the
12
+ # last event ID seen, or nil.
13
+ Event = Struct.new(:type, :data, :id, keyword_init: true)
14
+
15
+ # Error is a stream the parser refuses: a line longer than max_line.
16
+ class Error < StandardError; end
17
+
18
+ BOM = "\xEF\xBB\xBF".b.freeze
19
+ LINE_END = /[\r\n]/n
20
+ private_constant :BOM, :LINE_END
21
+
22
+ def initialize(max_line: 1 << 20)
23
+ @max_line = max_line
24
+ @buffer = +"".b
25
+ @skip_lf = false
26
+ @started = false
27
+ @type = +""
28
+ @data = +""
29
+ @id = nil
30
+ end
31
+
32
+ # feed parses the next bytes of the stream, yielding [:event, Event],
33
+ # [:retry, milliseconds] and [:comment, text] as they complete.
34
+ def feed(chunk, &)
35
+ bytes = chunk.b
36
+ if @skip_lf && !bytes.empty?
37
+ bytes = bytes.byteslice(1..) if bytes.getbyte(0) == 10
38
+ @skip_lf = false
39
+ end
40
+ @buffer << bytes
41
+ strip_bom
42
+ while (i = @buffer.index(LINE_END))
43
+ line = @buffer.byteslice(0, i)
44
+ @buffer = @buffer.byteslice((i + line_end_length(i))..)
45
+ line(line.force_encoding(Encoding::UTF_8).scrub, &)
46
+ end
47
+ raise Error, "an event-stream line exceeds #{@max_line} bytes" if @buffer.bytesize > @max_line
48
+ end
49
+
50
+ private
51
+
52
+ def strip_bom
53
+ return if @started || @buffer.bytesize < BOM.bytesize
54
+
55
+ @started = true
56
+ @buffer = @buffer.byteslice(BOM.bytesize..) if @buffer.start_with?(BOM)
57
+ end
58
+
59
+ # line_end_length is 2 for CRLF, else 1. A CR ending the buffer may be
60
+ # the first half of a CRLF: the LF is skipped when it arrives.
61
+ def line_end_length(index)
62
+ return 1 unless @buffer.getbyte(index) == 13
63
+ return 2 if @buffer.getbyte(index + 1) == 10
64
+
65
+ @skip_lf = index + 1 == @buffer.bytesize
66
+ 1
67
+ end
68
+
69
+ def line(line)
70
+ return dispatch { |e| yield :event, e } if line.empty?
71
+ return yield(:comment, line[1..]) if line.start_with?(":")
72
+
73
+ field, value = line.split(":", 2)
74
+ value = value.to_s.delete_prefix(" ")
75
+ case field
76
+ when "event" then @type = value
77
+ when "data" then @data << value << "\n"
78
+ when "id" then @id = value unless value.include?("\u0000")
79
+ when "retry" then yield :retry, value.to_i if value.match?(/\A\d+\z/)
80
+ end
81
+ end
82
+
83
+ def dispatch
84
+ data = @data
85
+ type = @type
86
+ @data = +""
87
+ @type = +""
88
+ return if data.empty?
89
+
90
+ yield Event.new(type: type.empty? ? "message" : type, data: data.chomp, id: @id)
91
+ end
92
+ end
93
+ private_constant :SSEParser
94
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ # FileStore keeps the last good ruleset in one file: the ruleset document
5
+ # exactly as the endpoint served it, so the file also works as an offline
6
+ # document. Its modification time is when the ruleset was received.
7
+ # Writes go to a unique temporary file renamed over the target, so
8
+ # processes sharing the path (Puma workers) never read a torn file.
9
+ class FileStore
10
+ def initialize(path)
11
+ @path = path.to_s
12
+ end
13
+
14
+ # load returns [json, received_at], or nil when there is no file.
15
+ def load
16
+ json = File.binread(@path)
17
+ [json, File.mtime(@path)]
18
+ rescue Errno::ENOENT
19
+ nil
20
+ end
21
+
22
+ def save(json)
23
+ tmp = "#{@path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
24
+ # The server ruleset holds private segments: readable by the owner only.
25
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC | File::BINARY, 0o600) { |f| f.write(json) }
26
+ File.rename(tmp, @path)
27
+ ensure
28
+ File.delete(tmp) if tmp && File.exist?(tmp)
29
+ end
30
+
31
+ def to_s = @path
32
+ end
33
+ private_constant :FileStore
34
+ end