smplkit 3.0.134 → 3.0.135
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 +4 -4
- data/lib/smplkit/client.rb +27 -27
- data/lib/smplkit/config/client.rb +44 -41
- data/lib/smplkit/event_stream.rb +453 -0
- data/lib/smplkit/flags/client.rb +45 -42
- data/lib/smplkit/jobs/client.rb +2 -2
- data/lib/smplkit/jobs/models.rb +2 -2
- data/lib/smplkit/logging/client.rb +69 -59
- data/lib/smplkit/transport.rb +1 -1
- data/lib/smplkit/version.rb +2 -2
- data/lib/smplkit.rb +1 -1
- metadata +2 -22
- data/lib/smplkit/ws.rb +0 -268
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "async"
|
|
5
|
+
require "async/http/client"
|
|
6
|
+
require "async/http/endpoint"
|
|
7
|
+
|
|
8
|
+
module Smplkit
|
|
9
|
+
# Manages the single live-updates event stream to the app service.
|
|
10
|
+
#
|
|
11
|
+
# A single +EventStream+ instance is shared across all product modules
|
|
12
|
+
# (config, flags, logging) within one +Smplkit::Client+. Product modules
|
|
13
|
+
# register listeners for specific event names; the shared stream dispatches
|
|
14
|
+
# incoming events to the appropriate listeners. Modules also register
|
|
15
|
+
# refetch callbacks, invoked after every successful *re*connect so their
|
|
16
|
+
# caches recover anything the server published while the stream was down.
|
|
17
|
+
#
|
|
18
|
+
# The stream runs on a dedicated SDK-owned thread that hosts the +Async+
|
|
19
|
+
# reactor and the underlying +async-http+ I/O. Public methods are
|
|
20
|
+
# thread-safe and non-blocking.
|
|
21
|
+
#
|
|
22
|
+
# Wire protocol — Server-Sent Events (SSE) over plain HTTPS:
|
|
23
|
+
#
|
|
24
|
+
# - +GET <app_base_url>/api/v1/events+ with +Accept: text/event-stream+
|
|
25
|
+
# and +Authorization: Bearer <api_key>+.
|
|
26
|
+
# - A 200 response with a +text/event-stream+ content type is a
|
|
27
|
+
# successful connect; auth failure is a plain HTTP 401.
|
|
28
|
+
# - Each SSE frame carries the event name in the +event:+ field and a
|
|
29
|
+
# JSON object in +data:+ (+{"id": "<key>"}+ for single-resource
|
|
30
|
+
# events, +{}+ for bulk refreshes and the initial +connected+ event).
|
|
31
|
+
# - The server emits a +: keepalive+ comment frame every 30 seconds when
|
|
32
|
+
# idle; any received bytes count as liveness. Reads that stall past
|
|
33
|
+
# +READ_TIMEOUT+ seconds tear the connection down for a reconnect.
|
|
34
|
+
# - The server's +retry:+ field seeds the reconnect backoff base.
|
|
35
|
+
#
|
|
36
|
+
# On disconnect the reactor reconnects with exponential backoff (base
|
|
37
|
+
# delay doubling up to +MAX_BACKOFF+ seconds), resetting to the base on
|
|
38
|
+
# every successful connect. +stop+ closes the stream from the outer
|
|
39
|
+
# thread; the reader exits and the daemon thread terminates.
|
|
40
|
+
class EventStream
|
|
41
|
+
# Seconds without any bytes from the server (events or keepalive
|
|
42
|
+
# comments) before the connection is considered dead — two missed
|
|
43
|
+
# 30-second server keepalives.
|
|
44
|
+
READ_TIMEOUT = 45
|
|
45
|
+
|
|
46
|
+
# Ceiling for the exponential reconnect backoff, in seconds.
|
|
47
|
+
MAX_BACKOFF = 60
|
|
48
|
+
|
|
49
|
+
# Initial reconnect backoff base, in seconds, used until the server
|
|
50
|
+
# supplies its own via the SSE +retry:+ field.
|
|
51
|
+
DEFAULT_RETRY = 1.0
|
|
52
|
+
|
|
53
|
+
# Sent on the stream request — the platform WAF rejects requests that
|
|
54
|
+
# carry no User-Agent. There is no caller-supplied header surface on the
|
|
55
|
+
# event stream, so the SDK default always applies.
|
|
56
|
+
USER_AGENT = Smplkit.user_agent.freeze
|
|
57
|
+
|
|
58
|
+
# Incremental parser for a +text/event-stream+ byte stream.
|
|
59
|
+
#
|
|
60
|
+
# Feed it raw chunks as they arrive; it returns the events completed by
|
|
61
|
+
# each chunk. Implements the SSE wire format: +\n+, +\r\n+, and +\r+
|
|
62
|
+
# line terminators (including a CRLF split across chunks), a leading
|
|
63
|
+
# UTF-8 BOM, comment lines (leading +:+), field values split across
|
|
64
|
+
# chunk boundaries, multiple +data:+ lines joined with +\n+, and the
|
|
65
|
+
# numeric +retry:+ field. Unknown fields are ignored.
|
|
66
|
+
class Parser
|
|
67
|
+
# One complete SSE event: +name+ is the event name (from the +event:+
|
|
68
|
+
# field, defaulting to +"message"+), +data+ the joined data payload.
|
|
69
|
+
Event = Struct.new(:name, :data, keyword_init: true)
|
|
70
|
+
|
|
71
|
+
UTF8_BOM = String.new("\xEF\xBB\xBF", encoding: Encoding::BINARY).freeze
|
|
72
|
+
|
|
73
|
+
# Milliseconds from the most recent valid +retry:+ field, or +nil+.
|
|
74
|
+
attr_reader :retry_ms
|
|
75
|
+
|
|
76
|
+
def initialize
|
|
77
|
+
@buffer = String.new(encoding: Encoding::BINARY)
|
|
78
|
+
@bom_pending = true
|
|
79
|
+
@event_type = ""
|
|
80
|
+
@data_lines = []
|
|
81
|
+
@retry_ms = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Consume one chunk of the stream. Returns the (possibly empty) array
|
|
85
|
+
# of +Event+s completed by this chunk.
|
|
86
|
+
def feed(chunk)
|
|
87
|
+
@buffer << chunk.dup.force_encoding(Encoding::BINARY)
|
|
88
|
+
strip_bom if @bom_pending
|
|
89
|
+
events = []
|
|
90
|
+
while (line = next_line)
|
|
91
|
+
handle_line(line, events)
|
|
92
|
+
end
|
|
93
|
+
events
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Drop a UTF-8 BOM at the very start of the stream. The BOM is three
|
|
99
|
+
# bytes and may itself arrive split across chunks, so stay pending
|
|
100
|
+
# while the buffer is still a strict prefix of it.
|
|
101
|
+
def strip_bom
|
|
102
|
+
if @buffer.bytesize >= UTF8_BOM.bytesize
|
|
103
|
+
@buffer = @buffer.byteslice(UTF8_BOM.bytesize..) if @buffer.start_with?(UTF8_BOM)
|
|
104
|
+
@bom_pending = false
|
|
105
|
+
elsif !UTF8_BOM.start_with?(@buffer)
|
|
106
|
+
@bom_pending = false
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Extract the next complete line (terminator: +\n+, +\r\n+, or bare
|
|
111
|
+
# +\r+), or +nil+ if the buffer holds none. A CR as the final buffered
|
|
112
|
+
# byte is held back — it may be the first half of a CRLF whose LF is
|
|
113
|
+
# still in flight. Line splitting happens on the raw bytes (0x0A/0x0D
|
|
114
|
+
# never appear inside a UTF-8 multi-byte sequence); the completed line
|
|
115
|
+
# is re-tagged UTF-8.
|
|
116
|
+
def next_line
|
|
117
|
+
cr = @buffer.index("\r")
|
|
118
|
+
lf = @buffer.index("\n")
|
|
119
|
+
return nil if cr.nil? && lf.nil?
|
|
120
|
+
|
|
121
|
+
if cr && (lf.nil? || cr < lf)
|
|
122
|
+
return nil if cr == @buffer.bytesize - 1
|
|
123
|
+
|
|
124
|
+
line = @buffer.byteslice(0, cr)
|
|
125
|
+
skip = @buffer.getbyte(cr + 1) == 0x0A ? 2 : 1
|
|
126
|
+
@buffer = @buffer.byteslice(cr + skip, @buffer.bytesize - cr - skip)
|
|
127
|
+
else
|
|
128
|
+
line = @buffer.byteslice(0, lf)
|
|
129
|
+
@buffer = @buffer.byteslice(lf + 1, @buffer.bytesize - lf - 1)
|
|
130
|
+
end
|
|
131
|
+
line.force_encoding(Encoding::UTF_8)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A blank line dispatches the accumulated event; a leading +:+ marks a
|
|
135
|
+
# comment (the server's keepalive frame) — ignored here, since mere
|
|
136
|
+
# receipt of its bytes already counted as liveness at the read layer.
|
|
137
|
+
def handle_line(line, events)
|
|
138
|
+
if line.empty?
|
|
139
|
+
flush_pending(events)
|
|
140
|
+
elsif !line.start_with?(":")
|
|
141
|
+
apply_field(*split_field(line))
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Split +"field: value"+ on the first colon, stripping at most one
|
|
146
|
+
# leading space from the value. A line with no colon is a field with
|
|
147
|
+
# an empty value.
|
|
148
|
+
def split_field(line)
|
|
149
|
+
sep = line.index(":")
|
|
150
|
+
return [line, ""] if sep.nil?
|
|
151
|
+
|
|
152
|
+
value = line[(sep + 1)..]
|
|
153
|
+
value = value[1..] if value.start_with?(" ")
|
|
154
|
+
[line[0...sep], value]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# +id:+ is deliberately ignored along with unknown fields — the SDK
|
|
158
|
+
# never resumes with +Last-Event-ID+.
|
|
159
|
+
def apply_field(field, value)
|
|
160
|
+
case field
|
|
161
|
+
when "event"
|
|
162
|
+
@event_type = value
|
|
163
|
+
when "data"
|
|
164
|
+
@data_lines << value
|
|
165
|
+
when "retry"
|
|
166
|
+
@retry_ms = Integer(value, 10) if value.match?(/\A\d+\z/)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Blank line: emit the accumulated event. Per the SSE spec an event
|
|
171
|
+
# with an empty data buffer is discarded (the event type still
|
|
172
|
+
# resets); multiple +data:+ lines join with +\n+.
|
|
173
|
+
def flush_pending(events)
|
|
174
|
+
unless @data_lines.empty?
|
|
175
|
+
name = @event_type.empty? ? "message" : @event_type
|
|
176
|
+
events << Event.new(name: name, data: @data_lines.join("\n"))
|
|
177
|
+
end
|
|
178
|
+
@event_type = ""
|
|
179
|
+
@data_lines = []
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def initialize(app_base_url:, api_key:, metrics: nil)
|
|
184
|
+
@app_base_url = app_base_url
|
|
185
|
+
@api_key = api_key
|
|
186
|
+
@metrics = metrics
|
|
187
|
+
@listeners = Hash.new { |h, k| h[k] = [] }
|
|
188
|
+
@refetch_callbacks = []
|
|
189
|
+
@listeners_lock = Mutex.new
|
|
190
|
+
@connection_status = "disconnected"
|
|
191
|
+
@closed = false
|
|
192
|
+
@stream_thread = nil
|
|
193
|
+
@client = nil
|
|
194
|
+
@response = nil
|
|
195
|
+
@connection_lock = Mutex.new
|
|
196
|
+
@retry_base = DEFAULT_RETRY
|
|
197
|
+
@attempt = 0
|
|
198
|
+
@ever_connected = false
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# ----- Listener registration ------------------------------------
|
|
202
|
+
|
|
203
|
+
def on(event_name, &callback)
|
|
204
|
+
@listeners_lock.synchronize { @listeners[event_name] << callback }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def off(event_name, callback)
|
|
208
|
+
@listeners_lock.synchronize { @listeners[event_name].delete(callback) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Register a refetch callback, invoked (with no arguments) after every
|
|
212
|
+
# successful *re*connect — never on the initial connect. Product modules
|
|
213
|
+
# use this to run their bulk-refresh path so caches recover events
|
|
214
|
+
# missed while the stream was down.
|
|
215
|
+
def on_reconnect(callback = nil, &block)
|
|
216
|
+
cb = callback || block
|
|
217
|
+
@listeners_lock.synchronize { @refetch_callbacks << cb }
|
|
218
|
+
cb
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def off_reconnect(callback)
|
|
222
|
+
@listeners_lock.synchronize { @refetch_callbacks.delete(callback) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Dispatch +payload+ to every listener registered for +event_name+.
|
|
226
|
+
# Event names nothing subscribed to dispatch to zero listeners — unknown
|
|
227
|
+
# events are ignored by construction. Listener exceptions are caught and
|
|
228
|
+
# logged; one bad listener never blocks the rest.
|
|
229
|
+
def dispatch(event_name, payload)
|
|
230
|
+
callbacks = @listeners_lock.synchronize { @listeners[event_name].dup }
|
|
231
|
+
callbacks.each do |cb|
|
|
232
|
+
cb.call(payload)
|
|
233
|
+
rescue StandardError => e
|
|
234
|
+
Smplkit.debug("events", "listener for #{event_name} raised: #{e.class}: #{e.message}")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# ----- Connection status ----------------------------------------
|
|
239
|
+
|
|
240
|
+
attr_reader :connection_status
|
|
241
|
+
|
|
242
|
+
# ----- Lifecycle ------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def start
|
|
245
|
+
return if @stream_thread&.alive?
|
|
246
|
+
|
|
247
|
+
Smplkit.debug("events", "starting shared event stream background thread")
|
|
248
|
+
@closed = false
|
|
249
|
+
@connection_status = "connecting"
|
|
250
|
+
@stream_thread = Thread.new { run_reactor }
|
|
251
|
+
@stream_thread.name = "smplkit-events" if @stream_thread.respond_to?(:name=)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def stop
|
|
255
|
+
Smplkit.debug("events", "stopping shared event stream")
|
|
256
|
+
@closed = true
|
|
257
|
+
close_active_connection
|
|
258
|
+
thread = @stream_thread
|
|
259
|
+
@stream_thread = nil
|
|
260
|
+
if thread
|
|
261
|
+
thread.join(2.0)
|
|
262
|
+
thread.kill if thread.alive?
|
|
263
|
+
end
|
|
264
|
+
# Set authoritatively after the thread is dead so a racing connect
|
|
265
|
+
# call (which also sets "connecting") cannot clobber this value.
|
|
266
|
+
@connection_status = "disconnected"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# ----- URL builder ----------------------------------------------
|
|
270
|
+
|
|
271
|
+
def build_events_url
|
|
272
|
+
url = @app_base_url.dup
|
|
273
|
+
url = "https://#{url}" unless url.start_with?("https://", "http://")
|
|
274
|
+
"#{url.chomp("/")}/api/v1/events"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# ----- Inbound event handling (extracted for tests) -------------
|
|
278
|
+
|
|
279
|
+
# Process one parsed SSE event the way the live read loop does: parse
|
|
280
|
+
# the JSON payload and dispatch it to the listeners registered for the
|
|
281
|
+
# event name.
|
|
282
|
+
#
|
|
283
|
+
# Returns +:dispatched+ or +:unparseable+ for the caller to log/observe;
|
|
284
|
+
# the live read loop ignores the return value.
|
|
285
|
+
def handle_event(event_name, data)
|
|
286
|
+
payload =
|
|
287
|
+
begin
|
|
288
|
+
JSON.parse(data)
|
|
289
|
+
rescue JSON::ParserError
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
unless payload.is_a?(Hash)
|
|
293
|
+
Smplkit.debug("events", "ignoring #{event_name.inspect} event with non-object payload")
|
|
294
|
+
return :unparseable
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
dispatch(event_name, payload)
|
|
298
|
+
:dispatched
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private
|
|
302
|
+
|
|
303
|
+
def run_reactor
|
|
304
|
+
Sync do |task|
|
|
305
|
+
stream_main(task)
|
|
306
|
+
end
|
|
307
|
+
rescue StandardError => e
|
|
308
|
+
Smplkit.debug("events", "event stream thread exited unexpectedly: #{e.class}: #{e.message}")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Connect/read/reconnect forever until +stop+. Every pass either ends
|
|
312
|
+
# with a clean server EOF or an exception (connect failure, read error,
|
|
313
|
+
# liveness timeout) — both funnel into the same backoff + retry.
|
|
314
|
+
def stream_main(task)
|
|
315
|
+
until @closed
|
|
316
|
+
begin
|
|
317
|
+
connect_and_stream(task)
|
|
318
|
+
rescue StandardError => e
|
|
319
|
+
return if @closed
|
|
320
|
+
|
|
321
|
+
Smplkit.debug("events", "stream error (url: #{build_events_url}): #{e.class}: #{e.message}")
|
|
322
|
+
end
|
|
323
|
+
return if @closed
|
|
324
|
+
|
|
325
|
+
@connection_status = "reconnecting"
|
|
326
|
+
delay = next_backoff_delay
|
|
327
|
+
Smplkit.debug("events", "reconnecting in #{delay}s")
|
|
328
|
+
task.sleep(delay)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# One connection lifetime: open the request, verify the SSE handshake,
|
|
333
|
+
# then read frames until EOF/error. The response and client are always
|
|
334
|
+
# torn down on the way out.
|
|
335
|
+
def connect_and_stream(task)
|
|
336
|
+
@connection_status = "connecting"
|
|
337
|
+
Smplkit.debug("events", "connecting to #{build_events_url}")
|
|
338
|
+
endpoint = Async::HTTP::Endpoint.parse(build_events_url)
|
|
339
|
+
client = Async::HTTP::Client.new(endpoint)
|
|
340
|
+
@connection_lock.synchronize { @client = client }
|
|
341
|
+
response = nil
|
|
342
|
+
begin
|
|
343
|
+
response = client.get(endpoint.path, request_headers)
|
|
344
|
+
@connection_lock.synchronize { @response = response }
|
|
345
|
+
verify_response!(response)
|
|
346
|
+
mark_connected
|
|
347
|
+
read_loop(task, response.body)
|
|
348
|
+
ensure
|
|
349
|
+
mark_disconnected
|
|
350
|
+
close_quietly(response)
|
|
351
|
+
close_quietly(client)
|
|
352
|
+
@connection_lock.synchronize do
|
|
353
|
+
@response = nil
|
|
354
|
+
@client = nil
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def request_headers
|
|
360
|
+
[
|
|
361
|
+
["accept", "text/event-stream"],
|
|
362
|
+
["authorization", "Bearer #{@api_key}"],
|
|
363
|
+
["user-agent", USER_AGENT]
|
|
364
|
+
]
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# A successful connect is exactly: HTTP 200 with a text/event-stream
|
|
368
|
+
# content type. Anything else (401 on bad auth, proxies serving HTML,
|
|
369
|
+
# ...) tears down and backs off.
|
|
370
|
+
def verify_response!(response)
|
|
371
|
+
status = response.status
|
|
372
|
+
content_type = response.headers["content-type"].to_s
|
|
373
|
+
return if status == 200 && content_type.start_with?("text/event-stream")
|
|
374
|
+
|
|
375
|
+
raise ConnectionError, "event stream connect failed: HTTP #{status} " \
|
|
376
|
+
"(content-type: #{content_type.inspect})"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def mark_connected
|
|
380
|
+
reconnected = @ever_connected
|
|
381
|
+
@ever_connected = true
|
|
382
|
+
@attempt = 0
|
|
383
|
+
@connection_status = "connected"
|
|
384
|
+
@metrics&.record_gauge("platform.event_connections", 1, unit: "connections")
|
|
385
|
+
Smplkit.debug("events", reconnected ? "event stream reconnected" : "event stream connected")
|
|
386
|
+
run_refetch_callbacks if reconnected
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Leaving the connected state (only): flip the gauge and status. A
|
|
390
|
+
# connect attempt that never completed the handshake records nothing.
|
|
391
|
+
def mark_disconnected
|
|
392
|
+
return unless @connection_status == "connected"
|
|
393
|
+
|
|
394
|
+
@connection_status = "reconnecting"
|
|
395
|
+
@metrics&.record_gauge("platform.event_connections", 0, unit: "connections")
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def run_refetch_callbacks
|
|
399
|
+
callbacks = @listeners_lock.synchronize { @refetch_callbacks.dup }
|
|
400
|
+
callbacks.each do |cb|
|
|
401
|
+
cb.call
|
|
402
|
+
rescue StandardError => e
|
|
403
|
+
Smplkit.debug("events", "refetch callback raised: #{e.class}: #{e.message}")
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Read chunks until EOF, feeding the SSE parser and dispatching the
|
|
408
|
+
# events it completes. Each read is individually wrapped in the liveness
|
|
409
|
+
# timeout, so any received bytes — including keepalive comment frames —
|
|
410
|
+
# reset the clock.
|
|
411
|
+
def read_loop(task, body)
|
|
412
|
+
parser = Parser.new
|
|
413
|
+
until @closed
|
|
414
|
+
chunk = task.with_timeout(READ_TIMEOUT) { body.read }
|
|
415
|
+
break if chunk.nil?
|
|
416
|
+
|
|
417
|
+
process_chunk(parser, chunk)
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def process_chunk(parser, chunk)
|
|
422
|
+
parser.feed(chunk).each { |event| handle_event(event.name, event.data) }
|
|
423
|
+
@retry_base = parser.retry_ms / 1000.0 unless parser.retry_ms.nil?
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Exponential backoff: base, 2x, 4x, ... capped at MAX_BACKOFF. The
|
|
427
|
+
# base comes from the server's +retry:+ field (DEFAULT_RETRY until one
|
|
428
|
+
# arrives); +mark_connected+ resets the exponent on every successful
|
|
429
|
+
# connect. No jitter.
|
|
430
|
+
def next_backoff_delay
|
|
431
|
+
delay = [@retry_base * (2**@attempt), MAX_BACKOFF].min
|
|
432
|
+
@attempt += 1 if delay < MAX_BACKOFF
|
|
433
|
+
delay
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def close_active_connection
|
|
437
|
+
response, client = @connection_lock.synchronize do
|
|
438
|
+
pair = [@response, @client]
|
|
439
|
+
@response = nil
|
|
440
|
+
@client = nil
|
|
441
|
+
pair
|
|
442
|
+
end
|
|
443
|
+
close_quietly(response)
|
|
444
|
+
close_quietly(client)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def close_quietly(resource)
|
|
448
|
+
resource&.close
|
|
449
|
+
rescue StandardError => e
|
|
450
|
+
Smplkit.debug("events", "close raised: #{e.class}: #{e.message}")
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|
data/lib/smplkit/flags/client.rb
CHANGED
|
@@ -18,18 +18,18 @@ require "digest"
|
|
|
18
18
|
# +json_flag+) whose +.get+ evaluates against the cached definitions, plus
|
|
19
19
|
# +refresh+ / +stats+ / +on_change+. The first live call transparently flushes
|
|
20
20
|
# discovery, fetches all flag definitions into the local cache, and opens the
|
|
21
|
-
# live-updates
|
|
21
|
+
# live-updates event stream — no explicit install step.
|
|
22
22
|
#
|
|
23
23
|
# The client supports two construction shapes:
|
|
24
24
|
#
|
|
25
25
|
# * *Wired* into +Smplkit::Client+ — borrows the parent's flags transport for
|
|
26
|
-
# both runtime fetch and CRUD, the parent's shared
|
|
26
|
+
# both runtime fetch and CRUD, the parent's shared event stream for the live
|
|
27
27
|
# channel, and +client.platform.contexts+ for evaluation-context
|
|
28
28
|
# registration. This is the common path.
|
|
29
29
|
# * *Standalone* — +FlagsClient.new(api_key: ..., base_url: ..., ...)+ builds
|
|
30
30
|
# and owns its own flags transport and a contexts client (against its own app
|
|
31
|
-
# transport), and on first live use opens and owns its own
|
|
32
|
-
# tears down only the owned transports and owned
|
|
31
|
+
# transport), and on first live use opens and owns its own event stream.
|
|
32
|
+
# +close+ tears down only the owned transports and owned event stream.
|
|
33
33
|
module Smplkit
|
|
34
34
|
module Flags
|
|
35
35
|
# Describes a flag definition change. Frozen — fields are set at construction.
|
|
@@ -37,7 +37,7 @@ module Smplkit
|
|
|
37
37
|
# @!attribute [r] id
|
|
38
38
|
# @return [String] id of the flag whose definition changed.
|
|
39
39
|
# @!attribute [r] source
|
|
40
|
-
# @return [String] origin of the change (e.g. +"
|
|
40
|
+
# @return [String] origin of the change (e.g. +"push"+ for a live
|
|
41
41
|
# update or +"manual"+ for a refresh).
|
|
42
42
|
# @!attribute [r] deleted
|
|
43
43
|
# @return [Boolean] whether the change was a deletion of the flag.
|
|
@@ -148,7 +148,7 @@ module Smplkit
|
|
|
148
148
|
# defaults). +environment+/+service+ resolve the same way (constructor
|
|
149
149
|
# argument wins). The app transport backs the standalone contexts client
|
|
150
150
|
# (evaluation-context registration); the app base URL is returned so a
|
|
151
|
-
# standalone client can open its own
|
|
151
|
+
# standalone client can open its own event stream against the app service.
|
|
152
152
|
#
|
|
153
153
|
# @api private
|
|
154
154
|
def self.flags_transport(api_key:, base_url:, profile:, base_domain:, scheme:,
|
|
@@ -186,8 +186,8 @@ module Smplkit
|
|
|
186
186
|
# is pure CRUD. The live surface (+boolean_flag+ / +string_flag+ /
|
|
187
187
|
# +number_flag+ / +json_flag+ / +refresh+ / +stats+ / +on_change+) connects
|
|
188
188
|
# lazily on first use — the first call flushes discovery, fetches all flag
|
|
189
|
-
# definitions into the local cache, and opens the live-updates
|
|
190
|
-
# explicit install step is required.
|
|
189
|
+
# definitions into the local cache, and opens the live-updates event
|
|
190
|
+
# stream. No explicit install step is required.
|
|
191
191
|
class FlagsClient
|
|
192
192
|
# @param api_key [String, nil] API key. When omitted, resolved from
|
|
193
193
|
# +SMPLKIT_API_KEY+ or +~/.smplkit+.
|
|
@@ -207,8 +207,8 @@ module Smplkit
|
|
|
207
207
|
# @param debug [Boolean, nil] Enable SDK debug logging.
|
|
208
208
|
# @param extra_headers [Hash{String => String}, nil] Extra headers
|
|
209
209
|
# attached to every request.
|
|
210
|
-
# @param streaming [Boolean] Live updates over
|
|
211
|
-
# +true+): the first live call opens a shared
|
|
210
|
+
# @param streaming [Boolean] Live updates over the event stream (default
|
|
211
|
+
# +true+): the first live call opens a shared stream and flag changes
|
|
212
212
|
# stream in. Set +false+ for the stateless read-through surface: the
|
|
213
213
|
# first live call still fetches all flag definitions once (blocking),
|
|
214
214
|
# evaluation stays local, +refresh+ re-fetches on demand, and NO socket
|
|
@@ -263,13 +263,13 @@ module Smplkit
|
|
|
263
263
|
# Live-surface state.
|
|
264
264
|
@flag_store = {}
|
|
265
265
|
@connected = false
|
|
266
|
-
@
|
|
266
|
+
@stream_subscribed = false
|
|
267
267
|
@cache = ResolutionCache.new
|
|
268
268
|
@handles = {}
|
|
269
269
|
@global_listeners = []
|
|
270
270
|
@key_listeners = Hash.new { |h, k| h[k] = [] }
|
|
271
|
-
@
|
|
272
|
-
@
|
|
271
|
+
@event_stream = nil
|
|
272
|
+
@owns_stream = false
|
|
273
273
|
@lock = Mutex.new
|
|
274
274
|
end
|
|
275
275
|
|
|
@@ -566,16 +566,16 @@ module Smplkit
|
|
|
566
566
|
|
|
567
567
|
# Release resources — only those this client owns.
|
|
568
568
|
#
|
|
569
|
-
# Tears down the owned
|
|
570
|
-
# borrows the parent's transport,
|
|
569
|
+
# Tears down the owned event stream (standalone install). A wired client
|
|
570
|
+
# borrows the parent's transport, event stream, and contexts client and
|
|
571
571
|
# closes none of them.
|
|
572
572
|
#
|
|
573
573
|
# @return [void]
|
|
574
574
|
def close
|
|
575
|
-
if @
|
|
576
|
-
@
|
|
577
|
-
@
|
|
578
|
-
@
|
|
575
|
+
if @owns_stream && @event_stream
|
|
576
|
+
@event_stream.stop
|
|
577
|
+
@event_stream = nil
|
|
578
|
+
@owns_stream = false
|
|
579
579
|
end
|
|
580
580
|
# Owned flags/app transports (standalone construction) release their
|
|
581
581
|
# Faraday connections on GC; there is no explicit shutdown to call.
|
|
@@ -680,29 +680,31 @@ module Smplkit
|
|
|
680
680
|
end
|
|
681
681
|
|
|
682
682
|
# ----------------------------------------------------------------
|
|
683
|
-
# Live surface: lazy connect + transport /
|
|
683
|
+
# Live surface: lazy connect + transport / event stream helpers
|
|
684
684
|
# ----------------------------------------------------------------
|
|
685
685
|
|
|
686
|
-
def
|
|
687
|
-
return @parent.
|
|
686
|
+
def ensure_event_stream
|
|
687
|
+
return @parent._ensure_event_stream unless @parent.nil?
|
|
688
688
|
|
|
689
|
-
if @
|
|
690
|
-
@
|
|
689
|
+
if @event_stream.nil?
|
|
690
|
+
@event_stream = EventStream.new(
|
|
691
691
|
app_base_url: @app_base_url, api_key: @standalone_api_key, metrics: @metrics
|
|
692
692
|
)
|
|
693
|
-
@
|
|
694
|
-
@
|
|
693
|
+
@event_stream.start
|
|
694
|
+
@owns_stream = true
|
|
695
695
|
end
|
|
696
|
-
@
|
|
696
|
+
@event_stream
|
|
697
697
|
end
|
|
698
698
|
|
|
699
699
|
# Open the live connection to the running Smpl Flags service.
|
|
700
700
|
#
|
|
701
701
|
# Flushes any buffered discovery declarations, fetches all flag
|
|
702
|
-
# definitions into the local cache, opens the shared
|
|
702
|
+
# definitions into the local cache, opens the shared event stream, and
|
|
703
703
|
# subscribes to +flag_changed+ / +flag_deleted+ / +flags_changed+ events.
|
|
704
|
-
#
|
|
705
|
-
#
|
|
704
|
+
# Also registers the bulk-refresh path as the stream's reconnect refetch,
|
|
705
|
+
# so a stream outage ends with a full re-sync. In stateless mode
|
|
706
|
+
# (+streaming: false+) no stream is ever created; +refresh+ re-fetches on
|
|
707
|
+
# demand.
|
|
706
708
|
#
|
|
707
709
|
# Idempotent and internal — every live method calls it on first use, so
|
|
708
710
|
# the live surface auto-connects with no explicit step.
|
|
@@ -723,13 +725,14 @@ module Smplkit
|
|
|
723
725
|
@connected = true
|
|
724
726
|
return unless @streaming
|
|
725
727
|
|
|
726
|
-
@
|
|
727
|
-
return if @
|
|
728
|
+
@event_stream = ensure_event_stream
|
|
729
|
+
return if @stream_subscribed
|
|
728
730
|
|
|
729
|
-
@
|
|
730
|
-
@
|
|
731
|
-
@
|
|
732
|
-
@
|
|
731
|
+
@event_stream.on("flag_changed") { |data| handle_flag_changed(data) }
|
|
732
|
+
@event_stream.on("flag_deleted") { |data| handle_flag_deleted(data) }
|
|
733
|
+
@event_stream.on("flags_changed") { |data| handle_flags_changed(data) }
|
|
734
|
+
@event_stream.on_reconnect { handle_flags_changed({}) }
|
|
735
|
+
@stream_subscribed = true
|
|
733
736
|
end
|
|
734
737
|
|
|
735
738
|
def do_refresh(_source)
|
|
@@ -759,7 +762,7 @@ module Smplkit
|
|
|
759
762
|
end
|
|
760
763
|
|
|
761
764
|
# ----------------------------------------------------------------
|
|
762
|
-
# Internal: event handlers (called by
|
|
765
|
+
# Internal: event handlers (called by EventStream)
|
|
763
766
|
# ----------------------------------------------------------------
|
|
764
767
|
|
|
765
768
|
def handle_flag_changed(data)
|
|
@@ -770,7 +773,7 @@ module Smplkit
|
|
|
770
773
|
new_data = fetch_flag_single_data(key)
|
|
771
774
|
@flag_store[key] = new_data
|
|
772
775
|
@cache.clear
|
|
773
|
-
fire_change_listeners(key, "
|
|
776
|
+
fire_change_listeners(key, "push") if pre != new_data
|
|
774
777
|
end
|
|
775
778
|
|
|
776
779
|
def handle_flag_deleted(data)
|
|
@@ -780,7 +783,7 @@ module Smplkit
|
|
|
780
783
|
existed = @flag_store.key?(key)
|
|
781
784
|
@flag_store.delete(key)
|
|
782
785
|
@cache.clear
|
|
783
|
-
fire_change_listeners(key, "
|
|
786
|
+
fire_change_listeners(key, "push", deleted: true) if existed
|
|
784
787
|
end
|
|
785
788
|
|
|
786
789
|
def handle_flags_changed(_data)
|
|
@@ -788,7 +791,7 @@ module Smplkit
|
|
|
788
791
|
begin
|
|
789
792
|
fetch_all_flags
|
|
790
793
|
rescue StandardError => e
|
|
791
|
-
Smplkit.debug("
|
|
794
|
+
Smplkit.debug("flags", "flags refresh after flags_changed failed: #{e.message}")
|
|
792
795
|
return
|
|
793
796
|
end
|
|
794
797
|
@cache.clear
|
|
@@ -798,7 +801,7 @@ module Smplkit
|
|
|
798
801
|
return if changed.empty?
|
|
799
802
|
|
|
800
803
|
# Global listener fires once.
|
|
801
|
-
first_event = FlagChangeEvent.new(id: changed.first, source: "
|
|
804
|
+
first_event = FlagChangeEvent.new(id: changed.first, source: "push")
|
|
802
805
|
@global_listeners.each do |cb|
|
|
803
806
|
cb.call(first_event)
|
|
804
807
|
rescue StandardError => e
|
|
@@ -808,7 +811,7 @@ module Smplkit
|
|
|
808
811
|
# Per-key listeners fire for each changed key.
|
|
809
812
|
changed.each do |k|
|
|
810
813
|
deleted = pre_store.key?(k) && !post_store.key?(k)
|
|
811
|
-
event = FlagChangeEvent.new(id: k, source: "
|
|
814
|
+
event = FlagChangeEvent.new(id: k, source: "push", deleted: deleted)
|
|
812
815
|
@key_listeners[k].each do |cb|
|
|
813
816
|
cb.call(event)
|
|
814
817
|
rescue StandardError => e
|
data/lib/smplkit/jobs/client.rb
CHANGED
|
@@ -268,8 +268,8 @@ module Smplkit
|
|
|
268
268
|
# The Smpl Jobs client — accessed via +client.jobs+.
|
|
269
269
|
#
|
|
270
270
|
# Unlike Config/Flags/Logging, Jobs has no live "phone-home" agent — no
|
|
271
|
-
# environment registration, no
|
|
272
|
-
# one client. Defining a job, triggering a run, and reading run history are
|
|
271
|
+
# environment registration, no event stream — so its entire surface lives
|
|
272
|
+
# on one client. Defining a job, triggering a run, and reading run history are
|
|
273
273
|
# all plain request/response calls here:
|
|
274
274
|
#
|
|
275
275
|
# client.jobs.{new_recurring_job,new_manual_job,schedule,get,list,delete,run,usage}
|