splitclient-rb 7.1.4.pre.rc7 → 7.1.4.pre.rc8
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGES.txt +1 -1
- data/Rakefile +7 -2
- data/ext/murmurhash/3_x64_128.c +117 -0
- data/ext/murmurhash/3_x86_32.c +88 -0
- data/ext/murmurhash/extconf.rb +5 -0
- data/ext/murmurhash/murmurhash.c +255 -0
- data/ext/murmurhash/murmurhash.h +94 -0
- data/lib/splitclient-rb.rb +6 -1
- data/lib/splitclient-rb/cache/hashers/impression_hasher.rb +34 -0
- data/lib/splitclient-rb/cache/observers/impression_observer.rb +22 -0
- data/lib/splitclient-rb/cache/repositories/impressions/memory_repository.rb +4 -18
- data/lib/splitclient-rb/cache/repositories/impressions/redis_repository.rb +7 -18
- data/lib/splitclient-rb/cache/repositories/impressions_repository.rb +1 -27
- data/lib/splitclient-rb/cache/routers/impression_router.rb +12 -14
- data/lib/splitclient-rb/cache/senders/impressions_count_sender.rb +73 -0
- data/lib/splitclient-rb/cache/senders/impressions_formatter.rb +11 -11
- data/lib/splitclient-rb/cache/senders/impressions_sender.rb +3 -3
- data/lib/splitclient-rb/clients/split_client.rb +24 -73
- data/lib/splitclient-rb/engine/api/impressions.rb +30 -13
- data/lib/splitclient-rb/engine/common/impressions_counter.rb +45 -0
- data/lib/splitclient-rb/engine/common/impressions_manager.rb +87 -0
- data/lib/splitclient-rb/engine/evaluator/splitter.rb +1 -5
- data/lib/splitclient-rb/engine/parser/evaluator.rb +0 -4
- data/lib/splitclient-rb/engine/sync_manager.rb +5 -6
- data/lib/splitclient-rb/engine/synchronizer.rb +9 -1
- data/lib/splitclient-rb/split_config.rb +31 -1
- data/lib/splitclient-rb/split_factory.rb +5 -2
- data/lib/splitclient-rb/version.rb +1 -1
- data/splitclient-rb.gemspec +8 -1
- metadata +14 -17
@@ -4,10 +4,10 @@ module SplitIoClient
|
|
4
4
|
module Cache
|
5
5
|
module Senders
|
6
6
|
class ImpressionsSender
|
7
|
-
def initialize(impressions_repository,
|
7
|
+
def initialize(impressions_repository, config, impressions_api)
|
8
8
|
@impressions_repository = impressions_repository
|
9
|
-
@api_key = api_key
|
10
9
|
@config = config
|
10
|
+
@impressions_api = impressions_api
|
11
11
|
end
|
12
12
|
|
13
13
|
def call
|
@@ -50,7 +50,7 @@ module SplitIoClient
|
|
50
50
|
end
|
51
51
|
|
52
52
|
def impressions_api
|
53
|
-
@impressions_api
|
53
|
+
@impressions_api
|
54
54
|
end
|
55
55
|
end
|
56
56
|
end
|
@@ -9,7 +9,7 @@ module SplitIoClient
|
|
9
9
|
# @param api_key [String] the API key for your split account
|
10
10
|
#
|
11
11
|
# @return [SplitIoClient] split.io client instance
|
12
|
-
def initialize(api_key, metrics, splits_repository, segments_repository, impressions_repository, metrics_repository, events_repository, sdk_blocker, config)
|
12
|
+
def initialize(api_key, metrics, splits_repository, segments_repository, impressions_repository, metrics_repository, events_repository, sdk_blocker, config, impressions_manager)
|
13
13
|
@api_key = api_key
|
14
14
|
@metrics = metrics
|
15
15
|
@splits_repository = splits_repository
|
@@ -20,17 +20,21 @@ module SplitIoClient
|
|
20
20
|
@sdk_blocker = sdk_blocker
|
21
21
|
@destroyed = false
|
22
22
|
@config = config
|
23
|
+
@impressions_manager = impressions_manager
|
23
24
|
end
|
24
25
|
|
25
26
|
def get_treatment(
|
26
27
|
key, split_name, attributes = {}, split_data = nil, store_impressions = true,
|
27
28
|
multiple = false, evaluator = nil
|
28
29
|
)
|
29
|
-
|
30
|
+
impressions = []
|
31
|
+
result = treatment(key, split_name, attributes, split_data, store_impressions, multiple, evaluator, 'get_treatment', impressions)
|
32
|
+
@impressions_manager.track(impressions)
|
33
|
+
|
30
34
|
if multiple
|
31
|
-
|
35
|
+
result.tap { |t| t.delete(:config) }
|
32
36
|
else
|
33
|
-
|
37
|
+
result[:treatment]
|
34
38
|
end
|
35
39
|
end
|
36
40
|
|
@@ -38,7 +42,11 @@ module SplitIoClient
|
|
38
42
|
key, split_name, attributes = {}, split_data = nil, store_impressions = true,
|
39
43
|
multiple = false, evaluator = nil
|
40
44
|
)
|
41
|
-
|
45
|
+
impressions = []
|
46
|
+
result = treatment(key, split_name, attributes, split_data, store_impressions, multiple, evaluator, 'get_treatment_with_config', impressions)
|
47
|
+
@impressions_manager.track(impressions)
|
48
|
+
|
49
|
+
result
|
42
50
|
end
|
43
51
|
|
44
52
|
def get_treatments(key, split_names, attributes = {})
|
@@ -74,53 +82,6 @@ module SplitIoClient
|
|
74
82
|
@destroyed = true
|
75
83
|
end
|
76
84
|
|
77
|
-
def store_impression(split_name, matching_key, bucketing_key, treatment, attributes)
|
78
|
-
time = (Time.now.to_f * 1000.0).to_i
|
79
|
-
|
80
|
-
@impressions_repository.add(
|
81
|
-
matching_key,
|
82
|
-
bucketing_key,
|
83
|
-
split_name,
|
84
|
-
treatment,
|
85
|
-
time
|
86
|
-
)
|
87
|
-
|
88
|
-
route_impression(split_name, matching_key, bucketing_key, time, treatment, attributes)
|
89
|
-
|
90
|
-
rescue StandardError => error
|
91
|
-
@config.log_found_exception(__method__.to_s, error)
|
92
|
-
end
|
93
|
-
|
94
|
-
def route_impression(split_name, matching_key, bucketing_key, time, treatment, attributes)
|
95
|
-
impression_router.add(
|
96
|
-
split_name: split_name,
|
97
|
-
matching_key: matching_key,
|
98
|
-
bucketing_key: bucketing_key,
|
99
|
-
time: time,
|
100
|
-
treatment: {
|
101
|
-
label: treatment[:label],
|
102
|
-
treatment: treatment[:treatment],
|
103
|
-
change_number: treatment[:change_number]
|
104
|
-
},
|
105
|
-
attributes: attributes
|
106
|
-
)
|
107
|
-
end
|
108
|
-
|
109
|
-
def route_impressions(split_names, matching_key, bucketing_key, time, treatments_labels_change_numbers, attributes)
|
110
|
-
impression_router.add_bulk(
|
111
|
-
split_names: split_names,
|
112
|
-
matching_key: matching_key,
|
113
|
-
bucketing_key: bucketing_key,
|
114
|
-
time: time,
|
115
|
-
treatments_labels_change_numbers: treatments_labels_change_numbers,
|
116
|
-
attributes: attributes
|
117
|
-
)
|
118
|
-
end
|
119
|
-
|
120
|
-
def impression_router
|
121
|
-
@impression_router ||= SplitIoClient::ImpressionRouter.new(@config)
|
122
|
-
end
|
123
|
-
|
124
85
|
def track(key, traffic_type_name, event_type, value = nil, properties = nil)
|
125
86
|
return false unless valid_client && @config.split_validator.valid_track_parameters(key, traffic_type_name, event_type, value, properties)
|
126
87
|
|
@@ -239,26 +200,20 @@ module SplitIoClient
|
|
239
200
|
|
240
201
|
bucketing_key, matching_key = keys_from_key(key)
|
241
202
|
bucketing_key = bucketing_key ? bucketing_key.to_s : nil
|
242
|
-
matching_key = matching_key ? matching_key.to_s : nil
|
203
|
+
matching_key = matching_key ? matching_key.to_s : nil
|
243
204
|
|
244
205
|
evaluator = Engine::Parser::Evaluator.new(@segments_repository, @splits_repository, @config, true)
|
245
206
|
start = Time.now
|
207
|
+
impressions = []
|
246
208
|
treatments_labels_change_numbers =
|
247
209
|
@splits_repository.get_splits(sanitized_split_names).each_with_object({}) do |(name, data), memo|
|
248
|
-
memo.merge!(name => treatment(key, name, attributes, data, false, true, evaluator))
|
210
|
+
memo.merge!(name => treatment(key, name, attributes, data, false, true, evaluator, calling_method, impressions))
|
249
211
|
end
|
250
212
|
latency = (Time.now - start) * 1000.0
|
251
213
|
# Measure
|
252
214
|
@metrics.time('sdk.' + calling_method, latency)
|
253
215
|
|
254
|
-
|
255
|
-
|
256
|
-
time = (Time.now.to_f * 1000.0).to_i
|
257
|
-
@impressions_repository.add_bulk(
|
258
|
-
matching_key, bucketing_key, treatments_for_impressions, time
|
259
|
-
) unless treatments_for_impressions == {}
|
260
|
-
|
261
|
-
route_impressions(sanitized_split_names, matching_key, bucketing_key, time, treatments_for_impressions, attributes)
|
216
|
+
@impressions_manager.track(impressions)
|
262
217
|
|
263
218
|
split_names_keys = treatments_labels_change_numbers.keys
|
264
219
|
treatments = treatments_labels_change_numbers.values.map do |v|
|
@@ -284,7 +239,7 @@ module SplitIoClient
|
|
284
239
|
# @return [String/Hash] Treatment as String or Hash of treatments in case of array of features
|
285
240
|
def treatment(
|
286
241
|
key, split_name, attributes = {}, split_data = nil, store_impressions = true,
|
287
|
-
multiple = false, evaluator = nil, calling_method = 'get_treatment'
|
242
|
+
multiple = false, evaluator = nil, calling_method = 'get_treatment', impressions = []
|
288
243
|
)
|
289
244
|
control_treatment = { treatment: Engine::Models::Treatment::CONTROL }
|
290
245
|
|
@@ -329,15 +284,18 @@ module SplitIoClient
|
|
329
284
|
end
|
330
285
|
|
331
286
|
latency = (Time.now - start) * 1000.0
|
332
|
-
|
333
|
-
|
287
|
+
|
288
|
+
impression = @impressions_manager.build_impression(matching_key, bucketing_key, split_name, treatment_data, { attributes: attributes, time: nil })
|
289
|
+
impressions << impression unless impression.nil?
|
334
290
|
|
335
291
|
# Measure
|
336
292
|
@metrics.time('sdk.' + calling_method, latency) unless multiple
|
337
293
|
rescue StandardError => error
|
294
|
+
p error
|
338
295
|
@config.log_found_exception(__method__.to_s, error)
|
339
296
|
|
340
|
-
|
297
|
+
impression = @impressions_manager.build_impression(matching_key, bucketing_key, split_name, control_treatment, { attributes: attributes, time: nil })
|
298
|
+
impressions << impression unless impression.nil?
|
341
299
|
|
342
300
|
return parsed_treatment(multiple, control_treatment.merge({ label: Engine::Models::Label::EXCEPTION }))
|
343
301
|
end
|
@@ -357,12 +315,5 @@ module SplitIoClient
|
|
357
315
|
def parsed_attributes(attributes)
|
358
316
|
return attributes || attributes.to_h
|
359
317
|
end
|
360
|
-
|
361
|
-
def get_treatment_for_impressions(treatments_labels_change_numbers)
|
362
|
-
return treatments_labels_change_numbers.select{|imp|
|
363
|
-
treatments_labels_change_numbers[imp][:label] != Engine::Models::Label::NOT_FOUND &&
|
364
|
-
!treatments_labels_change_numbers[imp][:label].nil?
|
365
|
-
}
|
366
|
-
end
|
367
318
|
end
|
368
319
|
end
|
@@ -14,16 +14,31 @@ module SplitIoClient
|
|
14
14
|
return
|
15
15
|
end
|
16
16
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
17
|
+
response = post_api("#{@config.events_uri}/testImpressions/bulk", @api_key, impressions, impressions_headers)
|
18
|
+
|
19
|
+
if response.success?
|
20
|
+
@config.split_logger.log_if_debug("Impressions reported: #{total_impressions(impressions)}")
|
21
|
+
else
|
22
|
+
@config.logger.error("Unexpected status code while posting impressions: #{response.status}." \
|
23
|
+
' - Check your API key and base URI')
|
24
|
+
raise 'Split SDK failed to connect to backend to post impressions'
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def post_count(impressions_count)
|
29
|
+
if impressions_count.nil? || impressions_count[:pf].empty?
|
30
|
+
@config.split_logger.log_if_debug('No impressions count to send')
|
31
|
+
return
|
32
|
+
end
|
33
|
+
|
34
|
+
response = post_api("#{@config.events_uri}/testImpressions/count", @api_key, impressions_count)
|
35
|
+
|
36
|
+
if response.success?
|
37
|
+
@config.split_logger.log_if_debug("Impressions count sent: #{impressions_count[:pf].length}")
|
38
|
+
else
|
39
|
+
@config.logger.error("Unexpected status code while posting impressions count: #{response.status}." \
|
40
|
+
' - Check your API key and base URI')
|
41
|
+
raise 'Split SDK failed to connect to backend to post impressions'
|
27
42
|
end
|
28
43
|
end
|
29
44
|
|
@@ -31,14 +46,16 @@ module SplitIoClient
|
|
31
46
|
return 0 if impressions.nil?
|
32
47
|
|
33
48
|
impressions.reduce(0) do |impressions_count, impression|
|
34
|
-
impressions_count += impression[:
|
49
|
+
impressions_count += impression[:i].length
|
35
50
|
end
|
36
51
|
end
|
37
52
|
|
38
53
|
private
|
39
54
|
|
40
|
-
def
|
41
|
-
|
55
|
+
def impressions_headers
|
56
|
+
{
|
57
|
+
'SplitSDKImpressionsMode' => @config.impressions_mode.to_s
|
58
|
+
}
|
42
59
|
end
|
43
60
|
end
|
44
61
|
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'concurrent'
|
4
|
+
|
5
|
+
module SplitIoClient
|
6
|
+
module Engine
|
7
|
+
module Common
|
8
|
+
TIME_INTERVAL_MS = 3600 * 1000
|
9
|
+
|
10
|
+
class ImpressionCounter
|
11
|
+
DEFAULT_AMOUNT = 1
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
@cache = Concurrent::Hash.new
|
15
|
+
end
|
16
|
+
|
17
|
+
def inc(split_name, time_frame)
|
18
|
+
key = make_key(split_name, time_frame)
|
19
|
+
|
20
|
+
current_amount = @cache[key]
|
21
|
+
@cache[key] = current_amount.nil? ? DEFAULT_AMOUNT : (current_amount + DEFAULT_AMOUNT)
|
22
|
+
end
|
23
|
+
|
24
|
+
def pop_all
|
25
|
+
to_return = Concurrent::Hash.new
|
26
|
+
|
27
|
+
@cache.each do |key, value|
|
28
|
+
to_return[key] = value
|
29
|
+
end
|
30
|
+
@cache.clear
|
31
|
+
|
32
|
+
to_return
|
33
|
+
end
|
34
|
+
|
35
|
+
def truncate_time_frame(timestamp_ms)
|
36
|
+
timestamp_ms - (timestamp_ms % TIME_INTERVAL_MS)
|
37
|
+
end
|
38
|
+
|
39
|
+
def make_key(split_name, time_frame)
|
40
|
+
"#{split_name}::#{truncate_time_frame(time_frame)}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SplitIoClient
|
4
|
+
module Engine
|
5
|
+
module Common
|
6
|
+
class ImpressionManager
|
7
|
+
def initialize(config, impressions_repository, impression_counter)
|
8
|
+
@config = config
|
9
|
+
@impressions_repository = impressions_repository
|
10
|
+
@impression_counter = impression_counter
|
11
|
+
@impression_router = SplitIoClient::ImpressionRouter.new(@config)
|
12
|
+
@impression_observer = SplitIoClient::Observers::ImpressionObserver.new
|
13
|
+
end
|
14
|
+
|
15
|
+
# added param time for test
|
16
|
+
def build_impression(matching_key, bucketing_key, split_name, treatment, params = {})
|
17
|
+
impression_data = impression_data(matching_key, bucketing_key, split_name, treatment, params[:time])
|
18
|
+
|
19
|
+
impression_data[:pt] = @impression_observer.test_and_set(impression_data) unless redis?
|
20
|
+
|
21
|
+
return impression_optimized(split_name, impression_data, params[:attributes]) if optimized? && !redis?
|
22
|
+
|
23
|
+
impression(impression_data, params[:attributes])
|
24
|
+
rescue StandardError => error
|
25
|
+
@config.log_found_exception(__method__.to_s, error)
|
26
|
+
end
|
27
|
+
|
28
|
+
def track(impressions)
|
29
|
+
@impressions_repository.add_bulk(impressions)
|
30
|
+
@impression_router.add_bulk(impressions)
|
31
|
+
rescue StandardError => error
|
32
|
+
@config.log_found_exception(__method__.to_s, error)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
# added param time for test
|
38
|
+
def impression_data(matching_key, bucketing_key, split_name, treatment, time = nil)
|
39
|
+
{
|
40
|
+
k: matching_key,
|
41
|
+
b: bucketing_key,
|
42
|
+
f: split_name,
|
43
|
+
t: treatment[:treatment],
|
44
|
+
r: applied_rule(treatment[:label]),
|
45
|
+
c: treatment[:change_number],
|
46
|
+
m: time || (Time.now.to_f * 1000.0).to_i,
|
47
|
+
pt: nil
|
48
|
+
}
|
49
|
+
end
|
50
|
+
|
51
|
+
def metadata
|
52
|
+
{
|
53
|
+
s: "#{@config.language}-#{@config.version}",
|
54
|
+
i: @config.machine_ip,
|
55
|
+
n: @config.machine_name
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
def applied_rule(label)
|
60
|
+
@config.labels_enabled ? label : nil
|
61
|
+
end
|
62
|
+
|
63
|
+
def optimized?
|
64
|
+
@config.impressions_mode == :optimized
|
65
|
+
end
|
66
|
+
|
67
|
+
def impression_optimized(split_name, impression_data, attributes)
|
68
|
+
@impression_counter.inc(split_name, impression_data[:m])
|
69
|
+
|
70
|
+
impression(impression_data, attributes) if should_queue_impression?(impression_data)
|
71
|
+
end
|
72
|
+
|
73
|
+
def should_queue_impression?(impression)
|
74
|
+
impression[:pt].nil? || (impression[:pt] < ((Time.now.to_f * 1000.0).to_i - Common::TIME_INTERVAL_MS))
|
75
|
+
end
|
76
|
+
|
77
|
+
def impression(impression_data, attributes)
|
78
|
+
{ m: metadata, i: impression_data, attributes: attributes }
|
79
|
+
end
|
80
|
+
|
81
|
+
def redis?
|
82
|
+
@config.impressions_adapter.class.to_s == 'SplitIoClient::Cache::Adapters::RedisAdapter'
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -1,7 +1,3 @@
|
|
1
|
-
unless defined?(JRUBY_VERSION)
|
2
|
-
require 'digest/murmurhash'
|
3
|
-
end
|
4
|
-
|
5
1
|
module SplitIoClient
|
6
2
|
# Misc class in charge of providing hash functions and
|
7
3
|
# determination of treatment based on concept of buckets
|
@@ -13,7 +9,7 @@ module SplitIoClient
|
|
13
9
|
when 'java'
|
14
10
|
Proc.new { |key, seed| Java::MurmurHash3.murmurhash3_x86_32(key, seed) }
|
15
11
|
else
|
16
|
-
Proc.new { |key, seed| Digest::
|
12
|
+
Proc.new { |key, seed| Digest::MurmurHashMRI3_x86_32.rawdigest(key, [seed].pack('L')) }
|
17
13
|
end
|
18
14
|
end
|
19
15
|
|
@@ -9,14 +9,13 @@ module SplitIoClient
|
|
9
9
|
repositories,
|
10
10
|
api_key,
|
11
11
|
config,
|
12
|
-
|
13
|
-
metrics
|
12
|
+
params
|
14
13
|
)
|
15
|
-
split_fetcher = SplitFetcher.new(repositories[:splits], api_key, metrics, config, sdk_blocker)
|
16
|
-
segment_fetcher = SegmentFetcher.new(repositories[:segments], api_key, metrics, config, sdk_blocker)
|
17
|
-
sync_params = { split_fetcher: split_fetcher, segment_fetcher: segment_fetcher }
|
14
|
+
split_fetcher = SplitFetcher.new(repositories[:splits], api_key, params[:metrics], config, params[:sdk_blocker])
|
15
|
+
segment_fetcher = SegmentFetcher.new(repositories[:segments], api_key, params[:metrics], config, params[:sdk_blocker])
|
16
|
+
sync_params = { split_fetcher: split_fetcher, segment_fetcher: segment_fetcher, imp_counter: params[:impression_counter] }
|
18
17
|
|
19
|
-
@synchronizer = Synchronizer.new(repositories, api_key, config, sdk_blocker, sync_params)
|
18
|
+
@synchronizer = Synchronizer.new(repositories, api_key, config, params[:sdk_blocker], sync_params)
|
20
19
|
notification_manager_keeper = SplitIoClient::SSE::NotificationManagerKeeper.new(config) do |manager|
|
21
20
|
manager.on_occupancy { |publisher_available| process_occupancy(publisher_available) }
|
22
21
|
manager.on_push_shutdown { process_push_shutdown }
|
@@ -23,6 +23,8 @@ module SplitIoClient
|
|
23
23
|
@sdk_blocker = sdk_blocker
|
24
24
|
@split_fetcher = params[:split_fetcher]
|
25
25
|
@segment_fetcher = params[:segment_fetcher]
|
26
|
+
@impressions_api = SplitIoClient::Api::Impressions.new(@api_key, @config)
|
27
|
+
@impression_counter = params[:imp_counter]
|
26
28
|
end
|
27
29
|
|
28
30
|
def sync_all
|
@@ -35,6 +37,7 @@ module SplitIoClient
|
|
35
37
|
impressions_sender
|
36
38
|
metrics_sender
|
37
39
|
events_sender
|
40
|
+
impressions_count_sender
|
38
41
|
end
|
39
42
|
|
40
43
|
def start_periodic_fetch
|
@@ -73,7 +76,7 @@ module SplitIoClient
|
|
73
76
|
|
74
77
|
# Starts thread which loops constantly and sends impressions to the Split API
|
75
78
|
def impressions_sender
|
76
|
-
ImpressionsSender.new(@impressions_repository, @
|
79
|
+
ImpressionsSender.new(@impressions_repository, @config, @impressions_api).call
|
77
80
|
end
|
78
81
|
|
79
82
|
# Starts thread which loops constantly and sends metrics to the Split API
|
@@ -85,6 +88,11 @@ module SplitIoClient
|
|
85
88
|
def events_sender
|
86
89
|
EventsSender.new(@events_repository, @config).call
|
87
90
|
end
|
91
|
+
|
92
|
+
# Starts thread which loops constantly and sends impressions count to the Split API
|
93
|
+
def impressions_count_sender
|
94
|
+
ImpressionsCountSender.new(@config, @impression_counter, @impressions_api).call
|
95
|
+
end
|
88
96
|
end
|
89
97
|
end
|
90
98
|
end
|