inventory_refresh 0.1.1 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +1 -0
  3. data/.gitignore +6 -0
  4. data/.travis.yml +3 -3
  5. data/Gemfile +4 -0
  6. data/inventory_refresh.gemspec +7 -5
  7. data/lib/inventory_refresh.rb +1 -0
  8. data/lib/inventory_refresh/inventory_collection.rb +115 -646
  9. data/lib/inventory_refresh/inventory_collection/builder.rb +249 -0
  10. data/lib/inventory_refresh/inventory_collection/graph.rb +0 -15
  11. data/lib/inventory_refresh/inventory_collection/helpers.rb +6 -0
  12. data/lib/inventory_refresh/inventory_collection/helpers/associations_helper.rb +80 -0
  13. data/lib/inventory_refresh/inventory_collection/helpers/initialize_helper.rb +456 -0
  14. data/lib/inventory_refresh/inventory_collection/helpers/questions_helper.rb +132 -0
  15. data/lib/inventory_refresh/inventory_collection/index/proxy.rb +1 -1
  16. data/lib/inventory_refresh/inventory_collection/index/type/skeletal.rb +5 -5
  17. data/lib/inventory_refresh/inventory_collection/reference.rb +4 -0
  18. data/lib/inventory_refresh/inventory_collection/scanner.rb +111 -18
  19. data/lib/inventory_refresh/inventory_collection/serialization.rb +7 -7
  20. data/lib/inventory_refresh/inventory_collection/unconnected_edge.rb +19 -0
  21. data/lib/inventory_refresh/inventory_object.rb +17 -11
  22. data/lib/inventory_refresh/inventory_object_lazy.rb +20 -10
  23. data/lib/inventory_refresh/persister.rb +212 -0
  24. data/lib/inventory_refresh/save_collection/base.rb +18 -3
  25. data/lib/inventory_refresh/save_collection/saver/base.rb +27 -64
  26. data/lib/inventory_refresh/save_collection/saver/concurrent_safe_batch.rb +73 -225
  27. data/lib/inventory_refresh/save_collection/saver/partial_upsert_helper.rb +226 -0
  28. data/lib/inventory_refresh/save_collection/saver/retention_helper.rb +115 -0
  29. data/lib/inventory_refresh/save_collection/saver/sql_helper.rb +122 -0
  30. data/lib/inventory_refresh/save_collection/saver/sql_helper_update.rb +24 -5
  31. data/lib/inventory_refresh/save_collection/saver/sql_helper_upsert.rb +6 -6
  32. data/lib/inventory_refresh/save_collection/sweeper.rb +69 -0
  33. data/lib/inventory_refresh/save_inventory.rb +18 -8
  34. data/lib/inventory_refresh/target_collection.rb +12 -0
  35. data/lib/inventory_refresh/version.rb +1 -1
  36. metadata +61 -19
  37. data/lib/inventory_refresh/save_collection/recursive.rb +0 -52
  38. data/lib/inventory_refresh/save_collection/saver/concurrent_safe.rb +0 -71
@@ -42,12 +42,14 @@ module InventoryRefresh
42
42
  "InventoryObjectLazy:('#{self}', #{inventory_collection}#{suffix})"
43
43
  end
44
44
 
45
+ # @param inventory_object [InventoryRefresh::InventoryObject] InventoryObject object owning this relation
46
+ # @param inventory_object_key [Symbol] InventoryObject object's attribute pointing to this relation
45
47
  # @return [InventoryRefresh::InventoryObject, Object] InventoryRefresh::InventoryObject instance or an attribute
46
48
  # on key
47
- def load
49
+ def load(inventory_object = nil, inventory_object_key = nil)
48
50
  transform_nested_secondary_indexes! if transform_nested_lazy_finds && nested_secondary_index?
49
51
 
50
- key ? load_object_with_key : load_object
52
+ load_object(inventory_object, inventory_object_key)
51
53
  end
52
54
 
53
55
  # return [Boolean] true if the Lazy object is causing a dependency, Lazy link is always a dependency if no :key
@@ -128,15 +130,15 @@ module InventoryRefresh
128
130
  skeletal_primary_index.build(full_reference)
129
131
  end
130
132
 
133
+ # @param loaded_object [InventoryRefresh::InventoryObject, NilClass] Loaded object or nil if object wasn't found
131
134
  # @return [Object] value found or :key or default value if the value is nil
132
- def load_object_with_key
135
+ def load_object_with_key(loaded_object)
133
136
  # TODO(lsmola) Log error if we are accessing path that is present in blacklist or not present in whitelist
134
- found = inventory_collection.find(reference)
135
- if found.present?
136
- if found.try(:data).present?
137
- found.data[key] || default
137
+ if loaded_object.present?
138
+ if loaded_object.try(:data).present?
139
+ loaded_object.data[key] || default
138
140
  else
139
- found.public_send(key) || default
141
+ loaded_object.public_send(key) || default
140
142
  end
141
143
  else
142
144
  default
@@ -144,8 +146,16 @@ module InventoryRefresh
144
146
  end
145
147
 
146
148
  # @return [InventoryRefresh::InventoryObject, NilClass] InventoryRefresh::InventoryObject instance or nil if not found
147
- def load_object
148
- inventory_collection.find(reference)
149
+ def load_object(inventory_object = nil, inventory_object_key = nil)
150
+ loaded_object = inventory_collection.find(reference)
151
+
152
+ if inventory_object && inventory_object_key && !loaded_object && reference.loadable?
153
+ # Object was not loaded, but the reference is pointing to something, lets return it as edge that should've
154
+ # been loaded.
155
+ inventory_object.inventory_collection.store_unconnected_edges(inventory_object, inventory_object_key, self)
156
+ end
157
+
158
+ key ? load_object_with_key(loaded_object) : loaded_object
149
159
  end
150
160
  end
151
161
  end
@@ -0,0 +1,212 @@
1
+ module InventoryRefresh
2
+ class Persister
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ attr_reader :manager, :target, :collections
7
+
8
+ attr_accessor :refresh_state_uuid, :refresh_state_part_uuid, :total_parts, :sweep_scope, :retry_count, :retry_max
9
+
10
+ # @param manager [ManageIQ::Providers::BaseManager] A manager object
11
+ # @param target [Object] A refresh Target object
12
+ def initialize(manager, target = nil)
13
+ @manager = manager
14
+ @target = target
15
+
16
+ @collections = {}
17
+
18
+ initialize_inventory_collections
19
+ end
20
+
21
+ # Interface for creating InventoryCollection under @collections
22
+ #
23
+ # @param builder_class [ManageIQ::Providers::Inventory::Persister::Builder] or subclasses
24
+ # @param collection_name [Symbol || Array] used as InventoryCollection:association
25
+ # @param extra_properties [Hash] props from InventoryCollection.initialize list
26
+ # - adds/overwrites properties added by builder
27
+ #
28
+ # @param settings [Hash] builder settings
29
+ # - @see ManageIQ::Providers::Inventory::Persister::Builder.default_options
30
+ # - @see make_builder_settings()
31
+ #
32
+ # @example
33
+ # add_collection(:vms, ManageIQ::Providers::Inventory::Persister::Builder::CloudManager) do |builder|
34
+ # builder.add_properties(
35
+ # :strategy => :local_db_cache_all,
36
+ # )
37
+ # )
38
+ #
39
+ # @see documentation https://github.com/ManageIQ/guides/tree/master/providers/persister/inventory_collections.md
40
+ #
41
+ def add_collection(collection_name, builder_class = inventory_collection_builder, extra_properties = {}, settings = {}, &block)
42
+ builder = builder_class.prepare_data(collection_name,
43
+ self.class,
44
+ builder_settings(settings),
45
+ &block)
46
+
47
+ builder.add_properties(extra_properties) if extra_properties.present?
48
+
49
+ builder.add_properties({:manager_uuids => target.try(:references, collection_name) || []}, :if_missing) if targeted?
50
+
51
+ builder.evaluate_lambdas!(self)
52
+
53
+ collections[collection_name] = builder.to_inventory_collection
54
+ end
55
+
56
+ # @return [Array<InventoryRefresh::InventoryCollection>] array of InventoryCollection objects of the persister
57
+ def inventory_collections
58
+ collections.values
59
+ end
60
+
61
+ # @return [Array<Symbol>] array of InventoryCollection object names of the persister
62
+ def inventory_collections_names
63
+ collections.keys
64
+ end
65
+
66
+ # @return [InventoryRefresh::InventoryCollection] returns a defined InventoryCollection or undefined method
67
+ def method_missing(method_name, *arguments, &block)
68
+ if inventory_collections_names.include?(method_name)
69
+ self.define_collections_reader(method_name)
70
+ send(method_name)
71
+ else
72
+ super
73
+ end
74
+ end
75
+
76
+ # @return [Boolean] true if InventoryCollection with passed method_name name is defined
77
+ def respond_to_missing?(method_name, _include_private = false)
78
+ inventory_collections_names.include?(method_name) || super
79
+ end
80
+
81
+ # Defines a new attr reader returning InventoryCollection object
82
+ def define_collections_reader(collection_key)
83
+ define_singleton_method(collection_key) do
84
+ collections[collection_key]
85
+ end
86
+ end
87
+
88
+ def inventory_collection_builder
89
+ ::InventoryRefresh::InventoryCollection::Builder
90
+ end
91
+
92
+ # Persists InventoryCollection objects into the DB
93
+ def persist!
94
+ InventoryRefresh::SaveInventory.save_inventory(manager, inventory_collections)
95
+ end
96
+
97
+ # Returns serialized Persisted object to JSON
98
+ # @return [String] serialized Persisted object to JSON
99
+ def to_json
100
+ JSON.dump(to_hash)
101
+ end
102
+
103
+ # @return [Hash] entire Persister object serialized to hash
104
+ def to_hash
105
+ collections_data = collections.map do |_, collection|
106
+ next if collection.data.blank? &&
107
+ collection.targeted_scope.primary_references.blank? &&
108
+ collection.all_manager_uuids.nil? &&
109
+ collection.skeletal_primary_index.index_data.blank?
110
+
111
+ collection.to_hash
112
+ end.compact
113
+
114
+ {
115
+ :refresh_state_uuid => refresh_state_uuid,
116
+ :refresh_state_part_uuid => refresh_state_part_uuid,
117
+ :retry_count => retry_count,
118
+ :retry_max => retry_max,
119
+ :total_parts => total_parts,
120
+ :sweep_scope => sweep_scope,
121
+ :collections => collections_data,
122
+ }
123
+ end
124
+
125
+ class << self
126
+ # Returns Persister object loaded from a passed JSON
127
+ #
128
+ # @param json_data [String] input JSON data
129
+ # @return [ManageIQ::Providers::Inventory::Persister] Persister object loaded from a passed JSON
130
+ def from_json(json_data, manager, target = nil)
131
+ from_hash(JSON.parse(json_data), manager, target)
132
+ end
133
+
134
+ # Returns Persister object built from serialized data
135
+ #
136
+ # @param persister_data [Hash] serialized Persister object in hash
137
+ # @return [ManageIQ::Providers::Inventory::Persister] Persister object built from serialized data
138
+ def from_hash(persister_data, manager, target = nil)
139
+ # TODO(lsmola) we need to pass serialized targeted scope here
140
+ target ||= InventoryRefresh::TargetCollection.new(:manager => manager)
141
+
142
+ new(manager, target).tap do |persister|
143
+ persister_data['collections'].each do |collection|
144
+ inventory_collection = persister.collections[collection['name'].try(:to_sym)]
145
+ raise "Unrecognized InventoryCollection name: #{inventory_collection}" if inventory_collection.blank?
146
+
147
+ inventory_collection.from_hash(collection, persister.collections)
148
+ end
149
+
150
+ persister.refresh_state_uuid = persister_data['refresh_state_uuid']
151
+ persister.refresh_state_part_uuid = persister_data['refresh_state_part_uuid']
152
+ persister.retry_count = persister_data['retry_count']
153
+ persister.retry_max = persister_data['retry_max']
154
+ persister.total_parts = persister_data['total_parts']
155
+ persister.sweep_scope = persister_data['sweep_scope']
156
+ end
157
+ end
158
+ end
159
+
160
+ protected
161
+
162
+ def initialize_inventory_collections
163
+ # can be implemented in a subclass
164
+ end
165
+
166
+ # @param extra_settings [Hash]
167
+ # :auto_inventory_attributes
168
+ # - auto creates inventory_object_attributes from target model_class setters
169
+ # - attributes used in InventoryObject.add_attributes
170
+ # :without_model_class
171
+ # - if false and no model_class derived or specified, throws exception
172
+ # - doesn't try to derive model class automatically
173
+ # - @see method ManageIQ::Providers::Inventory::Persister::Builder.auto_model_class
174
+ def builder_settings(extra_settings = {})
175
+ opts = inventory_collection_builder.default_options
176
+
177
+ opts[:shared_properties] = shared_options
178
+ opts[:auto_inventory_attributes] = true
179
+ opts[:without_model_class] = false
180
+
181
+ opts.merge(extra_settings)
182
+ end
183
+
184
+ def strategy
185
+ nil
186
+ end
187
+
188
+ def saver_strategy
189
+ :default
190
+ end
191
+
192
+ # Persisters for targeted refresh can override to true
193
+ def targeted?
194
+ false
195
+ end
196
+
197
+ def assert_graph_integrity?
198
+ false
199
+ end
200
+
201
+ # @return [Hash] kwargs shared for all InventoryCollection objects
202
+ def shared_options
203
+ {
204
+ :saver_strategy => saver_strategy,
205
+ :strategy => strategy,
206
+ :targeted => targeted?,
207
+ :parent => manager.presence,
208
+ :assert_graph_integrity => assert_graph_integrity?,
209
+ }
210
+ end
211
+ end
212
+ end
@@ -1,6 +1,5 @@
1
1
  require "inventory_refresh/logging"
2
2
  require "inventory_refresh/save_collection/saver/batch"
3
- require "inventory_refresh/save_collection/saver/concurrent_safe"
4
3
  require "inventory_refresh/save_collection/saver/concurrent_safe_batch"
5
4
  require "inventory_refresh/save_collection/saver/default"
6
5
 
@@ -14,7 +13,9 @@ module InventoryRefresh::SaveCollection
14
13
  # @param ems [ExtManagementSystem] manger owning the InventoryCollection object
15
14
  # @param inventory_collection [InventoryRefresh::InventoryCollection] InventoryCollection object we want to save
16
15
  def save_inventory_object_inventory(ems, inventory_collection)
17
- logger.debug("Saving collection #{inventory_collection} of size #{inventory_collection.size} to"\
16
+ return if skip?(inventory_collection)
17
+
18
+ logger.debug("----- BEGIN ----- Saving collection #{inventory_collection} of size #{inventory_collection.size} to"\
18
19
  " the database, for the manager: '#{ems.name}'...")
19
20
 
20
21
  if inventory_collection.custom_save_block.present?
@@ -23,12 +24,26 @@ module InventoryRefresh::SaveCollection
23
24
  else
24
25
  save_inventory(inventory_collection)
25
26
  end
26
- logger.debug("Saving collection #{inventory_collection}, for the manager: '#{ems.name}'...Complete")
27
+ logger.debug("----- END ----- Saving collection #{inventory_collection}, for the manager: '#{ems.name}'...Complete")
27
28
  inventory_collection.saved = true
28
29
  end
29
30
 
30
31
  private
31
32
 
33
+ # Returns true and sets collection as saved, if the collection should be skipped.
34
+ #
35
+ # @param inventory_collection [InventoryRefresh::InventoryCollection] InventoryCollection object we want to save
36
+ # @return [Boolean] True if processing of the collection should be skipped
37
+ def skip?(inventory_collection)
38
+ if inventory_collection.noop?
39
+ logger.debug("Skipping #{inventory_collection} processing because it will do no operation.")
40
+ inventory_collection.saved = true
41
+ return true
42
+ end
43
+
44
+ false
45
+ end
46
+
32
47
  # Saves one InventoryCollection object into the DB using a configured saver_strategy class.
33
48
  #
34
49
  # @param inventory_collection [InventoryRefresh::InventoryCollection] InventoryCollection object we want to save
@@ -24,7 +24,7 @@ module InventoryRefresh::SaveCollection
24
24
  @arel_primary_key = @model_class.arel_attribute(@primary_key)
25
25
  @unique_index_keys = inventory_collection.unique_index_keys
26
26
  @unique_index_keys_to_s = inventory_collection.manager_ref_to_cols.map(&:to_s)
27
- @select_keys = [@primary_key] + @unique_index_keys_to_s
27
+ @select_keys = [@primary_key] + @unique_index_keys_to_s + internal_columns.map(&:to_s)
28
28
  @unique_db_primary_keys = Set.new
29
29
  @unique_db_indexes = Set.new
30
30
 
@@ -77,18 +77,23 @@ module InventoryRefresh::SaveCollection
77
77
  def save_inventory_collection!
78
78
  # If we have a targeted InventoryCollection that wouldn't do anything, quickly skip it
79
79
  return if inventory_collection.noop?
80
- # If we want to use delete_complement strategy using :all_manager_uuids attribute, we are skipping any other
81
- # job. We want to do 1 :delete_complement job at 1 time, to keep to memory down.
82
- return delete_complement if inventory_collection.all_manager_uuids.present?
83
80
 
84
- save!(association)
81
+ # Delete_complement strategy using :all_manager_uuids attribute
82
+ delete_complement unless inventory_collection.delete_complement_noop?
83
+
84
+ # Create/Update/Archive/Delete records based on InventoryCollection data and scope
85
+ save!(association) unless inventory_collection.saving_noop?
85
86
  end
86
87
 
87
88
  protected
88
89
 
89
90
  attr_reader :inventory_collection, :association
90
91
 
91
- delegate :build_stringified_reference, :build_stringified_reference_for_record, :to => :inventory_collection
92
+ delegate :build_stringified_reference,
93
+ :build_stringified_reference_for_record,
94
+ :resource_version_column,
95
+ :internal_columns,
96
+ :to => :inventory_collection
92
97
 
93
98
  # Applies serialize method for each relevant attribute, which will cast the value to the right type.
94
99
  #
@@ -126,6 +131,8 @@ module InventoryRefresh::SaveCollection
126
131
  :batch_size, :batch_size_for_persisting, :model_class, :serializable_keys, :deserializable_keys, :pg_types, :table_name,
127
132
  :q_table_name
128
133
 
134
+ delegate :supports_column?, :to => :inventory_collection
135
+
129
136
  # Saves the InventoryCollection
130
137
  #
131
138
  # @param association [Symbol] An existing association on manager
@@ -200,27 +207,7 @@ module InventoryRefresh::SaveCollection
200
207
 
201
208
  # Deletes a complement of referenced data
202
209
  def delete_complement
203
- return unless inventory_collection.delete_allowed?
204
-
205
- all_manager_uuids_size = inventory_collection.all_manager_uuids.size
206
-
207
- logger.debug("Processing :delete_complement of #{inventory_collection} of size "\
208
- "#{all_manager_uuids_size}...")
209
- deleted_counter = 0
210
-
211
- inventory_collection.db_collection_for_comparison_for_complement_of(
212
- inventory_collection.all_manager_uuids
213
- ).find_in_batches do |batch|
214
- ActiveRecord::Base.transaction do
215
- batch.each do |record|
216
- record.public_send(inventory_collection.delete_method)
217
- deleted_counter += 1
218
- end
219
- end
220
- end
221
-
222
- logger.debug("Processing :delete_complement of #{inventory_collection} of size "\
223
- "#{all_manager_uuids_size}, deleted=#{deleted_counter}...Complete")
210
+ raise(":delete_complement method is supported only for :saver_strategy => [:batch, :concurrent_safe_batch]")
224
211
  end
225
212
 
226
213
  # Deletes/soft-deletes a given record
@@ -247,7 +234,7 @@ module InventoryRefresh::SaveCollection
247
234
  # Change the InventoryCollection's :association or :arel parameter to return distinct results. The :through
248
235
  # relations can return the same record multiple times. We don't want to do SELECT DISTINCT by default, since
249
236
  # it can be very slow.
250
- if false # TODO: Rails.env.production?
237
+ unless inventory_collection.assert_graph_integrity
251
238
  logger.warn("Please update :association or :arel for #{inventory_collection} to return a DISTINCT result. "\
252
239
  " The duplicate value is being ignored.")
253
240
  return false
@@ -273,7 +260,7 @@ module InventoryRefresh::SaveCollection
273
260
  subject = "#{hash} of #{inventory_collection} because of missing foreign key #{x} for "\
274
261
  "#{inventory_collection.parent.class.name}:"\
275
262
  "#{inventory_collection.parent.try(:id)}"
276
- if false # TODO: Rails.env.production?
263
+ unless inventory_collection.assert_graph_integrity
277
264
  logger.warn("Referential integrity check violated, ignoring #{subject}")
278
265
  return false
279
266
  else
@@ -299,8 +286,8 @@ module InventoryRefresh::SaveCollection
299
286
  # @param update_time [Time] data hash
300
287
  def assign_attributes_for_update!(hash, update_time)
301
288
  hash[:type] = model_class.name if supports_sti? && hash[:type].nil?
302
- hash[:updated_on] = update_time if supports_updated_on?
303
- hash[:updated_at] = update_time if supports_updated_at?
289
+ hash[:updated_on] = update_time if supports_column?(:updated_on)
290
+ hash[:updated_at] = update_time if supports_column?(:updated_at)
304
291
  end
305
292
 
306
293
  # Enriches data hash with timestamp and type columns
@@ -308,8 +295,8 @@ module InventoryRefresh::SaveCollection
308
295
  # @param hash [Hash] data hash
309
296
  # @param create_time [Time] data hash
310
297
  def assign_attributes_for_create!(hash, create_time)
311
- hash[:created_on] = create_time if supports_created_on?
312
- hash[:created_at] = create_time if supports_created_at?
298
+ hash[:created_on] = create_time if supports_column?(:created_on)
299
+ hash[:created_at] = create_time if supports_column?(:created_at)
313
300
  assign_attributes_for_update!(hash, create_time)
314
301
  end
315
302
 
@@ -343,49 +330,25 @@ module InventoryRefresh::SaveCollection
343
330
  @supports_sti_cache ||= inventory_collection.supports_sti?
344
331
  end
345
332
 
346
- # @return [Boolean] true if the model_class has created_on column
347
- def supports_created_on?
348
- @supports_created_on_cache ||= inventory_collection.supports_created_on?
349
- end
350
-
351
- # @return [Boolean] true if the model_class has updated_on column
352
- def supports_updated_on?
353
- @supports_updated_on_cache ||= inventory_collection.supports_updated_on?
354
- end
355
-
356
- # @return [Boolean] true if the model_class has created_at column
357
- def supports_created_at?
358
- @supports_created_at_cache ||= inventory_collection.supports_created_at?
359
- end
360
-
361
- # @return [Boolean] true if the model_class has updated_at column
362
- def supports_updated_at?
363
- @supports_updated_at_cache ||= inventory_collection.supports_updated_at?
364
- end
365
-
366
333
  # @return [Boolean] true if any serializable keys are present
367
334
  def serializable_keys?
368
335
  @serializable_keys_bool_cache ||= serializable_keys.present?
369
336
  end
370
337
 
371
- # @return [Boolean] true if the model_class has resource_timestamp column
338
+ # @return [Boolean] true if the keys we are saving have resource_timestamp column
372
339
  def supports_remote_data_timestamp?(all_attribute_keys)
373
340
  all_attribute_keys.include?(:resource_timestamp) # include? on Set is O(1)
374
341
  end
375
342
 
376
- # @return [Boolean] true if the model_class has resource_version column
343
+ # @return [Boolean] true if the keys we are saving have resource_counter column
377
344
  def supports_remote_data_version?(all_attribute_keys)
378
- all_attribute_keys.include?(:resource_version) # include? on Set is O(1)
379
- end
380
-
381
- # @return [Boolean] true if the model_class has resource_timestamps column
382
- def supports_resource_timestamps_max?
383
- @supports_resource_timestamps_max_cache ||= inventory_collection.supports_resource_timestamps_max?
345
+ all_attribute_keys.include?(:resource_counter) # include? on Set is O(1)
384
346
  end
385
347
 
386
- # @return [Boolean] true if the model_class has resource_versions column
387
- def supports_resource_versions_max?
388
- @supports_resource_versions_max_cache ||= inventory_collection.supports_resource_versions_max?
348
+ # @return [Boolean] true if the keys we are saving have resource_version column, which solves for a quick check
349
+ # if the record was modified
350
+ def supports_resource_version?(all_attribute_keys)
351
+ all_attribute_keys.include?(resource_version_column) # include? on Set is O(1)
389
352
  end
390
353
  end
391
354
  end