intrinio-realtime 5.2.0 → 5.4.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.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/lib/intrinio-realtime.rb +461 -158
  3. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc3ca7bfcaefea9195e278ebfeb51b51d9dc2b28f6912bc2135cb35f721853a0
4
- data.tar.gz: a3e611f37751686dbc269e8df2b94fa08cc612bf03c0c059c2c517c7b276829b
3
+ metadata.gz: 4489672939eedb16281b3cafdf67b88e272017854716b482be7c2e4f8da705be
4
+ data.tar.gz: 9aca80494ec93e780e033edf64fbf4ef9ce87ab2fb0d86756748be8ca3d04344
5
5
  SHA512:
6
- metadata.gz: a89f564cf81d506753fcef44ee796d80d8917e993bdc49fe65a0a9d0ecabfe7feda9b299ea8c9f61a43917e4883a1cfd40317c8ff1a884e570e2bc943d34cd57
7
- data.tar.gz: a6a41610d3b7bf78c25ff062269b75fad5c91a4ce7c517d8f8fc3d36689b63c07f10a61adf5a24f9b483c06be814fb58d448730a4d57b89daa571a9edb02aeb8
6
+ metadata.gz: 33bed178619655282ae60f6a10bb2e48f0b448c2b3d43c5aef8df82c9dfefff76d6e54fcc6f74b347513b9b461de1ce2859e60e6691349dde957993334832619
7
+ data.tar.gz: 84eaa2fad299104bd9980ac49ef5303c068f3545f03bfbcf56303044be5ba0301bbbb3922618557efcec499eb052baaa8d60f9d82c3a03437472c0219bc86120
@@ -1,5 +1,6 @@
1
1
  require 'logger'
2
2
  require 'uri'
3
+ require 'timeout'
3
4
  #require 'http'
4
5
  require 'net/http'
5
6
  require 'eventmachine'
@@ -19,14 +20,19 @@ module Intrinio
19
20
  OTC = "OTC".freeze
20
21
  IEX = "IEX".freeze
21
22
  CBOE_ONE = "CBOE_ONE".freeze
22
- PROVIDERS = [REALTIME, MANUAL, DELAYED_SIP, NASDAQ_BASIC, IEX, CBOE_ONE].freeze
23
- SUBPROVIDERS = [NO_SUBPROVIDER, CTA_A, CTA_B, UTP, OTC, NASDAQ_BASIC, IEX, CBOE_ONE].freeze
23
+ EQUITIES_EDGE = "EQUITIES_EDGE".freeze
24
+ PROVIDERS = [REALTIME, MANUAL, DELAYED_SIP, NASDAQ_BASIC, IEX, CBOE_ONE, EQUITIES_EDGE].freeze
25
+ SUBPROVIDERS = [NO_SUBPROVIDER, CTA_A, CTA_B, UTP, OTC, NASDAQ_BASIC, IEX, CBOE_ONE, EQUITIES_EDGE].freeze
24
26
  ASK = "Ask".freeze
25
27
  BID = "Bid".freeze
26
28
  CLIENT_INFO_HEADER_KEY = "Client-Information".freeze
27
- CLIENT_INFO_HEADER_VALUE = "IntrinioRealtimeRubySDKv5.2".freeze
29
+ CLIENT_INFO_HEADER_VALUE = "IntrinioRealtimeRubySDKv5.4".freeze
28
30
  MESSAGE_VERSION_HEADER_KEY = "UseNewEquitiesFormat".freeze
29
31
  MESSAGE_VERSION_HEADER_VALUE = "v2".freeze
32
+ CONNECT_TIMEOUT_SECONDS = 30
33
+ HTTP_TIMEOUT_SECONDS = 30
34
+ CLOSE_TIMEOUT_SECONDS = 5
35
+ WORKER_JOIN_TIMEOUT_SECONDS = 5
30
36
 
31
37
  def self.connect(options, on_trade, on_quote)
32
38
  EM.run do
@@ -149,8 +155,10 @@ module Intrinio
149
155
  def initialize(options, on_trade, on_quote)
150
156
  raise "Options parameter is required" if options.nil? || !options.is_a?(Hash)
151
157
  @stop = false
152
- @messages = Queue.new
153
- raise "Unable to create queue." if @messages.nil?
158
+ @trade_messages = Queue.new
159
+ @quote_messages = Queue.new
160
+ @message_available = Queue.new
161
+ @stop_message = Object.new
154
162
  @on_trade = on_trade
155
163
  @on_quote = on_quote
156
164
 
@@ -167,7 +175,7 @@ module Intrinio
167
175
  unless @provider
168
176
  @provider = REALTIME
169
177
  end
170
- raise "Provider must be 'IEX', 'REALTIME', 'DELAYED_SIP', 'NASDAQ_BASIC', 'CBOE_ONE', or 'MANUAL'" unless PROVIDERS.include?(@provider)
178
+ raise "Provider must be 'IEX', 'REALTIME', 'DELAYED_SIP', 'NASDAQ_BASIC', 'CBOE_ONE', or 'EQUITIES_EDGE', or 'MANUAL'" unless PROVIDERS.include?(@provider)
171
179
 
172
180
  @ip_address = options[:ip_address]
173
181
  raise "Missing option ip_address while in MANUAL mode." if @provider == MANUAL and (@ip_address.nil? || @ip_address.empty?)
@@ -188,11 +196,14 @@ module Intrinio
188
196
  end
189
197
 
190
198
  @threads = []
199
+ @worker_generation = 0
200
+ @worker_mutex = Mutex.new
191
201
 
192
202
  @channels = []
193
203
  @channels = parse_channels(options[:channels]) if options[:channels]
194
204
  bad_channels = @channels.select{|x| !x.is_a?(String)}
195
205
  raise "Invalid channels to join: #{bad_channels}" unless bad_channels.empty?
206
+ @channels_mutex = Mutex.new
196
207
 
197
208
  if options[:logger] == false
198
209
  @logger = nil
@@ -204,10 +215,19 @@ module Intrinio
204
215
  end
205
216
 
206
217
  @ready = false
218
+ @connecting = false
219
+ @closing = false
207
220
  @joined_channels = []
208
221
  @selfheal_timer = nil
222
+ @connect_timer = nil
209
223
  @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
210
224
  @ws = nil
225
+ @connection_generation = 0
226
+ @state_mutex = Mutex.new
227
+ @lifecycle_mutex = Mutex.new
228
+ @connect_timeout_seconds = CONNECT_TIMEOUT_SECONDS
229
+ @http_timeout_seconds = HTTP_TIMEOUT_SECONDS
230
+ @close_timeout_seconds = CLOSE_TIMEOUT_SECONDS
211
231
  end
212
232
 
213
233
  def provider
@@ -218,9 +238,11 @@ module Intrinio
218
238
  channels = parse_channels(channels)
219
239
  nonconforming = channels.select{|x| !x.is_a?(String)}
220
240
  return error("Invalid channels to join: #{nonconforming}") unless nonconforming.empty?
221
-
222
- @channels.concat(channels)
223
- @channels.uniq!
241
+
242
+ @channels_mutex.synchronize do
243
+ @channels.concat(channels)
244
+ @channels.uniq!
245
+ end
224
246
  debug "Joining channels #{channels}"
225
247
 
226
248
  refresh_channels()
@@ -231,54 +253,78 @@ module Intrinio
231
253
  nonconforming = channels.find{|x| !x.is_a?(String)}
232
254
  return error("Invalid channels to leave: #{nonconforming}") unless nonconforming.empty?
233
255
 
234
- channels.each{|c| @channels.delete(c)}
256
+ @channels_mutex.synchronize do
257
+ channels.each{|c| @channels.delete(c)}
258
+ end
235
259
  debug "Leaving channels #{channels}"
236
260
 
237
261
  refresh_channels()
238
262
  end
239
263
 
240
264
  def leave_all
241
- @channels = []
265
+ @channels_mutex.synchronize {@channels = []}
242
266
  debug "Leaving all channels"
243
267
  refresh_channels()
244
268
  end
245
269
 
246
270
  def connect
247
271
  raise "Must be run from within an EventMachine run loop" unless EM.reactor_running?
248
- return warn("Already connected!") if @ready
249
- debug "Connecting..."
250
-
251
- catch :fatal do
252
- begin
253
- @closing = false
254
- @ready = false
255
- refresh_token()
256
- refresh_websocket()
257
- rescue StandardError => e
258
- error("Connection error: #{e} \n#{e.backtrace.join("\n")}")
259
- try_self_heal()
272
+
273
+ generation = nil
274
+ reconnect_timer = nil
275
+ already_connected = @lifecycle_mutex.synchronize do
276
+ connected = @state_mutex.synchronize do
277
+ if @ready || @connecting
278
+ true
279
+ else
280
+ @closing = false
281
+ @connecting = true
282
+ @connection_generation += 1
283
+ generation = @connection_generation
284
+ reconnect_timer = @selfheal_timer
285
+ @selfheal_timer = nil
286
+ false
287
+ end
260
288
  end
289
+ start_workers unless connected
290
+ connected
261
291
  end
292
+ return warn("Already connected!") if already_connected
293
+
294
+ cancel_timer(reconnect_timer)
295
+ debug "Connecting..."
296
+
297
+ EM.defer(
298
+ proc { refresh_token },
299
+ proc { |token| finish_authorization(generation, token) },
300
+ proc { |exception| connection_attempt_failed(generation, exception) }
301
+ )
262
302
  end
263
303
 
264
304
  def disconnect
265
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
266
- @ready = false
267
- @closing = true
268
- @channels = []
269
- @joined_channels = []
270
- @ws.close() if @ws
271
- @stop = true
272
- sleep(2)
273
- @threads.each { |thread|
274
- if !thread.nil? && (!thread.pending_interrupt? || thread.status == "run" || thread.status == "Sleeping")
275
- then thread.join(7)
276
- elsif !thread.nil?
277
- then thread.kill
305
+ @lifecycle_mutex.synchronize do
306
+ socket = nil
307
+ reconnect_timer = nil
308
+ connect_timer = nil
309
+ @state_mutex.synchronize do
310
+ @closing = true
311
+ @ready = false
312
+ @connecting = false
313
+ @connection_generation += 1
314
+ socket = @ws
315
+ @ws = nil
316
+ reconnect_timer = @selfheal_timer
317
+ connect_timer = @connect_timer
318
+ @selfheal_timer = nil
319
+ @connect_timer = nil
278
320
  end
279
- }
280
- @threads = []
281
- @stop = false
321
+ @channels_mutex.synchronize {@joined_channels = []}
322
+
323
+ cancel_timer(reconnect_timer)
324
+ cancel_timer(connect_timer)
325
+ close_websocket(socket, true)
326
+ stop_workers
327
+ end
282
328
  info "Connection closed"
283
329
  end
284
330
 
@@ -293,7 +339,37 @@ module Intrinio
293
339
  private
294
340
 
295
341
  def queue_message(message)
296
- @messages.enq(message)
342
+ return if message.nil? || message.empty?
343
+
344
+ message_count = message.getbyte(0)
345
+ raise "Message does not contain an event count" if message_count.nil?
346
+
347
+ start_index = 1
348
+ trades = []
349
+ quotes = []
350
+ message_count.times do
351
+ raise "Message header is truncated at byte #{start_index}" if start_index + 2 > message.bytesize
352
+
353
+ message_type = message.getbyte(start_index)
354
+ message_length = message.getbyte(start_index + 1)
355
+ if message_length.nil? || message_length < 2 || start_index + message_length > message.bytesize
356
+ raise "Message body is truncated at byte #{start_index}"
357
+ end
358
+
359
+ chunk = message.byteslice(start_index, message_length)
360
+ if message_type == 0
361
+ trades << chunk
362
+ else
363
+ quotes << chunk
364
+ end
365
+ start_index += message_length
366
+ end
367
+
368
+ trades.each {|chunk| @trade_messages.enq(chunk)}
369
+ quotes.each {|chunk| @quote_messages.enq(chunk)}
370
+ (trades.length + quotes.length).times {@message_available.enq(true)}
371
+ rescue StandardError => e
372
+ error "Error adding message to queues: #{e}"
297
373
  end
298
374
 
299
375
  def parse_uint64(data)
@@ -330,6 +406,8 @@ module Intrinio
330
406
  IEX
331
407
  when 7
332
408
  CBOE_ONE
409
+ when 8
410
+ EQUITIES_EDGE
333
411
  else
334
412
  IEX
335
413
  end
@@ -373,7 +451,7 @@ module Intrinio
373
451
  trade = parse_trade(data, start_index)
374
452
  @on_trade.call(trade)
375
453
  return start_index + msg_length
376
- when 1 || 2 then
454
+ when 1, 2 then
377
455
  quote = parse_quote(data, start_index, msg_type)
378
456
  @on_quote.call(quote)
379
457
  return start_index + msg_length
@@ -381,47 +459,38 @@ module Intrinio
381
459
  return start_index + msg_length
382
460
  end
383
461
 
384
- def handle_data
385
- Thread.current.priority -= 1
386
- me = self
462
+ def handle_data(generation)
463
+ Thread.current.priority -= 1 rescue nil
387
464
  pop = nil
388
- until @stop do
465
+ until @stop || generation != @worker_generation do
389
466
  begin
390
- pop = nil
391
- data = nil
392
- pop = @messages.deq
393
- if pop.nil? || pop.empty?
394
- sleep(0.1)
395
- else
396
- begin
397
- data = pop.unpack('C*')
398
- rescue StandardError => ex
399
- me.send :error, "Error unpacking data from queue: #{ex} #{pop}"
400
- next
401
- end
402
- if !data then me.send :error, "Cannot process data. Data is nil. #{pop}" end
403
- start_index = 1
404
- count = data[0]
405
- # These are grouped (many) messages.
406
- # The first byte tells us how many there are.
407
- # From there, check the type and symbol length at index 0 of each chunk to know how many bytes each message has.
408
- count.times {start_index = handle_message(data, start_index)}
409
- end
467
+ signal = @message_available.deq
468
+ break if signal.equal?(@stop_message) || @stop || generation != @worker_generation
469
+
470
+ # Always drain a trade first. Quotes can be extremely high volume, so a
471
+ # shared FIFO lets them delay trades even when processing capacity is free.
472
+ pop = nonblocking_dequeue(@trade_messages)
473
+ pop = nonblocking_dequeue(@quote_messages) if pop.nil?
474
+ next if pop.nil?
475
+
476
+ data = pop.unpack('C*')
477
+ handle_message(data, 0)
410
478
  rescue StandardError => e
411
- me.send :error, "Error handling message from queue: #{e} #{pop} : #{data} ; count: #{count} ; start index: #{start_index}"
479
+ error "Error handling message from queue: #{e} #{pop}"
412
480
  rescue Exception => e
413
- #me.send :error, "General error handling message from queue: #{e} #{pop} : #{data} ; count: #{count} ; start index: #{start_index}"
481
+ break if @stop || generation != @worker_generation
482
+ error "General error handling message from queue: #{e} #{pop}"
414
483
  end
415
484
  end
416
485
  end
417
486
 
418
487
  def refresh_token
419
- @token = nil
420
-
421
488
  uri = URI.parse(auth_url)
422
489
  http = Net::HTTP.new(uri.host, uri.port)
423
490
  http.use_ssl = true if (auth_url.include?("https"))
424
- http.start
491
+ http.open_timeout = @http_timeout_seconds
492
+ http.read_timeout = @http_timeout_seconds
493
+ http.write_timeout = @http_timeout_seconds if http.respond_to?(:write_timeout=)
425
494
  request = Net::HTTP::Get.new(uri.request_uri)
426
495
  request.add_field(CLIENT_INFO_HEADER_KEY, CLIENT_INFO_HEADER_VALUE)
427
496
 
@@ -429,13 +498,13 @@ module Intrinio
429
498
  request.basic_auth(@username, @password)
430
499
  end
431
500
 
432
- response = http.request(request)
501
+ response = http.start {|session| session.request(request)}
433
502
 
434
- return fatal("Unable to authorize") if response.code == "401"
435
- return fatal("Could not get auth token") if response.code != "200"
503
+ raise "Unable to authorize" if response.code == "401"
504
+ raise "Could not get auth token (HTTP #{response.code})" if response.code != "200"
436
505
 
437
- @token = response.body
438
506
  debug "Token refreshed"
507
+ response.body
439
508
  end
440
509
 
441
510
  def auth_url
@@ -447,6 +516,7 @@ module Intrinio
447
516
  when DELAYED_SIP then url = "https://realtime-delayed-sip.intrinio.com/auth"
448
517
  when NASDAQ_BASIC then url = "https://realtime-nasdaq-basic.intrinio.com/auth"
449
518
  when CBOE_ONE then url = "https://cboe-one.intrinio.com/auth"
519
+ when EQUITIES_EDGE then url = "https://equities-edge.intrinio.com/auth"
450
520
  when MANUAL then url = "http://" + @ip_address + "/auth"
451
521
  end
452
522
 
@@ -474,123 +544,356 @@ module Intrinio
474
544
  when DELAYED_SIP then "wss://realtime-delayed-sip.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}&#{CLIENT_INFO_HEADER_KEY}=#{CLIENT_INFO_HEADER_VALUE}&#{MESSAGE_VERSION_HEADER_KEY}=#{MESSAGE_VERSION_HEADER_VALUE}#{delayed_part}"
475
545
  when NASDAQ_BASIC then "wss://realtime-nasdaq-basic.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}&#{CLIENT_INFO_HEADER_KEY}=#{CLIENT_INFO_HEADER_VALUE}&#{MESSAGE_VERSION_HEADER_KEY}=#{MESSAGE_VERSION_HEADER_VALUE}#{delayed_part}"
476
546
  when CBOE_ONE then "wss://cboe-one.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}&#{CLIENT_INFO_HEADER_KEY}=#{CLIENT_INFO_HEADER_VALUE}&#{MESSAGE_VERSION_HEADER_KEY}=#{MESSAGE_VERSION_HEADER_VALUE}#{delayed_part}"
547
+ when EQUITIES_EDGE then "wss://equities-edge.intrinio.com/socket/websocket?vsn=1.0.0&token=#{@token}&#{CLIENT_INFO_HEADER_KEY}=#{CLIENT_INFO_HEADER_VALUE}&#{MESSAGE_VERSION_HEADER_KEY}=#{MESSAGE_VERSION_HEADER_VALUE}#{delayed_part}"
477
548
  when MANUAL then "ws://" + @ip_address + "/socket/websocket?vsn=1.0.0&token=#{@token}&#{CLIENT_INFO_HEADER_KEY}=#{CLIENT_INFO_HEADER_VALUE}&#{MESSAGE_VERSION_HEADER_KEY}=#{MESSAGE_VERSION_HEADER_VALUE}#{delayed_part}"
478
549
  else raise "Unknown provider"
479
550
  end
480
551
  end
481
552
 
482
- def refresh_websocket
483
- me = self
484
-
485
- @ws.close() unless @ws.nil?
486
- @ready = false
487
- @joined_channels = []
553
+ def refresh_websocket(generation)
554
+ headers = {
555
+ :headers => {
556
+ CLIENT_INFO_HEADER_KEY => CLIENT_INFO_HEADER_VALUE,
557
+ MESSAGE_VERSION_HEADER_KEY => MESSAGE_VERSION_HEADER_VALUE
558
+ }
559
+ }
488
560
 
489
- @stop = true
490
- sleep(2)
491
- @threads.each { |thread|
492
- if !thread.nil? && (!thread.pending_interrupt? || thread.status == "run" || thread.status == "Sleeping")
493
- then thread.join(7)
494
- elsif !thread.nil?
495
- then thread.kill
561
+ old_socket = @state_mutex.synchronize {@ws}
562
+ ws = open_websocket(socket_url, headers) do |socket|
563
+ install_websocket_handlers(socket, generation)
564
+ @state_mutex.synchronize do
565
+ @ws = socket if generation == @connection_generation && !@closing
496
566
  end
497
- }
498
- @threads = []
499
- @stop = false
500
- @thread_quantity.times {@threads << Thread.new{handle_data}}
567
+ end
568
+
569
+ unless current_connection?(generation, ws)
570
+ close_websocket(ws, false)
571
+ return
572
+ end
501
573
 
502
- headers = {}
503
- headers[:headers] = {}
504
- headers[CLIENT_INFO_HEADER_KEY] = CLIENT_INFO_HEADER_VALUE
505
- headers[MESSAGE_VERSION_HEADER_KEY] = MESSAGE_VERSION_HEADER_VALUE
506
- @ws = ws = WebSocket::Client::Simple.connect(socket_url, headers)
574
+ close_websocket(old_socket, false) if old_socket && !old_socket.equal?(ws)
575
+ schedule_connect_timeout(generation, ws)
576
+ info "Connection opening"
577
+ end
507
578
 
508
- me.send :info, "Connection opening"
579
+ def open_websocket(url, headers, &block)
580
+ WebSocket::Client::Simple.connect(url, headers, &block)
581
+ end
509
582
 
510
- ws.on :open do
511
- me.send :info, "Connection established"
512
- me.send :ready, true
513
- if PROVIDERS.include?(me.send(:provider))
514
- me.send :refresh_channels
515
- end
516
- me.send :stop_self_heal
583
+ def install_websocket_handlers(ws, generation)
584
+ # websocket-client-simple emits with instance_exec, so self inside a
585
+ # raw handler is the socket. Rebind before touching client methods.
586
+ bind_socket_event(ws, :open) do
587
+ schedule_on_reactor {websocket_opened(generation, ws)}
517
588
  end
518
589
 
519
- ws.on :message do |frame|
520
- data_message = frame.data
521
- #me.send :debug, "Message: #{data_message}"
522
- begin
523
- unless data_message.nil?
524
- then me.send :queue_message, data_message
525
- end
526
- rescue StandardError => e
527
- me.send :error, "Error adding message to queue: #{data_message} #{e}"
590
+ bind_socket_event(ws, :message) do |frame|
591
+ next unless current_connection?(generation, ws)
592
+ if frame.respond_to?(:type) && frame.type != :binary
593
+ debug "Ignoring non-binary websocket message"
594
+ next
528
595
  end
596
+ queue_message(frame.data) unless frame.data.nil?
529
597
  end
530
-
531
- ws.on :close do |e|
532
- me.send :ready, false
533
- me.send :info, "Connection closing...: #{e}"
534
- me.send :try_self_heal
598
+
599
+ bind_socket_event(ws, :close) do |exception|
600
+ schedule_on_reactor {websocket_closed(generation, ws, exception)}
535
601
  end
536
602
 
537
- ws.on :error do |e|
538
- me.send :ready, false
539
- me.send :error, "Connection error: #{e}"
540
- me.send :try_self_heal
603
+ bind_socket_event(ws, :error) do |exception|
604
+ schedule_on_reactor {websocket_errored(generation, ws, exception)}
541
605
  end
542
606
  end
607
+
608
+ def bind_socket_event(ws, event, &callback)
609
+ client = self
610
+ ws.on(event) do |*args|
611
+ client.instance_exec(*args, &callback)
612
+ end
613
+ end
614
+
615
+ def websocket_opened(generation, ws)
616
+ timer = nil
617
+ opened = @state_mutex.synchronize do
618
+ if generation == @connection_generation && @ws.equal?(ws) && !@closing
619
+ @ready = true
620
+ @connecting = false
621
+ timer = @connect_timer
622
+ @connect_timer = nil
623
+ true
624
+ else
625
+ false
626
+ end
627
+ end
628
+ return close_websocket(ws, false) unless opened
629
+
630
+ cancel_timer(timer)
631
+ @channels_mutex.synchronize {@joined_channels = []}
632
+ info "Connection established"
633
+
634
+ begin
635
+ channels_refreshed = !PROVIDERS.include?(provider) || refresh_channels
636
+ return unless channels_refreshed
637
+ stop_self_heal
638
+ rescue StandardError => e
639
+ connection_attempt_failed(generation, e, ws)
640
+ end
641
+ end
642
+
643
+ def websocket_closed(generation, ws, exception)
644
+ return unless current_connection?(generation, ws)
645
+ info "Connection closing...: #{exception}"
646
+ connection_attempt_failed(generation, exception || StandardError.new("Websocket closed"), ws)
647
+ end
648
+
649
+ def websocket_errored(generation, ws, exception)
650
+ return unless current_connection?(generation, ws)
651
+ error "Connection error: #{exception}"
652
+ connection_attempt_failed(generation, exception || StandardError.new("Websocket error"), ws)
653
+ end
543
654
 
544
655
  def refresh_channels
545
- return unless @ready
546
- debug "Refreshing channels"
547
-
548
- # Join new channels
549
- new_channels = @channels - @joined_channels
550
- new_channels.each do |channel|
551
- #msg = join_message(channel)
552
- #@ws.send(msg.to_json)
553
- msg = join_binary_message(channel)
554
- @ws.send(msg)
555
- info "Joined #{channel}"
656
+ state = @state_mutex.synchronize do
657
+ [@ready, @ws, @connection_generation]
556
658
  end
557
-
558
- # Leave old channels
559
- old_channels = @joined_channels - @channels
560
- old_channels.each do |channel|
561
- #msg = leave__message(channel)
562
- #@ws.send(msg.to_json)
563
- msg = leave_binary_message(channel)
564
- @ws.send(msg)
565
- info "Left #{channel}"
659
+ return unless state[0] && state[1]
660
+
661
+ ws = state[1]
662
+ generation = state[2]
663
+ debug "Refreshing channels"
664
+
665
+ desired_channels = nil
666
+ @channels_mutex.synchronize do
667
+ desired_channels = Array.new(@channels)
668
+ joined_channels = Array.new(@joined_channels)
669
+
670
+ # Keep the desired and server-side subscription sets in lockstep. Calls
671
+ # to join/leave wait briefly while these sends complete, then perform
672
+ # another refresh with their newer desired set.
673
+ new_channels = desired_channels - joined_channels
674
+ new_channels.each do |channel|
675
+ msg = join_binary_message(channel)
676
+ ws.send(msg, :type => :binary)
677
+ raise "Websocket changed while joining channels" unless current_connection?(generation, ws)
678
+ @joined_channels << channel unless @joined_channels.include?(channel)
679
+ info "Joined #{channel}"
680
+ end
681
+
682
+ old_channels = joined_channels - desired_channels
683
+ old_channels.each do |channel|
684
+ msg = leave_binary_message(channel)
685
+ ws.send(msg, :type => :binary)
686
+ raise "Websocket changed while leaving channels" unless current_connection?(generation, ws)
687
+ @joined_channels.delete(channel)
688
+ info "Left #{channel}"
689
+ end
566
690
  end
567
-
568
- @channels.uniq!
569
- @joined_channels = Array.new(@channels)
570
- debug "Current channels: #{@channels}"
691
+
692
+ debug "Current channels: #{desired_channels}"
693
+ true
694
+ rescue StandardError => e
695
+ connection_attempt_failed(generation, e, ws) if generation && ws
696
+ false
571
697
  end
572
698
 
573
699
  def try_self_heal
574
- return if @closing
700
+ delay = nil
701
+ @state_mutex.synchronize do
702
+ return if @closing || @ready || @connecting || @selfheal_timer
703
+
704
+ delay = @selfheal_backoffs.first
705
+ @selfheal_backoffs.delete_at(0) if @selfheal_backoffs.count > 1
706
+ # A marker prevents close and error callbacks for the same failed socket
707
+ # from scheduling two reconnect loops before EM.add_timer returns.
708
+ @selfheal_timer = :scheduling
709
+ end
575
710
  debug "Attempting to self-heal"
576
-
577
- time = @selfheal_backoffs.first
578
- @selfheal_backoffs.delete_at(0) if @selfheal_backoffs.count > 1
579
-
580
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
581
-
582
- @selfheal_timer = EM.add_timer(time/1000) do
583
- connect()
711
+
712
+ timer = nil
713
+ timer = EM.add_timer(delay / 1000.0) do
714
+ should_connect = @state_mutex.synchronize do
715
+ active = @selfheal_timer.equal?(timer) || @selfheal_timer == :scheduling
716
+ @selfheal_timer = nil if active
717
+ active && !@closing && !@ready && !@connecting
718
+ end
719
+ connect if should_connect
720
+ end
721
+ @state_mutex.synchronize do
722
+ @selfheal_timer = timer if @selfheal_timer == :scheduling
584
723
  end
585
724
  end
586
725
 
587
726
  def stop_self_heal
588
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
589
- @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
727
+ timer = @state_mutex.synchronize do
728
+ value = @selfheal_timer
729
+ @selfheal_timer = nil
730
+ @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
731
+ value
732
+ end
733
+ cancel_timer(timer)
734
+ end
735
+
736
+ def finish_authorization(generation, token)
737
+ authorized = @state_mutex.synchronize do
738
+ if generation == @connection_generation && @connecting && !@closing
739
+ @token = token
740
+ true
741
+ else
742
+ false
743
+ end
744
+ end
745
+ return unless authorized
746
+
747
+ refresh_websocket(generation)
748
+ rescue StandardError => e
749
+ connection_attempt_failed(generation, e)
750
+ end
751
+
752
+ def connection_attempt_failed(generation, exception, socket = nil)
753
+ failed_socket = nil
754
+ connect_timer = nil
755
+ current = @state_mutex.synchronize do
756
+ socket_matches = socket.nil? || @ws.equal?(socket)
757
+ if generation == @connection_generation && socket_matches && !@closing
758
+ failed_socket = @ws
759
+ @ws = nil
760
+ @ready = false
761
+ @connecting = false
762
+ connect_timer = @connect_timer
763
+ @connect_timer = nil
764
+ true
765
+ else
766
+ false
767
+ end
768
+ end
769
+ return unless current
770
+
771
+ @channels_mutex.synchronize {@joined_channels = []}
772
+ cancel_timer(connect_timer)
773
+ close_websocket(failed_socket, false)
774
+ message = exception.respond_to?(:message) ? exception.message : exception.to_s
775
+ error "Connection attempt failed: #{message}"
776
+ try_self_heal
777
+ end
778
+
779
+ def schedule_connect_timeout(generation, ws)
780
+ timer = EM.add_timer(@connect_timeout_seconds) do
781
+ connection_attempt_failed(
782
+ generation,
783
+ Timeout::Error.new("Websocket connect timed out after #{@connect_timeout_seconds} seconds"),
784
+ ws
785
+ )
786
+ end
787
+
788
+ previous_timer = nil
789
+ keep_timer = @state_mutex.synchronize do
790
+ if generation == @connection_generation && @ws.equal?(ws) && @connecting && !@closing
791
+ previous_timer = @connect_timer
792
+ @connect_timer = timer
793
+ true
794
+ else
795
+ false
796
+ end
797
+ end
798
+ cancel_timer(previous_timer)
799
+ cancel_timer(timer) unless keep_timer
800
+ end
801
+
802
+ def current_connection?(generation, ws)
803
+ @state_mutex.synchronize do
804
+ generation == @connection_generation && @ws.equal?(ws) && !@closing
805
+ end
806
+ end
807
+
808
+ def start_workers
809
+ @worker_mutex.synchronize do
810
+ live_threads = @threads.select(&:alive?)
811
+ return if live_threads.length == @thread_quantity
812
+
813
+ live_threads.each(&:kill)
814
+ @threads.each {|thread| thread.join(0.1) if thread.alive?}
815
+ @threads = []
816
+ clear_message_queues
817
+ @stop = false
818
+ @worker_generation += 1
819
+ generation = @worker_generation
820
+ @thread_quantity.times do
821
+ @threads << Thread.new {handle_data(generation)}
822
+ end
823
+ end
824
+ end
825
+
826
+ def stop_workers
827
+ @worker_mutex.synchronize do
828
+ @stop = true
829
+ @worker_generation += 1
830
+ threads = Array.new(@threads)
831
+ threads.length.times {@message_available.enq(@stop_message)}
832
+
833
+ threads.each do |thread|
834
+ next unless thread.alive?
835
+ next if thread.equal?(Thread.current)
836
+ unless thread.join(WORKER_JOIN_TIMEOUT_SECONDS)
837
+ warn "Worker thread timed out while stopping"
838
+ thread.kill
839
+ thread.join(0.1)
840
+ end
841
+ end
842
+
843
+ @threads = []
844
+ clear_message_queues
845
+ @stop = false
846
+ end
847
+ end
848
+
849
+ def clear_message_queues
850
+ @trade_messages.clear
851
+ @quote_messages.clear
852
+ @message_available.clear
853
+ end
854
+
855
+ def nonblocking_dequeue(queue)
856
+ queue.deq(true)
857
+ rescue ThreadError
858
+ nil
859
+ end
860
+
861
+ def close_websocket(ws, wait_for_close)
862
+ return if ws.nil?
863
+
864
+ close_thread = Thread.new do
865
+ begin
866
+ ws.close
867
+ rescue StandardError => e
868
+ error "Error closing websocket: #{e.message}"
869
+ end
870
+ end
871
+ return unless wait_for_close
872
+
873
+ unless close_thread.join(@close_timeout_seconds)
874
+ warn "Websocket close timed out"
875
+ close_thread.kill
876
+ close_thread.join(0.1)
877
+ end
878
+ end
879
+
880
+ def cancel_timer(timer)
881
+ return if timer.nil? || timer == :scheduling
882
+ EM.cancel_timer(timer)
883
+ rescue StandardError
884
+ nil
885
+ end
886
+
887
+ def schedule_on_reactor(&block)
888
+ if EM.reactor_running?
889
+ EM.schedule(&block)
890
+ else
891
+ block.call
892
+ end
590
893
  end
591
894
 
592
895
  def ready(val)
593
- @ready = val
896
+ @state_mutex.synchronize {@ready = val}
594
897
  end
595
898
 
596
899
  def debug(message)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: intrinio-realtime
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.2.0
4
+ version: 5.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Intrinio
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-07-08 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: eventmachine