legion-cache 1.3.20 → 1.3.21
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 +4 -4
- data/CHANGELOG.md +13 -0
- data/legion-cache.gemspec +1 -1
- data/lib/legion/cache/cacheable.rb +17 -9
- data/lib/legion/cache/helper.rb +27 -33
- data/lib/legion/cache/local.rb +45 -12
- data/lib/legion/cache/memcached.rb +77 -11
- data/lib/legion/cache/memory.rb +27 -5
- data/lib/legion/cache/pool.rb +27 -4
- data/lib/legion/cache/redis.rb +42 -23
- data/lib/legion/cache/redis_hash.rb +23 -8
- data/lib/legion/cache/settings.rb +65 -10
- data/lib/legion/cache/version.rb +1 -1
- data/lib/legion/cache.rb +206 -19
- metadata +3 -3
data/lib/legion/cache/pool.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'connection_pool'
|
|
4
|
+
require 'legion/logging/helper'
|
|
4
5
|
|
|
5
6
|
module Legion
|
|
6
7
|
module Cache
|
|
7
8
|
module Pool
|
|
8
|
-
extend self
|
|
9
|
+
extend self
|
|
10
|
+
extend Legion::Logging::Helper
|
|
9
11
|
|
|
10
12
|
def connected?
|
|
11
13
|
@connected ||= false
|
|
@@ -28,10 +30,12 @@ module Legion
|
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
def close
|
|
31
|
-
client
|
|
33
|
+
return unless @client
|
|
34
|
+
|
|
35
|
+
@client.shutdown(&:close)
|
|
32
36
|
@client = nil
|
|
33
37
|
@connected = false
|
|
34
|
-
|
|
38
|
+
log.info "#{pool_log_name} pool closed"
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
def restart(**opts)
|
|
@@ -42,7 +46,26 @@ module Legion
|
|
|
42
46
|
client_hash[:timeout] = opts[:timeout] if opts.key? :timeout
|
|
43
47
|
client(**client_hash)
|
|
44
48
|
@connected = true
|
|
45
|
-
|
|
49
|
+
log.info "#{pool_log_name} pool restarted"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def pool_log_name
|
|
55
|
+
if respond_to?(:name)
|
|
56
|
+
label = name.to_s
|
|
57
|
+
return label unless label.empty? || label.start_with?('#<')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
segments = if instance_variable_defined?(:@component_logger) && @component_logger.respond_to?(:segments)
|
|
61
|
+
Array(@component_logger.segments)
|
|
62
|
+
elsif log.respond_to?(:segments)
|
|
63
|
+
Array(log.segments)
|
|
64
|
+
else
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
segments.empty? ? 'cache.pool' : segments.join('.')
|
|
46
69
|
end
|
|
47
70
|
end
|
|
48
71
|
end
|
data/lib/legion/cache/redis.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'openssl'
|
|
4
4
|
require 'redis'
|
|
5
|
+
require 'legion/logging/helper'
|
|
5
6
|
require 'legion/cache/pool'
|
|
6
7
|
require 'legion/cache/settings'
|
|
7
8
|
|
|
@@ -10,14 +11,17 @@ module Legion
|
|
|
10
11
|
module Redis
|
|
11
12
|
include Legion::Cache::Pool
|
|
12
13
|
extend self
|
|
14
|
+
extend Legion::Logging::Helper
|
|
13
15
|
|
|
14
16
|
def client(pool_size: 20, timeout: 5, server: nil, servers: [], cluster: nil, replica: false, # rubocop:disable Metrics/ParameterLists
|
|
17
|
+
logger: nil,
|
|
15
18
|
fixed_hostname: nil, username: nil, password: nil, db: nil, reconnect_attempts: [0, 0.5, 1], **)
|
|
16
19
|
return @client unless @client.nil?
|
|
17
20
|
|
|
18
21
|
@pool_size = pool_size
|
|
19
22
|
@timeout = timeout
|
|
20
23
|
@cluster_mode = Array(cluster).compact.any?
|
|
24
|
+
@component_logger = logger || log
|
|
21
25
|
|
|
22
26
|
@client = ConnectionPool.new(size: pool_size, timeout: timeout) do
|
|
23
27
|
build_redis_client(server: server, servers: servers, cluster: cluster,
|
|
@@ -26,8 +30,13 @@ module Legion
|
|
|
26
30
|
reconnect_attempts: reconnect_attempts)
|
|
27
31
|
end
|
|
28
32
|
@connected = true
|
|
29
|
-
|
|
33
|
+
cache_logger.info "Redis connected to #{resolved_redis_address(server: server, servers: servers, cluster: cluster)}"
|
|
30
34
|
@client
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
handle_exception(e, level: :error, handled: false, operation: :redis_client,
|
|
37
|
+
server: server, servers: Array(servers), cluster_nodes: Array(cluster))
|
|
38
|
+
@connected = false
|
|
39
|
+
raise
|
|
31
40
|
end
|
|
32
41
|
|
|
33
42
|
def build_redis_client(server: nil, servers: [], cluster: nil, replica: false, fixed_hostname: nil, # rubocop:disable Metrics/ParameterLists
|
|
@@ -44,7 +53,7 @@ module Legion
|
|
|
44
53
|
resolved = Legion::Cache::Settings.resolve_servers(
|
|
45
54
|
driver: 'redis', server: server, servers: servers
|
|
46
55
|
)
|
|
47
|
-
host, port = resolved.first
|
|
56
|
+
host, port = Legion::Cache::Settings.parse_server_address(resolved.first, default_port: 6379)
|
|
48
57
|
redis_opts = { host: host, port: port.to_i, reconnect_attempts: reconnect_attempts,
|
|
49
58
|
timeout: @timeout }
|
|
50
59
|
redis_opts[:username] = username unless username.nil?
|
|
@@ -61,31 +70,39 @@ module Legion
|
|
|
61
70
|
|
|
62
71
|
def get(key)
|
|
63
72
|
result = client.with { |conn| conn.get(key) }
|
|
64
|
-
|
|
73
|
+
cache_logger.debug "[cache] GET #{key} hit=#{!result.nil?}"
|
|
65
74
|
result
|
|
66
75
|
rescue ::Redis::BaseError => e
|
|
67
|
-
log_cluster_error(e)
|
|
76
|
+
log_cluster_error('redis_get', e, key: key)
|
|
68
77
|
raise
|
|
69
78
|
end
|
|
70
|
-
|
|
79
|
+
|
|
80
|
+
def fetch(key, ttl = nil)
|
|
81
|
+
result = get(key)
|
|
82
|
+
return result unless result.nil? && block_given?
|
|
83
|
+
|
|
84
|
+
result = yield
|
|
85
|
+
set(key, result, ttl)
|
|
86
|
+
result
|
|
87
|
+
end
|
|
71
88
|
|
|
72
89
|
def set(key, value, ttl = nil)
|
|
73
90
|
args = {}
|
|
74
91
|
args[:ex] = ttl unless ttl.nil?
|
|
75
92
|
result = client.with { |conn| conn.set(key, value, **args) == 'OK' }
|
|
76
|
-
|
|
93
|
+
cache_logger.debug "[cache] SET #{key} ttl=#{ttl.inspect} success=#{result}"
|
|
77
94
|
result
|
|
78
95
|
rescue ::Redis::BaseError => e
|
|
79
|
-
log_cluster_error(e)
|
|
96
|
+
log_cluster_error('redis_set', e, key: key, ttl: ttl)
|
|
80
97
|
raise
|
|
81
98
|
end
|
|
82
99
|
|
|
83
100
|
def delete(key)
|
|
84
101
|
result = client.with { |conn| conn.del(key) == 1 }
|
|
85
|
-
|
|
102
|
+
cache_logger.debug "[cache] DELETE #{key} success=#{result}"
|
|
86
103
|
result
|
|
87
104
|
rescue ::Redis::BaseError => e
|
|
88
|
-
log_cluster_error(e)
|
|
105
|
+
log_cluster_error('redis_delete', e, key: key)
|
|
89
106
|
raise
|
|
90
107
|
end
|
|
91
108
|
|
|
@@ -97,10 +114,10 @@ module Legion
|
|
|
97
114
|
conn.flushdb == 'OK'
|
|
98
115
|
end
|
|
99
116
|
end
|
|
100
|
-
|
|
117
|
+
cache_logger.debug '[cache] FLUSH completed'
|
|
101
118
|
result
|
|
102
119
|
rescue ::Redis::BaseError => e
|
|
103
|
-
log_cluster_error(e)
|
|
120
|
+
log_cluster_error('redis_flush', e)
|
|
104
121
|
raise
|
|
105
122
|
end
|
|
106
123
|
|
|
@@ -116,10 +133,10 @@ module Legion
|
|
|
116
133
|
keys.zip(values).to_h
|
|
117
134
|
end
|
|
118
135
|
end
|
|
119
|
-
|
|
136
|
+
cache_logger.debug "[cache] MGET keys=#{keys.size}"
|
|
120
137
|
result
|
|
121
138
|
rescue ::Redis::BaseError => e
|
|
122
|
-
log_cluster_error(e)
|
|
139
|
+
log_cluster_error('redis_mget', e, key_count: keys.size)
|
|
123
140
|
raise
|
|
124
141
|
end
|
|
125
142
|
|
|
@@ -133,15 +150,19 @@ module Legion
|
|
|
133
150
|
conn.mset(*hash.flatten) == 'OK'
|
|
134
151
|
end
|
|
135
152
|
end
|
|
136
|
-
|
|
153
|
+
cache_logger.debug "[cache] MSET keys=#{hash.size}"
|
|
137
154
|
result
|
|
138
155
|
rescue ::Redis::BaseError => e
|
|
139
|
-
log_cluster_error(e)
|
|
156
|
+
log_cluster_error('redis_mset', e, key_count: hash.size)
|
|
140
157
|
raise
|
|
141
158
|
end
|
|
142
159
|
|
|
143
160
|
private
|
|
144
161
|
|
|
162
|
+
def cache_logger
|
|
163
|
+
@component_logger || log
|
|
164
|
+
end
|
|
165
|
+
|
|
145
166
|
def cluster_mget(conn, keys)
|
|
146
167
|
groups = group_keys_by_slot(keys)
|
|
147
168
|
result = {}
|
|
@@ -165,14 +186,14 @@ module Legion
|
|
|
165
186
|
node_info = conn.cluster('nodes')
|
|
166
187
|
primaries = node_info.lines.select { |l| l.include?('master') }.map { |l| l.split[1].split('@').first }
|
|
167
188
|
primaries.each do |addr|
|
|
168
|
-
host, port =
|
|
189
|
+
host, port = Legion::Cache::Settings.parse_server_address(addr, default_port: 6379)
|
|
169
190
|
node = ::Redis.new(host: host, port: port.to_i)
|
|
170
191
|
node.flushdb
|
|
171
192
|
node.close
|
|
172
193
|
end
|
|
173
194
|
true
|
|
174
195
|
rescue StandardError => e
|
|
175
|
-
|
|
196
|
+
handle_exception(e, level: :warn, handled: true, operation: :cluster_flush, fallback: :single_flushdb)
|
|
176
197
|
conn.flushdb == 'OK'
|
|
177
198
|
end
|
|
178
199
|
|
|
@@ -190,14 +211,12 @@ module Legion
|
|
|
190
211
|
|
|
191
212
|
Legion::Cache::Settings.resolve_servers(driver: 'redis', server: server, servers: Array(servers)).first
|
|
192
213
|
rescue StandardError => e
|
|
193
|
-
|
|
214
|
+
handle_exception(e, level: :warn, handled: true, operation: :resolved_redis_address)
|
|
194
215
|
'unknown'
|
|
195
216
|
end
|
|
196
217
|
|
|
197
|
-
def log_cluster_error(error)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
Legion::Logging.warn "Redis cluster error: #{error.class} — #{error.message}"
|
|
218
|
+
def log_cluster_error(operation, error, **)
|
|
219
|
+
handle_exception(error, level: :warn, handled: false, operation: operation, **)
|
|
201
220
|
end
|
|
202
221
|
|
|
203
222
|
def redis_tls_options(port:)
|
|
@@ -219,7 +238,7 @@ module Legion
|
|
|
219
238
|
|
|
220
239
|
Legion::Settings[:cache][:tls] || {}
|
|
221
240
|
rescue StandardError => e
|
|
222
|
-
|
|
241
|
+
handle_exception(e, level: :warn, handled: true, operation: :cache_tls_settings)
|
|
223
242
|
{}
|
|
224
243
|
end
|
|
225
244
|
end
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Cache
|
|
5
7
|
module RedisHash
|
|
8
|
+
extend Legion::Logging::Helper
|
|
9
|
+
|
|
6
10
|
module_function
|
|
7
11
|
|
|
8
12
|
# Returns true when the Redis driver is loaded and the connection pool is live.
|
|
9
13
|
def redis_available?
|
|
10
14
|
pool = Legion::Cache.instance_variable_get(:@client)
|
|
11
15
|
return false if pool.nil?
|
|
16
|
+
return false unless Legion::Cache.respond_to?(:driver_name) && Legion::Cache.driver_name == 'redis'
|
|
12
17
|
|
|
13
18
|
Legion::Cache.connected?
|
|
14
|
-
rescue StandardError
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
handle_exception(e, level: :warn, handled: true, operation: :redis_hash_available)
|
|
15
21
|
false
|
|
16
22
|
end
|
|
17
23
|
|
|
@@ -24,6 +30,7 @@ module Legion
|
|
|
24
30
|
flat = hash.flat_map { |k, v| [k.to_s, v.to_s] }
|
|
25
31
|
conn.hset(key, *flat)
|
|
26
32
|
end
|
|
33
|
+
log.debug "[cache:redis_hash] HSET #{key} fields=#{hash.size}"
|
|
27
34
|
true
|
|
28
35
|
rescue StandardError => e
|
|
29
36
|
log_redis_error('hset', e)
|
|
@@ -34,9 +41,11 @@ module Legion
|
|
|
34
41
|
def hgetall(key)
|
|
35
42
|
return nil unless redis_available?
|
|
36
43
|
|
|
37
|
-
Legion::Cache.instance_variable_get(:@client).with do |conn|
|
|
44
|
+
result = Legion::Cache.instance_variable_get(:@client).with do |conn|
|
|
38
45
|
conn.hgetall(key)
|
|
39
46
|
end
|
|
47
|
+
log.debug "[cache:redis_hash] HGETALL #{key} fields=#{result.size}"
|
|
48
|
+
result
|
|
40
49
|
rescue StandardError => e
|
|
41
50
|
log_redis_error('hgetall', e)
|
|
42
51
|
nil
|
|
@@ -46,9 +55,11 @@ module Legion
|
|
|
46
55
|
def hdel(key, *fields)
|
|
47
56
|
return 0 unless redis_available?
|
|
48
57
|
|
|
49
|
-
Legion::Cache.instance_variable_get(:@client).with do |conn|
|
|
58
|
+
result = Legion::Cache.instance_variable_get(:@client).with do |conn|
|
|
50
59
|
conn.hdel(key, *fields)
|
|
51
60
|
end
|
|
61
|
+
log.debug "[cache:redis_hash] HDEL #{key} fields=#{fields.size} removed=#{result}"
|
|
62
|
+
result
|
|
52
63
|
rescue StandardError => e
|
|
53
64
|
log_redis_error('hdel', e)
|
|
54
65
|
0
|
|
@@ -61,6 +72,7 @@ module Legion
|
|
|
61
72
|
Legion::Cache.instance_variable_get(:@client).with do |conn|
|
|
62
73
|
conn.zadd(key, score.to_f, member.to_s)
|
|
63
74
|
end
|
|
75
|
+
log.debug "[cache:redis_hash] ZADD #{key} member=#{member}"
|
|
64
76
|
true
|
|
65
77
|
rescue StandardError => e
|
|
66
78
|
log_redis_error('zadd', e)
|
|
@@ -75,9 +87,11 @@ module Legion
|
|
|
75
87
|
opts = {}
|
|
76
88
|
opts[:limit] = limit if limit
|
|
77
89
|
|
|
78
|
-
Legion::Cache.instance_variable_get(:@client).with do |conn|
|
|
90
|
+
result = Legion::Cache.instance_variable_get(:@client).with do |conn|
|
|
79
91
|
conn.zrangebyscore(key, min, max, **opts)
|
|
80
92
|
end
|
|
93
|
+
log.debug "[cache:redis_hash] ZRANGEBYSCORE #{key} results=#{result.size}"
|
|
94
|
+
result
|
|
81
95
|
rescue StandardError => e
|
|
82
96
|
log_redis_error('zrangebyscore', e)
|
|
83
97
|
[]
|
|
@@ -90,6 +104,7 @@ module Legion
|
|
|
90
104
|
Legion::Cache.instance_variable_get(:@client).with do |conn|
|
|
91
105
|
conn.zrem(key, member.to_s)
|
|
92
106
|
end
|
|
107
|
+
log.debug "[cache:redis_hash] ZREM #{key} member=#{member}"
|
|
93
108
|
true
|
|
94
109
|
rescue StandardError => e
|
|
95
110
|
log_redis_error('zrem', e)
|
|
@@ -100,18 +115,18 @@ module Legion
|
|
|
100
115
|
def expire(key, seconds)
|
|
101
116
|
return false unless redis_available?
|
|
102
117
|
|
|
103
|
-
Legion::Cache.instance_variable_get(:@client).with do |conn|
|
|
118
|
+
result = Legion::Cache.instance_variable_get(:@client).with do |conn|
|
|
104
119
|
conn.expire(key, seconds.to_i) == 1
|
|
105
120
|
end
|
|
121
|
+
log.debug "[cache:redis_hash] EXPIRE #{key} seconds=#{seconds} success=#{result}"
|
|
122
|
+
result
|
|
106
123
|
rescue StandardError => e
|
|
107
124
|
log_redis_error('expire', e)
|
|
108
125
|
false
|
|
109
126
|
end
|
|
110
127
|
|
|
111
128
|
def log_redis_error(method, error)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
Legion::Logging.warn "[cache:redis_hash] #{method} failed: #{error.class} — #{error.message}"
|
|
129
|
+
handle_exception(error, level: :warn, handled: true, operation: method)
|
|
115
130
|
end
|
|
116
131
|
end
|
|
117
132
|
end
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
rescue StandardError => e
|
|
6
|
-
warn "legion-cache: failed to require legion/settings: #{e.message}"
|
|
7
|
-
end
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
require 'legion/logging/helper'
|
|
8
5
|
|
|
9
6
|
module Legion
|
|
10
7
|
module Cache
|
|
11
8
|
module Settings
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
extend Legion::Logging::Helper
|
|
10
|
+
|
|
11
|
+
begin
|
|
12
|
+
require 'legion/settings'
|
|
13
|
+
rescue StandardError => e
|
|
14
|
+
handle_exception(e,
|
|
15
|
+
level: :error,
|
|
16
|
+
handled: true,
|
|
17
|
+
operation: :cache_settings_require_legion_settings)
|
|
18
|
+
end
|
|
19
|
+
|
|
14
20
|
def self.default
|
|
15
21
|
{
|
|
16
22
|
driver: driver,
|
|
@@ -69,8 +75,34 @@ module Legion
|
|
|
69
75
|
all = Array(servers) + Array(server)
|
|
70
76
|
all = ["127.0.0.1:#{port}"] if all.empty?
|
|
71
77
|
|
|
72
|
-
all.map! { |s|
|
|
73
|
-
all.uniq
|
|
78
|
+
all.map! { |s| normalize_server(s, port: port) }
|
|
79
|
+
resolved = all.uniq
|
|
80
|
+
log.debug "Legion::Cache::Settings resolved driver=#{gem_driver} servers=#{resolved.join(', ')}"
|
|
81
|
+
resolved
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.parse_server_address(server, default_port:)
|
|
85
|
+
raw = server.to_s.strip
|
|
86
|
+
return ['127.0.0.1', default_port] if raw.empty?
|
|
87
|
+
|
|
88
|
+
bracketed = raw.match(/\A\[(?<host>[^\]]+)\](?::(?<port>\d+))?\z/)
|
|
89
|
+
return [bracketed[:host], (bracketed[:port] || default_port).to_i] if bracketed
|
|
90
|
+
|
|
91
|
+
return [raw, default_port] if ipv6_literal?(raw)
|
|
92
|
+
|
|
93
|
+
host, explicit_port = raw.split(':', 2)
|
|
94
|
+
if explicit_port&.match?(/\A\d+\z/)
|
|
95
|
+
[host, explicit_port.to_i]
|
|
96
|
+
else
|
|
97
|
+
[raw, default_port]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.register_defaults!
|
|
102
|
+
return unless defined?(Legion::Settings) && Legion::Settings.respond_to?(:merge_settings)
|
|
103
|
+
|
|
104
|
+
Legion::Settings.merge_settings(:cache, default)
|
|
105
|
+
Legion::Settings.merge_settings(:cache_local, local)
|
|
74
106
|
end
|
|
75
107
|
|
|
76
108
|
def self.normalize_driver(name)
|
|
@@ -84,13 +116,36 @@ module Legion
|
|
|
84
116
|
def self.driver(prefer = 'dalli')
|
|
85
117
|
secondary = prefer == 'dalli' ? 'redis' : 'dalli'
|
|
86
118
|
if Gem::Specification.find_all_by_name(prefer).any?
|
|
119
|
+
log.debug "Legion::Cache::Settings selected driver=#{prefer}"
|
|
87
120
|
prefer
|
|
88
121
|
elsif Gem::Specification.find_all_by_name(secondary).any?
|
|
122
|
+
log.info "Legion::Cache::Settings falling back driver=#{secondary} preferred=#{prefer}"
|
|
89
123
|
secondary
|
|
90
124
|
else
|
|
91
|
-
|
|
125
|
+
error = NameError.new('Legion::Cache.driver is nil')
|
|
126
|
+
handle_exception(error, level: :error, handled: false, operation: :cache_settings_driver, preferred: prefer)
|
|
127
|
+
raise error
|
|
92
128
|
end
|
|
93
129
|
end
|
|
130
|
+
|
|
131
|
+
def self.normalize_server(server, port:)
|
|
132
|
+
host, resolved_port = parse_server_address(server, default_port: port)
|
|
133
|
+
format_server(host, resolved_port)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def self.format_server(host, port)
|
|
137
|
+
return "[#{host}]:#{port}" if ipv6_literal?(host)
|
|
138
|
+
|
|
139
|
+
"#{host}:#{port}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.ipv6_literal?(value)
|
|
143
|
+
IPAddr.new(value).ipv6?
|
|
144
|
+
rescue IPAddr::InvalidAddressError
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
register_defaults!
|
|
94
149
|
end
|
|
95
150
|
end
|
|
96
151
|
end
|