intrinio-realtime 5.3.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 +453 -155
  3. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f696c1e3e3db91f9218efaf3fcb490c54791debf839ff10d564c0d29ced96039
4
- data.tar.gz: 1531abd465e870b89122bb2c4865a5cabe8ac24703b1cb77f5cd0f0045645158
3
+ metadata.gz: 4489672939eedb16281b3cafdf67b88e272017854716b482be7c2e4f8da705be
4
+ data.tar.gz: 9aca80494ec93e780e033edf64fbf4ef9ce87ab2fb0d86756748be8ca3d04344
5
5
  SHA512:
6
- metadata.gz: 50311b794219b5c7075e358f371723faaefef1bfd0702f28094f715f1f1b030c7102eaf17dcf50beb00a894dcd7c54b9c4e5090937e638fd495785e811143fa5
7
- data.tar.gz: 348ed82e5ae99575c8989d753229ad9a9aeabb077737690839b0d281e631e9d19262d0638b9325c8fe62566c06dc7a9d10a1be1b13451e67d80fdde3c11f6291
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'
@@ -25,9 +26,13 @@ module Intrinio
25
26
  ASK = "Ask".freeze
26
27
  BID = "Bid".freeze
27
28
  CLIENT_INFO_HEADER_KEY = "Client-Information".freeze
28
- CLIENT_INFO_HEADER_VALUE = "IntrinioRealtimeRubySDKv5.3".freeze
29
+ CLIENT_INFO_HEADER_VALUE = "IntrinioRealtimeRubySDKv5.4".freeze
29
30
  MESSAGE_VERSION_HEADER_KEY = "UseNewEquitiesFormat".freeze
30
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
31
36
 
32
37
  def self.connect(options, on_trade, on_quote)
33
38
  EM.run do
@@ -150,8 +155,10 @@ module Intrinio
150
155
  def initialize(options, on_trade, on_quote)
151
156
  raise "Options parameter is required" if options.nil? || !options.is_a?(Hash)
152
157
  @stop = false
153
- @messages = Queue.new
154
- 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
155
162
  @on_trade = on_trade
156
163
  @on_quote = on_quote
157
164
 
@@ -189,11 +196,14 @@ module Intrinio
189
196
  end
190
197
 
191
198
  @threads = []
199
+ @worker_generation = 0
200
+ @worker_mutex = Mutex.new
192
201
 
193
202
  @channels = []
194
203
  @channels = parse_channels(options[:channels]) if options[:channels]
195
204
  bad_channels = @channels.select{|x| !x.is_a?(String)}
196
205
  raise "Invalid channels to join: #{bad_channels}" unless bad_channels.empty?
206
+ @channels_mutex = Mutex.new
197
207
 
198
208
  if options[:logger] == false
199
209
  @logger = nil
@@ -205,10 +215,19 @@ module Intrinio
205
215
  end
206
216
 
207
217
  @ready = false
218
+ @connecting = false
219
+ @closing = false
208
220
  @joined_channels = []
209
221
  @selfheal_timer = nil
222
+ @connect_timer = nil
210
223
  @selfheal_backoffs = Array.new(SELF_HEAL_BACKOFFS)
211
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
212
231
  end
213
232
 
214
233
  def provider
@@ -219,9 +238,11 @@ module Intrinio
219
238
  channels = parse_channels(channels)
220
239
  nonconforming = channels.select{|x| !x.is_a?(String)}
221
240
  return error("Invalid channels to join: #{nonconforming}") unless nonconforming.empty?
222
-
223
- @channels.concat(channels)
224
- @channels.uniq!
241
+
242
+ @channels_mutex.synchronize do
243
+ @channels.concat(channels)
244
+ @channels.uniq!
245
+ end
225
246
  debug "Joining channels #{channels}"
226
247
 
227
248
  refresh_channels()
@@ -232,54 +253,78 @@ module Intrinio
232
253
  nonconforming = channels.find{|x| !x.is_a?(String)}
233
254
  return error("Invalid channels to leave: #{nonconforming}") unless nonconforming.empty?
234
255
 
235
- channels.each{|c| @channels.delete(c)}
256
+ @channels_mutex.synchronize do
257
+ channels.each{|c| @channels.delete(c)}
258
+ end
236
259
  debug "Leaving channels #{channels}"
237
260
 
238
261
  refresh_channels()
239
262
  end
240
263
 
241
264
  def leave_all
242
- @channels = []
265
+ @channels_mutex.synchronize {@channels = []}
243
266
  debug "Leaving all channels"
244
267
  refresh_channels()
245
268
  end
246
269
 
247
270
  def connect
248
271
  raise "Must be run from within an EventMachine run loop" unless EM.reactor_running?
249
- return warn("Already connected!") if @ready
250
- debug "Connecting..."
251
-
252
- catch :fatal do
253
- begin
254
- @closing = false
255
- @ready = false
256
- refresh_token()
257
- refresh_websocket()
258
- rescue StandardError => e
259
- error("Connection error: #{e} \n#{e.backtrace.join("\n")}")
260
- 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
261
288
  end
289
+ start_workers unless connected
290
+ connected
262
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
+ )
263
302
  end
264
303
 
265
304
  def disconnect
266
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
267
- @ready = false
268
- @closing = true
269
- @channels = []
270
- @joined_channels = []
271
- @ws.close() if @ws
272
- @stop = true
273
- sleep(2)
274
- @threads.each { |thread|
275
- if !thread.nil? && (!thread.pending_interrupt? || thread.status == "run" || thread.status == "Sleeping")
276
- then thread.join(7)
277
- elsif !thread.nil?
278
- 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
279
320
  end
280
- }
281
- @threads = []
282
- @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
283
328
  info "Connection closed"
284
329
  end
285
330
 
@@ -294,7 +339,37 @@ module Intrinio
294
339
  private
295
340
 
296
341
  def queue_message(message)
297
- @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}"
298
373
  end
299
374
 
300
375
  def parse_uint64(data)
@@ -376,7 +451,7 @@ module Intrinio
376
451
  trade = parse_trade(data, start_index)
377
452
  @on_trade.call(trade)
378
453
  return start_index + msg_length
379
- when 1 || 2 then
454
+ when 1, 2 then
380
455
  quote = parse_quote(data, start_index, msg_type)
381
456
  @on_quote.call(quote)
382
457
  return start_index + msg_length
@@ -384,47 +459,38 @@ module Intrinio
384
459
  return start_index + msg_length
385
460
  end
386
461
 
387
- def handle_data
388
- Thread.current.priority -= 1
389
- me = self
462
+ def handle_data(generation)
463
+ Thread.current.priority -= 1 rescue nil
390
464
  pop = nil
391
- until @stop do
465
+ until @stop || generation != @worker_generation do
392
466
  begin
393
- pop = nil
394
- data = nil
395
- pop = @messages.deq
396
- if pop.nil? || pop.empty?
397
- sleep(0.1)
398
- else
399
- begin
400
- data = pop.unpack('C*')
401
- rescue StandardError => ex
402
- me.send :error, "Error unpacking data from queue: #{ex} #{pop}"
403
- next
404
- end
405
- if !data then me.send :error, "Cannot process data. Data is nil. #{pop}" end
406
- start_index = 1
407
- count = data[0]
408
- # These are grouped (many) messages.
409
- # The first byte tells us how many there are.
410
- # From there, check the type and symbol length at index 0 of each chunk to know how many bytes each message has.
411
- count.times {start_index = handle_message(data, start_index)}
412
- 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)
413
478
  rescue StandardError => e
414
- 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}"
415
480
  rescue Exception => e
416
- #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}"
417
483
  end
418
484
  end
419
485
  end
420
486
 
421
487
  def refresh_token
422
- @token = nil
423
-
424
488
  uri = URI.parse(auth_url)
425
489
  http = Net::HTTP.new(uri.host, uri.port)
426
490
  http.use_ssl = true if (auth_url.include?("https"))
427
- 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=)
428
494
  request = Net::HTTP::Get.new(uri.request_uri)
429
495
  request.add_field(CLIENT_INFO_HEADER_KEY, CLIENT_INFO_HEADER_VALUE)
430
496
 
@@ -432,13 +498,13 @@ module Intrinio
432
498
  request.basic_auth(@username, @password)
433
499
  end
434
500
 
435
- response = http.request(request)
501
+ response = http.start {|session| session.request(request)}
436
502
 
437
- return fatal("Unable to authorize") if response.code == "401"
438
- 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"
439
505
 
440
- @token = response.body
441
506
  debug "Token refreshed"
507
+ response.body
442
508
  end
443
509
 
444
510
  def auth_url
@@ -484,118 +550,350 @@ module Intrinio
484
550
  end
485
551
  end
486
552
 
487
- def refresh_websocket
488
- me = self
489
-
490
- @ws.close() unless @ws.nil?
491
- @ready = false
492
- @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
+ }
493
560
 
494
- @stop = true
495
- sleep(2)
496
- @threads.each { |thread|
497
- if !thread.nil? && (!thread.pending_interrupt? || thread.status == "run" || thread.status == "Sleeping")
498
- then thread.join(7)
499
- elsif !thread.nil?
500
- 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
501
566
  end
502
- }
503
- @threads = []
504
- @stop = false
505
- @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
506
573
 
507
- headers = {}
508
- headers[:headers] = {}
509
- headers[CLIENT_INFO_HEADER_KEY] = CLIENT_INFO_HEADER_VALUE
510
- headers[MESSAGE_VERSION_HEADER_KEY] = MESSAGE_VERSION_HEADER_VALUE
511
- @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
512
578
 
513
- me.send :info, "Connection opening"
579
+ def open_websocket(url, headers, &block)
580
+ WebSocket::Client::Simple.connect(url, headers, &block)
581
+ end
514
582
 
515
- ws.on :open do
516
- me.send :info, "Connection established"
517
- me.send :ready, true
518
- if PROVIDERS.include?(me.send(:provider))
519
- me.send :refresh_channels
520
- end
521
- 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)}
522
588
  end
523
589
 
524
- ws.on :message do |frame|
525
- data_message = frame.data
526
- #me.send :debug, "Message: #{data_message}"
527
- begin
528
- unless data_message.nil?
529
- then me.send :queue_message, data_message
530
- end
531
- rescue StandardError => e
532
- 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
533
595
  end
596
+ queue_message(frame.data) unless frame.data.nil?
534
597
  end
535
-
536
- ws.on :close do |e|
537
- me.send :ready, false
538
- me.send :info, "Connection closing...: #{e}"
539
- me.send :try_self_heal
598
+
599
+ bind_socket_event(ws, :close) do |exception|
600
+ schedule_on_reactor {websocket_closed(generation, ws, exception)}
540
601
  end
541
602
 
542
- ws.on :error do |e|
543
- me.send :ready, false
544
- me.send :error, "Connection error: #{e}"
545
- me.send :try_self_heal
603
+ bind_socket_event(ws, :error) do |exception|
604
+ schedule_on_reactor {websocket_errored(generation, ws, exception)}
546
605
  end
547
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
548
654
 
549
655
  def refresh_channels
550
- return unless @ready
551
- debug "Refreshing channels"
552
-
553
- # Join new channels
554
- new_channels = @channels - @joined_channels
555
- new_channels.each do |channel|
556
- #msg = join_message(channel)
557
- #@ws.send(msg.to_json)
558
- msg = join_binary_message(channel)
559
- @ws.send(msg)
560
- info "Joined #{channel}"
656
+ state = @state_mutex.synchronize do
657
+ [@ready, @ws, @connection_generation]
561
658
  end
562
-
563
- # Leave old channels
564
- old_channels = @joined_channels - @channels
565
- old_channels.each do |channel|
566
- #msg = leave__message(channel)
567
- #@ws.send(msg.to_json)
568
- msg = leave_binary_message(channel)
569
- @ws.send(msg)
570
- 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
571
690
  end
572
-
573
- @channels.uniq!
574
- @joined_channels = Array.new(@channels)
575
- 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
576
697
  end
577
698
 
578
699
  def try_self_heal
579
- 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
580
710
  debug "Attempting to self-heal"
581
-
582
- time = @selfheal_backoffs.first
583
- @selfheal_backoffs.delete_at(0) if @selfheal_backoffs.count > 1
584
-
585
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
586
-
587
- @selfheal_timer = EM.add_timer(time/1000) do
588
- 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
589
723
  end
590
724
  end
591
725
 
592
726
  def stop_self_heal
593
- EM.cancel_timer(@selfheal_timer) if @selfheal_timer
594
- @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
595
893
  end
596
894
 
597
895
  def ready(val)
598
- @ready = val
896
+ @state_mutex.synchronize {@ready = val}
599
897
  end
600
898
 
601
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.3.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-09-03 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