ultra_marathon 0.1.7 → 0.1.10
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +97 -1
- data/lib/core_ext/class.rb +13 -0
- data/lib/core_ext/extensions.rb +4 -0
- data/lib/core_ext/object.rb +12 -0
- data/lib/core_ext/string.rb +9 -0
- data/lib/ultra_marathon.rb +2 -1
- data/lib/ultra_marathon/abstract_runner.rb +29 -144
- data/lib/ultra_marathon/base_runner.rb +216 -0
- data/lib/ultra_marathon/callbacks.rb +15 -50
- data/lib/ultra_marathon/collection_runner.rb +111 -0
- data/lib/ultra_marathon/contexticution.rb +53 -0
- data/lib/ultra_marathon/instrumentation.rb +32 -9
- data/lib/ultra_marathon/instrumentation/profile.rb +38 -9
- data/lib/ultra_marathon/instrumentation/store.rb +103 -0
- data/lib/ultra_marathon/logging.rb +1 -1
- data/lib/ultra_marathon/store.rb +13 -4
- data/lib/ultra_marathon/sub_context.rb +36 -0
- data/lib/ultra_marathon/sub_runner.rb +116 -60
- data/lib/ultra_marathon/version.rb +1 -1
- data/spec/spec_helper.rb +11 -0
- data/spec/ultra_marathon/abstract_runner_spec.rb +44 -1
- data/spec/ultra_marathon/collection_runner_spec.rb +96 -0
- data/spec/ultra_marathon/instrumentation/profile_spec.rb +5 -4
- data/spec/ultra_marathon/instrumentation/store_spec.rb +73 -0
- data/spec/ultra_marathon/instrumentation_spec.rb +1 -1
- data/spec/ultra_marathon/sub_runner_spec.rb +5 -1
- metadata +15 -1
@@ -1,10 +1,11 @@
|
|
1
1
|
require 'set'
|
2
2
|
require 'active_support/concern'
|
3
|
-
require '
|
3
|
+
require 'ultra_marathon/contexticution'
|
4
4
|
|
5
5
|
module UltraMarathon
|
6
6
|
module Callbacks
|
7
7
|
extend ActiveSupport::Concern
|
8
|
+
include Contexticution
|
8
9
|
|
9
10
|
private
|
10
11
|
|
@@ -15,56 +16,16 @@ module UltraMarathon
|
|
15
16
|
def callback_conditions_met?(options)
|
16
17
|
conditions_met = true
|
17
18
|
if options.key? :if
|
18
|
-
conditions_met &&=
|
19
|
+
conditions_met &&= contexticute(options[:if])
|
19
20
|
elsif options.key? :unless
|
20
|
-
conditions_met &&= !
|
21
|
+
conditions_met &&= !contexticute(options[:unless])
|
21
22
|
end
|
22
23
|
conditions_met
|
23
24
|
end
|
24
25
|
|
25
|
-
# Exectutes the object in the context of the instance,
|
26
|
-
# whether an explicitly callable object or a string/symbol
|
27
|
-
# representation of one
|
28
|
-
def call_proc_or_symbol(object, args=[], options={})
|
29
|
-
options = options.dup
|
30
|
-
options[:context] ||= self
|
31
|
-
bound_proc = bind_to_context(object, options[:context])
|
32
|
-
evaluate_block_with_arguments(bound_proc, args)
|
33
|
-
end
|
34
|
-
|
35
|
-
# Binds a proc to the given context. If a symbol is passed in,
|
36
|
-
# retrieves the bound method.
|
37
|
-
def bind_to_context(symbol_or_proc, context)
|
38
|
-
if symbol_or_proc.is_a?(Symbol)
|
39
|
-
context.method(symbol_or_proc)
|
40
|
-
elsif symbol_or_proc.respond_to? :call
|
41
|
-
symbol_or_proc.bind(context)
|
42
|
-
else
|
43
|
-
raise ArgumentError.new("Cannot bind #{callback.class} to #{context}. Expected callable object or symbol.")
|
44
|
-
end
|
45
|
-
end
|
46
|
-
|
47
|
-
# Applies a block in context with the correct number of arguments.
|
48
|
-
# If there are more arguments than the arity, takes the first n
|
49
|
-
# arguments where (n) is the arity of the block.
|
50
|
-
# If there are the same number of arguments, splats them into the block.
|
51
|
-
# Otherwise throws an argument error.
|
52
|
-
def evaluate_block_with_arguments(block, args)
|
53
|
-
# If block.arity < 0, when a block takes a variable number of args,
|
54
|
-
# the one's complement (-n-1) is the number of required arguments
|
55
|
-
required_arguments = block.arity < 0 ? ~block.arity : block.arity
|
56
|
-
if args.length >= required_arguments
|
57
|
-
if block.arity < 0
|
58
|
-
instance_exec(*args, &block)
|
59
|
-
else
|
60
|
-
instance_exec(*args.first(block.arity), &block)
|
61
|
-
end
|
62
|
-
else
|
63
|
-
raise ArgumentError.new("wrong number of arguments (#{args.size} for #{required_arguments})")
|
64
|
-
end
|
65
|
-
end
|
66
|
-
|
67
26
|
module ClassMethods
|
27
|
+
include Contexticution
|
28
|
+
|
68
29
|
## Public Class Methods
|
69
30
|
|
70
31
|
# Add one or more new callbacks for class
|
@@ -113,11 +74,15 @@ module UltraMarathon
|
|
113
74
|
end
|
114
75
|
|
115
76
|
# Defines class level accessor that memoizes the set of callbacks
|
116
|
-
#
|
77
|
+
# @param callback_name [String, Symbol]
|
78
|
+
# @example
|
79
|
+
# add_callbacks_accessor(:after_save)
|
117
80
|
#
|
118
|
-
#
|
119
|
-
#
|
120
|
-
#
|
81
|
+
# # Equivalent to
|
82
|
+
# #
|
83
|
+
# # def self.after_save_callbacks
|
84
|
+
# # @after_save_callbacks ||= []
|
85
|
+
# # end
|
121
86
|
def add_callbacks_accessor(callback_name)
|
122
87
|
accessor_name = "#{callback_name}_callbacks"
|
123
88
|
instance_variable_name = :"@#{accessor_name}"
|
@@ -167,7 +132,7 @@ module UltraMarathon
|
|
167
132
|
callbacks = self.class.send :"#{callback_name}_callbacks"
|
168
133
|
callbacks.each do |callback, options|
|
169
134
|
next unless callback_conditions_met?(options)
|
170
|
-
|
135
|
+
contexticute(callback, args, options[:context] || self)
|
171
136
|
end
|
172
137
|
end
|
173
138
|
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
require 'set'
|
2
|
+
require 'ultra_marathon/base_runner'
|
3
|
+
require 'ultra_marathon/store'
|
4
|
+
require 'ultra_marathon/sub_context'
|
5
|
+
require 'ultra_marathon/sub_runner'
|
6
|
+
|
7
|
+
module UltraMarathon
|
8
|
+
class CollectionRunner < BaseRunner
|
9
|
+
attr_reader :collection, :options, :run_block, :name
|
10
|
+
attr_memo_reader :sub_runner_class, -> { options[:sub_runner].try_call }
|
11
|
+
|
12
|
+
after_run :write_logs
|
13
|
+
instrumentation_prefix lambda { |collection| "collection.#{collection.name}." }
|
14
|
+
|
15
|
+
# Takes a collection, each of which will be run in its own subrunner. The collection
|
16
|
+
# Also takes a number of options:
|
17
|
+
#
|
18
|
+
# name: The name of the collection run block
|
19
|
+
# sub_name: A callable object (passed the index of the collection) that
|
20
|
+
# should return a unique (to the collection) name for that subrunner
|
21
|
+
# Defaults to :"#{options[:name]}__#{index}"
|
22
|
+
# sub_runner: Class inheiriting from UltraMarathon::SubRunner in which each
|
23
|
+
# run_block will be run
|
24
|
+
# Defaults to UltraMarathon::SubRunner
|
25
|
+
#
|
26
|
+
# iterator: Method called to iterate over collection. For example, a Rails
|
27
|
+
# application may wish to use :find_each with an ActiveRecord::Relation
|
28
|
+
# to batch queries
|
29
|
+
# Defaults to :each
|
30
|
+
#
|
31
|
+
# threaded: Run each iteration in its own thread
|
32
|
+
def initialize(collection, options={}, &run_block)
|
33
|
+
@collection, @run_block = collection, run_block
|
34
|
+
@name = options[:name]
|
35
|
+
@options = {
|
36
|
+
sub_name: proc { |index| :"#{options[:name]}__#{index}" },
|
37
|
+
sub_runner: SubRunner,
|
38
|
+
iterator: :each,
|
39
|
+
threaded: false
|
40
|
+
}.merge(options)
|
41
|
+
end
|
42
|
+
|
43
|
+
def unrun_sub_runners
|
44
|
+
@unrun_sub_runners ||= begin
|
45
|
+
store = Store.new
|
46
|
+
index = 0
|
47
|
+
collection.send(options[:iterator]) do |item|
|
48
|
+
this_index = index
|
49
|
+
index += 1
|
50
|
+
store << new_item_sub_runner(item, this_index)
|
51
|
+
end
|
52
|
+
store
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def threaded?
|
57
|
+
false
|
58
|
+
end
|
59
|
+
|
60
|
+
def complete?
|
61
|
+
unrun_sub_runners.empty? && running_sub_runners.empty?
|
62
|
+
end
|
63
|
+
|
64
|
+
# Set of all sub runners that should be run before this one.
|
65
|
+
# This class cannot do anything with this information, but it is useful
|
66
|
+
# to the enveloping runner.
|
67
|
+
def parents
|
68
|
+
@parents ||= Set.new(options[:requires])
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def write_logs
|
74
|
+
log_header
|
75
|
+
log_all_sub_runners
|
76
|
+
log_summary
|
77
|
+
end
|
78
|
+
|
79
|
+
def log_header
|
80
|
+
logger.info "Running Collection #{options[:name]}"
|
81
|
+
end
|
82
|
+
|
83
|
+
def new_item_sub_runner(item, index)
|
84
|
+
item_options = sub_runner_item_options(item, index)
|
85
|
+
item_sub_context = build_item_sub_context(item, item_options)
|
86
|
+
sub_runner_class.new(item_options, item_sub_context)
|
87
|
+
end
|
88
|
+
|
89
|
+
# By default, run the sub runner inside this context. Unlikely to be what you
|
90
|
+
# want
|
91
|
+
def sub_runner_base_options
|
92
|
+
{
|
93
|
+
context: options[:context] || self,
|
94
|
+
threaded: options[:threaded]
|
95
|
+
}
|
96
|
+
end
|
97
|
+
|
98
|
+
def sub_runner_item_options(item, index)
|
99
|
+
name = options[:sub_name].try_call(index, item)
|
100
|
+
sub_runner_base_options.merge(name: name)
|
101
|
+
end
|
102
|
+
|
103
|
+
def build_item_sub_context(item, options)
|
104
|
+
Proc.new do |run_block|
|
105
|
+
SubContext.new(options[:context]) do
|
106
|
+
instance_exec(*item, &run_block)
|
107
|
+
end
|
108
|
+
end.call(run_block)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'core_ext/proc'
|
2
|
+
|
3
|
+
module UltraMarathon
|
4
|
+
module Contexticution
|
5
|
+
|
6
|
+
# @param object [Symbol, String, Proc] a symbol representing a method name,
|
7
|
+
# a string to be eval'd, or a proc to be called in the given context
|
8
|
+
# @param args [Array] arguments to be passed to +object+
|
9
|
+
# @param context [Object] the context in which to evaluate +object+. Defaults
|
10
|
+
# to self
|
11
|
+
def contexticute(object, args=[], context=self)
|
12
|
+
bound_proc = bind_to_context(object, context)
|
13
|
+
evaluate_block_with_arguments(bound_proc, args)
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
# Binds a proc to the given context. If a symbol is passed in,
|
18
|
+
# retrieves the bound method. If it is a string, generates a lambda wrapping
|
19
|
+
# it.
|
20
|
+
def bind_to_context(object, context)
|
21
|
+
if object.is_a?(Symbol)
|
22
|
+
context.method(object)
|
23
|
+
elsif object.respond_to?(:call)
|
24
|
+
object.bind(context)
|
25
|
+
elsif object.is_a?(String)
|
26
|
+
eval("lambda { #{object} }").bind(context)
|
27
|
+
else
|
28
|
+
raise ArgumentError.new("Cannot bind #{callback.class} to #{context}. Expected Symbol, String, or object responding to #call.")
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Applies a block in context with the correct number of arguments.
|
33
|
+
# If there are more arguments than the arity, takes the first n
|
34
|
+
# arguments where (n) is the arity of the block.
|
35
|
+
# If there are the same number of arguments, splats them into the block.
|
36
|
+
# Otherwise throws an argument error.
|
37
|
+
def evaluate_block_with_arguments(block, args)
|
38
|
+
# If block.arity < 0, when a block takes a variable number of args,
|
39
|
+
# the one's complement (-n-1) is the number of required arguments
|
40
|
+
required_arguments = block.arity < 0 ? ~block.arity : block.arity
|
41
|
+
if args.length >= required_arguments
|
42
|
+
if block.arity < 0
|
43
|
+
instance_exec(*args, &block)
|
44
|
+
else
|
45
|
+
instance_exec(*args.first(block.arity), &block)
|
46
|
+
end
|
47
|
+
else
|
48
|
+
raise ArgumentError.new("wrong number of arguments (#{args.size} for #{required_arguments})")
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
end
|
@@ -1,25 +1,48 @@
|
|
1
|
+
require 'active_support/concern'
|
1
2
|
require 'ultra_marathon/instrumentation/profile'
|
3
|
+
require 'ultra_marathon/instrumentation/store'
|
2
4
|
|
3
5
|
module UltraMarathon
|
4
6
|
module Instrumentation
|
5
|
-
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
# Creates a default UltraMarathon::Instrumentation::Store and stores
|
9
|
+
# all instrumented profiles there
|
6
10
|
|
7
|
-
|
11
|
+
module ClassMethods
|
12
|
+
## Public Class Methods
|
8
13
|
|
14
|
+
# @param prefix_or_proc [String, Proc] the prefix. If a Proc, it will be
|
15
|
+
# passed self for each instance
|
16
|
+
# @return [String, Proc, nil]
|
17
|
+
def instrumentation_prefix(prefix_or_proc=nil)
|
18
|
+
if prefix_or_proc
|
19
|
+
@instrumentation_prefix = prefix_or_proc
|
20
|
+
else
|
21
|
+
@instrumentation_prefix
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# The default instrumentation store for the included class
|
27
|
+
# @return [UltraMarathon::Instrumentation::Store]
|
9
28
|
def instrumentations
|
10
|
-
@instrumentations ||=
|
29
|
+
@instrumentations ||= UltraMarathon::Instrumentation::Store.new([], prefix: instrumentation_prefix)
|
11
30
|
end
|
12
31
|
|
13
32
|
private
|
14
33
|
|
15
34
|
## Private Instance Methods
|
16
35
|
|
17
|
-
#
|
18
|
-
#
|
19
|
-
def
|
20
|
-
|
21
|
-
|
22
|
-
|
36
|
+
# @return [String] the prefix for the default instrumentation store passed
|
37
|
+
# to {.instrumentation_prefix}
|
38
|
+
def instrumentation_prefix
|
39
|
+
self.class.instrumentation_prefix.try_call(self)
|
40
|
+
end
|
41
|
+
|
42
|
+
# @return [Object]
|
43
|
+
# @see UltraMarathon::Instrumentation::Store#instrument
|
44
|
+
def instrument(*args, &block)
|
45
|
+
instrumentations.instrument(*args, &block)
|
23
46
|
end
|
24
47
|
end
|
25
48
|
end
|
@@ -1,19 +1,25 @@
|
|
1
|
+
require 'ultra_marathon/instrumentation/profile'
|
1
2
|
module UltraMarathon
|
2
3
|
module Instrumentation
|
3
4
|
class Profile
|
5
|
+
DATETIME_FORMAT = '%H:%M:%S:%L'.freeze
|
6
|
+
RAW_TIME_FORMAT = '%02d:%02d:%02d:%03d'.freeze
|
4
7
|
attr_reader :name, :start_time, :end_time
|
5
8
|
|
6
9
|
## Public Instance Methods
|
7
10
|
|
11
|
+
# @param name [String] name of the instrumented block
|
12
|
+
# @param block [Proc] block to be instrumented
|
8
13
|
def initialize(name, &block)
|
9
14
|
@name = name
|
10
15
|
# Ruby cannot marshal procs or lambdas, so we need to define a method.
|
11
|
-
# Binding to self allows us to intercept logging calls.
|
12
16
|
define_singleton_method :instrumented_block do
|
13
17
|
block.call
|
14
18
|
end
|
15
19
|
end
|
16
20
|
|
21
|
+
# Sets {#start_time}, runs the initialized block, then sets {#end_time}
|
22
|
+
# @return [Object] the return value of the initialized block
|
17
23
|
def call
|
18
24
|
@start_time = Time.now
|
19
25
|
begin
|
@@ -24,31 +30,54 @@ module UltraMarathon
|
|
24
30
|
return_value
|
25
31
|
end
|
26
32
|
|
27
|
-
#
|
33
|
+
# @return [Float] the total time in seconds to the nanosecond
|
28
34
|
def total_time
|
29
|
-
|
35
|
+
@total_time ||= end_time - start_time
|
30
36
|
end
|
31
37
|
|
38
|
+
# @return [String] {#total_time} formatted per {RAW_TIME_FORMAT}
|
32
39
|
def formatted_total_time
|
33
|
-
|
34
|
-
seconds = (duration % 60).floor
|
35
|
-
minutes = (duration / 60).floor
|
36
|
-
hours = (duration / 3600).floor
|
37
|
-
sprintf(TIME_FORMAT, hours, minutes, seconds)
|
40
|
+
format_seconds(total_time)
|
38
41
|
end
|
39
42
|
|
43
|
+
# @return [String] {#start_time} formatted per {DATETIME_FORMAT}
|
40
44
|
def formatted_start_time
|
41
45
|
format_time(start_time)
|
42
46
|
end
|
43
47
|
|
48
|
+
# @return [String] {#end_time} formatted per {DATETIME_FORMAT}
|
44
49
|
def formatted_end_time
|
45
50
|
format_time(end_time)
|
46
51
|
end
|
47
52
|
|
53
|
+
# Comparison delegated to {#total_time}
|
54
|
+
# @param other_profile [Profile]
|
55
|
+
# @return [Integer] {#total_time} <=> other_profile.total_time
|
56
|
+
def <=>(other_profile)
|
57
|
+
total_time <=> other_profile.total_time
|
58
|
+
end
|
59
|
+
|
60
|
+
# Profiles are considered equal if their names are `eql?`
|
61
|
+
# @param other_profile [Profile]
|
62
|
+
# @return [Boolean] delegates to {#name}
|
63
|
+
def eql?(other_profile)
|
64
|
+
name.eql? other_profile.name
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
48
69
|
## Private Instance Methods
|
49
70
|
|
71
|
+
def format_seconds(total_seconds)
|
72
|
+
seconds = (total_seconds % 60).floor
|
73
|
+
minutes = (total_seconds / 60).floor
|
74
|
+
hours = (total_seconds / 3600).floor
|
75
|
+
milliseconds = (total_seconds - total_seconds.to_i) * 1000.0
|
76
|
+
sprintf(RAW_TIME_FORMAT, hours, minutes, seconds, milliseconds)
|
77
|
+
end
|
78
|
+
|
50
79
|
def format_time(time)
|
51
|
-
|
80
|
+
time.strftime(DATETIME_FORMAT)
|
52
81
|
end
|
53
82
|
end
|
54
83
|
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
require 'set'
|
2
|
+
module UltraMarathon
|
3
|
+
module Instrumentation
|
4
|
+
class Store < SortedSet
|
5
|
+
attr_reader :options
|
6
|
+
|
7
|
+
# @param new_members [Array, Set]
|
8
|
+
# @param options [Hash]
|
9
|
+
# @option options [String] :prefix ('') will prefix the name of every
|
10
|
+
# name passed into {#instrument}
|
11
|
+
def initialize(new_members=[], options={})
|
12
|
+
super(new_members)
|
13
|
+
@options = {
|
14
|
+
prefix: ''
|
15
|
+
}.merge(options)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Instruments given block, setting its start time and end time
|
19
|
+
# Stores the resulting profile in in itself
|
20
|
+
# @param name [String] name of the instrumented block
|
21
|
+
# @param block [Proc] block to instrument
|
22
|
+
# @return [Object] return value of the instrumented block
|
23
|
+
def instrument(name, &block)
|
24
|
+
profile = Profile.new(full_name(name), &block)
|
25
|
+
return_value = profile.call
|
26
|
+
self.add(profile)
|
27
|
+
return_value
|
28
|
+
end
|
29
|
+
|
30
|
+
# The passed in prefix
|
31
|
+
# @return [String]
|
32
|
+
def prefix
|
33
|
+
options[:prefix]
|
34
|
+
end
|
35
|
+
|
36
|
+
# Access a profile by name. Instrumentations shouldn't have to know about
|
37
|
+
# their fully qualified name, so the unprefixed version should be passed
|
38
|
+
# @param name [String] name of the profile that was passed to
|
39
|
+
# {#instrument}
|
40
|
+
# @return [UltraMarathon::Instrumentation::Profile, nil]
|
41
|
+
def [](name)
|
42
|
+
full_name = full_name(name)
|
43
|
+
detect do |profile|
|
44
|
+
profile.name == full_name
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Accumulated total time for all stored profiles
|
49
|
+
# @return [Float]
|
50
|
+
def total_time
|
51
|
+
total_times.reduce(0.0, :+)
|
52
|
+
end
|
53
|
+
|
54
|
+
# @return [Float] the mean time for all profiles
|
55
|
+
def mean_runtime
|
56
|
+
total_time / size
|
57
|
+
end
|
58
|
+
|
59
|
+
# @return [UltraMarthon::Instrumentation::Profile] the profile in the
|
60
|
+
# middle of the pack per
|
61
|
+
# {UltraMarthon::Instrumentation::Profile#total_time}
|
62
|
+
def median
|
63
|
+
to_a[size / 2]
|
64
|
+
end
|
65
|
+
|
66
|
+
|
67
|
+
# Please forgive me Mr. Brooks, I had to Google it
|
68
|
+
# @return [Float] the standard deviation from the mean
|
69
|
+
def standard_deviation
|
70
|
+
sum_of_squares = total_times.reduce(0) do |sum, total_time|
|
71
|
+
sum + (mean_runtime - total_time) ** 2
|
72
|
+
end
|
73
|
+
Math.sqrt(sum_of_squares / size)
|
74
|
+
end
|
75
|
+
|
76
|
+
# Adds all profiles from the other_store
|
77
|
+
# @param other_store [UltraMarathon::Instrumentation::Profile]
|
78
|
+
# @return [self] the other_store
|
79
|
+
def merge!(other_store)
|
80
|
+
other_store.each do |member|
|
81
|
+
add(member)
|
82
|
+
end
|
83
|
+
self
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
## Private Instance Methods
|
89
|
+
|
90
|
+
# Adds the prefix to the name
|
91
|
+
# @param name [String] the raw name
|
92
|
+
# @return [String] the prefixed name
|
93
|
+
def full_name(name)
|
94
|
+
"#{prefix}#{name}"
|
95
|
+
end
|
96
|
+
|
97
|
+
# @return [Array<Float>] the total times for all stored profiles
|
98
|
+
def total_times
|
99
|
+
map(&:total_time)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|