nose-cli 0.1.0pre
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/nose +26 -0
- data/bin/random_rubis +105 -0
- data/bin/restart-cassandra.sh +20 -0
- data/bin/run-experiments.sh +61 -0
- data/data/nose-cli/nose.yml.example +32 -0
- data/lib/nose_cli.rb +364 -0
- data/lib/nose_cli/analyze.rb +94 -0
- data/lib/nose_cli/benchmark.rb +145 -0
- data/lib/nose_cli/collect_results.rb +55 -0
- data/lib/nose_cli/console.rb +50 -0
- data/lib/nose_cli/create.rb +35 -0
- data/lib/nose_cli/diff_plans.rb +39 -0
- data/lib/nose_cli/dump.rb +67 -0
- data/lib/nose_cli/execute.rb +241 -0
- data/lib/nose_cli/export.rb +39 -0
- data/lib/nose_cli/genworkload.rb +24 -0
- data/lib/nose_cli/graph.rb +24 -0
- data/lib/nose_cli/load.rb +44 -0
- data/lib/nose_cli/measurements.rb +36 -0
- data/lib/nose_cli/plan_schema.rb +84 -0
- data/lib/nose_cli/proxy.rb +32 -0
- data/lib/nose_cli/random_plans.rb +82 -0
- data/lib/nose_cli/recost.rb +45 -0
- data/lib/nose_cli/reformat.rb +22 -0
- data/lib/nose_cli/repl.rb +144 -0
- data/lib/nose_cli/search.rb +77 -0
- data/lib/nose_cli/search_all.rb +120 -0
- data/lib/nose_cli/search_bench.rb +52 -0
- data/lib/nose_cli/shared_options.rb +30 -0
- data/lib/nose_cli/texify.rb +141 -0
- data/lib/nose_cli/why.rb +70 -0
- data/templates/completions.erb +56 -0
- data/templates/man.erb +33 -0
- data/templates/report.erb +138 -0
- data/templates/subman.erb +19 -0
- metadata +345 -0
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NoSE
|
4
|
+
module CLI
|
5
|
+
# Add a command to run a proxy server capable of executing queries
|
6
|
+
class NoSECLI < Thor
|
7
|
+
desc 'proxy PLAN_FILE', 'start a proxy with the given PLAN_FILE'
|
8
|
+
|
9
|
+
long_desc <<-LONGDESC
|
10
|
+
`nose proxy` loads the schema and execution plans from the given file.
|
11
|
+
It then starts the configured proxy server which is able to execute
|
12
|
+
the given set of plans. Available proxy servers can be seen in the
|
13
|
+
`lib/nose/proxy` subdirectory.
|
14
|
+
LONGDESC
|
15
|
+
|
16
|
+
def proxy(plan_file)
|
17
|
+
result = load_results plan_file
|
18
|
+
backend = get_backend(options, result)
|
19
|
+
|
20
|
+
# Create a new instance of the proxy class
|
21
|
+
proxy_class = get_class 'proxy', options
|
22
|
+
proxy = proxy_class.new options[:proxy], result, backend
|
23
|
+
|
24
|
+
# Start the proxy server
|
25
|
+
trap 'INT' do
|
26
|
+
proxy.stop
|
27
|
+
end
|
28
|
+
proxy.start
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NoSE
|
4
|
+
module CLI
|
5
|
+
# Add a command to generate a graphic of the schema from a workload
|
6
|
+
class NoSECLI < Thor
|
7
|
+
desc 'random-plans WORKLOAD TAG',
|
8
|
+
'output random plans for the statement with TAG in WORKLOAD'
|
9
|
+
|
10
|
+
long_desc <<-LONGDESC
|
11
|
+
`nose random-plans` produces a set of randomly chosen plans for a given
|
12
|
+
workload. This is useful when evaluating the cost model for a given
|
13
|
+
system as this should give a good range of different execution plans.
|
14
|
+
LONGDESC
|
15
|
+
|
16
|
+
shared_option :format
|
17
|
+
shared_option :output
|
18
|
+
|
19
|
+
option :count, type: :numeric, default: 5, aliases: '-n',
|
20
|
+
desc: 'the number of random plans to produce'
|
21
|
+
option :all, type: :boolean, default: false, aliases: '-a',
|
22
|
+
desc: 'whether to include all possible plans, if given ' \
|
23
|
+
'then --count is ignored'
|
24
|
+
|
25
|
+
def random_plans(workload_name, tag)
|
26
|
+
# Find the statement with the given tag
|
27
|
+
workload = Workload.load workload_name
|
28
|
+
statement = workload.find_with_tag tag
|
29
|
+
|
30
|
+
# Generate a random set of plans
|
31
|
+
indexes = IndexEnumerator.new(workload).indexes_for_workload
|
32
|
+
cost_model = get_class('cost', options[:cost_model][:name]) \
|
33
|
+
.new(**options[:cost_model])
|
34
|
+
plans = find_random_plans statement, workload, indexes, cost_model,
|
35
|
+
options
|
36
|
+
results = random_plan_results workload, indexes, plans, cost_model
|
37
|
+
output_random_plans results, options[:output], options[:format]
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
# Generate random plans for a givne statement
|
43
|
+
# @return [Array]
|
44
|
+
def find_random_plans(statement, workload, indexes, cost_model, options)
|
45
|
+
planner = Plans::QueryPlanner.new workload, indexes, cost_model
|
46
|
+
plans = planner.find_plans_for_query(statement).to_a
|
47
|
+
plans = plans.sample options[:count] unless options[:all]
|
48
|
+
|
49
|
+
plans
|
50
|
+
end
|
51
|
+
|
52
|
+
# Build a results structure for these plans
|
53
|
+
# @return [OpenStruct]
|
54
|
+
def random_plan_results(workload, indexes, plans, cost_model)
|
55
|
+
results = OpenStruct.new
|
56
|
+
results.workload = workload
|
57
|
+
results.model = workload.model
|
58
|
+
results.enumerated_indexes = indexes
|
59
|
+
results.indexes = plans.map(&:indexes).flatten(1).to_set
|
60
|
+
results.plans = plans
|
61
|
+
results.cost_model = cost_model
|
62
|
+
|
63
|
+
results
|
64
|
+
end
|
65
|
+
|
66
|
+
# Output the results in the specified format
|
67
|
+
# @return [void]
|
68
|
+
def output_random_plans(results, output, format)
|
69
|
+
if output.nil?
|
70
|
+
file = $stdout
|
71
|
+
else
|
72
|
+
File.open(output, 'w')
|
73
|
+
end
|
74
|
+
begin
|
75
|
+
send(('output_' + format).to_sym, results, file, true)
|
76
|
+
ensure
|
77
|
+
file.close unless output.nil?
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NoSE
|
4
|
+
module CLI
|
5
|
+
# Add a command to recalculate costs with a new model
|
6
|
+
class NoSECLI < Thor
|
7
|
+
desc 'recost PLAN_FILE COST_MODEL',
|
8
|
+
'recost the workload in PLAN_FILE with COST_MODEL'
|
9
|
+
|
10
|
+
long_desc <<-LONGDESC
|
11
|
+
`nose recost` takes a given plan file as input and then produces a new
|
12
|
+
JSON file with new values for the expected query cost from the named
|
13
|
+
cost model. This only works for cost models which don't require any
|
14
|
+
parameters such as `Cost::RequestCountCost`.
|
15
|
+
LONGDESC
|
16
|
+
|
17
|
+
def recost(plan_file, cost_model)
|
18
|
+
result = load_results plan_file
|
19
|
+
|
20
|
+
# Get the new cost model and recost all the queries
|
21
|
+
new_cost_model = get_class('cost', cost_model).new
|
22
|
+
result.plans.each { |plan| plan.cost_model = new_cost_model }
|
23
|
+
|
24
|
+
# Update the cost values
|
25
|
+
result.cost_model = new_cost_model
|
26
|
+
result.total_cost = total_cost result.workload, result.plans
|
27
|
+
|
28
|
+
output_json result
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
# Get the total cost for a set of plans in a given workload
|
34
|
+
# @return [Fixnum]
|
35
|
+
def total_cost(workload, plans)
|
36
|
+
plan_hash = Hash[plans.map { |plan| [plan.query, plan] }]
|
37
|
+
|
38
|
+
workload.statement_weights.map do |statement, weight|
|
39
|
+
next 0 unless statement.is_a? Query
|
40
|
+
weight * plan_hash[statement].cost
|
41
|
+
end.inject(0, &:+)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NoSE
|
4
|
+
module CLI
|
5
|
+
# Add a command to reformat a plan file
|
6
|
+
class NoSECLI < Thor
|
7
|
+
desc 'reformat PLAN_FILE',
|
8
|
+
'reformat the data in PLAN_FILE'
|
9
|
+
|
10
|
+
shared_option :format
|
11
|
+
shared_option :mix
|
12
|
+
|
13
|
+
def reformat(plan_file)
|
14
|
+
result = load_results plan_file, options[:mix]
|
15
|
+
result.recalculate_cost
|
16
|
+
|
17
|
+
# Output the results in the specified format
|
18
|
+
send(('output_' + options[:format]).to_sym, result)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'formatador'
|
4
|
+
|
5
|
+
begin
|
6
|
+
require 'readline'
|
7
|
+
rescue LoadError
|
8
|
+
nil
|
9
|
+
end
|
10
|
+
|
11
|
+
module NoSE
|
12
|
+
module CLI
|
13
|
+
# Add a command to run a REPL which evaluates queries
|
14
|
+
class NoSECLI < Thor
|
15
|
+
desc 'repl PLAN_FILE', 'start the REPL with the given PLAN_FILE'
|
16
|
+
|
17
|
+
long_desc <<-LONGDESC
|
18
|
+
`nose repl` starts a terminal interface which loads execution plans
|
19
|
+
from the given file. It then loops continuously and accepts statements
|
20
|
+
from the workload and executes plans against the database. The result
|
21
|
+
of any query will be printed to the terminal.
|
22
|
+
LONGDESC
|
23
|
+
|
24
|
+
def repl(plan_file)
|
25
|
+
result = load_results plan_file
|
26
|
+
backend = get_backend(options, result)
|
27
|
+
|
28
|
+
load_history
|
29
|
+
loop do
|
30
|
+
begin
|
31
|
+
line = read_line
|
32
|
+
rescue Interrupt
|
33
|
+
line = nil
|
34
|
+
end
|
35
|
+
break if line.nil?
|
36
|
+
|
37
|
+
next if line.empty?
|
38
|
+
|
39
|
+
execute_statement line, result, backend
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
# Load the history file
|
46
|
+
# @return [void]
|
47
|
+
def load_history
|
48
|
+
return unless Object.const_defined? 'Readline'
|
49
|
+
|
50
|
+
history_file = File.expand_path '~/.nose_history'
|
51
|
+
begin
|
52
|
+
File.foreach(history_file) do |line|
|
53
|
+
Readline::HISTORY.push line.chomp
|
54
|
+
end
|
55
|
+
rescue Errno::ENOENT
|
56
|
+
nil
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Save the history file
|
61
|
+
# @return [void]
|
62
|
+
def save_history
|
63
|
+
return unless Object.const_defined? 'Readline'
|
64
|
+
|
65
|
+
history_file = File.expand_path '~/.nose_history'
|
66
|
+
File.open(history_file, 'w') do |f|
|
67
|
+
Readline::HISTORY.each do |line|
|
68
|
+
f.puts line
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# Get the next inputted line in the REPL
|
74
|
+
# @return [String]
|
75
|
+
def read_line
|
76
|
+
prefix = '>> '
|
77
|
+
|
78
|
+
if Object.const_defined? 'Readline'
|
79
|
+
line = Readline.readline prefix
|
80
|
+
return if line.nil?
|
81
|
+
|
82
|
+
Readline::HISTORY.push line unless line.chomp.empty?
|
83
|
+
else
|
84
|
+
print prefix
|
85
|
+
line = gets
|
86
|
+
end
|
87
|
+
|
88
|
+
line.chomp
|
89
|
+
end
|
90
|
+
|
91
|
+
# Parse a statement from a given string of text
|
92
|
+
# @return [Statement]
|
93
|
+
def parse_statement(text, workload)
|
94
|
+
begin
|
95
|
+
statement = Statement.parse text, workload.model
|
96
|
+
rescue ParseFailed => e
|
97
|
+
puts '! ' + e.message
|
98
|
+
statement = nil
|
99
|
+
end
|
100
|
+
|
101
|
+
statement
|
102
|
+
end
|
103
|
+
|
104
|
+
# Try to execute a statement read from the REPL
|
105
|
+
# @return [void]
|
106
|
+
def execute_statement(line, result, backend)
|
107
|
+
# Parse the statement
|
108
|
+
statement = parse_statement line, result.workload
|
109
|
+
return if statement.nil?
|
110
|
+
|
111
|
+
begin
|
112
|
+
results, elapsed = backend_execute_statement backend, statement
|
113
|
+
rescue NotImplementedError, Backend::PlanNotFound => e
|
114
|
+
puts '! ' + e.message
|
115
|
+
else
|
116
|
+
display_statement_result results, elapsed
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Execute the statement on the provided backend and measure the runtime
|
121
|
+
def backend_execute_statement(backend, statement)
|
122
|
+
start_time = Time.now.utc
|
123
|
+
|
124
|
+
if statement.is_a? Query
|
125
|
+
results = backend.query statement
|
126
|
+
else
|
127
|
+
backend.update statement
|
128
|
+
results = []
|
129
|
+
end
|
130
|
+
|
131
|
+
elapsed = Time.now.utc - start_time
|
132
|
+
|
133
|
+
[results, elapsed]
|
134
|
+
end
|
135
|
+
|
136
|
+
# Show the results of executing a statement
|
137
|
+
# @return [void]
|
138
|
+
def display_statement_result(results, elapsed)
|
139
|
+
Formatador.display_compact_table results unless results.empty?
|
140
|
+
puts format('(%d rows in %.2fs)', results.length, elapsed)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'formatador'
|
4
|
+
require 'ostruct'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
module NoSE
|
8
|
+
module CLI
|
9
|
+
# Add a command to run the advisor for a given workload
|
10
|
+
class NoSECLI < Thor
|
11
|
+
desc 'search NAME', 'run the workload NAME'
|
12
|
+
|
13
|
+
long_desc <<-LONGDESC
|
14
|
+
`nose search` is the primary command to run the NoSE advisor. It will
|
15
|
+
load the given workload, enumerate indexes for each statement, and
|
16
|
+
construct and solve an ILP to produce statement execution plans.
|
17
|
+
LONGDESC
|
18
|
+
|
19
|
+
shared_option :mix
|
20
|
+
shared_option :format
|
21
|
+
shared_option :output
|
22
|
+
|
23
|
+
option :max_space, type: :numeric, default: Float::INFINITY,
|
24
|
+
aliases: '-s',
|
25
|
+
desc: 'maximum space allocated to indexes'
|
26
|
+
option :enumerated, type: :boolean, default: false, aliases: '-e',
|
27
|
+
desc: 'whether enumerated indexes should be output'
|
28
|
+
option :read_only, type: :boolean, default: false,
|
29
|
+
desc: 'whether to ignore update statements'
|
30
|
+
option :objective, type: :string, default: 'cost',
|
31
|
+
enum: %w(cost space indexes),
|
32
|
+
desc: 'the objective function to use in the ILP'
|
33
|
+
option :by_id_graph, type: :boolean, default: false,
|
34
|
+
desc: 'whether to group generated indexes in' \
|
35
|
+
'graphs by ID',
|
36
|
+
aliases: '-i'
|
37
|
+
|
38
|
+
def search(name)
|
39
|
+
# Get the workload and cost model
|
40
|
+
workload = Workload.load name
|
41
|
+
workload.mix = options[:mix].to_sym \
|
42
|
+
unless options[:mix] == 'default' && workload.mix != :default
|
43
|
+
workload.remove_updates if options[:read_only]
|
44
|
+
cost_model = get_class_from_config options, 'cost', :cost_model
|
45
|
+
|
46
|
+
# Execute the advisor
|
47
|
+
objective = Search::Objective.const_get options[:objective].upcase
|
48
|
+
result = search_result workload, cost_model, options[:max_space],
|
49
|
+
objective, options[:by_id_graph]
|
50
|
+
output_search_result result, options unless result.nil?
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
# Output results from the search procedure
|
56
|
+
# @return [void]
|
57
|
+
def output_search_result(result, options)
|
58
|
+
# Output the results in the specified format
|
59
|
+
file = if options[:output].nil?
|
60
|
+
$stdout
|
61
|
+
else
|
62
|
+
File.open(options[:output], 'w')
|
63
|
+
end
|
64
|
+
|
65
|
+
begin
|
66
|
+
backend = get_backend options, result
|
67
|
+
send(('output_' + options[:format]).to_sym,
|
68
|
+
result, file, options[:enumerated], backend)
|
69
|
+
rescue
|
70
|
+
nil
|
71
|
+
ensure
|
72
|
+
file.close unless options[:output].nil?
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
|
5
|
+
module NoSE
|
6
|
+
module CLI
|
7
|
+
# Add a command to reformat a plan file
|
8
|
+
class NoSECLI < Thor
|
9
|
+
desc 'search-all NAME DIRECTORY',
|
10
|
+
'output all possible schemas for the workload NAME under ' \
|
11
|
+
'different storage constraints to DIRECTORY'
|
12
|
+
|
13
|
+
long_desc <<-LONGDESC
|
14
|
+
`nose search-all` is a convenience for executing `nose search` with a
|
15
|
+
variety of different storage constraints. It will start with the
|
16
|
+
smallest and largest possible schemas and then perform a binary search
|
17
|
+
throughout the search space to discover schemas of various sizes.
|
18
|
+
LONGDESC
|
19
|
+
|
20
|
+
shared_option :format
|
21
|
+
shared_option :mix
|
22
|
+
|
23
|
+
option :enumerated, type: :boolean, aliases: '-e',
|
24
|
+
desc: 'whether enumerated indexes should be output'
|
25
|
+
option :read_only, type: :boolean, default: false,
|
26
|
+
desc: 'whether to ignore update statements'
|
27
|
+
option :max_results, type: :numeric, default: Float::INFINITY,
|
28
|
+
aliases: '-n',
|
29
|
+
desc: 'the maximum number of results to produce'
|
30
|
+
def search_all(name, directory)
|
31
|
+
# Load the workload and cost model and create the output directory
|
32
|
+
workload = Workload.load name
|
33
|
+
workload.mix = options[:mix].to_sym \
|
34
|
+
unless options[:mix] == 'default' && workload.mix != :default
|
35
|
+
workload.remove_updates if options[:read_only]
|
36
|
+
cost_model = get_class_from_config options, 'cost', :cost_model
|
37
|
+
FileUtils.mkdir_p(directory) unless Dir.exist?(directory)
|
38
|
+
|
39
|
+
# Run the search and output the results
|
40
|
+
results = search_results workload, cost_model, options[:max_results]
|
41
|
+
output_results results, directory, options
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
# Get a list of all possible search results
|
47
|
+
# @return [Array<Search::Results>]
|
48
|
+
def search_results(workload, cost_model, max_results)
|
49
|
+
# Start with the maximum possible size and divide in two
|
50
|
+
max_result = search_result workload, cost_model
|
51
|
+
max_size = max_result.total_size
|
52
|
+
min_result = search_result workload, cost_model,
|
53
|
+
Float::INFINITY, Search::Objective::SPACE
|
54
|
+
min_size = min_result.total_size
|
55
|
+
min_result = search_result workload, cost_model, min_size
|
56
|
+
|
57
|
+
# If we only have one result, return
|
58
|
+
return [max_result] if max_size == min_size
|
59
|
+
|
60
|
+
results = [max_result, min_result]
|
61
|
+
num_results = 2
|
62
|
+
sizes = Set.new [min_size, max_size]
|
63
|
+
size_queue = [(max_size - min_size) / 2.0 + min_size]
|
64
|
+
until size_queue.empty?
|
65
|
+
# Stop if we found the appropriate number of results
|
66
|
+
return results if num_results >= max_results
|
67
|
+
|
68
|
+
# Find a new size to examine
|
69
|
+
size = size_queue.pop
|
70
|
+
|
71
|
+
# Continue dividing the range of examined sizes
|
72
|
+
next_size = sizes.sort.detect { |n| n > size }
|
73
|
+
next_size = (next_size - size) / 2.0 + size
|
74
|
+
|
75
|
+
prev_size = sizes.sort.reverse_each.detect { |n| n < size }
|
76
|
+
prev_size = (size - prev_size) / 2.0 + prev_size
|
77
|
+
|
78
|
+
begin
|
79
|
+
@logger.info "Running search with size #{size}"
|
80
|
+
|
81
|
+
result = search_result workload, cost_model, size
|
82
|
+
next if sizes.include?(result.total_size) || result.nil?
|
83
|
+
rescue Search::NoSolutionException, Plans::NoPlanException
|
84
|
+
# No result was found, so only explore the larger side
|
85
|
+
@logger.info "No solution for size #{size}"
|
86
|
+
else
|
87
|
+
# Add the smaller size to the queue and save the result
|
88
|
+
size_queue.push prev_size unless sizes.include? prev_size
|
89
|
+
results.push result
|
90
|
+
num_results += 1
|
91
|
+
end
|
92
|
+
|
93
|
+
# Add the larger size to the queue
|
94
|
+
size_queue.push(next_size) unless sizes.include? next_size
|
95
|
+
|
96
|
+
# Note that we visited this size (and the result size)
|
97
|
+
sizes.add size
|
98
|
+
sizes.add result.total_size unless result.nil?
|
99
|
+
end
|
100
|
+
|
101
|
+
results
|
102
|
+
end
|
103
|
+
|
104
|
+
# Output all results to file
|
105
|
+
# @return [void]
|
106
|
+
def output_results(results, directory, options)
|
107
|
+
results.sort_by!(&:total_size)
|
108
|
+
results.each_with_index do |result, i|
|
109
|
+
file = File.open File.join(directory, "#{i}.#{options[:format]}"), 'w'
|
110
|
+
begin
|
111
|
+
send(('output_' + options[:format]).to_sym,
|
112
|
+
result, file, options[:enumerated])
|
113
|
+
ensure
|
114
|
+
file.close
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|