activejob 4.2.11.3 → 5.0.0.beta1
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of activejob might be problematic. Click here for more details.
- checksums.yaml +5 -5
- data/CHANGELOG.md +88 -54
- data/MIT-LICENSE +1 -1
- data/README.md +5 -5
- data/lib/active_job.rb +2 -1
- data/lib/active_job/arguments.rb +38 -19
- data/lib/active_job/async_job.rb +77 -0
- data/lib/active_job/base.rb +3 -1
- data/lib/active_job/callbacks.rb +2 -2
- data/lib/active_job/core.rb +42 -5
- data/lib/active_job/enqueuing.rb +5 -0
- data/lib/active_job/gem_version.rb +4 -4
- data/lib/active_job/logging.rb +17 -3
- data/lib/active_job/queue_adapter.rb +44 -16
- data/lib/active_job/queue_adapters.rb +86 -0
- data/lib/active_job/queue_adapters/async_adapter.rb +23 -0
- data/lib/active_job/queue_adapters/backburner_adapter.rb +6 -8
- data/lib/active_job/queue_adapters/delayed_job_adapter.rb +9 -7
- data/lib/active_job/queue_adapters/inline_adapter.rb +5 -7
- data/lib/active_job/queue_adapters/qu_adapter.rb +11 -9
- data/lib/active_job/queue_adapters/que_adapter.rb +9 -7
- data/lib/active_job/queue_adapters/queue_classic_adapter.rb +22 -20
- data/lib/active_job/queue_adapters/resque_adapter.rb +8 -10
- data/lib/active_job/queue_adapters/sidekiq_adapter.rb +15 -17
- data/lib/active_job/queue_adapters/sneakers_adapter.rb +11 -11
- data/lib/active_job/queue_adapters/sucker_punch_adapter.rb +5 -7
- data/lib/active_job/queue_adapters/test_adapter.rb +25 -16
- data/lib/active_job/queue_name.rb +1 -1
- data/lib/active_job/queue_priority.rb +44 -0
- data/lib/active_job/test_helper.rb +144 -46
- data/lib/rails/generators/job/job_generator.rb +1 -2
- data/lib/rails/generators/job/templates/job.rb +1 -1
- metadata +15 -10
data/lib/active_job/base.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
require 'active_job/core'
|
2
2
|
require 'active_job/queue_adapter'
|
3
3
|
require 'active_job/queue_name'
|
4
|
+
require 'active_job/queue_priority'
|
4
5
|
require 'active_job/enqueuing'
|
5
6
|
require 'active_job/execution'
|
6
7
|
require 'active_job/callbacks'
|
@@ -35,7 +36,7 @@ module ActiveJob #:nodoc:
|
|
35
36
|
# Records that are passed in are serialized/deserialized using Global
|
36
37
|
# ID. More information can be found in Arguments.
|
37
38
|
#
|
38
|
-
# To enqueue a job to be performed as soon the queueing system is free:
|
39
|
+
# To enqueue a job to be performed as soon as the queueing system is free:
|
39
40
|
#
|
40
41
|
# ProcessPhotoJob.perform_later(photo)
|
41
42
|
#
|
@@ -57,6 +58,7 @@ module ActiveJob #:nodoc:
|
|
57
58
|
include Core
|
58
59
|
include QueueAdapter
|
59
60
|
include QueueName
|
61
|
+
include QueuePriority
|
60
62
|
include Enqueuing
|
61
63
|
include Execution
|
62
64
|
include Callbacks
|
data/lib/active_job/callbacks.rb
CHANGED
@@ -3,8 +3,8 @@ require 'active_support/callbacks'
|
|
3
3
|
module ActiveJob
|
4
4
|
# = Active Job Callbacks
|
5
5
|
#
|
6
|
-
# Active Job provides hooks during the
|
7
|
-
# to trigger logic during the
|
6
|
+
# Active Job provides hooks during the life cycle of a job. Callbacks allow you
|
7
|
+
# to trigger logic during the life cycle of a job. Available callbacks are:
|
8
8
|
#
|
9
9
|
# * <tt>before_enqueue</tt>
|
10
10
|
# * <tt>around_enqueue</tt>
|
data/lib/active_job/core.rb
CHANGED
@@ -1,4 +1,6 @@
|
|
1
1
|
module ActiveJob
|
2
|
+
# Provides general behavior that will be included into every Active Job
|
3
|
+
# object that inherits from ActiveJob::Base.
|
2
4
|
module Core
|
3
5
|
extend ActiveSupport::Concern
|
4
6
|
|
@@ -16,6 +18,12 @@ module ActiveJob
|
|
16
18
|
# Queue in which the job will reside.
|
17
19
|
attr_writer :queue_name
|
18
20
|
|
21
|
+
# Priority that the job will have (lower is more priority).
|
22
|
+
attr_writer :priority
|
23
|
+
|
24
|
+
# ID optionally provided by adapter
|
25
|
+
attr_accessor :provider_job_id
|
26
|
+
|
19
27
|
# I18n.locale to be used during the job.
|
20
28
|
attr_accessor :locale
|
21
29
|
end
|
@@ -25,11 +33,8 @@ module ActiveJob
|
|
25
33
|
module ClassMethods
|
26
34
|
# Creates a new job instance from a hash created with +serialize+
|
27
35
|
def deserialize(job_data)
|
28
|
-
job
|
29
|
-
job.
|
30
|
-
job.queue_name = job_data['queue_name']
|
31
|
-
job.serialized_arguments = job_data['arguments']
|
32
|
-
job.locale = job_data['locale'] || I18n.locale
|
36
|
+
job = job_data['job_class'].constantize.new
|
37
|
+
job.deserialize(job_data)
|
33
38
|
job
|
34
39
|
end
|
35
40
|
|
@@ -41,6 +46,7 @@ module ActiveJob
|
|
41
46
|
# * <tt>:wait</tt> - Enqueues the job with the specified delay
|
42
47
|
# * <tt>:wait_until</tt> - Enqueues the job at the time specified
|
43
48
|
# * <tt>:queue</tt> - Enqueues the job on the specified queue
|
49
|
+
# * <tt>:priority</tt> - Enqueues the job with the specified priority
|
44
50
|
#
|
45
51
|
# ==== Examples
|
46
52
|
#
|
@@ -49,6 +55,7 @@ module ActiveJob
|
|
49
55
|
# VideoJob.set(wait_until: Time.now.tomorrow).perform_later(Video.last)
|
50
56
|
# VideoJob.set(queue: :some_queue, wait: 5.minutes).perform_later(Video.last)
|
51
57
|
# VideoJob.set(queue: :some_queue, wait_until: Time.now.tomorrow).perform_later(Video.last)
|
58
|
+
# VideoJob.set(queue: :some_queue, wait: 5.minutes, priority: 10).perform_later(Video.last)
|
52
59
|
def set(options={})
|
53
60
|
ConfiguredJob.new(self, options)
|
54
61
|
end
|
@@ -60,6 +67,7 @@ module ActiveJob
|
|
60
67
|
@arguments = arguments
|
61
68
|
@job_id = SecureRandom.uuid
|
62
69
|
@queue_name = self.class.queue_name
|
70
|
+
@priority = self.class.priority
|
63
71
|
end
|
64
72
|
|
65
73
|
# Returns a hash with the job data that can safely be passed to the
|
@@ -69,11 +77,40 @@ module ActiveJob
|
|
69
77
|
'job_class' => self.class.name,
|
70
78
|
'job_id' => job_id,
|
71
79
|
'queue_name' => queue_name,
|
80
|
+
'priority' => priority,
|
72
81
|
'arguments' => serialize_arguments(arguments),
|
73
82
|
'locale' => I18n.locale
|
74
83
|
}
|
75
84
|
end
|
76
85
|
|
86
|
+
# Attaches the stored job data to the current instance. Receives a hash
|
87
|
+
# returned from +serialize+
|
88
|
+
#
|
89
|
+
# ==== Examples
|
90
|
+
#
|
91
|
+
# class DeliverWebhookJob < ActiveJob::Base
|
92
|
+
# def serialize
|
93
|
+
# super.merge('attempt_number' => (@attempt_number || 0) + 1)
|
94
|
+
# end
|
95
|
+
#
|
96
|
+
# def deserialize(job_data)
|
97
|
+
# super
|
98
|
+
# @attempt_number = job_data['attempt_number']
|
99
|
+
# end
|
100
|
+
#
|
101
|
+
# rescue_from(TimeoutError) do |exception|
|
102
|
+
# raise exception if @attempt_number > 5
|
103
|
+
# retry_job(wait: 10)
|
104
|
+
# end
|
105
|
+
# end
|
106
|
+
def deserialize(job_data)
|
107
|
+
self.job_id = job_data['job_id']
|
108
|
+
self.queue_name = job_data['queue_name']
|
109
|
+
self.priority = job_data['priority']
|
110
|
+
self.serialized_arguments = job_data['arguments']
|
111
|
+
self.locale = job_data['locale'] || I18n.locale
|
112
|
+
end
|
113
|
+
|
77
114
|
private
|
78
115
|
def deserialize_arguments_if_needed
|
79
116
|
if defined?(@serialized_arguments) && @serialized_arguments.present?
|
data/lib/active_job/enqueuing.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
require 'active_job/arguments'
|
2
2
|
|
3
3
|
module ActiveJob
|
4
|
+
# Provides behavior for enqueuing and retrying jobs.
|
4
5
|
module Enqueuing
|
5
6
|
extend ActiveSupport::Concern
|
6
7
|
|
@@ -31,6 +32,7 @@ module ActiveJob
|
|
31
32
|
# * <tt>:wait</tt> - Enqueues the job with the specified delay
|
32
33
|
# * <tt>:wait_until</tt> - Enqueues the job at the time specified
|
33
34
|
# * <tt>:queue</tt> - Enqueues the job on the specified queue
|
35
|
+
# * <tt>:priority</tt> - Enqueues the job with the specified priority
|
34
36
|
#
|
35
37
|
# ==== Examples
|
36
38
|
#
|
@@ -53,6 +55,7 @@ module ActiveJob
|
|
53
55
|
# * <tt>:wait</tt> - Enqueues the job with the specified delay
|
54
56
|
# * <tt>:wait_until</tt> - Enqueues the job at the time specified
|
55
57
|
# * <tt>:queue</tt> - Enqueues the job on the specified queue
|
58
|
+
# * <tt>:priority</tt> - Enqueues the job with the specified priority
|
56
59
|
#
|
57
60
|
# ==== Examples
|
58
61
|
#
|
@@ -60,10 +63,12 @@ module ActiveJob
|
|
60
63
|
# my_job_instance.enqueue wait: 5.minutes
|
61
64
|
# my_job_instance.enqueue queue: :important
|
62
65
|
# my_job_instance.enqueue wait_until: Date.tomorrow.midnight
|
66
|
+
# my_job_instance.enqueue priority: 10
|
63
67
|
def enqueue(options={})
|
64
68
|
self.scheduled_at = options[:wait].seconds.from_now.to_f if options[:wait]
|
65
69
|
self.scheduled_at = options[:wait_until].to_f if options[:wait_until]
|
66
70
|
self.queue_name = self.class.queue_name_from_part(options[:queue]) if options[:queue]
|
71
|
+
self.priority = options[:priority].to_i if options[:priority]
|
67
72
|
run_callbacks :enqueue do
|
68
73
|
if self.scheduled_at
|
69
74
|
self.class.queue_adapter.enqueue_at self, self.scheduled_at
|
data/lib/active_job/logging.rb
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
require 'active_support/core_ext/hash/transform_values'
|
1
2
|
require 'active_support/core_ext/string/filters'
|
2
3
|
require 'active_support/tagged_logging'
|
3
4
|
require 'active_support/logger'
|
@@ -40,7 +41,7 @@ module ActiveJob
|
|
40
41
|
def tag_logger(*tags)
|
41
42
|
if logger.respond_to?(:tagged)
|
42
43
|
tags.unshift "ActiveJob" unless logger_tagged_by_active_job?
|
43
|
-
logger.tagged(*tags){ yield }
|
44
|
+
ActiveJob::Base.logger.tagged(*tags){ yield }
|
44
45
|
else
|
45
46
|
yield
|
46
47
|
end
|
@@ -81,18 +82,31 @@ module ActiveJob
|
|
81
82
|
|
82
83
|
private
|
83
84
|
def queue_name(event)
|
84
|
-
event.payload[:adapter].name.demodulize.remove('Adapter') + "(#{event.payload[:job].queue_name})"
|
85
|
+
event.payload[:adapter].class.name.demodulize.remove('Adapter') + "(#{event.payload[:job].queue_name})"
|
85
86
|
end
|
86
87
|
|
87
88
|
def args_info(job)
|
88
89
|
if job.arguments.any?
|
89
90
|
' with arguments: ' +
|
90
|
-
job.arguments.map { |arg| arg
|
91
|
+
job.arguments.map { |arg| format(arg).inspect }.join(', ')
|
91
92
|
else
|
92
93
|
''
|
93
94
|
end
|
94
95
|
end
|
95
96
|
|
97
|
+
def format(arg)
|
98
|
+
case arg
|
99
|
+
when Hash
|
100
|
+
arg.transform_values { |value| format(value) }
|
101
|
+
when Array
|
102
|
+
arg.map { |value| format(value) }
|
103
|
+
when GlobalID::Identification
|
104
|
+
arg.to_global_id rescue arg
|
105
|
+
else
|
106
|
+
arg
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
96
110
|
def scheduled_at(event)
|
97
111
|
Time.at(event.payload[:job].scheduled_at).utc
|
98
112
|
end
|
@@ -1,35 +1,63 @@
|
|
1
1
|
require 'active_job/queue_adapters/inline_adapter'
|
2
|
+
require 'active_support/core_ext/class/attribute'
|
2
3
|
require 'active_support/core_ext/string/inflections'
|
3
4
|
|
4
5
|
module ActiveJob
|
5
|
-
# The <tt>
|
6
|
-
# correct adapter. The default queue adapter is the
|
6
|
+
# The <tt>ActiveJob::QueueAdapter</tt> module is used to load the
|
7
|
+
# correct adapter. The default queue adapter is the +:inline+ queue.
|
7
8
|
module QueueAdapter #:nodoc:
|
8
9
|
extend ActiveSupport::Concern
|
9
10
|
|
11
|
+
included do
|
12
|
+
class_attribute :_queue_adapter, instance_accessor: false, instance_predicate: false
|
13
|
+
self.queue_adapter = :inline
|
14
|
+
end
|
15
|
+
|
10
16
|
# Includes the setter method for changing the active queue adapter.
|
11
17
|
module ClassMethods
|
12
|
-
|
18
|
+
# Returns the backend queue provider. The default queue adapter
|
19
|
+
# is the +:inline+ queue. See QueueAdapters for more information.
|
20
|
+
def queue_adapter
|
21
|
+
_queue_adapter
|
22
|
+
end
|
13
23
|
|
14
24
|
# Specify the backend queue provider. The default queue adapter
|
15
|
-
# is the
|
25
|
+
# is the +:inline+ queue. See QueueAdapters for more
|
16
26
|
# information.
|
17
|
-
def queue_adapter=(
|
18
|
-
|
19
|
-
case name_or_adapter
|
20
|
-
when :test
|
21
|
-
ActiveJob::QueueAdapters::TestAdapter.new
|
22
|
-
when Symbol, String
|
23
|
-
load_adapter(name_or_adapter)
|
24
|
-
else
|
25
|
-
name_or_adapter if name_or_adapter.respond_to?(:enqueue)
|
26
|
-
end
|
27
|
+
def queue_adapter=(name_or_adapter_or_class)
|
28
|
+
self._queue_adapter = interpret_adapter(name_or_adapter_or_class)
|
27
29
|
end
|
28
30
|
|
29
31
|
private
|
30
|
-
|
31
|
-
|
32
|
+
|
33
|
+
def interpret_adapter(name_or_adapter_or_class)
|
34
|
+
case name_or_adapter_or_class
|
35
|
+
when Symbol, String
|
36
|
+
ActiveJob::QueueAdapters.lookup(name_or_adapter_or_class).new
|
37
|
+
else
|
38
|
+
if queue_adapter?(name_or_adapter_or_class)
|
39
|
+
name_or_adapter_or_class
|
40
|
+
elsif queue_adapter_class?(name_or_adapter_or_class)
|
41
|
+
ActiveSupport::Deprecation.warn "Passing an adapter class is deprecated " \
|
42
|
+
"and will be removed in Rails 5.1. Please pass an adapter name " \
|
43
|
+
"(.queue_adapter = :#{name_or_adapter_or_class.name.demodulize.remove('Adapter').underscore}) " \
|
44
|
+
"or an instance (.queue_adapter = #{name_or_adapter_or_class.name}.new) instead."
|
45
|
+
name_or_adapter_or_class.new
|
46
|
+
else
|
47
|
+
raise ArgumentError
|
48
|
+
end
|
32
49
|
end
|
50
|
+
end
|
51
|
+
|
52
|
+
QUEUE_ADAPTER_METHODS = [:enqueue, :enqueue_at].freeze
|
53
|
+
|
54
|
+
def queue_adapter?(object)
|
55
|
+
QUEUE_ADAPTER_METHODS.all? { |meth| object.respond_to?(meth) }
|
56
|
+
end
|
57
|
+
|
58
|
+
def queue_adapter_class?(object)
|
59
|
+
object.is_a?(Class) && QUEUE_ADAPTER_METHODS.all? { |meth| object.public_method_defined?(meth) }
|
60
|
+
end
|
33
61
|
end
|
34
62
|
end
|
35
63
|
end
|
@@ -12,6 +12,8 @@ module ActiveJob
|
|
12
12
|
# * {Sidekiq}[http://sidekiq.org]
|
13
13
|
# * {Sneakers}[https://github.com/jondot/sneakers]
|
14
14
|
# * {Sucker Punch}[https://github.com/brandonhilkert/sucker_punch]
|
15
|
+
# * {Active Job Async Job}[http://api.rubyonrails.org/classes/ActiveJob/QueueAdapters/AsyncAdapter.html]
|
16
|
+
# * {Active Job Inline}[http://api.rubyonrails.org/classes/ActiveJob/QueueAdapters/InlineAdapter.html]
|
15
17
|
#
|
16
18
|
# === Backends Features
|
17
19
|
#
|
@@ -26,15 +28,86 @@ module ActiveJob
|
|
26
28
|
# | Sidekiq | Yes | Yes | Yes | Queue | No | Job |
|
27
29
|
# | Sneakers | Yes | Yes | No | Queue | Queue | No |
|
28
30
|
# | Sucker Punch | Yes | Yes | No | No | No | No |
|
31
|
+
# | Active Job Async | Yes | Yes | Yes | No | No | No |
|
29
32
|
# | Active Job Inline | No | Yes | N/A | N/A | N/A | N/A |
|
30
33
|
#
|
34
|
+
# ==== Async
|
35
|
+
#
|
36
|
+
# Yes: The Queue Adapter runs the jobs in a separate or forked process.
|
37
|
+
#
|
38
|
+
# No: The job is run in the same process.
|
39
|
+
#
|
40
|
+
# ==== Queues
|
41
|
+
#
|
42
|
+
# Yes: Jobs may set which queue they are run in with queue_as or by using the set
|
43
|
+
# method.
|
44
|
+
#
|
45
|
+
# ==== Delayed
|
46
|
+
#
|
47
|
+
# Yes: The adapter will run the job in the future through perform_later.
|
48
|
+
#
|
49
|
+
# (Gem): An additional gem is required to use perform_later with this adapter.
|
50
|
+
#
|
51
|
+
# No: The adapter will run jobs at the next opportunity and cannot use perform_later.
|
52
|
+
#
|
53
|
+
# N/A: The adapter does not support queueing.
|
54
|
+
#
|
31
55
|
# NOTE:
|
32
56
|
# queue_classic supports job scheduling since version 3.1.
|
33
57
|
# For older versions you can use the queue_classic-later gem.
|
34
58
|
#
|
59
|
+
# ==== Priorities
|
60
|
+
#
|
61
|
+
# The order in which jobs are processed can be configured differently depending
|
62
|
+
# on the adapter.
|
63
|
+
#
|
64
|
+
# Job: Any class inheriting from the adapter may set the priority on the job
|
65
|
+
# object relative to other jobs.
|
66
|
+
#
|
67
|
+
# Queue: The adapter can set the priority for job queues, when setting a queue
|
68
|
+
# with Active Job this will be respected.
|
69
|
+
#
|
70
|
+
# Yes: Allows the priority to be set on the job object, at the queue level or
|
71
|
+
# as default configuration option.
|
72
|
+
#
|
73
|
+
# No: Does not allow the priority of jobs to be configured.
|
74
|
+
#
|
75
|
+
# N/A: The adapter does not support queueing, and therefore sorting them.
|
76
|
+
#
|
77
|
+
# ==== Timeout
|
78
|
+
#
|
79
|
+
# When a job will stop after the allotted time.
|
80
|
+
#
|
81
|
+
# Job: The timeout can be set for each instance of the job class.
|
82
|
+
#
|
83
|
+
# Queue: The timeout is set for all jobs on the queue.
|
84
|
+
#
|
85
|
+
# Global: The adapter is configured that all jobs have a maximum run time.
|
86
|
+
#
|
87
|
+
# N/A: This adapter does not run in a separate process, and therefore timeout
|
88
|
+
# is unsupported.
|
89
|
+
#
|
90
|
+
# ==== Retries
|
91
|
+
#
|
92
|
+
# Job: The number of retries can be set per instance of the job class.
|
93
|
+
#
|
94
|
+
# Yes: The Number of retries can be configured globally, for each instance or
|
95
|
+
# on the queue. This adapter may also present failed instances of the job class
|
96
|
+
# that can be restarted.
|
97
|
+
#
|
98
|
+
# Global: The adapter has a global number of retries.
|
99
|
+
#
|
100
|
+
# N/A: The adapter does not run in a separate process, and therefore doesn't
|
101
|
+
# support retries.
|
102
|
+
#
|
103
|
+
# === Async and Inline Queue Adapters
|
104
|
+
#
|
105
|
+
# Active Job has two built-in queue adapters intended for development and
|
106
|
+
# testing: +:async+ and +:inline+.
|
35
107
|
module QueueAdapters
|
36
108
|
extend ActiveSupport::Autoload
|
37
109
|
|
110
|
+
autoload :AsyncAdapter
|
38
111
|
autoload :InlineAdapter
|
39
112
|
autoload :BackburnerAdapter
|
40
113
|
autoload :DelayedJobAdapter
|
@@ -46,5 +119,18 @@ module ActiveJob
|
|
46
119
|
autoload :SneakersAdapter
|
47
120
|
autoload :SuckerPunchAdapter
|
48
121
|
autoload :TestAdapter
|
122
|
+
|
123
|
+
ADAPTER = 'Adapter'.freeze
|
124
|
+
private_constant :ADAPTER
|
125
|
+
|
126
|
+
class << self
|
127
|
+
# Returns adapter for specified name.
|
128
|
+
#
|
129
|
+
# ActiveJob::QueueAdapters.lookup(:sidekiq)
|
130
|
+
# # => ActiveJob::QueueAdapters::SidekiqAdapter
|
131
|
+
def lookup(name)
|
132
|
+
const_get(name.to_s.camelize << ADAPTER)
|
133
|
+
end
|
134
|
+
end
|
49
135
|
end
|
50
136
|
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'active_job/async_job'
|
2
|
+
|
3
|
+
module ActiveJob
|
4
|
+
module QueueAdapters
|
5
|
+
# == Active Job Async adapter
|
6
|
+
#
|
7
|
+
# When enqueueing jobs with the Async adapter the job will be executed
|
8
|
+
# asynchronously using {AsyncJob}[http://api.rubyonrails.org/classes/ActiveJob/AsyncJob.html].
|
9
|
+
#
|
10
|
+
# To use +AsyncJob+ set the queue_adapter config to +:async+.
|
11
|
+
#
|
12
|
+
# Rails.application.config.active_job.queue_adapter = :async
|
13
|
+
class AsyncAdapter
|
14
|
+
def enqueue(job) #:nodoc:
|
15
|
+
ActiveJob::AsyncJob.enqueue(job.serialize, queue: job.queue_name)
|
16
|
+
end
|
17
|
+
|
18
|
+
def enqueue_at(job, timestamp) #:nodoc:
|
19
|
+
ActiveJob::AsyncJob.enqueue_at(job.serialize, timestamp, queue: job.queue_name)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|