statsig 1.10.0 → 1.20.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/client_initialize_helpers.rb +132 -0
- data/lib/config_result.rb +16 -3
- data/lib/diagnostics.rb +44 -0
- data/lib/dynamic_config.rb +37 -0
- data/lib/error_boundary.rb +57 -0
- data/lib/evaluation_details.rb +42 -0
- data/lib/evaluation_helpers.rb +1 -0
- data/lib/evaluator.rb +127 -14
- data/lib/id_list.rb +1 -0
- data/lib/interfaces/data_store.rb +19 -0
- data/lib/layer.rb +39 -0
- data/lib/network.rb +39 -11
- data/lib/spec_store.rb +183 -43
- data/lib/statsig.rb +213 -4
- data/lib/statsig_driver.rb +192 -62
- data/lib/statsig_errors.rb +11 -0
- data/lib/statsig_event.rb +6 -1
- data/lib/statsig_logger.rb +81 -13
- data/lib/statsig_options.rb +114 -7
- data/lib/statsig_user.rb +79 -16
- metadata +70 -8
data/lib/layer.rb
CHANGED
@@ -1,7 +1,22 @@
|
|
1
|
+
# typed: false
|
2
|
+
|
3
|
+
##
|
4
|
+
# Contains the current values from Statsig.
|
5
|
+
# Will contain layer default values for all shared parameters in that layer.
|
6
|
+
# If a parameter is in an active experiment, and the current user is allocated to that experiment,
|
7
|
+
# those parameters will be updated to reflect the experiment values not the layer defaults.
|
8
|
+
#
|
9
|
+
# Layers Documentation: https://docs.statsig.com/layers
|
1
10
|
class Layer
|
11
|
+
extend T::Sig
|
12
|
+
|
13
|
+
sig { returns(String) }
|
2
14
|
attr_accessor :name
|
15
|
+
|
16
|
+
sig { returns(String) }
|
3
17
|
attr_accessor :rule_id
|
4
18
|
|
19
|
+
sig { params(name: String, value: T::Hash[String, T.untyped], rule_id: String, exposure_log_func: T.any(Method, Proc, NilClass)).void }
|
5
20
|
def initialize(name, value = {}, rule_id = '', exposure_log_func = nil)
|
6
21
|
@name = name
|
7
22
|
@value = value
|
@@ -9,6 +24,12 @@ class Layer
|
|
9
24
|
@exposure_log_func = exposure_log_func
|
10
25
|
end
|
11
26
|
|
27
|
+
sig { params(index: String, default_value: T.untyped).returns(T.untyped) }
|
28
|
+
##
|
29
|
+
# Get the value for the given key (index), falling back to the default_value if it cannot be found.
|
30
|
+
#
|
31
|
+
# @param index The name of parameter being fetched
|
32
|
+
# @param default_value The fallback value if the name cannot be found
|
12
33
|
def get(index, default_value)
|
13
34
|
return default_value if @value.nil? || !@value.key?(index)
|
14
35
|
|
@@ -18,4 +39,22 @@ class Layer
|
|
18
39
|
|
19
40
|
@value[index]
|
20
41
|
end
|
42
|
+
|
43
|
+
sig { params(index: String, default_value: T.untyped).returns(T.untyped) }
|
44
|
+
##
|
45
|
+
# Get the value for the given key (index), falling back to the default_value if it cannot be found
|
46
|
+
# or is found to have a different type from the default_value.
|
47
|
+
#
|
48
|
+
# @param index The name of parameter being fetched
|
49
|
+
# @param default_value The fallback value if the name cannot be found
|
50
|
+
def get_typed(index, default_value)
|
51
|
+
return default_value if @value.nil? || !@value.key?(index)
|
52
|
+
return default_value if @value[index].class != default_value.class and default_value.class != TrueClass and default_value.class != FalseClass
|
53
|
+
|
54
|
+
if @exposure_log_func.is_a? Proc
|
55
|
+
@exposure_log_func.call(self, index)
|
56
|
+
end
|
57
|
+
|
58
|
+
@value[index]
|
59
|
+
end
|
21
60
|
end
|
data/lib/network.rb
CHANGED
@@ -1,28 +1,56 @@
|
|
1
|
+
# typed: true
|
2
|
+
|
1
3
|
require 'http'
|
2
4
|
require 'json'
|
3
5
|
require 'securerandom'
|
6
|
+
require 'sorbet-runtime'
|
4
7
|
|
5
8
|
$retry_codes = [408, 500, 502, 503, 504, 522, 524, 599]
|
6
9
|
|
7
10
|
module Statsig
|
11
|
+
class NetworkError < StandardError
|
12
|
+
attr_reader :http_code
|
13
|
+
|
14
|
+
def initialize(msg = nil, http_code = nil)
|
15
|
+
super(msg)
|
16
|
+
@http_code = http_code
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
8
20
|
class Network
|
9
|
-
|
21
|
+
extend T::Sig
|
22
|
+
|
23
|
+
sig { params(server_secret: String, api: String, local_mode: T::Boolean, backoff_mult: Integer).void }
|
24
|
+
|
25
|
+
def initialize(server_secret, api, local_mode, backoff_mult = 10)
|
10
26
|
super()
|
11
27
|
unless api.end_with?('/')
|
12
28
|
api += '/'
|
13
29
|
end
|
14
30
|
@server_secret = server_secret
|
15
31
|
@api = api
|
32
|
+
@local_mode = local_mode
|
16
33
|
@backoff_multiplier = backoff_mult
|
17
34
|
@session_id = SecureRandom.uuid
|
18
35
|
end
|
19
36
|
|
37
|
+
sig { params(endpoint: String, body: String, retries: Integer, backoff: Integer)
|
38
|
+
.returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)]) }
|
39
|
+
|
20
40
|
def post_helper(endpoint, body, retries = 0, backoff = 1)
|
41
|
+
if @local_mode
|
42
|
+
return nil, nil
|
43
|
+
end
|
44
|
+
|
45
|
+
meta = Statsig.get_statsig_metadata
|
21
46
|
http = HTTP.headers(
|
22
|
-
{
|
23
|
-
|
24
|
-
|
25
|
-
|
47
|
+
{
|
48
|
+
"STATSIG-API-KEY" => @server_secret,
|
49
|
+
"STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_i.to_s,
|
50
|
+
"STATSIG-SERVER-SESSION-ID" => @session_id,
|
51
|
+
"Content-Type" => "application/json; charset=UTF-8",
|
52
|
+
"STATSIG-SDK-TYPE" => meta['sdkType'],
|
53
|
+
"STATSIG-SDK-VERSION" => meta['sdkVersion'],
|
26
54
|
}).accept(:json)
|
27
55
|
begin
|
28
56
|
res = http.post(@api + endpoint, body: body)
|
@@ -32,8 +60,8 @@ module Statsig
|
|
32
60
|
sleep backoff
|
33
61
|
return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
34
62
|
end
|
35
|
-
return res, nil
|
36
|
-
return nil,
|
63
|
+
return res, nil if res.status.success?
|
64
|
+
return nil, NetworkError.new("Got an exception when making request to #{@api + endpoint}: #{res.to_s}", res.status.to_i) unless retries > 0 && $retry_codes.include?(res.code)
|
37
65
|
## status code retry
|
38
66
|
sleep backoff
|
39
67
|
post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
@@ -41,7 +69,7 @@ module Statsig
|
|
41
69
|
|
42
70
|
def check_gate(user, gate_name)
|
43
71
|
begin
|
44
|
-
request_body = JSON.generate({'user' => user&.serialize(false), 'gateName' => gate_name})
|
72
|
+
request_body = JSON.generate({ 'user' => user&.serialize(false), 'gateName' => gate_name })
|
45
73
|
response, _ = post_helper('check_gate', request_body)
|
46
74
|
return JSON.parse(response.body) unless response.nil?
|
47
75
|
false
|
@@ -52,7 +80,7 @@ module Statsig
|
|
52
80
|
|
53
81
|
def get_config(user, dynamic_config_name)
|
54
82
|
begin
|
55
|
-
request_body = JSON.generate({'user' => user&.serialize(false), 'configName' => dynamic_config_name})
|
83
|
+
request_body = JSON.generate({ 'user' => user&.serialize(false), 'configName' => dynamic_config_name })
|
56
84
|
response, _ = post_helper('get_config', request_body)
|
57
85
|
return JSON.parse(response.body) unless response.nil?
|
58
86
|
nil
|
@@ -63,8 +91,8 @@ module Statsig
|
|
63
91
|
|
64
92
|
def post_logs(events)
|
65
93
|
begin
|
66
|
-
json_body = JSON.generate({'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata})
|
67
|
-
post_helper('log_event', json_body,
|
94
|
+
json_body = JSON.generate({ 'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata })
|
95
|
+
post_helper('log_event', json_body, 5)
|
68
96
|
rescue
|
69
97
|
end
|
70
98
|
end
|
data/lib/spec_store.rb
CHANGED
@@ -1,71 +1,157 @@
|
|
1
|
+
# typed: false
|
1
2
|
require 'net/http'
|
2
3
|
require 'uri'
|
3
|
-
|
4
|
+
require 'evaluation_details'
|
4
5
|
require 'id_list'
|
6
|
+
require 'concurrent-ruby'
|
5
7
|
|
6
8
|
module Statsig
|
7
9
|
class SpecStore
|
8
|
-
|
10
|
+
|
11
|
+
CONFIG_SPECS_KEY = "statsig.cache"
|
12
|
+
|
13
|
+
attr_accessor :last_config_sync_time
|
14
|
+
attr_accessor :initial_config_sync_time
|
15
|
+
attr_accessor :init_reason
|
16
|
+
|
17
|
+
def initialize(network, options, error_callback, init_diagnostics = nil)
|
18
|
+
@init_reason = EvaluationReason::UNINITIALIZED
|
9
19
|
@network = network
|
10
|
-
@
|
11
|
-
@
|
12
|
-
@
|
13
|
-
@
|
20
|
+
@options = options
|
21
|
+
@error_callback = error_callback
|
22
|
+
@last_config_sync_time = 0
|
23
|
+
@initial_config_sync_time = 0
|
24
|
+
@rulesets_sync_interval = options.rulesets_sync_interval
|
25
|
+
@id_lists_sync_interval = options.idlists_sync_interval
|
26
|
+
@rules_updated_callback = options.rules_updated_callback
|
27
|
+
@specs = {
|
14
28
|
:gates => {},
|
15
29
|
:configs => {},
|
16
30
|
:layers => {},
|
17
31
|
:id_lists => {},
|
32
|
+
:experiment_to_layer => {}
|
18
33
|
}
|
19
|
-
|
20
|
-
|
21
|
-
|
34
|
+
|
35
|
+
@id_list_thread_pool = Concurrent::FixedThreadPool.new(
|
36
|
+
options.idlist_threadpool_size,
|
37
|
+
max_queue: 100,
|
38
|
+
fallback_policy: :discard,
|
39
|
+
)
|
40
|
+
|
41
|
+
unless @options.bootstrap_values.nil?
|
42
|
+
begin
|
43
|
+
if !@options.data_store.nil?
|
44
|
+
puts 'data_store gets priority over bootstrap_values. bootstrap_values will be ignored'
|
45
|
+
else
|
46
|
+
init_diagnostics&.mark("bootstrap", "start", "load")
|
47
|
+
if process(options.bootstrap_values)
|
48
|
+
@init_reason = EvaluationReason::BOOTSTRAP
|
49
|
+
end
|
50
|
+
init_diagnostics&.mark("bootstrap", "end", "load", @init_reason == EvaluationReason::BOOTSTRAP)
|
51
|
+
end
|
52
|
+
rescue
|
53
|
+
puts 'the provided bootstrapValues is not a valid JSON string'
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
unless @options.data_store.nil?
|
58
|
+
init_diagnostics&.mark("data_store", "start", "load")
|
59
|
+
@options.data_store.init
|
60
|
+
load_from_storage_adapter
|
61
|
+
init_diagnostics&.mark("data_store", "end", "load", @init_reason == EvaluationReason::DATA_ADAPTER)
|
62
|
+
end
|
63
|
+
|
64
|
+
if @init_reason == EvaluationReason::UNINITIALIZED
|
65
|
+
download_config_specs(init_diagnostics)
|
66
|
+
end
|
67
|
+
|
68
|
+
@initial_config_sync_time = @last_config_sync_time == 0 ? -1 : @last_config_sync_time
|
69
|
+
get_id_lists(init_diagnostics)
|
22
70
|
|
23
71
|
@config_sync_thread = sync_config_specs
|
24
72
|
@id_lists_sync_thread = sync_id_lists
|
25
73
|
end
|
26
74
|
|
75
|
+
def is_ready_for_checks
|
76
|
+
@last_config_sync_time != 0
|
77
|
+
end
|
78
|
+
|
27
79
|
def shutdown
|
28
80
|
@config_sync_thread&.exit
|
29
81
|
@id_lists_sync_thread&.exit
|
82
|
+
@id_list_thread_pool.shutdown
|
83
|
+
@id_list_thread_pool.wait_for_termination(timeout = 3)
|
84
|
+
unless @options.data_store.nil?
|
85
|
+
@options.data_store.shutdown
|
86
|
+
end
|
30
87
|
end
|
31
88
|
|
32
89
|
def has_gate?(gate_name)
|
33
|
-
@
|
90
|
+
@specs[:gates].key?(gate_name)
|
34
91
|
end
|
35
92
|
|
36
93
|
def has_config?(config_name)
|
37
|
-
@
|
94
|
+
@specs[:configs].key?(config_name)
|
38
95
|
end
|
39
96
|
|
40
97
|
def has_layer?(layer_name)
|
41
|
-
@
|
98
|
+
@specs[:layers].key?(layer_name)
|
42
99
|
end
|
43
100
|
|
44
101
|
def get_gate(gate_name)
|
45
102
|
return nil unless has_gate?(gate_name)
|
46
|
-
@
|
103
|
+
@specs[:gates][gate_name]
|
47
104
|
end
|
48
105
|
|
49
106
|
def get_config(config_name)
|
50
107
|
return nil unless has_config?(config_name)
|
51
|
-
@
|
108
|
+
@specs[:configs][config_name]
|
52
109
|
end
|
53
110
|
|
54
111
|
def get_layer(layer_name)
|
55
112
|
return nil unless has_layer?(layer_name)
|
56
|
-
@
|
113
|
+
@specs[:layers][layer_name]
|
57
114
|
end
|
58
115
|
|
59
116
|
def get_id_list(list_name)
|
60
|
-
@
|
117
|
+
@specs[:id_lists][list_name]
|
118
|
+
end
|
119
|
+
|
120
|
+
def get_raw_specs
|
121
|
+
@specs
|
122
|
+
end
|
123
|
+
|
124
|
+
def maybe_restart_background_threads
|
125
|
+
if @config_sync_thread.nil? or !@config_sync_thread.alive?
|
126
|
+
@config_sync_thread = sync_config_specs
|
127
|
+
end
|
128
|
+
if @id_lists_sync_thread.nil? or !@id_lists_sync_thread.alive?
|
129
|
+
@id_lists_sync_thread = sync_id_lists
|
130
|
+
end
|
61
131
|
end
|
62
132
|
|
63
133
|
private
|
64
134
|
|
135
|
+
def load_from_storage_adapter
|
136
|
+
cached_values = @options.data_store.get(CONFIG_SPECS_KEY)
|
137
|
+
if cached_values.nil?
|
138
|
+
return
|
139
|
+
end
|
140
|
+
process(cached_values, true)
|
141
|
+
@init_reason = EvaluationReason::DATA_ADAPTER
|
142
|
+
end
|
143
|
+
|
144
|
+
def save_to_storage_adapter(specs_string)
|
145
|
+
if @options.data_store.nil?
|
146
|
+
return
|
147
|
+
end
|
148
|
+
@options.data_store.set(CONFIG_SPECS_KEY, specs_string)
|
149
|
+
end
|
150
|
+
|
65
151
|
def sync_config_specs
|
66
152
|
Thread.new do
|
67
153
|
loop do
|
68
|
-
sleep @rulesets_sync_interval
|
154
|
+
sleep @options.rulesets_sync_interval
|
69
155
|
download_config_specs
|
70
156
|
end
|
71
157
|
end
|
@@ -80,55 +166,102 @@ module Statsig
|
|
80
166
|
end
|
81
167
|
end
|
82
168
|
|
83
|
-
def download_config_specs
|
169
|
+
def download_config_specs(init_diagnostics = nil)
|
170
|
+
init_diagnostics&.mark("download_config_specs", "start", "network_request")
|
171
|
+
|
172
|
+
error = nil
|
84
173
|
begin
|
85
|
-
response, e = @network.post_helper('download_config_specs', JSON.generate({'sinceTime' => @
|
174
|
+
response, e = @network.post_helper('download_config_specs', JSON.generate({ 'sinceTime' => @last_config_sync_time }))
|
175
|
+
code = response&.status.to_i
|
176
|
+
if e.is_a? NetworkError
|
177
|
+
code = e.http_code
|
178
|
+
end
|
179
|
+
init_diagnostics&.mark("download_config_specs", "end", "network_request", code)
|
180
|
+
|
86
181
|
if e.nil?
|
87
|
-
|
182
|
+
unless response.nil?
|
183
|
+
init_diagnostics&.mark("download_config_specs", "start", "process")
|
184
|
+
|
185
|
+
if process(response.body)
|
186
|
+
@init_reason = EvaluationReason::NETWORK
|
187
|
+
@rules_updated_callback.call(response.body.to_s, @last_config_sync_time) unless response.body.nil? or @rules_updated_callback.nil?
|
188
|
+
end
|
189
|
+
|
190
|
+
init_diagnostics&.mark("download_config_specs", "end", "process", @init_reason == EvaluationReason::NETWORK)
|
191
|
+
end
|
192
|
+
|
193
|
+
nil
|
88
194
|
else
|
89
|
-
e
|
195
|
+
error = e
|
90
196
|
end
|
91
197
|
rescue StandardError => e
|
92
|
-
e
|
198
|
+
error = e
|
93
199
|
end
|
200
|
+
|
201
|
+
@error_callback.call(error) unless error.nil? or @error_callback.nil?
|
94
202
|
end
|
95
203
|
|
96
|
-
def process(
|
97
|
-
if
|
98
|
-
return
|
204
|
+
def process(specs_string, from_adapter = false)
|
205
|
+
if specs_string.nil?
|
206
|
+
return false
|
99
207
|
end
|
100
208
|
|
101
|
-
|
102
|
-
return unless specs_json
|
209
|
+
specs_json = JSON.parse(specs_string)
|
210
|
+
return false unless specs_json.is_a? Hash
|
211
|
+
|
212
|
+
@last_config_sync_time = specs_json['time'] || @last_config_sync_time
|
213
|
+
return false unless specs_json['has_updates'] == true &&
|
103
214
|
!specs_json['feature_gates'].nil? &&
|
104
215
|
!specs_json['dynamic_configs'].nil? &&
|
105
|
-
!specs_json['layer_configs'].nil?
|
216
|
+
!specs_json['layer_configs'].nil?
|
106
217
|
|
107
218
|
new_gates = {}
|
108
219
|
new_configs = {}
|
109
220
|
new_layers = {}
|
221
|
+
new_exp_to_layer = {}
|
222
|
+
|
223
|
+
specs_json['feature_gates'].each { |gate| new_gates[gate['name']] = gate }
|
224
|
+
specs_json['dynamic_configs'].each { |config| new_configs[config['name']] = config }
|
225
|
+
specs_json['layer_configs'].each { |layer| new_layers[layer['name']] = layer }
|
226
|
+
|
227
|
+
if specs_json['layers'].is_a?(Hash)
|
228
|
+
specs_json['layers'].each { |layer_name, experiments|
|
229
|
+
experiments.each { |experiment_name| new_exp_to_layer[experiment_name] = layer_name }
|
230
|
+
}
|
231
|
+
end
|
110
232
|
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
@
|
115
|
-
|
116
|
-
|
233
|
+
@specs[:gates] = new_gates
|
234
|
+
@specs[:configs] = new_configs
|
235
|
+
@specs[:layers] = new_layers
|
236
|
+
@specs[:experiment_to_layer] = new_exp_to_layer
|
237
|
+
|
238
|
+
unless from_adapter
|
239
|
+
save_to_storage_adapter(specs_string)
|
240
|
+
end
|
241
|
+
true
|
117
242
|
end
|
118
243
|
|
119
|
-
def get_id_lists
|
120
|
-
|
244
|
+
def get_id_lists(init_diagnostics = nil)
|
245
|
+
init_diagnostics&.mark("get_id_lists", "start", "network_request")
|
246
|
+
response, e = @network.post_helper('get_id_lists', JSON.generate({ 'statsigMetadata' => Statsig.get_statsig_metadata }))
|
121
247
|
if !e.nil? || response.nil?
|
122
248
|
return
|
123
249
|
end
|
250
|
+
init_diagnostics&.mark("get_id_lists", "end", "network_request", response.status.to_i)
|
124
251
|
|
125
252
|
begin
|
126
253
|
server_id_lists = JSON.parse(response)
|
127
|
-
local_id_lists = @
|
254
|
+
local_id_lists = @specs[:id_lists]
|
128
255
|
if !server_id_lists.is_a?(Hash) || !local_id_lists.is_a?(Hash)
|
129
256
|
return
|
130
257
|
end
|
131
|
-
|
258
|
+
tasks = []
|
259
|
+
|
260
|
+
if server_id_lists.length == 0
|
261
|
+
return
|
262
|
+
end
|
263
|
+
|
264
|
+
init_diagnostics&.mark("get_id_lists", "start", "process", server_id_lists.length)
|
132
265
|
|
133
266
|
server_id_lists.each do |list_name, list|
|
134
267
|
server_list = IDList.new(list)
|
@@ -157,11 +290,17 @@ module Statsig
|
|
157
290
|
next
|
158
291
|
end
|
159
292
|
|
160
|
-
|
293
|
+
tasks << Concurrent::Promise.execute(:executor => @id_list_thread_pool) do
|
161
294
|
download_single_id_list(local_list)
|
162
295
|
end
|
163
296
|
end
|
164
|
-
|
297
|
+
|
298
|
+
result = Concurrent::Promise.all?(*tasks).execute.wait(@id_lists_sync_interval)
|
299
|
+
if result.state != :fulfilled
|
300
|
+
init_diagnostics&.mark("get_id_lists", "end", "process", false)
|
301
|
+
return # timed out
|
302
|
+
end
|
303
|
+
|
165
304
|
delete_lists = []
|
166
305
|
local_id_lists.each do |list_name, list|
|
167
306
|
unless server_id_lists.key? list_name
|
@@ -171,6 +310,7 @@ module Statsig
|
|
171
310
|
delete_lists.each do |list_name|
|
172
311
|
local_id_lists.delete list_name
|
173
312
|
end
|
313
|
+
init_diagnostics&.mark("get_id_lists", "end", "process", true)
|
174
314
|
rescue
|
175
315
|
# Ignored, will try again
|
176
316
|
end
|
@@ -178,7 +318,7 @@ module Statsig
|
|
178
318
|
|
179
319
|
def download_single_id_list(list)
|
180
320
|
nil unless list.is_a? IDList
|
181
|
-
http = HTTP.headers({'Range' => "bytes=#{list&.size || 0}-"}).accept(:json)
|
321
|
+
http = HTTP.headers({ 'Range' => "bytes=#{list&.size || 0}-" }).accept(:json)
|
182
322
|
begin
|
183
323
|
res = http.get(list.url)
|
184
324
|
nil unless res.status.success?
|
@@ -186,7 +326,7 @@ module Statsig
|
|
186
326
|
nil if content_length.nil? || content_length <= 0
|
187
327
|
content = res.body.to_s
|
188
328
|
unless content.is_a?(String) && (content[0] == '-' || content[0] == '+')
|
189
|
-
@
|
329
|
+
@specs[:id_lists].delete(list.name)
|
190
330
|
return
|
191
331
|
end
|
192
332
|
ids_clone = list.ids # clone the list, operate on the new list, and swap out the old list, so the operation is thread-safe
|
@@ -195,7 +335,7 @@ module Statsig
|
|
195
335
|
line = li.strip
|
196
336
|
next if line.length <= 1
|
197
337
|
op = line[0]
|
198
|
-
id = line[1..]
|
338
|
+
id = line[1..line.length]
|
199
339
|
if op == '+'
|
200
340
|
ids_clone.add(id)
|
201
341
|
elsif op == '-'
|