kameleoon-client-ruby 3.21.0 → 3.22.1

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.
@@ -10,6 +10,13 @@ require 'kameleoon/data/manager/assigned_variation'
10
10
  require 'kameleoon/data/manager/forced_experiment_variation'
11
11
  require 'kameleoon/data/manager/legal_consent'
12
12
  require 'kameleoon/data/manager/visitor_manager'
13
+ require 'kameleoon/events/data_file_update_event'
14
+ require 'kameleoon/events/data_file_update_handler'
15
+ require 'kameleoon/events/event_manager'
16
+ require 'kameleoon/events/event_type'
17
+ require 'kameleoon/events/http_request_failure'
18
+ require 'kameleoon/events/http_request_handler'
19
+ require 'kameleoon/events/request_type'
13
20
  require 'kameleoon/exceptions'
14
21
  require 'kameleoon/hybrid/manager'
15
22
  require 'kameleoon/managers/data/data_manager'
@@ -22,8 +29,7 @@ require 'kameleoon/network/activity_event'
22
29
  require 'kameleoon/network/network_manager'
23
30
  require 'kameleoon/network/url_provider'
24
31
  require 'kameleoon/network/cookie/cookie_manager'
25
- require 'kameleoon/real_time/real_time_configuration_service'
26
- require 'kameleoon/storage/cache_factory'
32
+ require 'kameleoon/real_time/real_time_event_service'
27
33
  require 'kameleoon/targeting/models'
28
34
  require 'kameleoon/targeting/targeting_manager'
29
35
  require 'kameleoon/types/variable'
@@ -58,7 +64,8 @@ module Kameleoon
58
64
  @scheduler = Rufus::Scheduler.new
59
65
  @site_code = site_code
60
66
  @config = config
61
- @real_time_configuration_service = nil
67
+ @disposed = false
68
+ @real_time_event_service = nil
62
69
  @update_configuration_handler = nil
63
70
  @fetch_configuration_update_job = nil
64
71
  data_file = Configuration::DataFile.new(config.environment, nil)
@@ -67,11 +74,13 @@ module Kameleoon
67
74
  @data_manager, config.session_duration_second, @scheduler
68
75
  )
69
76
  @hybrid_manager = Hybrid::ManagerImpl.new(HYBRID_EXPIRATION_TIME, @data_manager)
77
+ @event_manager = Events::EventManager.new
70
78
  @network_manager = Network::NetworkManager.new(
71
79
  config.environment,
72
80
  config.default_timeout_millisecond,
73
81
  Network::AccessTokenSourceFactory.new(config.client_id, config.client_secret),
74
- Network::UrlProvider.new(site_code, config.network_domain)
82
+ Network::UrlProvider.new(site_code, config.network_domain),
83
+ @event_manager
75
84
  )
76
85
  @tracking_manager = Managers::Tracking::TrackingManager.new(
77
86
  @data_manager, @network_manager, @visitor_manager, config.tracking_interval_second, @scheduler
@@ -81,7 +90,7 @@ module Kameleoon
81
90
  @data_manager, @network_manager, @visitor_manager
82
91
  )
83
92
  @cookie_manager = Network::Cookie::CookieManager.new(@data_manager, @visitor_manager, config.top_level_domain)
84
- @readiness = ClientReadiness.new
93
+ @readiness = ClientReadiness.new(site_code, config.environment)
85
94
  @targeting_manager = Targeting::TargetingManager.new(@data_manager, @visitor_manager)
86
95
 
87
96
  if @config.verbose_mode == true && Logging::KameleoonLogger.log_level == Logging::LogLevel::WARNING
@@ -95,25 +104,53 @@ module Kameleoon
95
104
  ##
96
105
  # Block until the SDK has loaded its configuration and is ready to use.
97
106
  #
98
- # If the initial fetch fails the client keeps retrying in the background, so
99
- # this method returns as soon as any fetch succeeds. The wait is always
100
- # bounded by a timeout, so it never blocks indefinitely: when the timeout
101
- # elapses before the configuration is loaded, the method returns false.
107
+ # The method returns the result of the configuration fetch: `true` once the
108
+ # SDK has been initialized, or `false` as soon as the fetch has failed
109
+ # (without waiting for background retries) or when no fetch result is
110
+ # available within the timeout. The failure which prevented the SDK from
111
+ # initializing is reported to the log.
112
+ #
113
+ # An expired timeout or a failed fetch does not affect the SDK state: the
114
+ # client keeps retrying in the background, and once the SDK becomes ready,
115
+ # a subsequent call returns true.
102
116
  #
103
117
  # @param [Integer, nil] timeout_millisecond Maximum time to wait, in
104
118
  # milliseconds. When nil or negative, config.default_timeout_millisecond
105
119
  # is applied.
106
- # @return [Boolean] true if the SDK is ready, false if the timeout elapsed first.
120
+ # @return [Boolean] true if the SDK is ready, false if the fetch failed or
121
+ # the timeout elapsed first.
107
122
  def wait_init(timeout_millisecond = nil)
108
123
  Logging::KameleoonLogger.info('CALL: KameleoonClient.wait_init(timeout_millisecond: %s)', timeout_millisecond)
109
124
  if timeout_millisecond.nil? || timeout_millisecond.negative?
110
125
  timeout_millisecond = @config.default_timeout_millisecond
111
126
  end
112
- result = @readiness.wait(timeout_millisecond / 1000.0)
127
+ error = @readiness.wait_with_timeout(timeout_millisecond / 1000.0)
128
+ if error.nil?
129
+ Logging::KameleoonLogger.info('Kameleoon is initialized')
130
+ else
131
+ Logging::KameleoonLogger.error('Kameleoon failed to initialize due to error: %s', error.message)
132
+ end
133
+ result = error.nil?
113
134
  Logging::KameleoonLogger.info('RETURN: KameleoonClient.wait_init -> (result: %s)', result)
114
135
  result
115
136
  end
116
137
 
138
+ ##
139
+ # Indicates whether the SDK is ready for use, i.e. its configuration has been
140
+ # successfully loaded. Unlike `wait_init`, it returns immediately without blocking.
141
+ #
142
+ # It returns `true` if the SDK has been successfully initialized, `false` otherwise
143
+ # (including while the initialization is still pending or has failed).
144
+ #
145
+ # Readiness is monotonic: once the SDK becomes ready it stays ready.
146
+ #
147
+ # @return [Boolean] whether the SDK is ready for use.
148
+ def is_ready? # rubocop:disable Naming/PredicateName
149
+ ready = @readiness.ready?
150
+ Logging::KameleoonLogger.info('CALL/RETURN: KameleoonClient.is_ready? -> (ready: %s)', ready)
151
+ ready
152
+ end
153
+
117
154
  ##
118
155
  # Obtain a visitor code.
119
156
  #
@@ -712,6 +749,24 @@ module Kameleoon
712
749
  map_active_features
713
750
  end
714
751
 
752
+ ##
753
+ # Sets the SDK event handler for the specified event type.
754
+ #
755
+ # The handler is called when the corresponding SDK event occurs. Supported event types are
756
+ # defined in `Kameleoon::Events::EventType`. Passing `nil` clears the handler for the
757
+ # specified event type.
758
+ #
759
+ # @param event_type [Symbol] The SDK event type to handle, one of the
760
+ # `Kameleoon::Events::EventType` constants.
761
+ # @param handler [Object, nil] The handler to register, or `nil` to remove the current handler.
762
+ # The handler must respond to the methods required by the selected event type
763
+ # (see `Kameleoon::Events::HttpRequestHandler` and `Kameleoon::Events::DataFileUpdateHandler`).
764
+ def set_event_handler(event_type, handler)
765
+ Logging::KameleoonLogger.info("CALL: KameleoonClient.set_event_handler(event_type: '%s', handler)", event_type)
766
+ @event_manager.set_event_handler(event_type, handler)
767
+ Logging::KameleoonLogger.info("RETURN: KameleoonClient.set_event_handler(event_type: '%s', handler)", event_type)
768
+ end
769
+
715
770
  ##
716
771
  # The `on_update_configuration()` method allows you to handle the event when configuration
717
772
  # has updated data. It takes one input parameter: callable **handler**. The handler
@@ -719,7 +774,13 @@ module Kameleoon
719
774
  #
720
775
  # @param handler [Callable | NilClass] The handler that will be called when the configuration
721
776
  # is updated using a real-time configuration event.
777
+ #
778
+ # DEPRECATED. Please use `set_event_handler(Kameleoon::Events::EventType::DATAFILE_UPDATE, handler)` instead.
722
779
  def on_update_configuration(handler)
780
+ Logging::KameleoonLogger.info(
781
+ '[DEPRECATION] `on_update_configuration` is deprecated. ' \
782
+ 'Please use `set_event_handler(Kameleoon::Events::EventType::DATAFILE_UPDATE, handler)` instead.'
783
+ )
723
784
  Logging::KameleoonLogger.info('CALL/RETURN: KameleoonClient.on_update_configuration(handler)')
724
785
  @update_configuration_handler = handler
725
786
  end
@@ -826,44 +887,41 @@ module Kameleoon
826
887
 
827
888
  HYBRID_EXPIRATION_TIME = 5
828
889
 
829
- def fetch_configuration_initially
830
- Logging::KameleoonLogger.info('Initial configuration fetch is started.')
831
- Thread.new do
832
- ok = false
833
- begin
834
- ok = obtain_configuration
835
- Logging::KameleoonLogger.error('Initial configuration fetch failed') unless ok
836
- rescue StandardError => e
837
- Logging::KameleoonLogger.error('Initial configuration fetch failed: %s', e)
838
- end
839
- @readiness.set(ok)
840
- real_time_update = ok && @data_manager.data_file.settings.real_time_update
841
- manage_configuration_update(real_time_update)
842
- end
843
- end
844
-
890
+ # Fetches the configuration in a background thread; used for the initial fetch,
891
+ # the polling schedule, and the streaming events alike. The fetch outcome settles
892
+ # the client readiness, and a failed fetch always falls back to the polling mode:
893
+ # a streaming event means the configuration has changed, so staying in the
894
+ # streaming mode would leave the SDK serving the outdated configuration until
895
+ # the next event arrives. The server-provided settings are not mutated, so a
896
+ # later successful fetch switches back to streaming when requested.
845
897
  def fetch_configuration_job(time_stamp = nil)
846
898
  Thread.new do
899
+ cause = nil
847
900
  ok = false
848
901
  begin
849
902
  ok = obtain_configuration(time_stamp)
850
903
  rescue StandardError => e
904
+ cause = e
851
905
  Logging::KameleoonLogger.error('Error occurred during configuration fetching: %s', e)
852
906
  end
853
- # Release any threads still waiting in `wait_init` once a (re)fetch
854
- # finally succeeds after an unsuccessful initial fetch.
855
- @readiness.set(ok)
856
- real_time_update = @data_manager.data_file.settings.real_time_update
857
- if !ok && real_time_update
858
- @data_manager.data_file.settings.real_time_update = false
859
- real_time_update = false
860
- Logging::KameleoonLogger.warning('Switching to polling mode due to failed fetch')
861
- end
862
- manage_configuration_update(real_time_update)
907
+ settle_readiness(ok, cause)
908
+ manage_configuration_update(ok && @data_manager.data_file.settings.real_time_update)
863
909
  end
864
910
  end
865
911
 
866
- def start_configuration_update_job_if_needed
912
+ # A successful fetch marks the SDK ready, a failed one reports the failure so
913
+ # `wait_init` fails fast instead of sleeping out its timeout. A failure never
914
+ # reverts an already-ready client.
915
+ def settle_readiness(ok, cause)
916
+ if ok
917
+ @readiness.mark_ready
918
+ else
919
+ cause ||= Exception::KameleoonError.new('configuration fetch failed')
920
+ @readiness.mark_not_ready(cause)
921
+ end
922
+ end
923
+
924
+ def start_polling_datafile_update
867
925
  return unless @fetch_configuration_update_job.nil?
868
926
 
869
927
  @fetch_configuration_update_job = @scheduler.schedule_every @config.refresh_interval_second do
@@ -872,7 +930,7 @@ module Kameleoon
872
930
  end
873
931
  end
874
932
 
875
- def stop_configuration_update_job_if_needed
933
+ def stop_polling_datafile_update
876
934
  return if @fetch_configuration_update_job.nil?
877
935
 
878
936
  @fetch_configuration_update_job&.unschedule
@@ -880,27 +938,34 @@ module Kameleoon
880
938
  Logging::KameleoonLogger.info('Scheduled job to fetch configuration is stopped.')
881
939
  end
882
940
 
883
- def start_real_time_configuration_service_if_needed
884
- return unless @real_time_configuration_service.nil?
941
+ def start_real_time_event_service_if_needed
942
+ return unless @real_time_event_service.nil?
885
943
 
886
944
  url = @network_manager.url_provider.make_real_time_url
887
945
  fetch_func = proc { |real_time_event| fetch_configuration_job(real_time_event.time_stamp) }
888
- @real_time_configuration_service =
889
- Kameleoon::RealTime::RealTimeConfigurationService.new(url, fetch_func)
946
+ @real_time_event_service =
947
+ Kameleoon::RealTime::RealTimeEventService.new(url, fetch_func)
890
948
  end
891
949
 
892
- def stop_real_time_configuration_service_if_needed
893
- @real_time_configuration_service&.close
894
- @real_time_configuration_service = nil
950
+ def stop_real_time_event_service_if_needed
951
+ @real_time_event_service&.close
952
+ @real_time_event_service = nil
895
953
  end
896
954
 
955
+ # A disposed client must not (re)start the update services: a fetch which was
956
+ # in flight when `dispose` ran would otherwise resurrect them. `@disposed` is
957
+ # set before `dispose` stops the services, so a fetch completing after that
958
+ # point can only stop things.
897
959
  def manage_configuration_update(is_real_time_update)
898
- if is_real_time_update
899
- stop_configuration_update_job_if_needed
900
- start_real_time_configuration_service_if_needed
960
+ if @disposed
961
+ stop_polling_datafile_update
962
+ stop_real_time_event_service_if_needed
963
+ elsif is_real_time_update
964
+ stop_polling_datafile_update
965
+ start_real_time_event_service_if_needed
901
966
  else
902
- stop_real_time_configuration_service_if_needed
903
- start_configuration_update_job_if_needed
967
+ stop_real_time_event_service_if_needed
968
+ start_polling_datafile_update
904
969
  end
905
970
  end
906
971
 
@@ -927,34 +992,44 @@ module Kameleoon
927
992
  if response.configuration
928
993
  configuration = JSON.parse(response.configuration)
929
994
  data_file = Configuration::DataFile.new(@config.environment, configuration, response.last_modified)
930
- apply_new_configuration(data_file)
931
- call_update_handler_if_needed(!time_stamp.nil?)
995
+ source = time_stamp.nil? ? Events::DataFileUpdateEvent::Source::POLLING
996
+ : Events::DataFileUpdateEvent::Source::STREAMING
997
+ apply_new_configuration(data_file, source)
932
998
  Logging::KameleoonLogger.info('Feature flags are fetched: %s', response.inspect)
933
999
  end
934
1000
  true
935
1001
  end
936
1002
 
937
- def apply_new_configuration(data_file)
938
- Logging::KameleoonLogger.debug('CALL: KameleoonClient.apply_new_configuration(data_file: %s)', data_file)
1003
+ def apply_new_configuration(data_file, source)
1004
+ Logging::KameleoonLogger.debug('CALL: KameleoonClient.apply_new_configuration(data_file: %s, source: %s)',
1005
+ data_file, source)
939
1006
  @data_manager.data_file = data_file
940
1007
  @network_manager.url_provider.apply_data_api_domain(data_file.settings.data_api_domain)
941
- Logging::KameleoonLogger.debug('RETURN: KameleoonClient.apply_new_configuration(data_file: %s)', data_file)
1008
+ @event_manager.fire_data_file_update(Events::DataFileUpdateEvent.new(source, data_file.date_modified))
1009
+ call_deprecated_update_handler if source == Events::DataFileUpdateEvent::Source::STREAMING
1010
+ Logging::KameleoonLogger.debug('RETURN: KameleoonClient.apply_new_configuration(data_file: %s, source: %s)',
1011
+ data_file, source)
942
1012
  end
943
1013
 
944
1014
  ##
945
- # Call the handler when configuration was updated with new time stamp.
946
- #
947
- # @param need_call [Bool] Indicates if we need to call handler or not.
948
- def call_update_handler_if_needed(need_call)
949
- return if !need_call || @update_configuration_handler.nil?
950
-
951
- @update_configuration_handler.call
1015
+ # Call the deprecated update configuration handler when configuration was updated
1016
+ # by a real-time (streaming) notification.
1017
+ def call_deprecated_update_handler
1018
+ handler = @update_configuration_handler
1019
+ return if handler.nil?
1020
+
1021
+ begin
1022
+ handler.call
1023
+ rescue StandardError => e
1024
+ Logging::KameleoonLogger.warning('Update configuration handler failed: %s', e)
1025
+ end
952
1026
  end
953
1027
 
954
1028
  def dispose(_object_id = nil)
955
1029
  Logging::KameleoonLogger.debug('CALL: KameleoonClient.dispose')
956
- stop_configuration_update_job_if_needed
957
- stop_real_time_configuration_service_if_needed
1030
+ @disposed = true
1031
+ stop_polling_datafile_update
1032
+ stop_real_time_event_service_if_needed
958
1033
  @visitor_manager.stop
959
1034
  @tracking_manager.stop
960
1035
  @scheduler.shutdown
@@ -24,7 +24,7 @@ module Kameleoon
24
24
  key = get_client_key(site_code, config.environment)
25
25
  client = @clients.compute_if_absent(key) do
26
26
  client = KameleoonClient.new(site_code, config)
27
- client.send(:fetch_configuration_initially)
27
+ client.send(:fetch_configuration_job)
28
28
  client
29
29
  end
30
30
  Logging::KameleoonLogger.info(
@@ -11,6 +11,7 @@ module Kameleoon
11
11
  class TrackingManager
12
12
  LINES_DELIMETER = "\n"
13
13
  REQUEST_SIZE_LIMIT = 2560 * 1024 # 2.5 * 1024^2 characters
14
+ PROBE_MAX_VISITORS = 1
14
15
 
15
16
  def initialize(
16
17
  data_manager, network_manager, visitor_manager, track_interval_seconds, scheduler
@@ -19,7 +20,8 @@ module Kameleoon
19
20
  'CALL: TrackingManager.new(data_manager, network_manager. visitor_manager, ' \
20
21
  'track_interval_seconds: %s, scheduler)', track_interval_seconds
21
22
  )
22
- @tracking_visitors = LockVisitorTrackingRegistry.new(visitor_manager)
23
+ @tracking_visitors = ConcurrentVisitorTrackingRegistry.new(visitor_manager)
24
+ @probe_mode = false
23
25
  @data_manager = data_manager
24
26
  @network_manager = network_manager
25
27
  @visitor_manager = visitor_manager
@@ -45,10 +47,19 @@ module Kameleoon
45
47
 
46
48
  def track_all
47
49
  Logging::KameleoonLogger.debug('CALL: TrackingManager.track_all')
48
- track(@tracking_visitors.extract)
50
+ if @probe_mode
51
+ track_probe
52
+ else
53
+ track(@tracking_visitors.extract)
54
+ end
49
55
  Logging::KameleoonLogger.debug('RETURN: TrackingManager.track_all')
50
56
  end
51
57
 
58
+ # Test purpose only
59
+ def probe_mode?
60
+ @probe_mode
61
+ end
62
+
52
63
  def track_visitor(visitor_code)
53
64
  Logging::KameleoonLogger.debug("CALL: TrackingManager.track_visitor(visitor_code: '%s')", visitor_code)
54
65
  track([visitor_code])
@@ -57,6 +68,14 @@ module Kameleoon
57
68
 
58
69
  private
59
70
 
71
+ # Sends one visitor's data; visitors without data are dropped (as on the normal path) until one is found.
72
+ def track_probe
73
+ until (visitor_code = @tracking_visitors.extract(PROBE_MAX_VISITORS)).empty?
74
+ return if track(visitor_code)
75
+ end
76
+ end
77
+
78
+ # Returns whether a request was sent.
60
79
  def track(visitor_codes)
61
80
  builder = TrackingBuilder.new(visitor_codes, @data_manager.data_file, @visitor_manager, REQUEST_SIZE_LIMIT)
62
81
  builder.build
@@ -71,8 +90,9 @@ module Kameleoon
71
90
  perform_tracking_request(builder.visitor_codes_to_send, builder.unsent_visitor_data, builder.tracking_lines)
72
91
  end
73
92
 
93
+ # Returns whether a request was sent.
74
94
  def perform_tracking_request(visitor_codes, visitor_data, tracking_lines)
75
- return if visitor_data.empty?
95
+ return false if visitor_data.empty?
76
96
 
77
97
  lines = tracking_lines.join(LINES_DELIMETER)
78
98
  # mark unsent data as transmitted
@@ -83,14 +103,32 @@ module Kameleoon
83
103
  Logging::KameleoonLogger.info('Successful request for tracking visitors: %s, data: %s',
84
104
  visitor_codes, visitor_data)
85
105
  visitor_data.each { |d| d.mark_as_sent if d.is_a?(Kameleoon::Data) }
106
+ leave_probe_mode
86
107
  else
87
108
  Logging::KameleoonLogger.error('Tracking request failed')
88
109
  Logging::KameleoonLogger.info('Failed request for tracking visitors: %s, data: %s',
89
110
  visitor_codes, visitor_data)
90
111
  visitor_data.each { |d| d.mark_as_unsent if d.is_a?(Kameleoon::Data) }
112
+ enter_probe_mode
91
113
  @tracking_visitors.add_all(visitor_codes)
92
114
  end
93
115
  end
116
+ true
117
+ end
118
+
119
+ def enter_probe_mode
120
+ return if @probe_mode
121
+
122
+ @probe_mode = true
123
+ Logging::KameleoonLogger.info("Tracking request failed: only one visitor's data per tracking request " \
124
+ 'will be sent until a request succeeds')
125
+ end
126
+
127
+ def leave_probe_mode
128
+ return unless @probe_mode
129
+
130
+ @probe_mode = false
131
+ Logging::KameleoonLogger.info('Tracking request succeeded: full-size tracking requests are restored')
94
132
  end
95
133
  end
96
134
  end
@@ -1,91 +1,91 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
-
5
3
  module Kameleoon
6
4
  module Managers
7
5
  module Tracking
8
- class LockVisitorTrackingRegistry
6
+ # Visitor codes waiting for a tracking request, in a Hash used as an insertion-ordered set.
7
+ #
8
+ # Lock-free on the hot path (MRI): `add` is a single Hash#[]=, which the GVL makes atomic (String keys hash
9
+ # in C, so no Ruby code runs inside it), and request threads never block on the registry. The Hash is never
10
+ # swapped out; `extract` removes with Hash#shift (O(1), oldest first) under a lock that only serializes
11
+ # extractors, so a concurrently added code is either returned now or kept for the next extraction, never
12
+ # lost. Nothing here iterates the live Hash: an `add` during iteration would raise in the request thread.
13
+ # Other engines run threads in parallel and take the lock on `add` as well: JRuby's Hash raises
14
+ # ConcurrencyError on unsynchronized concurrent writes; TruffleRuby's shared collections are thread-safe,
15
+ # but the lock-free path has not been measured there.
16
+ class ConcurrentVisitorTrackingRegistry
9
17
  LIMITED_EXTRACTION_THRESHOLD_COEFFICIENT = 2
10
18
  REMOVAL_FACTOR = 0.8
19
+ UNLIMITED_EXTRACTION = 2**62
20
+ LOCK_FREE_ADD = (RUBY_ENGINE == 'ruby')
11
21
 
12
22
  def initialize(visitor_manager, storage_limit = 1_000_000, extraction_limit = 20_000)
13
23
  @visitor_manager = visitor_manager
14
24
  @storage_limit = storage_limit
15
25
  @extraction_limit = extraction_limit
16
- @visitors = Set.new
17
- @mutex = Mutex.new
26
+ @visitors = {}
27
+ @extract_mutex = Mutex.new
18
28
  end
19
29
 
20
- def add(visitor_code)
21
- @mutex.synchronize { @visitors.add(visitor_code) }
22
- end
30
+ if LOCK_FREE_ADD
31
+ def add(visitor_code)
32
+ @visitors[visitor_code] = true
33
+ end
23
34
 
24
- def add_all(visitor_codes)
25
- @mutex.synchronize do
26
- @visitors.merge(visitor_codes)
27
- if @visitors.size > @storage_limit
28
- erase_nonexistent_visitors
29
- erase_to_storage_limit
30
- end
35
+ def add_all(visitor_codes)
36
+ visitor_codes.each { |vc| @visitors[vc] = true }
37
+ erase_to_storage_limit if @visitors.size > @storage_limit
38
+ end
39
+ else
40
+ def add(visitor_code)
41
+ @extract_mutex.synchronize { @visitors[visitor_code] = true }
31
42
  end
43
+
44
+ def add_all(visitor_codes)
45
+ @extract_mutex.synchronize { visitor_codes.each { |vc| @visitors[vc] = true } }
46
+ erase_to_storage_limit if @visitors.size > @storage_limit # takes the (non-reentrant) mutex itself
47
+ end
48
+ end
49
+
50
+ # Removes and returns visitor codes, oldest first: all of them while the registry holds no more than
51
+ # `limit` codes and fewer than twice the extraction limit, otherwise `min(limit, extraction_limit)`.
52
+ def extract(limit = UNLIMITED_EXTRACTION)
53
+ @extract_mutex.synchronize { shift_visitors(extraction_count(@visitors.size, limit)) }
32
54
  end
33
55
 
34
- def extract
35
- should_extract_all_be_used ? extract_all : extract_limited
56
+ # Test purpose only
57
+ def visitors
58
+ @visitors.keys
36
59
  end
37
60
 
38
61
  private
39
62
 
40
- def should_extract_all_be_used
41
- @visitors.size < @extraction_limit * LIMITED_EXTRACTION_THRESHOLD_COEFFICIENT
42
- end
63
+ def extraction_count(size, limit)
64
+ return size if (size <= limit) && (size < @extraction_limit * LIMITED_EXTRACTION_THRESHOLD_COEFFICIENT)
43
65
 
44
- def extract_all
45
- old_visitors = nil
46
- new_visitors = Set.new
47
- @mutex.synchronize do
48
- old_visitors = @visitors
49
- @visitors = new_visitors
50
- end
51
- old_visitors
66
+ [limit, @extraction_limit].min
52
67
  end
53
68
 
54
- def extract_limited
55
- extracted = nil
56
- @mutex.synchronize do
57
- if should_extract_all_be_used
58
- extracted = extract_all
59
- next
60
- end
61
- i = 0
62
- extracted = []
63
- @visitors.each do |vc|
64
- break if i >= @extraction_limit
69
+ # Fewer than `count` codes come back only if the registry runs empty meanwhile.
70
+ def shift_visitors(count)
71
+ extracted = []
72
+ while extracted.size < count
73
+ pair = @visitors.shift
74
+ break if pair.nil?
65
75
 
66
- extracted.push(vc)
67
- i += 1
68
- @visitors.delete(vc)
69
- end
76
+ extracted.push(pair[0])
70
77
  end
71
78
  extracted
72
79
  end
73
80
 
74
- # Not thread-safe
75
- def erase_nonexistent_visitors
76
- @visitors.delete_if { |vc| @visitor_manager.get_visitor(vc).nil? }
77
- end
78
-
79
- # Not thread-safe
80
81
  def erase_to_storage_limit
81
- visitors_to_remove_count = @visitors.size - (@storage_limit * REMOVAL_FACTOR).to_i
82
- return if visitors_to_remove_count <= 0
83
-
84
- @visitors.each do |vc|
85
- break if visitors_to_remove_count.zero?
86
-
87
- visitors_to_remove_count -= 1
88
- @visitors.delete(vc)
82
+ @extract_mutex.synchronize do
83
+ # Drain and re-insert instead of `delete_if`, so that concurrent adds never hit an iteration in progress.
84
+ # Codes added while draining stay in the live Hash.
85
+ drained = shift_visitors(@visitors.size)
86
+ drained.each { |vc| @visitors[vc] = true unless @visitor_manager.peek_visitor(vc).nil? }
87
+ visitors_to_remove_count = @visitors.size - (@storage_limit * REMOVAL_FACTOR).to_i
88
+ visitors_to_remove_count.times { @visitors.shift }
89
89
  end
90
90
  end
91
91
  end