nose 0.1.0pre

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/lib/nose/backend/cassandra.rb +390 -0
  3. data/lib/nose/backend/file.rb +185 -0
  4. data/lib/nose/backend/mongo.rb +242 -0
  5. data/lib/nose/backend.rb +557 -0
  6. data/lib/nose/cost/cassandra.rb +33 -0
  7. data/lib/nose/cost/entity_count.rb +27 -0
  8. data/lib/nose/cost/field_size.rb +31 -0
  9. data/lib/nose/cost/request_count.rb +32 -0
  10. data/lib/nose/cost.rb +68 -0
  11. data/lib/nose/debug.rb +45 -0
  12. data/lib/nose/enumerator.rb +199 -0
  13. data/lib/nose/indexes.rb +239 -0
  14. data/lib/nose/loader/csv.rb +99 -0
  15. data/lib/nose/loader/mysql.rb +199 -0
  16. data/lib/nose/loader/random.rb +48 -0
  17. data/lib/nose/loader/sql.rb +105 -0
  18. data/lib/nose/loader.rb +38 -0
  19. data/lib/nose/model/entity.rb +136 -0
  20. data/lib/nose/model/fields.rb +293 -0
  21. data/lib/nose/model.rb +113 -0
  22. data/lib/nose/parser.rb +202 -0
  23. data/lib/nose/plans/execution_plan.rb +282 -0
  24. data/lib/nose/plans/filter.rb +99 -0
  25. data/lib/nose/plans/index_lookup.rb +302 -0
  26. data/lib/nose/plans/limit.rb +42 -0
  27. data/lib/nose/plans/query_planner.rb +361 -0
  28. data/lib/nose/plans/sort.rb +49 -0
  29. data/lib/nose/plans/update.rb +60 -0
  30. data/lib/nose/plans/update_planner.rb +270 -0
  31. data/lib/nose/plans.rb +135 -0
  32. data/lib/nose/proxy/mysql.rb +275 -0
  33. data/lib/nose/proxy.rb +102 -0
  34. data/lib/nose/query_graph.rb +481 -0
  35. data/lib/nose/random/barbasi_albert.rb +48 -0
  36. data/lib/nose/random/watts_strogatz.rb +50 -0
  37. data/lib/nose/random.rb +391 -0
  38. data/lib/nose/schema.rb +89 -0
  39. data/lib/nose/search/constraints.rb +143 -0
  40. data/lib/nose/search/problem.rb +328 -0
  41. data/lib/nose/search/results.rb +200 -0
  42. data/lib/nose/search.rb +266 -0
  43. data/lib/nose/serialize.rb +747 -0
  44. data/lib/nose/statements/connection.rb +160 -0
  45. data/lib/nose/statements/delete.rb +83 -0
  46. data/lib/nose/statements/insert.rb +146 -0
  47. data/lib/nose/statements/query.rb +161 -0
  48. data/lib/nose/statements/update.rb +101 -0
  49. data/lib/nose/statements.rb +645 -0
  50. data/lib/nose/timing.rb +79 -0
  51. data/lib/nose/util.rb +305 -0
  52. data/lib/nose/workload.rb +244 -0
  53. data/lib/nose.rb +37 -0
  54. data/templates/workload.erb +42 -0
  55. metadata +700 -0
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NoSE
4
+ module Plans
5
+ # A simple state class to hold the cardinality for updates
6
+ class UpdateState
7
+ attr_reader :statement, :cardinality
8
+
9
+ def initialize(statement, cardinality)
10
+ @statement = statement
11
+ @cardinality = cardinality
12
+ end
13
+
14
+ # XXX This is just a placeholder since this is
15
+ # currently used by the query planner
16
+ # @return [Boolean]
17
+ def answered?
18
+ true
19
+ end
20
+ end
21
+
22
+ # A plan for executing an update
23
+ class UpdatePlan < AbstractPlan
24
+ attr_reader :statement, :index, :query_plans, :update_steps, :cost_model,
25
+ :update_fields
26
+
27
+ include Comparable
28
+
29
+ def initialize(statement, index, trees, update_steps, cost_model)
30
+ @statement = statement
31
+ @index = index
32
+ @trees = trees
33
+ @query_plans = nil # these will be set later when we pick indexes
34
+ update_steps.each { |step| step.calculate_cost cost_model }
35
+ @update_steps = update_steps
36
+ @cost_model = cost_model
37
+
38
+ # Update with fields specified in the settings and conditions
39
+ # (rewrite from foreign keys to IDs if needed)
40
+ @update_fields = if statement.is_a?(Connection) ||
41
+ statement.is_a?(Delete)
42
+ []
43
+ else
44
+ statement.settings.map(&:field)
45
+ end
46
+ @update_fields += statement.conditions.each_value.map(&:field)
47
+ @update_fields.map! do |field|
48
+ field.is_a?(Fields::ForeignKeyField) ? field.entity.id_field : field
49
+ end
50
+ end
51
+
52
+ # The weight of this query for a given workload
53
+ # @return [Fixnum]
54
+ def weight
55
+ return 1 if @workload.nil?
56
+
57
+ @workload.statement_weights[@statement]
58
+ end
59
+
60
+ # The group of the associated statement
61
+ # @return [String]
62
+ def group
63
+ @statement.group
64
+ end
65
+
66
+ # Name the plan by the statement
67
+ # @return [String]
68
+ def name
69
+ "#{@statement.text} for #{@index.key}"
70
+ end
71
+
72
+ # The steps for this plan are the update steps
73
+ # @return [Array<UpdatePlanStep>]
74
+ def steps
75
+ @update_steps
76
+ end
77
+
78
+ # Parameters to this update plan
79
+ # @return [Hash]
80
+ def params
81
+ conditions = if @statement.respond_to?(:conditions)
82
+ @statement.conditions
83
+ else
84
+ {}
85
+ end
86
+ settings = if @statement.respond_to?(:settings)
87
+ @statement.settings
88
+ else
89
+ []
90
+ end
91
+
92
+ params = conditions.merge Hash[settings.map do |setting|
93
+ [setting.field.id, Condition.new(setting.field, :'=', setting.value)]
94
+ end]
95
+
96
+ convert_param_keys params
97
+ end
98
+
99
+ # Select query plans to actually use here
100
+ # @return [void]
101
+ def select_query_plans(indexes = nil, &block)
102
+ if block_given?
103
+ @query_plans = @trees.map(&block)
104
+ else
105
+ @query_plans = @trees.map do |tree|
106
+ plan = tree.select_using_indexes(indexes).min_by(&:cost)
107
+ fail if plan.nil?
108
+ plan
109
+ end
110
+ end
111
+
112
+ update_support_fields
113
+
114
+ @trees = nil
115
+ end
116
+
117
+ # Compare all the fields for the plan for equality
118
+ # @return [Boolean]
119
+ def eql?(other)
120
+ return false unless other.is_a? UpdatePlan
121
+ fail 'plans must be resolved before checking equality' \
122
+ if @query_plans.nil? || other.query_plans.nil?
123
+
124
+ @statement == other.statement &&
125
+ @index == other.index &&
126
+ @query_plans == other.query_plans &&
127
+ @update_steps == other.update_steps &&
128
+ @cost_model == other.cost_model
129
+ end
130
+
131
+ # :nocov:
132
+ def to_color
133
+ "\n statement: " + @statement.to_color +
134
+ "\n index: " + @index.to_color +
135
+ "\n query_plans: " + @query_plans.to_color +
136
+ "\nupdate_steps: " + @update_steps.to_color +
137
+ "\n cost_model: " + @cost_model.to_color
138
+ end
139
+ # :nocov:
140
+
141
+ # Two plans are compared by their execution cost
142
+ # @return [Boolean]
143
+ def <=>(other)
144
+ cost <=> other.cost
145
+ end
146
+
147
+ # The cost of performing the update on this index
148
+ # @return [Fixnum]
149
+ def update_cost
150
+ @update_steps.sum_by(&:cost)
151
+ end
152
+
153
+ # The cost is the sum of all the query costs plus the update costs
154
+ # @return [Fixnum]
155
+ def cost
156
+ @query_plans.sum_by(&:cost) + update_cost
157
+ end
158
+
159
+ private
160
+
161
+ # Add fields from support queries to those which should be updated
162
+ # @return [void]
163
+ def update_support_fields
164
+ # Add fields fetched from support queries
165
+ @update_fields += @query_plans.flat_map do |query_plan|
166
+ query_plan.query.select.to_a
167
+ end.compact
168
+ end
169
+
170
+ # Ensure we only use primary keys for conditions
171
+ # @return [Hash]
172
+ def convert_param_keys(params)
173
+ Hash[params.each_value.map do |condition|
174
+ field = condition.field
175
+ if field.is_a?(Fields::ForeignKeyField)
176
+ field = field.entity.id_field
177
+ condition = Condition.new field, condition.operator,
178
+ condition.value
179
+ end
180
+
181
+ [field.id, condition]
182
+ end]
183
+ end
184
+ end
185
+
186
+ # A planner for update statements in the workload
187
+ class UpdatePlanner
188
+ def initialize(model, trees, cost_model, by_id_graph = false)
189
+ @logger = Logging.logger['nose::update_planner']
190
+
191
+ @model = model
192
+ @cost_model = cost_model
193
+ @by_id_graph = by_id_graph
194
+
195
+ # Remove anything not a support query then group by statement and index
196
+ @query_plans = trees.select do |tree|
197
+ tree.query.is_a? SupportQuery
198
+ end
199
+ @query_plans = @query_plans.group_by { |tree| tree.query.statement }
200
+ @query_plans.each do |plan_stmt, plan_trees|
201
+ @query_plans[plan_stmt] = plan_trees.group_by do |tree|
202
+ index = tree.query.index
203
+ index = index.to_id_path if @by_id_path
204
+
205
+ index
206
+ end
207
+ end
208
+ end
209
+
210
+ # Find the necessary update plans for a given set of indexes
211
+ # @return [Array<UpdatePlan>]
212
+ def find_plans_for_update(statement, indexes)
213
+ indexes = indexes.map(&:to_id_graph).to_set if @by_id_graph
214
+
215
+ indexes.map do |index|
216
+ next unless statement.modifies_index?(index)
217
+
218
+ if (@query_plans[statement] &&
219
+ @query_plans[statement][index]).nil?
220
+ trees = []
221
+
222
+ if statement.is_a? Insert
223
+ cardinality = 1
224
+ else
225
+ cardinality = Cardinality.filter index.entries,
226
+ statement.eq_fields,
227
+ statement.range_field
228
+ end
229
+ else
230
+ # Get the cardinality of the last step to use for the update state
231
+ trees = @query_plans[statement][index]
232
+ plans = trees.map do |tree|
233
+ tree.select_using_indexes(indexes).min_by(&:cost)
234
+ end
235
+
236
+ # Multiply the cardinalities because we are crossing multiple
237
+ # relationships and need the cross-product
238
+ cardinality = plans.product_by { |p| p.last.state.cardinality }
239
+ end
240
+
241
+ state = UpdateState.new statement, cardinality
242
+ update_steps = update_steps statement, index, state
243
+ UpdatePlan.new statement, index, trees, update_steps, @cost_model
244
+ end.compact
245
+ end
246
+
247
+ private
248
+
249
+ # Find the required update steps
250
+ # @return [Array<UpdatePlanStep>]
251
+ def update_steps(statement, index, state)
252
+ update_steps = []
253
+ update_steps << DeletePlanStep.new(index, state) \
254
+ if statement.requires_delete?(index)
255
+
256
+ if statement.requires_insert?(index)
257
+ fields = if statement.is_a?(Connect)
258
+ statement.conditions.each_value.map(&:field)
259
+ else
260
+ statement.settings.map(&:field)
261
+ end
262
+
263
+ update_steps << InsertPlanStep.new(index, state, fields)
264
+ end
265
+
266
+ update_steps
267
+ end
268
+ end
269
+ end
270
+ end
data/lib/nose/plans.rb ADDED
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NoSE
4
+ # Statement planning and abstract models of execution steps
5
+ module Plans
6
+ # A single step in a statement plan
7
+ class PlanStep
8
+ include Supertype
9
+
10
+ attr_accessor :state, :parent
11
+ attr_reader :children, :cost, :fields
12
+
13
+ def initialize
14
+ @children = Set.new
15
+ @parent = nil
16
+ @fields = Set.new
17
+ end
18
+
19
+ # :nocov:
20
+ def to_color
21
+ # Split on capital letters and remove the last two parts (PlanStep)
22
+ self.class.name.split('::').last.split(/(?=[A-Z])/)[0..-3] \
23
+ .map(&:downcase).join(' ').capitalize
24
+ end
25
+ # :nocov:
26
+
27
+ # Set the children of the current plan step
28
+ # @return [void]
29
+ def children=(children)
30
+ @children = children.to_set
31
+
32
+ # Track the parent step of each step
33
+ children.each do |child|
34
+ child.instance_variable_set(:@parent, self)
35
+ fields = child.instance_variable_get(:@fields) + self.fields
36
+ child.instance_variable_set(:@fields, fields)
37
+ end
38
+ end
39
+
40
+ # Mark the fields in this index as fetched
41
+ # @return [void]
42
+ def add_fields_from_index(index)
43
+ @fields += index.all_fields
44
+ end
45
+
46
+ # Get the list of steps which led us here
47
+ # If a cost model is not provided, statement plans using
48
+ # this step cannot be evaluated on the basis of cost
49
+ #
50
+ # (this is to support PlanStep#parent_index which does not need cost)
51
+ # @return [QueryPlan]
52
+ def parent_steps(cost_model = nil)
53
+ steps = nil
54
+
55
+ if @parent.nil?
56
+ steps = QueryPlan.new state.query, cost_model
57
+ else
58
+ steps = @parent.parent_steps cost_model
59
+ steps << self
60
+ end
61
+
62
+ steps
63
+ end
64
+
65
+ # Find the closest index to this step
66
+ # @return [PlanStep]
67
+ def parent_index
68
+ step = parent_steps.to_a.reverse_each.find do |parent_step|
69
+ parent_step.is_a? IndexLookupPlanStep
70
+ end
71
+ step.index unless step.nil?
72
+ end
73
+
74
+ # Calculate the cost of executing this step in the plan
75
+ # @return [Fixnum]
76
+ def calculate_cost(cost_model)
77
+ @cost = cost_model.method((subtype_name + '_cost').to_sym).call self
78
+ end
79
+
80
+ # Add the Subtype module to all step classes
81
+ # @return [void]
82
+ def self.inherited(child_class)
83
+ child_class.send(:include, Subtype)
84
+ end
85
+ end
86
+
87
+ # A dummy step used to inspect failed statement plans
88
+ class PrunedPlanStep < PlanStep
89
+ def state
90
+ OpenStruct.new answered?: true
91
+ end
92
+ end
93
+
94
+ # The root of a tree of statement plans used as a placeholder
95
+ class RootPlanStep < PlanStep
96
+ def initialize(state)
97
+ super()
98
+ @state = state
99
+ @cost = 0
100
+ end
101
+ end
102
+
103
+ # This superclass defines what is necessary for manually defined
104
+ # and automatically generated plans to provide for execution
105
+ class AbstractPlan
106
+ attr_reader :group, :name, :weight
107
+
108
+ # @abstract Subclasses should produce the steps for executing this query
109
+ def steps
110
+ fail NotImplementedError
111
+ end
112
+
113
+ # @abstract Subclasses should produce the fields selected by this plan
114
+ def select_fields
115
+ []
116
+ end
117
+
118
+ # @abstract Subclasses should produce the parameters
119
+ # necessary for this plan
120
+ def params
121
+ fail NotImplementedError
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ require_relative 'plans/filter'
128
+ require_relative 'plans/index_lookup'
129
+ require_relative 'plans/limit'
130
+ require_relative 'plans/sort'
131
+ require_relative 'plans/update'
132
+
133
+ require_relative 'plans/query_planner'
134
+ require_relative 'plans/update_planner'
135
+ require_relative 'plans/execution_plan'
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mysql'
4
+
5
+ module NoSE
6
+ module Proxy
7
+ # A proxy which speaks the MySQL protocol and executes queries
8
+ class MysqlProxy < ProxyBase
9
+ def initialize(*args)
10
+ super
11
+
12
+ # Initialize a hash for the state of sockets
13
+ @state = {}
14
+ end
15
+
16
+ # Authenticate the client and process queries
17
+ def handle_connection(socket)
18
+ return authenticate socket if @state[socket].nil?
19
+
20
+ # Retrieve the saved state of the socket
21
+ protocol = @state[socket]
22
+
23
+ begin
24
+ protocol.process_command(&method(:process_query))
25
+ rescue ::Mysql::ClientError::ServerGoneError
26
+ # Ensure the socket is closed and remove the state
27
+ remove_connection socket
28
+ return false
29
+ end
30
+
31
+ # Keep this socket around
32
+ true
33
+ end
34
+
35
+ # Remove the state of the socket
36
+ def remove_connection(socket)
37
+ socket.close
38
+ @state.delete socket
39
+ end
40
+
41
+ private
42
+
43
+ # Auth the client and prepare for query processsing
44
+ # @return [Boolean]
45
+ def authenticate(socket)
46
+ protocol = ::Mysql::ServerProtocol.new socket
47
+
48
+ # Try to authenticate
49
+ begin
50
+ protocol.authenticate
51
+ rescue
52
+ remove_connection socket
53
+ return false
54
+ end
55
+
56
+ @state[socket] = protocol
57
+
58
+ true
59
+ end
60
+
61
+ # Execute the query on the backend and return the result
62
+ def process_query(protocol, query)
63
+ begin
64
+ @logger.debug { "Got query #{query}" }
65
+ result = query_result query
66
+ @logger.debug "Executed query with #{result.size} results"
67
+ rescue ParseFailed => exc
68
+ protocol.error ::Mysql::ServerError::ER_PARSE_ERROR, exc.message
69
+ rescue Backend::PlanNotFound => exc
70
+ protocol.error ::Mysql::ServerError::ER_UNKNOWN_STMT_HANDLER,
71
+ exc.message
72
+ end
73
+
74
+ result
75
+ end
76
+
77
+ private
78
+
79
+ # Get the result of the query from the backend
80
+ def query_result(query)
81
+ query = Statement.parse query, @result.workload.model
82
+ @backend.query(query).lazy.map do |row|
83
+ Hash[query.select.map { |field| [field.name, row[field.id]] }]
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ # Extend the client library with necessary server code
90
+ class ::Mysql
91
+ # Simple class which doesn't do connection setup
92
+ class ServerProtocol < Protocol
93
+ def initialize(socket)
94
+ # We need a much simpler initialization than the default class
95
+ @sock = socket
96
+ end
97
+
98
+ # Perform authentication
99
+ def authenticate
100
+ reset
101
+ write InitialPacket.serialize
102
+ AuthenticationPacket.parse read # TODO: Check auth
103
+ write ResultPacket.serialize 0
104
+ end
105
+
106
+ # Send an error message with the given number and text
107
+ def error(errno, message)
108
+ write ErrorPacket.serialize errno, message
109
+ end
110
+
111
+ # Process a single incoming command
112
+ def process_command(&block)
113
+ reset
114
+ pkt = read
115
+ command = pkt.utiny
116
+
117
+ case command
118
+ when COM_QUIT
119
+ # Stop processing because the client left
120
+ return
121
+ when COM_QUERY
122
+ process_query pkt.to_s, &block
123
+ when COM_PING
124
+ write ResultPacket.serialize 0
125
+ else
126
+ # Return error for invalid commands
127
+ protocol.error ::Mysql::ServerError::ER_NOT_SUPPORTED_YET,
128
+ 'Command not supported'
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ # Handle an individual query
135
+ def process_query(query)
136
+ # Execute the query on the backend
137
+ result = yield self, query
138
+ return if result.nil?
139
+
140
+ # Return the list of fields in the result
141
+ field_names = result.any? ? result.peek.keys : []
142
+ write_fields result, field_names
143
+ write_rows result, field_names
144
+ end
145
+
146
+ # Write the list of fields for the resulting rows
147
+ def write_fields(result, field_names)
148
+ write ResultPacket.serialize field_names.count
149
+ field_names.each do |field_name|
150
+ type, = Protocol.value2net result.first[field_name]
151
+
152
+ write FieldPacket.serialize '', '', '', field_name, '', 1, type,
153
+ Field::NOT_NULL_FLAG, 0, ''
154
+ end
155
+ write EOFPacket.serialize
156
+ end
157
+
158
+ # Write a packet for each row in the results
159
+ def write_rows(result, field_names)
160
+ result.each do |row|
161
+ values = field_names.map { |field_name| row[field_name] }
162
+ write(values.map do |value|
163
+ Protocol.value2net(value.to_s).last
164
+ end.inject('', &:+))
165
+ end
166
+ write EOFPacket.serialize
167
+ end
168
+ end
169
+
170
+ # Add serialization of the initial packet
171
+ class InitialPacket
172
+ # Serialize the initial server hello
173
+ # @return [String]
174
+ def self.serialize
175
+ [
176
+ ::Mysql::Protocol::VERSION,
177
+ 'nose',
178
+ 0,
179
+ 'AAAAAAAA',
180
+ 0,
181
+ CLIENT_PROTOCOL_41 | CLIENT_SECURE_CONNECTION,
182
+ 33, # utf8_general_ci
183
+ SERVER_STATUS_AUTOCOMMIT,
184
+ 'AAAAAAAAAAAA'
185
+ ].pack('CZ*Va8CvCvx13Z*')
186
+ end
187
+ end
188
+
189
+ # Add serialization of result packets
190
+ class ResultPacket
191
+ # Serialize a simple OK response
192
+ # rubocop:disable Metrics/ParameterLists
193
+ # @return [String]
194
+ def self.serialize(field_count, affected_rows = 0, insert_id = 0,
195
+ server_status = 0, warning_count = 0, message = '')
196
+ return Packet.lcb(field_count) unless field_count.zero?
197
+
198
+ Packet.lcb(field_count) +
199
+ Packet.lcb(affected_rows) +
200
+ Packet.lcb(insert_id) +
201
+ [
202
+ server_status,
203
+ warning_count
204
+ ].pack('vv') +
205
+ Packet.lcs(message)
206
+ end
207
+ # rubocop:enable Metrics/ParameterLists
208
+ end
209
+
210
+ # Add serialization of field packets
211
+ class FieldPacket
212
+ # Serialize all the data for a field
213
+ # rubocop:disable Metrics/ParameterLists
214
+ # @return [String]
215
+ def self.serialize(db, table, org_table, name, org_name, length, type,
216
+ flags, decimals, default)
217
+ Packet.lcs('def') + # catalog
218
+ Packet.lcs(db) +
219
+ Packet.lcs(table) +
220
+ Packet.lcs(org_table) +
221
+ Packet.lcs(name) +
222
+ Packet.lcs(org_name) +
223
+ [
224
+ 0x0c,
225
+ 33, # utf8_general_ci
226
+ length,
227
+ type,
228
+ flags,
229
+ decimals,
230
+ 0
231
+ ].pack('CvVCvCv') + Packet.lcs(default)
232
+ end
233
+ # rubocop:enable Metrics/ParameterLists
234
+ end
235
+
236
+ # Add parsing of auth packets
237
+ class AuthenticationPacket
238
+ # Parse the incoming authentication packet
239
+ def self.parse(_pkt)
240
+ # XXX: Unneeded for now since we don't handle auth
241
+ # client_flags = pkt.ulong
242
+ # max_packet_size = pkt.ulong
243
+ # charset_number = pkt.lcb
244
+ # f1 = pkt.read(23)
245
+ # username = pkt.string
246
+ # scrambled_password = pkt.lcs
247
+ # databasename = pkt.string
248
+ end
249
+ end
250
+
251
+ # Simple EOF packet
252
+ class EOFPacket
253
+ # Static string to indicate EOF
254
+ # @return [String]
255
+ def self.serialize
256
+ "\xfe\x00\x00\x00\x00"
257
+ end
258
+ end
259
+
260
+ # Serialize an error message
261
+ class ErrorPacket
262
+ # Generate a packet with a given error number and message
263
+ # @return [String]
264
+ def self.serialize(errno, message)
265
+ [
266
+ 0xff,
267
+ errno,
268
+ '#',
269
+ @sqlstate,
270
+ message
271
+ ].pack('Cvaa5a*')
272
+ end
273
+ end
274
+ end
275
+ end