magick-feature-flags 1.4.2 → 1.5.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 +4 -4
- data/README.md +71 -7
- data/app/controllers/magick/adminui/features_controller.rb +46 -0
- data/config/magick.rb.example +5 -3
- data/lib/generators/magick/install/templates/magick.rb +5 -3
- data/lib/magick/adapters/registry.rb +138 -55
- data/lib/magick/admin_ui.rb +5 -1
- data/lib/magick/config.rb +10 -4
- data/lib/magick/feature.rb +313 -166
- data/lib/magick/rails/railtie.rb +37 -2
- data/lib/magick/version.rb +1 -1
- data/lib/magick/versioning.rb +205 -51
- data/lib/magick.rb +61 -0
- metadata +1 -1
data/lib/magick/rails/railtie.rb
CHANGED
|
@@ -87,15 +87,18 @@ if defined?(Rails)
|
|
|
87
87
|
# Supports both config/features.rb and config/initializers/features.rb
|
|
88
88
|
config.after_initialize do
|
|
89
89
|
# Try config/features.rb first (recommended location)
|
|
90
|
+
# definition_mode suppresses audit/version recording: boot replays
|
|
91
|
+
# the declarative definitions in every container, and recording
|
|
92
|
+
# those would flood history with identical snapshots.
|
|
90
93
|
features_file = Rails.root.join('config', 'features.rb')
|
|
91
94
|
if File.exist?(features_file)
|
|
92
|
-
load features_file
|
|
95
|
+
Magick.definition_mode { load features_file }
|
|
93
96
|
else
|
|
94
97
|
# Fallback to config/initializers/features.rb (already loaded by Rails, but check anyway)
|
|
95
98
|
initializer_file = Rails.root.join('config', 'initializers', 'features.rb')
|
|
96
99
|
if File.exist?(initializer_file) && !defined?(Magick::Rails::FeaturesLoaded)
|
|
97
100
|
# Only load if not already loaded (Rails may have already loaded it)
|
|
98
|
-
load initializer_file
|
|
101
|
+
Magick.definition_mode { load initializer_file }
|
|
99
102
|
end
|
|
100
103
|
end
|
|
101
104
|
begin
|
|
@@ -132,6 +135,18 @@ if defined?(Rails)
|
|
|
132
135
|
end
|
|
133
136
|
end
|
|
134
137
|
|
|
138
|
+
# Revive the Pub/Sub subscriber in forked workers. `config.to_prepare`
|
|
139
|
+
# only re-runs per request in development; in production it runs once at
|
|
140
|
+
# boot, BEFORE Puma forks its workers under `preload_app!`. Those workers
|
|
141
|
+
# inherit a dead subscriber thread and would never receive cross-process
|
|
142
|
+
# cache invalidations. This middleware calls `ensure_subscriber!` (a
|
|
143
|
+
# pid-guarded no-op once the subscriber is running) on each request, so a
|
|
144
|
+
# forked worker starts its own subscriber on its first request. In
|
|
145
|
+
# single-mode Puma (no fork) it is effectively free.
|
|
146
|
+
initializer 'magick.subscriber_middleware' do |app|
|
|
147
|
+
app.middleware.use Magick::Rails::SubscriberMiddleware
|
|
148
|
+
end
|
|
149
|
+
|
|
135
150
|
# Terminate the Pub/Sub subscriber + async metrics thread on process exit.
|
|
136
151
|
# Without this, Ruby waits on the blocking `Redis#subscribe` call inside
|
|
137
152
|
# the subscriber thread and Puma/Rails shutdown stalls.
|
|
@@ -145,6 +160,26 @@ if defined?(Rails)
|
|
|
145
160
|
end
|
|
146
161
|
end
|
|
147
162
|
|
|
163
|
+
# Ensures each process (including Puma workers forked under `preload_app!`)
|
|
164
|
+
# has a live Redis Pub/Sub subscriber for cross-process cache invalidation.
|
|
165
|
+
# `ensure_subscriber!` returns immediately once `@owner_pid == Process.pid`,
|
|
166
|
+
# so the per-request cost is a single pid comparison after the first call.
|
|
167
|
+
class SubscriberMiddleware
|
|
168
|
+
def initialize(app)
|
|
169
|
+
@app = app
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def call(env)
|
|
173
|
+
begin
|
|
174
|
+
registry = Magick.adapter_registry
|
|
175
|
+
registry.ensure_subscriber! if registry.respond_to?(:ensure_subscriber!)
|
|
176
|
+
rescue StandardError
|
|
177
|
+
# Best-effort: never break a request over subscriber bookkeeping.
|
|
178
|
+
end
|
|
179
|
+
@app.call(env)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
148
183
|
# Request store integration
|
|
149
184
|
module RequestStoreIntegration
|
|
150
185
|
def self.included(base)
|
data/lib/magick/version.rb
CHANGED
data/lib/magick/versioning.rb
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
3
6
|
module Magick
|
|
4
7
|
class Versioning
|
|
8
|
+
DEFAULT_MAX_VERSIONS = 50
|
|
9
|
+
|
|
10
|
+
# Version history lives under a reserved pseudo-feature namespace so that
|
|
11
|
+
# feature reads (get_all_data) never drag snapshot blobs along, and so
|
|
12
|
+
# deleting a feature does not destroy its ActiveRecord archive row.
|
|
13
|
+
STORE_PREFIX = '__magick_versions:'
|
|
14
|
+
|
|
5
15
|
class Version
|
|
6
|
-
attr_reader :version, :feature_data, :timestamp, :created_by
|
|
16
|
+
attr_reader :version, :feature_data, :timestamp, :created_by, :action
|
|
7
17
|
|
|
8
|
-
def initialize(version, feature_data, created_by: nil)
|
|
18
|
+
def initialize(version, feature_data, created_by: nil, action: nil, timestamp: nil)
|
|
9
19
|
@version = version
|
|
10
20
|
@feature_data = feature_data
|
|
11
|
-
@timestamp = Time.now
|
|
21
|
+
@timestamp = timestamp || Time.now
|
|
12
22
|
@created_by = created_by
|
|
23
|
+
@action = action
|
|
13
24
|
end
|
|
14
25
|
|
|
15
26
|
def to_h
|
|
@@ -17,66 +28,65 @@ module Magick
|
|
|
17
28
|
version: version,
|
|
18
29
|
feature_data: feature_data,
|
|
19
30
|
timestamp: timestamp.iso8601,
|
|
20
|
-
created_by: created_by
|
|
31
|
+
created_by: created_by,
|
|
32
|
+
action: action
|
|
21
33
|
}
|
|
22
34
|
end
|
|
23
35
|
end
|
|
24
36
|
|
|
25
|
-
def initialize(adapter_registry)
|
|
37
|
+
def initialize(adapter_registry, max_versions: DEFAULT_MAX_VERSIONS)
|
|
26
38
|
@adapter_registry = adapter_registry
|
|
27
|
-
@
|
|
39
|
+
@max_versions = max_versions.to_i.positive? ? max_versions.to_i : DEFAULT_MAX_VERSIONS
|
|
40
|
+
# Process-local cache of the hot window, rehydrated lazily from the
|
|
41
|
+
# adapters so history survives restarts and is shared across containers.
|
|
42
|
+
@hot = {}
|
|
28
43
|
@mutex = Mutex.new
|
|
29
44
|
end
|
|
30
45
|
|
|
46
|
+
attr_reader :max_versions
|
|
47
|
+
|
|
48
|
+
# Explicit manual snapshot. Kept as public API from earlier releases;
|
|
49
|
+
# since 1.5.0 every Feature mutation also snapshots automatically.
|
|
31
50
|
def save_version(feature_name, version: nil, created_by: nil)
|
|
32
51
|
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
# Compute version + append under the same mutex so two concurrent
|
|
36
|
-
# save_version calls on the same feature can't both assign version N.
|
|
37
|
-
version_data = @mutex.synchronize do
|
|
38
|
-
list = (@versions[feature_name_str] ||= [])
|
|
39
|
-
resolved_version = version || (list.empty? ? 1 : list.last.version + 1)
|
|
40
|
-
entry = Version.new(resolved_version, feature.to_h, created_by: created_by)
|
|
41
|
-
list << entry
|
|
42
|
-
@adapter_registry.set(feature_name, "version_#{resolved_version}", entry.to_h)
|
|
43
|
-
entry
|
|
44
|
-
end
|
|
52
|
+
append(feature.name, snapshot_of(feature), version: version, created_by: created_by, action: 'manual')
|
|
53
|
+
end
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
# Called by Feature#record_change after every successful mutation.
|
|
56
|
+
# snapshot: allows callers (delete) to capture state before the mutation.
|
|
57
|
+
def record_change(feature, action: nil, created_by: nil, snapshot: nil)
|
|
58
|
+
data = snapshot ? deep_symbolize(JSON.parse(JSON.generate(snapshot))) : snapshot_of(feature)
|
|
59
|
+
append(feature.name, data, created_by: created_by, action: action)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Hot window (last max_versions, memory/Redis) by default; all: true
|
|
63
|
+
# merges the unlimited ActiveRecord archive when one is configured.
|
|
64
|
+
def get_versions(feature_name, all: false)
|
|
65
|
+
name = feature_name.to_s
|
|
66
|
+
hot = @mutex.synchronize { hot_window(name).dup }
|
|
67
|
+
return hot unless all
|
|
49
68
|
|
|
50
|
-
|
|
69
|
+
older = archive_versions(name).reject { |a| hot.any? { |h| h.version == a.version } }
|
|
70
|
+
(older + hot).sort_by(&:version)
|
|
51
71
|
end
|
|
52
72
|
|
|
73
|
+
# Restore the feature to the snapshot stored in the given version, then
|
|
74
|
+
# record the rollback itself as a new version + audit entry (history only
|
|
75
|
+
# ever rolls forward). Restores state wholesale: value (including false/
|
|
76
|
+
# empty), status, group, the entire targeting hash, and dependencies.
|
|
53
77
|
def rollback(feature_name, version)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return false unless target_version
|
|
78
|
+
entry = find_version(feature_name, version)
|
|
79
|
+
return false unless entry
|
|
57
80
|
|
|
58
81
|
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
# Restore feature state
|
|
62
|
-
feature.set_value(feature_data[:value]) if feature_data[:value]
|
|
63
|
-
feature.set_status(feature_data[:status]) if feature_data[:status]
|
|
64
|
-
|
|
65
|
-
# Restore targeting
|
|
66
|
-
feature_data[:targeting]&.each do |type, values|
|
|
67
|
-
Array(values).each do |value|
|
|
68
|
-
case type.to_sym
|
|
69
|
-
when :user
|
|
70
|
-
feature.enable_for_user(value)
|
|
71
|
-
when :group
|
|
72
|
-
feature.enable_for_group(value)
|
|
73
|
-
when :role
|
|
74
|
-
feature.enable_for_role(value)
|
|
75
|
-
end
|
|
76
|
-
end
|
|
82
|
+
Magick.suppress_change_recording do
|
|
83
|
+
feature.restore_snapshot!(entry.feature_data)
|
|
77
84
|
end
|
|
78
85
|
|
|
79
|
-
|
|
86
|
+
actor = Magick.current_actor
|
|
87
|
+
Magick.audit_log&.log(feature_name, 'rollback', user_id: actor, changes: { rolled_back_to: version })
|
|
88
|
+
record_change(feature, action: 'rollback', created_by: actor)
|
|
89
|
+
|
|
80
90
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
81
91
|
Magick::Rails::Events.rollback(feature_name, version: version)
|
|
82
92
|
end
|
|
@@ -84,17 +94,161 @@ module Magick
|
|
|
84
94
|
true
|
|
85
95
|
end
|
|
86
96
|
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Deep copy via JSON so stored snapshots never alias the feature's live
|
|
100
|
+
# targeting hash, and in-process entries match adapter-rehydrated ones.
|
|
101
|
+
def snapshot_of(feature)
|
|
102
|
+
deep_symbolize(JSON.parse(JSON.generate(feature.to_h)))
|
|
89
103
|
end
|
|
90
104
|
|
|
91
|
-
|
|
105
|
+
def append(name, snapshot, version: nil, created_by: nil, action: nil)
|
|
106
|
+
entry = @mutex.synchronize do
|
|
107
|
+
window = hot_window(name)
|
|
108
|
+
resolved = version || (window.last ? window.last.version + 1 : 1)
|
|
109
|
+
record = Version.new(resolved, snapshot, created_by: created_by, action: action)
|
|
110
|
+
window << record
|
|
111
|
+
window.shift while window.size > @max_versions
|
|
112
|
+
persist_hot_window(name, window)
|
|
113
|
+
persist_archive(name, record)
|
|
114
|
+
record
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
118
|
+
Magick::Rails::Events.version_saved(name, version: entry.version, created_by: created_by)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
entry
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Callers must hold @mutex.
|
|
125
|
+
def hot_window(name)
|
|
126
|
+
@hot[name] ||= load_hot_window(name)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def load_hot_window(name)
|
|
130
|
+
raw = read_hot_list(name)
|
|
131
|
+
raw = read_archive_tail(name) if raw.nil? || raw.empty?
|
|
132
|
+
Array(raw).filter_map { |h| rehydrate(h) }.sort_by(&:version).last(@max_versions)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def read_hot_list(name)
|
|
136
|
+
hot_adapters.each do |adapter|
|
|
137
|
+
list = safely { adapter.get(store_name(name), 'versions') }
|
|
138
|
+
list = parse_json(list) if list.is_a?(String)
|
|
139
|
+
return list if list.is_a?(Array) && !list.empty?
|
|
140
|
+
end
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Seed the hot window from the archive when memory/Redis are empty
|
|
145
|
+
# (fresh boot, Redis flush) so version numbering continues, not restarts.
|
|
146
|
+
def read_archive_tail(name)
|
|
147
|
+
adapter = archive_adapter
|
|
148
|
+
return nil unless adapter
|
|
149
|
+
|
|
150
|
+
data = safely { adapter.get_all_data(store_name(name)) }
|
|
151
|
+
return nil unless data.is_a?(Hash)
|
|
92
152
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
153
|
+
data.filter_map { |key, value| value if key.to_s.start_with?('version_') }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def archive_versions(name)
|
|
157
|
+
adapter = archive_adapter
|
|
158
|
+
return [] unless adapter
|
|
159
|
+
|
|
160
|
+
data = safely { adapter.get_all_data(store_name(name)) }
|
|
161
|
+
return [] unless data.is_a?(Hash)
|
|
162
|
+
|
|
163
|
+
data.filter_map { |key, value| rehydrate(value) if key.to_s.start_with?('version_') }.sort_by(&:version)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def persist_hot_window(name, window)
|
|
167
|
+
payload = window.map(&:to_h)
|
|
168
|
+
hot_adapters.each do |adapter|
|
|
169
|
+
safely { adapter.set(store_name(name), 'versions', payload) }
|
|
97
170
|
end
|
|
98
171
|
end
|
|
172
|
+
|
|
173
|
+
def persist_archive(name, entry)
|
|
174
|
+
adapter = archive_adapter
|
|
175
|
+
return unless adapter
|
|
176
|
+
|
|
177
|
+
safely { adapter.set(store_name(name), "version_#{entry.version}", entry.to_h) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def find_version(feature_name, version)
|
|
181
|
+
name = feature_name.to_s
|
|
182
|
+
hot = @mutex.synchronize { hot_window(name).dup }
|
|
183
|
+
found = hot.find { |v| v.version == version }
|
|
184
|
+
return found if found
|
|
185
|
+
|
|
186
|
+
adapter = archive_adapter
|
|
187
|
+
return nil unless adapter
|
|
188
|
+
|
|
189
|
+
raw = safely { adapter.get(store_name(name), "version_#{version}") }
|
|
190
|
+
raw ? rehydrate(raw) : nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Hot window lives in memory + Redis (capped); the ActiveRecord adapter
|
|
194
|
+
# keeps the unlimited archive, one version_<n> key per entry.
|
|
195
|
+
def hot_adapters
|
|
196
|
+
registry = @adapter_registry
|
|
197
|
+
if registry.respond_to?(:memory_adapter)
|
|
198
|
+
[registry.memory_adapter, registry.redis_adapter].compact
|
|
199
|
+
else
|
|
200
|
+
[registry]
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def archive_adapter
|
|
205
|
+
registry = @adapter_registry
|
|
206
|
+
registry.respond_to?(:active_record_adapter) ? registry.active_record_adapter : nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def store_name(name)
|
|
210
|
+
"#{STORE_PREFIX}#{name}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def rehydrate(raw)
|
|
214
|
+
raw = parse_json(raw) if raw.is_a?(String)
|
|
215
|
+
return nil unless raw.is_a?(Hash)
|
|
216
|
+
|
|
217
|
+
data = deep_symbolize(raw)
|
|
218
|
+
timestamp = data[:timestamp]
|
|
219
|
+
timestamp = safely { Time.parse(timestamp) } if timestamp.is_a?(String)
|
|
220
|
+
Version.new(
|
|
221
|
+
data[:version],
|
|
222
|
+
data[:feature_data] || {},
|
|
223
|
+
created_by: data[:created_by],
|
|
224
|
+
action: data[:action],
|
|
225
|
+
timestamp: timestamp
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def parse_json(str)
|
|
230
|
+
JSON.parse(str)
|
|
231
|
+
rescue JSON::ParserError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def deep_symbolize(obj)
|
|
236
|
+
case obj
|
|
237
|
+
when Hash
|
|
238
|
+
obj.each_with_object({}) { |(k, v), out| out[k.to_sym] = deep_symbolize(v) }
|
|
239
|
+
when Array
|
|
240
|
+
obj.map { |v| deep_symbolize(v) }
|
|
241
|
+
else
|
|
242
|
+
obj
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Version bookkeeping is best-effort: a flaky adapter must never turn a
|
|
247
|
+
# feature toggle into an exception.
|
|
248
|
+
def safely
|
|
249
|
+
yield
|
|
250
|
+
rescue StandardError
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
99
253
|
end
|
|
100
254
|
end
|
data/lib/magick.rb
CHANGED
|
@@ -216,6 +216,64 @@ module Magick
|
|
|
216
216
|
@versioning ||= Versioning.new(adapter_registry || default_adapter_registry)
|
|
217
217
|
end
|
|
218
218
|
|
|
219
|
+
# When false, Feature#record_change skips version snapshots (audit log
|
|
220
|
+
# entries are still written). Set via `versioning enabled: false` in the
|
|
221
|
+
# configuration DSL.
|
|
222
|
+
attr_writer :versioning_enabled
|
|
223
|
+
|
|
224
|
+
def versioning_enabled?
|
|
225
|
+
@versioning_enabled.nil? || @versioning_enabled != false
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Attribute all changes made inside the block to the given actor. Audit
|
|
229
|
+
# entries pick it up as user_id and versions as created_by, unless the
|
|
230
|
+
# call site passes an explicit user_id:.
|
|
231
|
+
#
|
|
232
|
+
# Magick.with_actor(current_user.id) { Magick[:checkout].enable }
|
|
233
|
+
def with_actor(actor)
|
|
234
|
+
previous = Thread.current[:magick_actor]
|
|
235
|
+
Thread.current[:magick_actor] = actor
|
|
236
|
+
yield
|
|
237
|
+
ensure
|
|
238
|
+
Thread.current[:magick_actor] = previous
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def current_actor
|
|
242
|
+
Thread.current[:magick_actor]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Suppress audit/version recording while declarative feature definitions
|
|
246
|
+
# are (re)applied. Process boot replays config/features.rb in every
|
|
247
|
+
# container; recording those replays would flood history with identical
|
|
248
|
+
# snapshots. The Rails railtie wraps the features file load in this;
|
|
249
|
+
# non-Rails apps should do the same around their definition file.
|
|
250
|
+
def definition_mode
|
|
251
|
+
previous = Thread.current[:magick_definition_mode]
|
|
252
|
+
Thread.current[:magick_definition_mode] = true
|
|
253
|
+
yield
|
|
254
|
+
ensure
|
|
255
|
+
Thread.current[:magick_definition_mode] = previous
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def definition_mode?
|
|
259
|
+
Thread.current[:magick_definition_mode] == true
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Reentrancy guard for Feature#record_change: the outermost public
|
|
263
|
+
# mutator records once; nested mutator calls (enable -> set_value) run
|
|
264
|
+
# silently so one logical operation never produces multiple entries.
|
|
265
|
+
def suppress_change_recording
|
|
266
|
+
previous = Thread.current[:magick_change_recording]
|
|
267
|
+
Thread.current[:magick_change_recording] = true
|
|
268
|
+
yield
|
|
269
|
+
ensure
|
|
270
|
+
Thread.current[:magick_change_recording] = previous
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def change_recording_suppressed?
|
|
274
|
+
Thread.current[:magick_change_recording] == true || definition_mode?
|
|
275
|
+
end
|
|
276
|
+
|
|
219
277
|
# Manually enable Redis tracking for performance metrics
|
|
220
278
|
# Useful if Redis adapter becomes available after initial configuration
|
|
221
279
|
def enable_redis_tracking(enable: true)
|
|
@@ -280,6 +338,9 @@ module Magick
|
|
|
280
338
|
@adapter_registry = nil
|
|
281
339
|
@default_adapter = nil
|
|
282
340
|
@default_adapter_registry = nil
|
|
341
|
+
@versioning = nil
|
|
342
|
+
@audit_log = nil
|
|
343
|
+
@versioning_enabled = nil
|
|
283
344
|
@performance_metrics&.clear!
|
|
284
345
|
end
|
|
285
346
|
|