nose-cli 0.1.0pre
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 +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
|