optimizely-sdk 3.2.0 → 3.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: bb7a6c83f167e9c4e5166a6a4d8d4a9d4a0da2d4
4
- data.tar.gz: 0a6b87960154256a3073c319be67779a395df7e4
3
+ metadata.gz: b6172194b87939a24b453c42267ee0431b7e86f4
4
+ data.tar.gz: 26fabaaa1ff0a7d6ea4aa2d6728b75dbe85d646c
5
5
  SHA512:
6
- metadata.gz: 1b47592ea9b7a94640a3d3d2f876eea4578b726bfc7a644a4ef2427493b510d00e7ebd7f2475cb9fca69c36167afb5691a58465ce639e863caf960e900821e8f
7
- data.tar.gz: d4165dbdb6a9982d9fcd6582c34acbab71a9e41a31ec44037a8aead5462f6daad1142b97ee61ab116e5433836bd283c6356c1658885a7c06513c5246f3297091
6
+ metadata.gz: e7deb33a0386fa18352dc926a9920cd1a7a56373dfb5e7f1a03f1161013b867add78d13cdf051d3cde9762710bb9ee9adbc77669dee0a08ca9b9bda461df8f5e
7
+ data.tar.gz: 2c17a1ca3bef13c7c44e53c1a50ff32991f7abd966d7b9cd7af57708f395ea95a69ecab24090af936488ea21c8810b83c826ba0c01481058bd61a6f5da3d7fbd
data/lib/optimizely.rb CHANGED
@@ -22,6 +22,9 @@ require_relative 'optimizely/config_manager/static_project_config_manager'
22
22
  require_relative 'optimizely/decision_service'
23
23
  require_relative 'optimizely/error_handler'
24
24
  require_relative 'optimizely/event_builder'
25
+ require_relative 'optimizely/event/forwarding_event_processor'
26
+ require_relative 'optimizely/event/event_factory'
27
+ require_relative 'optimizely/event/user_event_factory'
25
28
  require_relative 'optimizely/event_dispatcher'
26
29
  require_relative 'optimizely/exceptions'
27
30
  require_relative 'optimizely/helpers/constants'
@@ -35,8 +38,8 @@ module Optimizely
35
38
  class Project
36
39
  attr_reader :notification_center
37
40
  # @api no-doc
38
- attr_reader :config_manager, :decision_service, :error_handler,
39
- :event_builder, :event_dispatcher, :logger
41
+ attr_reader :config_manager, :decision_service, :error_handler, :event_dispatcher,
42
+ :event_processor, :logger, :stopped
40
43
 
41
44
  # Constructor for Projects.
42
45
  #
@@ -49,8 +52,9 @@ module Optimizely
49
52
  # @param skip_json_validation - Optional boolean param to skip JSON schema validation of the provided datafile.
50
53
  # @params sdk_key - Optional string uniquely identifying the datafile corresponding to project and environment combination.
51
54
  # Must provide at least one of datafile or sdk_key.
52
- # @param config_manager - Optional Responds to get_config.
55
+ # @param config_manager - Optional Responds to 'config' method.
53
56
  # @param notification_center - Optional Instance of NotificationCenter.
57
+ # @param event_processor - Optional Responds to process.
54
58
 
55
59
  def initialize(
56
60
  datafile = nil,
@@ -61,7 +65,8 @@ module Optimizely
61
65
  user_profile_service = nil,
62
66
  sdk_key = nil,
63
67
  config_manager = nil,
64
- notification_center = nil
68
+ notification_center = nil,
69
+ event_processor = nil
65
70
  )
66
71
  @logger = logger || NoOpLogger.new
67
72
  @error_handler = error_handler || NoOpErrorHandler.new
@@ -77,7 +82,7 @@ module Optimizely
77
82
 
78
83
  @notification_center = notification_center.is_a?(Optimizely::NotificationCenter) ? notification_center : NotificationCenter.new(@logger, @error_handler)
79
84
 
80
- @config_manager = if config_manager.respond_to?(:get_config)
85
+ @config_manager = if config_manager.respond_to?(:config)
81
86
  config_manager
82
87
  elsif sdk_key
83
88
  HTTPProjectConfigManager.new(
@@ -91,8 +96,14 @@ module Optimizely
91
96
  else
92
97
  StaticProjectConfigManager.new(datafile, @logger, @error_handler, skip_json_validation)
93
98
  end
99
+
94
100
  @decision_service = DecisionService.new(@logger, @user_profile_service)
95
- @event_builder = EventBuilder.new(@logger)
101
+
102
+ @event_processor = if event_processor.respond_to?(:process)
103
+ event_processor
104
+ else
105
+ ForwardingEventProcessor.new(@event_dispatcher, @logger, @notification_center)
106
+ end
96
107
  end
97
108
 
98
109
  # Buckets visitor and sends impression event to Optimizely.
@@ -243,20 +254,17 @@ module Optimizely
243
254
  return nil
244
255
  end
245
256
 
246
- conversion_event = @event_builder.create_conversion_event(config, event, user_id, attributes, event_tags)
257
+ user_event = UserEventFactory.create_conversion_event(config, event, user_id, attributes, event_tags)
258
+ @event_processor.process(user_event)
247
259
  @logger.log(Logger::INFO, "Tracking event '#{event_key}' for user '#{user_id}'.")
248
- @logger.log(Logger::INFO,
249
- "Dispatching conversion event to URL #{conversion_event.url} with params #{conversion_event.params}.")
250
- begin
251
- @event_dispatcher.dispatch_event(conversion_event)
252
- rescue => e
253
- @logger.log(Logger::ERROR, "Unable to dispatch conversion event. Error: #{e}")
254
- end
255
260
 
256
- @notification_center.send_notifications(
257
- NotificationCenter::NOTIFICATION_TYPES[:TRACK],
258
- event_key, user_id, attributes, event_tags, conversion_event
259
- )
261
+ if @notification_center.notification_count(NotificationCenter::NOTIFICATION_TYPES[:TRACK]).positive?
262
+ log_event = EventFactory.create_log_event(user_event, @logger)
263
+ @notification_center.send_notifications(
264
+ NotificationCenter::NOTIFICATION_TYPES[:TRACK],
265
+ event_key, user_id, attributes, event_tags, log_event
266
+ )
267
+ end
260
268
  nil
261
269
  end
262
270
 
@@ -369,6 +377,32 @@ module Optimizely
369
377
  enabled_features
370
378
  end
371
379
 
380
+ # Get the value of the specified variable in the feature flag.
381
+ #
382
+ # @param feature_flag_key - String key of feature flag the variable belongs to
383
+ # @param variable_key - String key of variable for which we are getting the value
384
+ # @param user_id - String user ID
385
+ # @param attributes - Hash representing visitor attributes and values which need to be recorded.
386
+ #
387
+ # @return [*] the type-casted variable value.
388
+ # @return [nil] if the feature flag or variable are not found.
389
+
390
+ def get_feature_variable(feature_flag_key, variable_key, user_id, attributes = nil)
391
+ unless is_valid
392
+ @logger.log(Logger::ERROR, InvalidProjectConfigError.new('get_feature_variable').message)
393
+ return nil
394
+ end
395
+ variable_value = get_feature_variable_for_type(
396
+ feature_flag_key,
397
+ variable_key,
398
+ nil,
399
+ user_id,
400
+ attributes
401
+ )
402
+
403
+ variable_value
404
+ end
405
+
372
406
  # Get the String value of the specified variable in the feature flag.
373
407
  #
374
408
  # @param feature_flag_key - String key of feature flag the variable belongs to
@@ -481,6 +515,14 @@ module Optimizely
481
515
  config.is_a?(Optimizely::ProjectConfig)
482
516
  end
483
517
 
518
+ def close
519
+ return if @stopped
520
+
521
+ @stopped = true
522
+ @config_manager.stop! if @config_manager.respond_to?(:stop!)
523
+ @event_processor.stop! if @event_processor.respond_to?(:stop!)
524
+ end
525
+
484
526
  private
485
527
 
486
528
  def get_variation_with_config(experiment_key, user_id, attributes, config)
@@ -556,6 +598,9 @@ module Optimizely
556
598
  return nil if variable.nil?
557
599
 
558
600
  feature_enabled = false
601
+
602
+ # If variable_type is nil, set it equal to variable['type']
603
+ variable_type ||= variable['type']
559
604
  # Returns nil if type differs
560
605
  if variable['type'] != variable_type
561
606
  @logger.log(Logger::WARN,
@@ -663,18 +708,16 @@ module Optimizely
663
708
  def send_impression(config, experiment, variation_key, user_id, attributes = nil)
664
709
  experiment_key = experiment['key']
665
710
  variation_id = config.get_variation_id_from_key(experiment_key, variation_key)
666
- impression_event = @event_builder.create_impression_event(config, experiment, variation_id, user_id, attributes)
667
- @logger.log(Logger::INFO,
668
- "Dispatching impression event to URL #{impression_event.url} with params #{impression_event.params}.")
669
- begin
670
- @event_dispatcher.dispatch_event(impression_event)
671
- rescue => e
672
- @logger.log(Logger::ERROR, "Unable to dispatch impression event. Error: #{e}")
673
- end
711
+ user_event = UserEventFactory.create_impression_event(config, experiment, variation_id, user_id, attributes)
712
+ @event_processor.process(user_event)
713
+ return unless @notification_center.notification_count(NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE]).positive?
714
+
715
+ @logger.log(Logger::INFO, "Activating user '#{user_id}' in experiment '#{experiment_key}'.")
674
716
  variation = config.get_variation_from_id(experiment_key, variation_id)
717
+ log_event = EventFactory.create_log_event(user_event, @logger)
675
718
  @notification_center.send_notifications(
676
719
  NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE],
677
- experiment, user_id, attributes, variation, impression_event
720
+ experiment, user_id, attributes, variation, log_event
678
721
  )
679
722
  end
680
723
 
@@ -30,7 +30,7 @@ module Optimizely
30
30
  class HTTPProjectConfigManager < ProjectConfigManager
31
31
  # Config manager that polls for the datafile and updated ProjectConfig based on an update interval.
32
32
 
33
- attr_reader :config
33
+ attr_reader :stopped
34
34
 
35
35
  # Initialize config manager. One of sdk_key or url has to be set to be able to use.
36
36
  #
@@ -38,9 +38,9 @@ module Optimizely
38
38
  # datafile: Optional JSON string representing the project.
39
39
  # polling_interval - Optional floating point number representing time interval in seconds
40
40
  # at which to request datafile and set ProjectConfig.
41
- # blocking_timeout -
42
- # auto_update -
43
- # start_by_default -
41
+ # blocking_timeout - Optional Time in seconds to block the config call until config object has been initialized.
42
+ # auto_update - Boolean indicates to run infinitely or only once.
43
+ # start_by_default - Boolean indicates to start by default AsyncScheduler.
44
44
  # url - Optional string representing URL from where to fetch the datafile. If set it supersedes the sdk_key.
45
45
  # url_template - Optional string template which in conjunction with sdk_key
46
46
  # determines URL from where to fetch the datafile.
@@ -72,6 +72,7 @@ module Optimizely
72
72
  @last_modified = nil
73
73
  @async_scheduler = AsyncScheduler.new(method(:fetch_datafile_config), @polling_interval, auto_update, @logger)
74
74
  @async_scheduler.start! if start_by_default == true
75
+ @stopped = false
75
76
  @skip_json_validation = skip_json_validation
76
77
  @notification_center = notification_center.is_a?(Optimizely::NotificationCenter) ? notification_center : NotificationCenter.new(@logger, @error_handler)
77
78
  @config = datafile.nil? ? nil : DatafileProjectConfig.create(datafile, @logger, @error_handler, @skip_json_validation)
@@ -84,20 +85,36 @@ module Optimizely
84
85
  end
85
86
 
86
87
  def start!
88
+ if @stopped
89
+ @logger.log(Logger::WARN, 'Not starting. Already stopped.')
90
+ return
91
+ end
92
+
87
93
  @async_scheduler.start!
94
+ @stopped = false
88
95
  end
89
96
 
90
97
  def stop!
98
+ if @stopped
99
+ @logger.log(Logger::WARN, 'Not pausing. Manager has not been started.')
100
+ return
101
+ end
102
+
91
103
  @async_scheduler.stop!
104
+ @config = nil
105
+ @stopped = true
92
106
  end
93
107
 
94
- def get_config
108
+ def config
95
109
  # Get Project Config.
96
110
 
111
+ # if stopped is true, then simply return @config.
97
112
  # If the background datafile polling thread is running. and config has been initalized,
98
- # we simply return config.
113
+ # we simply return @config.
99
114
  # If it is not, we wait and block maximum for @blocking_timeout.
100
115
  # If thread is not running, we fetch the datafile and update config.
116
+ return @config if @stopped
117
+
101
118
  if @async_scheduler.running
102
119
  return @config if ready?
103
120
 
@@ -196,7 +213,7 @@ module Optimizely
196
213
  return
197
214
  end
198
215
 
199
- unless polling_interval.is_a? Integer
216
+ unless polling_interval.is_a? Numeric
200
217
  @logger.log(
201
218
  Logger::ERROR,
202
219
  "Polling interval '#{polling_interval}' has invalid type. Defaulting to #{Helpers::Constants::CONFIG_MANAGER['DEFAULT_UPDATE_INTERVAL']} seconds."
@@ -205,7 +222,7 @@ module Optimizely
205
222
  return
206
223
  end
207
224
 
208
- unless polling_interval.between?(Helpers::Constants::CONFIG_MANAGER['MIN_SECONDS_LIMIT'], Helpers::Constants::CONFIG_MANAGER['MAX_SECONDS_LIMIT'])
225
+ unless polling_interval.positive? && polling_interval <= Helpers::Constants::CONFIG_MANAGER['MAX_SECONDS_LIMIT']
209
226
  @logger.log(
210
227
  Logger::DEBUG,
211
228
  "Polling interval '#{polling_interval}' has invalid range. Defaulting to #{Helpers::Constants::CONFIG_MANAGER['DEFAULT_UPDATE_INTERVAL']} seconds."
@@ -218,9 +235,9 @@ module Optimizely
218
235
  end
219
236
 
220
237
  def blocking_timeout(blocking_timeout)
221
- # Sets time in seconds to block the get_config call until config has been initialized.
238
+ # Sets time in seconds to block the config call until config has been initialized.
222
239
  #
223
- # blocking_timeout - Time in seconds after which to update datafile.
240
+ # blocking_timeout - Time in seconds to block the config call.
224
241
 
225
242
  # If valid set given timeout, default blocking_timeout otherwise.
226
243
 
@@ -229,7 +246,7 @@ module Optimizely
229
246
  Logger::DEBUG,
230
247
  "Blocking timeout is not provided. Defaulting to #{Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']} seconds."
231
248
  )
232
- @polling_interval = Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']
249
+ @blocking_timeout = Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']
233
250
  return
234
251
  end
235
252
 
@@ -238,7 +255,7 @@ module Optimizely
238
255
  Logger::ERROR,
239
256
  "Blocking timeout '#{blocking_timeout}' has invalid type. Defaulting to #{Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']} seconds."
240
257
  )
241
- @polling_interval = Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']
258
+ @blocking_timeout = Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']
242
259
  return
243
260
  end
244
261
 
@@ -247,7 +264,7 @@ module Optimizely
247
264
  Logger::DEBUG,
248
265
  "Blocking timeout '#{blocking_timeout}' has invalid range. Defaulting to #{Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']} seconds."
249
266
  )
250
- @polling_interval = Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']
267
+ @blocking_timeout = Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']
251
268
  return
252
269
  end
253
270
 
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2019, Optimizely and contributors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ require_relative 'event_processor'
19
+ require_relative '../helpers/validator'
20
+ module Optimizely
21
+ class BatchEventProcessor < EventProcessor
22
+ # BatchEventProcessor is a batched implementation of the Interface EventProcessor.
23
+ # Events passed to the BatchEventProcessor are immediately added to a EventQueue.
24
+ # The BatchEventProcessor maintains a single consumer thread that pulls events off of
25
+ # the BlockingQueue and buffers them for either a configured batch size or for a
26
+ # maximum duration before the resulting LogEvent is sent to the NotificationCenter.
27
+
28
+ attr_reader :event_queue, :event_dispatcher, :current_batch, :started, :batch_size, :flush_interval
29
+
30
+ DEFAULT_BATCH_SIZE = 10
31
+ DEFAULT_BATCH_INTERVAL = 30_000 # interval in milliseconds
32
+ DEFAULT_QUEUE_CAPACITY = 1000
33
+
34
+ FLUSH_SIGNAL = 'FLUSH_SIGNAL'
35
+ SHUTDOWN_SIGNAL = 'SHUTDOWN_SIGNAL'
36
+
37
+ def initialize(
38
+ event_queue: SizedQueue.new(DEFAULT_QUEUE_CAPACITY),
39
+ event_dispatcher: Optimizely::EventDispatcher.new,
40
+ batch_size: DEFAULT_BATCH_SIZE,
41
+ flush_interval: DEFAULT_BATCH_INTERVAL,
42
+ logger: NoOpLogger.new,
43
+ notification_center: nil
44
+ )
45
+ @event_queue = event_queue
46
+ @logger = logger
47
+ @event_dispatcher = event_dispatcher
48
+ @batch_size = if (batch_size.is_a? Integer) && positive_number?(batch_size)
49
+ batch_size
50
+ else
51
+ @logger.log(Logger::DEBUG, "Setting to default batch_size: #{DEFAULT_BATCH_SIZE}.")
52
+ DEFAULT_BATCH_SIZE
53
+ end
54
+ @flush_interval = if positive_number?(flush_interval)
55
+ flush_interval
56
+ else
57
+ @logger.log(Logger::DEBUG, "Setting to default flush_interval: #{DEFAULT_BATCH_INTERVAL} ms.")
58
+ DEFAULT_BATCH_INTERVAL
59
+ end
60
+ @notification_center = notification_center
61
+ @mutex = Mutex.new
62
+ @received = ConditionVariable.new
63
+ @current_batch = []
64
+ @started = false
65
+ start!
66
+ end
67
+
68
+ def start!
69
+ if @started == true
70
+ @logger.log(Logger::WARN, 'Service already started.')
71
+ return
72
+ end
73
+ @flushing_interval_deadline = Helpers::DateTimeUtils.create_timestamp + @flush_interval
74
+ @thread = Thread.new { run }
75
+ @started = true
76
+ end
77
+
78
+ def flush
79
+ @mutex.synchronize do
80
+ @event_queue << FLUSH_SIGNAL
81
+ @received.signal
82
+ end
83
+ end
84
+
85
+ def process(user_event)
86
+ @logger.log(Logger::DEBUG, "Received userEvent: #{user_event}")
87
+
88
+ if !@started || !@thread.alive?
89
+ @logger.log(Logger::WARN, 'Executor shutdown, not accepting tasks.')
90
+ return
91
+ end
92
+
93
+ @mutex.synchronize do
94
+ begin
95
+ @event_queue << user_event
96
+ @received.signal
97
+ rescue Exception
98
+ @logger.log(Logger::WARN, 'Payload not accepted by the queue.')
99
+ return
100
+ end
101
+ end
102
+ end
103
+
104
+ def stop!
105
+ return unless @started
106
+
107
+ @mutex.synchronize do
108
+ @event_queue << SHUTDOWN_SIGNAL
109
+ @received.signal
110
+ end
111
+
112
+ @started = false
113
+ @logger.log(Logger::WARN, 'Stopping scheduler.')
114
+ @thread.exit
115
+ end
116
+
117
+ private
118
+
119
+ def run
120
+ loop do
121
+ if Helpers::DateTimeUtils.create_timestamp > @flushing_interval_deadline
122
+ @logger.log(
123
+ Logger::DEBUG,
124
+ 'Deadline exceeded flushing current batch.'
125
+ )
126
+ flush_queue!
127
+ end
128
+
129
+ item = nil
130
+
131
+ @mutex.synchronize do
132
+ @received.wait(@mutex, 0.05)
133
+ item = @event_queue.pop if @event_queue.length.positive?
134
+ end
135
+
136
+ if item.nil?
137
+ sleep(0.05)
138
+ next
139
+ end
140
+
141
+ if item == SHUTDOWN_SIGNAL
142
+ @logger.log(Logger::INFO, 'Received shutdown signal.')
143
+ break
144
+ end
145
+
146
+ if item == FLUSH_SIGNAL
147
+ @logger.log(Logger::DEBUG, 'Received flush signal.')
148
+ flush_queue!
149
+ next
150
+ end
151
+
152
+ add_to_batch(item) if item.is_a? Optimizely::UserEvent
153
+ end
154
+ rescue SignalException
155
+ @logger.log(Logger::INFO, 'Interrupted while processing buffer.')
156
+ rescue Exception => e
157
+ @logger.log(Logger::ERROR, "Uncaught exception processing buffer. #{e.message}")
158
+ ensure
159
+ @logger.log(
160
+ Logger::INFO,
161
+ 'Exiting processing loop. Attempting to flush pending events.'
162
+ )
163
+ flush_queue!
164
+ end
165
+
166
+ def flush_queue!
167
+ return if @current_batch.empty?
168
+
169
+ log_event = Optimizely::EventFactory.create_log_event(@current_batch, @logger)
170
+ begin
171
+ @event_dispatcher.dispatch_event(log_event)
172
+ @notification_center&.send_notifications(
173
+ NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT],
174
+ log_event
175
+ )
176
+ rescue StandardError => e
177
+ @logger.log(Logger::ERROR, "Error dispatching event: #{log_event} #{e.message}.")
178
+ end
179
+ @current_batch = []
180
+ end
181
+
182
+ def add_to_batch(user_event)
183
+ if should_split?(user_event)
184
+ flush_queue!
185
+ @current_batch = []
186
+ end
187
+
188
+ # Reset the deadline if starting a new batch.
189
+ @flushing_interval_deadline = (Helpers::DateTimeUtils.create_timestamp + @flush_interval) if @current_batch.empty?
190
+
191
+ @logger.log(Logger::DEBUG, "Adding user event: #{user_event} to batch.")
192
+ @current_batch << user_event
193
+ return unless @current_batch.length >= @batch_size
194
+
195
+ @logger.log(Logger::DEBUG, 'Flushing on max batch size!')
196
+ flush_queue!
197
+ end
198
+
199
+ def should_split?(user_event)
200
+ return false if @current_batch.empty?
201
+
202
+ current_context = @current_batch.last.event_context
203
+ new_context = user_event.event_context
204
+
205
+ # Revisions should match
206
+ unless current_context[:revision] == new_context[:revision]
207
+ @logger.log(Logger::DEBUG, 'Revisions mismatched: Flushing current batch.')
208
+ return true
209
+ end
210
+
211
+ # Projects should match
212
+ unless current_context[:project_id] == new_context[:project_id]
213
+ @logger.log(Logger::DEBUG, 'Project Ids mismatched: Flushing current batch.')
214
+ return true
215
+ end
216
+ false
217
+ end
218
+
219
+ def positive_number?(value)
220
+ # Returns true if the given value is positive finite number.
221
+ # false otherwise.
222
+ Helpers::Validator.finite_number?(value) && value.positive?
223
+ end
224
+ end
225
+ end