rflow 0.0.5 → 1.0.0a1
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 +4 -4
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +21 -0
- data/.yardopts +1 -0
- data/Gemfile +5 -1
- data/Guardfile +8 -0
- data/LICENSE +190 -0
- data/NOTES +26 -13
- data/README.md +448 -0
- data/Rakefile +5 -12
- data/bin/rflow +23 -20
- data/example/basic_config.rb +2 -2
- data/example/basic_extensions.rb +8 -8
- data/example/http_config.rb +1 -1
- data/example/http_extensions.rb +15 -15
- data/lib/rflow.rb +15 -387
- data/lib/rflow/component.rb +105 -50
- data/lib/rflow/component/port.rb +25 -24
- data/lib/rflow/components/raw.rb +4 -4
- data/lib/rflow/components/raw/extensions.rb +2 -2
- data/lib/rflow/configuration.rb +54 -36
- data/lib/rflow/configuration/component.rb +2 -3
- data/lib/rflow/configuration/connection.rb +9 -10
- data/lib/rflow/configuration/migrations/{20010101000001_create_settings.rb → 20010101000000_create_settings.rb} +2 -2
- data/lib/rflow/configuration/migrations/20010101000001_create_shards.rb +21 -0
- data/lib/rflow/configuration/migrations/20010101000002_create_components.rb +7 -2
- data/lib/rflow/configuration/migrations/20010101000003_create_ports.rb +3 -3
- data/lib/rflow/configuration/migrations/20010101000004_create_connections.rb +2 -2
- data/lib/rflow/configuration/port.rb +3 -4
- data/lib/rflow/configuration/ruby_dsl.rb +59 -35
- data/lib/rflow/configuration/setting.rb +8 -7
- data/lib/rflow/configuration/shard.rb +24 -0
- data/lib/rflow/configuration/uuid_keyed.rb +3 -3
- data/lib/rflow/connection.rb +21 -10
- data/lib/rflow/connections/zmq_connection.rb +45 -44
- data/lib/rflow/logger.rb +67 -0
- data/lib/rflow/master.rb +127 -0
- data/lib/rflow/message.rb +14 -14
- data/lib/rflow/pid_file.rb +84 -0
- data/lib/rflow/shard.rb +148 -0
- data/lib/rflow/version.rb +1 -1
- data/rflow.gemspec +22 -28
- data/schema/message.avsc +8 -8
- data/spec/fixtures/config_ints.rb +4 -4
- data/spec/fixtures/config_shards.rb +30 -0
- data/spec/fixtures/extensions_ints.rb +8 -8
- data/spec/rflow_component_port_spec.rb +58 -0
- data/spec/rflow_configuration_ruby_dsl_spec.rb +148 -0
- data/spec/rflow_configuration_spec.rb +4 -4
- data/spec/rflow_message_data_raw.rb +2 -2
- data/spec/rflow_message_data_spec.rb +6 -6
- data/spec/rflow_message_spec.rb +13 -13
- data/spec/rflow_spec.rb +294 -71
- data/spec/schema_spec.rb +2 -2
- data/spec/spec_helper.rb +6 -4
- data/temp.rb +21 -21
- metadata +56 -65
- data/.rvmrc +0 -1
- data/README +0 -0
@@ -6,12 +6,13 @@ class RFlow
|
|
6
6
|
class Component < ConfigDB
|
7
7
|
include UUIDKeyed
|
8
8
|
include ActiveModel::Validations
|
9
|
-
|
9
|
+
|
10
10
|
class ComponentInvalid < StandardError; end
|
11
11
|
class ComponentNotFound < StandardError; end
|
12
12
|
|
13
13
|
serialize :options, Hash
|
14
14
|
|
15
|
+
belongs_to :shard, :primary_key => 'uuid', :foreign_key => 'shard_uuid'
|
15
16
|
has_many :input_ports, :primary_key => 'uuid', :foreign_key => 'component_uuid'
|
16
17
|
has_many :output_ports, :primary_key => 'uuid', :foreign_key => 'component_uuid'
|
17
18
|
|
@@ -19,9 +20,7 @@ class RFlow
|
|
19
20
|
#has_many :input_connections, :through => :input_ports, :source => :input_connections
|
20
21
|
#has_many :output_connections, :through => :output_ports, :source => :output_connection
|
21
22
|
|
22
|
-
|
23
23
|
validates_uniqueness_of :name
|
24
|
-
|
25
24
|
end
|
26
25
|
end
|
27
26
|
end
|
@@ -10,18 +10,18 @@ class RFlow
|
|
10
10
|
include ActiveModel::Validations
|
11
11
|
|
12
12
|
serialize :options, Hash
|
13
|
-
|
13
|
+
|
14
14
|
belongs_to :input_port, :primary_key => 'uuid', :foreign_key => 'input_port_uuid'
|
15
15
|
belongs_to :output_port,:primary_key => 'uuid', :foreign_key => 'output_port_uuid'
|
16
16
|
|
17
17
|
before_create :merge_default_options!
|
18
|
-
|
18
|
+
|
19
19
|
validates_uniqueness_of :uuid
|
20
20
|
validates_presence_of :output_port_uuid, :input_port_uuid
|
21
21
|
|
22
22
|
validate :all_required_options_present
|
23
23
|
|
24
|
-
|
24
|
+
|
25
25
|
def all_required_options_present
|
26
26
|
self.class.required_options.each do |option_name|
|
27
27
|
unless self.options.include? option_name.to_s
|
@@ -30,7 +30,7 @@ class RFlow
|
|
30
30
|
end
|
31
31
|
end
|
32
32
|
|
33
|
-
|
33
|
+
|
34
34
|
def merge_default_options!
|
35
35
|
self.options ||= {}
|
36
36
|
self.class.default_options.each do |option_name, default_value_or_proc|
|
@@ -43,7 +43,7 @@ class RFlow
|
|
43
43
|
# used in validations. To be overridden.
|
44
44
|
def self.required_options; []; end
|
45
45
|
|
46
|
-
|
46
|
+
|
47
47
|
# Should return a hash of default options, where the keys are
|
48
48
|
# the option names and the values are either default option
|
49
49
|
# values or Procs that take a single connection argument. This
|
@@ -53,7 +53,7 @@ class RFlow
|
|
53
53
|
|
54
54
|
end
|
55
55
|
|
56
|
-
|
56
|
+
|
57
57
|
# STI Subclass for ZMQ connections and their required options
|
58
58
|
class ZMQConnection < Connection
|
59
59
|
|
@@ -61,15 +61,15 @@ class RFlow
|
|
61
61
|
{
|
62
62
|
'output_socket_type' => 'PUSH',
|
63
63
|
'output_address' => lambda{|conn| "ipc://rflow.#{conn.uuid}"},
|
64
|
-
'output_responsibility' => '
|
64
|
+
'output_responsibility' => 'connect',
|
65
65
|
'input_socket_type' => 'PULL',
|
66
66
|
'input_address' => lambda{|conn| "ipc://rflow.#{conn.uuid}"},
|
67
|
-
'input_responsibility' => '
|
67
|
+
'input_responsibility' => 'bind',
|
68
68
|
}
|
69
69
|
end
|
70
70
|
end
|
71
71
|
|
72
|
-
|
72
|
+
|
73
73
|
# STI Subclass for AMQP connections and their required options
|
74
74
|
class AMQPConnection < Connection
|
75
75
|
|
@@ -95,4 +95,3 @@ class RFlow
|
|
95
95
|
end
|
96
96
|
end
|
97
97
|
end
|
98
|
-
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class CreateShards < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table(:shards, :id => false) do |t|
|
4
|
+
t.string :uuid, :limit => 36, :primary => true
|
5
|
+
t.string :name
|
6
|
+
t.integer :count
|
7
|
+
|
8
|
+
# STI
|
9
|
+
t.string :type
|
10
|
+
|
11
|
+
t.timestamps
|
12
|
+
end
|
13
|
+
|
14
|
+
add_index :shards, :uuid, :unique => true
|
15
|
+
add_index :shards, :name, :unique => true
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.down
|
19
|
+
drop_table :shards
|
20
|
+
end
|
21
|
+
end
|
@@ -6,13 +6,18 @@ class CreateComponents < ActiveRecord::Migration
|
|
6
6
|
t.boolean :managed, :default => true
|
7
7
|
t.text :specification
|
8
8
|
t.text :options
|
9
|
-
|
9
|
+
|
10
|
+
# UUID version of belongs_to :shard
|
11
|
+
t.string :shard_uuid
|
12
|
+
|
10
13
|
t.timestamps
|
11
14
|
end
|
12
15
|
|
13
16
|
add_index :components, :uuid, :unique => true
|
17
|
+
add_index :components, :name, :unique => true
|
18
|
+
add_index :components, :shard_uuid
|
14
19
|
end
|
15
|
-
|
20
|
+
|
16
21
|
def self.down
|
17
22
|
drop_table :components
|
18
23
|
end
|
@@ -7,9 +7,9 @@ class CreatePorts < ActiveRecord::Migration
|
|
7
7
|
# For STI
|
8
8
|
t.text :type
|
9
9
|
|
10
|
-
# UUID version of belongs_to :component
|
10
|
+
# UUID version of belongs_to :component
|
11
11
|
t.string :component_uuid
|
12
|
-
|
12
|
+
|
13
13
|
t.timestamps
|
14
14
|
end
|
15
15
|
|
@@ -17,7 +17,7 @@ class CreatePorts < ActiveRecord::Migration
|
|
17
17
|
add_index :ports, :component_uuid
|
18
18
|
add_index :ports, [:component_uuid, :name], :unique => true
|
19
19
|
end
|
20
|
-
|
20
|
+
|
21
21
|
def self.down
|
22
22
|
drop_table :ports
|
23
23
|
end
|
@@ -14,13 +14,13 @@ class CreateConnections < ActiveRecord::Migration
|
|
14
14
|
t.string :input_port_key, :default => '0'
|
15
15
|
|
16
16
|
t.text :options
|
17
|
-
|
17
|
+
|
18
18
|
t.timestamps
|
19
19
|
end
|
20
20
|
|
21
21
|
add_index :connections, :uuid, :unique => true
|
22
22
|
end
|
23
|
-
|
23
|
+
|
24
24
|
def self.down
|
25
25
|
drop_table :connections
|
26
26
|
end
|
@@ -6,7 +6,7 @@ class RFlow
|
|
6
6
|
class Port < ConfigDB
|
7
7
|
include UUIDKeyed
|
8
8
|
include ActiveModel::Validations
|
9
|
-
|
9
|
+
|
10
10
|
class PortInvalid < StandardError; end
|
11
11
|
|
12
12
|
belongs_to :component, :primary_key => 'uuid', :foreign_key => 'component_uuid'
|
@@ -18,13 +18,12 @@ class RFlow
|
|
18
18
|
# STI-based classes
|
19
19
|
class InputPort < Port;
|
20
20
|
has_many :input_connections, :class_name => 'RFlow::Configuration::Connection', :primary_key => 'uuid', :foreign_key => 'input_port_uuid'
|
21
|
-
has_many :
|
21
|
+
has_many :connections, :class_name => 'RFlow::Configuration::Connection', :primary_key => 'uuid', :foreign_key => 'input_port_uuid'
|
22
22
|
end
|
23
23
|
|
24
24
|
class OutputPort < Port;
|
25
25
|
has_many :output_connections, :class_name => 'RFlow::Configuration::Connection', :primary_key => 'uuid', :foreign_key => 'output_port_uuid'
|
26
|
-
has_many :
|
26
|
+
has_many :connections, :class_name => 'RFlow::Configuration::Connection', :primary_key => 'uuid', :foreign_key => 'output_port_uuid'
|
27
27
|
end
|
28
28
|
end
|
29
29
|
end
|
30
|
-
|
@@ -6,12 +6,14 @@ class RFlow
|
|
6
6
|
# Ruby DSL config file controller.
|
7
7
|
# TODO: more docs and examples
|
8
8
|
class RubyDSL
|
9
|
-
attr_accessor :setting_specs, :
|
10
|
-
|
9
|
+
attr_accessor :setting_specs, :shard_specs, :connection_specs, :allocated_system_ports
|
10
|
+
|
11
11
|
def initialize
|
12
12
|
@setting_specs = []
|
13
|
-
@
|
13
|
+
@shard_specs = [{:name => "DEFAULT", :type => :process, :count => 1, :components => []}]
|
14
14
|
@connection_specs = []
|
15
|
+
|
16
|
+
@current_shard = @shard_specs.first
|
15
17
|
end
|
16
18
|
|
17
19
|
# Helper function to extract the line of the config that
|
@@ -19,7 +21,7 @@ class RFlow
|
|
19
21
|
def get_config_line(call_history)
|
20
22
|
call_history.first.split(':in').first
|
21
23
|
end
|
22
|
-
|
24
|
+
|
23
25
|
# DSL method to specify a name/value pair. RFlow core uses the
|
24
26
|
# 'rflow.' prefix on all of its settings. Custom settings
|
25
27
|
# should use a custom (unique) prefix
|
@@ -27,11 +29,28 @@ class RFlow
|
|
27
29
|
setting_specs << {:name => setting_name.to_s, :value => setting_value.to_s, :config_line => get_config_line(caller)}
|
28
30
|
end
|
29
31
|
|
32
|
+
# DSL method to specify a shard block for either a process or thread
|
33
|
+
def shard(shard_name, shard_options={})
|
34
|
+
raise ArgumentError, "Cannot use DEFAULT as a shard name" if shard_name == 'DEFAULT'
|
35
|
+
shard_type = if shard_options[:thread] || shard_options[:type] == :thread
|
36
|
+
:thread
|
37
|
+
else
|
38
|
+
:process
|
39
|
+
end
|
40
|
+
|
41
|
+
shard_count = shard_options[shard_type] || shard_options[:count] || 1
|
42
|
+
|
43
|
+
@current_shard = {:name => shard_name, :type => shard_type, :count => shard_count, :components => [], :config_line => get_config_line(caller)}
|
44
|
+
@shard_specs << @current_shard
|
45
|
+
yield self
|
46
|
+
@current_shard = @shard_specs.first
|
47
|
+
end
|
48
|
+
|
30
49
|
# DSL method to specify a component. Expects a name,
|
31
50
|
# specification, and set of component specific options, that
|
32
51
|
# must be marshallable into the database (i.e. should all be strings)
|
33
52
|
def component(component_name, component_specification, component_options={})
|
34
|
-
|
53
|
+
@current_shard[:components] << {
|
35
54
|
:name => component_name,
|
36
55
|
:specification => component_specification.to_s, :options => component_options,
|
37
56
|
:config_line => get_config_line(caller)
|
@@ -55,9 +74,9 @@ class RFlow
|
|
55
74
|
input_component_name, input_port_name, input_port_key = parse_connection_string(input_string)
|
56
75
|
|
57
76
|
connection_specs << {
|
58
|
-
:name => output_string + '=>' + input_string,
|
77
|
+
:name => output_string + '=>' + input_string,
|
59
78
|
:output_component_name => output_component_name,
|
60
|
-
:output_port_name => output_port_name, :output_port_key => output_port_key,
|
79
|
+
:output_port_name => output_port_name, :output_port_key => output_port_key,
|
61
80
|
:output_string => output_string,
|
62
81
|
:input_component_name => input_component_name,
|
63
82
|
:input_port_name => input_port_name, :input_port_key => input_port_key,
|
@@ -76,16 +95,16 @@ class RFlow
|
|
76
95
|
[matched[1], matched[2], (matched[3] || nil)]
|
77
96
|
end
|
78
97
|
|
79
|
-
|
98
|
+
|
80
99
|
# Method to process the 'DSL' objects into the config database
|
81
100
|
# via ActiveRecord
|
82
101
|
def process
|
83
102
|
process_setting_specs
|
84
|
-
|
103
|
+
process_shard_specs
|
85
104
|
process_connection_specs
|
86
105
|
end
|
87
106
|
|
88
|
-
|
107
|
+
|
89
108
|
# Iterates through each setting specified in the DSL and
|
90
109
|
# creates rows in the database corresponding to the setting
|
91
110
|
def process_setting_specs
|
@@ -95,17 +114,36 @@ class RFlow
|
|
95
114
|
end
|
96
115
|
end
|
97
116
|
|
98
|
-
|
99
|
-
# Iterates through each
|
100
|
-
#
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
RFlow
|
117
|
+
|
118
|
+
# Iterates through each shard specified in the DSL and creates
|
119
|
+
# rows in the database corresponding to the shard and included
|
120
|
+
# components
|
121
|
+
def process_shard_specs
|
122
|
+
@shard_specs.each do |shard_spec|
|
123
|
+
RFlow.logger.debug "Found #{shard_spec[:type]} shard '#{shard_spec[:name]}', creating"
|
124
|
+
|
125
|
+
shard_class = case shard_spec[:type]
|
126
|
+
when :process
|
127
|
+
RFlow::Configuration::ProcessShard
|
128
|
+
when :thread
|
129
|
+
RFlow::Configuration::ThreadShard
|
130
|
+
else
|
131
|
+
raise RFlow::Configuration::Shard::ShardInvalid, "Invalid shard: #{shard_spec.inspect}"
|
132
|
+
end
|
133
|
+
|
134
|
+
shard = shard_class.create! :name => shard_spec[:name], :count => shard_spec[:count]
|
135
|
+
|
136
|
+
shard_spec[:components].each do |component_spec|
|
137
|
+
RFlow.logger.debug "Shard '#{shard_spec[:name]}' found component '#{component_spec[:name]}', creating"
|
138
|
+
RFlow::Configuration::Component.create!(:shard => shard,
|
139
|
+
:name => component_spec[:name],
|
140
|
+
:specification => component_spec[:specification],
|
141
|
+
:options => component_spec[:options])
|
142
|
+
end
|
105
143
|
end
|
106
144
|
end
|
107
145
|
|
108
|
-
|
146
|
+
|
109
147
|
# Iterates through each component specified in the DSL and uses
|
110
148
|
# 'process_connection' to insert all the parts of the connection
|
111
149
|
# into the database
|
@@ -121,36 +159,22 @@ class RFlow
|
|
121
159
|
# ZeroMQ ipc sockets
|
122
160
|
def process_connection_spec(connection_spec)
|
123
161
|
RFlow.logger.debug "Found connection from '#{connection_spec[:output_string]}' to '#{connection_spec[:input_string]}', creating"
|
124
|
-
|
162
|
+
|
125
163
|
# an input port can be associated with multiple outputs, but
|
126
164
|
# an output port can only be associated with one input
|
127
165
|
output_component = RFlow::Configuration::Component.find_by_name connection_spec[:output_component_name]
|
128
166
|
raise RFlow::Configuration::Component::ComponentNotFound, "#{connection_spec[:output_component_name]}" unless output_component
|
129
167
|
output_port = output_component.output_ports.find_or_initialize_by_name :name => connection_spec[:output_port_name]
|
130
168
|
output_port.save!
|
131
|
-
|
169
|
+
|
132
170
|
input_component = RFlow::Configuration::Component.find_by_name connection_spec[:input_component_name]
|
133
171
|
raise RFlow::Configuration::Component::ComponentNotFound, "#{connection_spec[:input_component_name]}" unless input_component
|
134
172
|
input_port = input_component.input_ports.find_or_initialize_by_name :name => connection_spec[:input_port_name]
|
135
173
|
input_port.save!
|
136
174
|
|
137
|
-
# Create a unique ZMQ address
|
138
|
-
# zmq_address = "ipc://run/rflow.#{output_component.uuid}.#{output_port.uuid}"
|
139
|
-
# if connection_spec[:output_port_key]
|
140
|
-
# zmq_address << ".#{connection_spec[:output_port_key].gsub(/[^\w]/, '').downcase}"
|
141
|
-
# end
|
142
|
-
|
143
175
|
connection = RFlow::Configuration::ZMQConnection.new(:name => connection_spec[:name],
|
144
176
|
:output_port_key => connection_spec[:output_port_key],
|
145
177
|
:input_port_key => connection_spec[:input_port_key])
|
146
|
-
# :options => {
|
147
|
-
# 'output_socket_type' => "PUSH",
|
148
|
-
# 'output_address' => zmq_address,
|
149
|
-
# 'output_responsibility' => "bind",
|
150
|
-
# 'input_socket_type' => "PULL",
|
151
|
-
# 'input_address' => zmq_address,
|
152
|
-
# 'input_responsibility' => "connect",
|
153
|
-
# })
|
154
178
|
|
155
179
|
connection.output_port = output_port
|
156
180
|
connection.input_port = input_port
|
@@ -170,7 +194,7 @@ class RFlow
|
|
170
194
|
raise RFlow::Configuration::Connection::ConnectionInvalid, error_message
|
171
195
|
end
|
172
196
|
|
173
|
-
|
197
|
+
|
174
198
|
# Method called within the config file itself
|
175
199
|
def self.configure
|
176
200
|
config_file = self.new
|
@@ -8,19 +8,20 @@ class RFlow
|
|
8
8
|
|
9
9
|
include ActiveModel::Validations
|
10
10
|
|
11
|
-
|
11
|
+
self.primary_key = 'name'
|
12
|
+
|
12
13
|
attr_accessible :name, :value
|
13
|
-
|
14
|
+
|
14
15
|
DEFAULTS = {
|
15
16
|
'rflow.application_name' => 'rflow',
|
16
|
-
|
17
|
+
|
17
18
|
'rflow.application_directory_path' => '.',
|
18
19
|
'rflow.pid_directory_path' => 'run', #lambda {File.join(Setting['rflow.application_directory_path'], 'run')},
|
19
20
|
'rflow.log_directory_path' => 'log', #lambda {File.join(Setting['rflow.application_directory_path'], 'log')},
|
20
21
|
|
21
22
|
'rflow.log_file_path' => lambda {File.join(Setting['rflow.log_directory_path'], Setting['rflow.application_name'] + '.log')},
|
22
23
|
'rflow.pid_file_path' => lambda {File.join(Setting['rflow.pid_directory_path'], Setting['rflow.application_name'] + '.pid')},
|
23
|
-
|
24
|
+
|
24
25
|
'rflow.log_level' => 'INFO',
|
25
26
|
}
|
26
27
|
|
@@ -41,7 +42,7 @@ class RFlow
|
|
41
42
|
#validate :valid_writable_path, :if => :directory_path?
|
42
43
|
|
43
44
|
# TODO: Think about making this a regex check to pull in other,
|
44
|
-
# externally-defined settings
|
45
|
+
# externally-defined settings
|
45
46
|
def directory_path?
|
46
47
|
DIRECTORY_PATHS.include? self.name
|
47
48
|
end
|
@@ -51,7 +52,7 @@ class RFlow
|
|
51
52
|
errors.add :value, "setting '#{self.name}' is not a directory ('#{File.expand_path self.value}')"
|
52
53
|
end
|
53
54
|
end
|
54
|
-
|
55
|
+
|
55
56
|
def valid_writable_path
|
56
57
|
unless File.writable? self.value
|
57
58
|
errors.add :value, "setting '#{self.name}' is not writable ('#{File.expand_path self.value}')"
|
@@ -61,7 +62,7 @@ class RFlow
|
|
61
62
|
def self.[](setting_name)
|
62
63
|
Setting.find(setting_name).value rescue nil
|
63
64
|
end
|
64
|
-
|
65
|
+
|
65
66
|
end
|
66
67
|
end
|
67
68
|
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'rflow/configuration/uuid_keyed'
|
3
|
+
|
4
|
+
class RFlow
|
5
|
+
class Configuration
|
6
|
+
|
7
|
+
class Shard < ConfigDB
|
8
|
+
include UUIDKeyed
|
9
|
+
include ActiveModel::Validations
|
10
|
+
|
11
|
+
class ShardInvalid < StandardError; end
|
12
|
+
|
13
|
+
has_many :components, :primary_key => 'uuid', :foreign_key => 'shard_uuid'
|
14
|
+
|
15
|
+
validates_presence_of :name
|
16
|
+
validates_uniqueness_of :name
|
17
|
+
validates_numericality_of :count, :only_integer => true, :greater_than => 0
|
18
|
+
end
|
19
|
+
|
20
|
+
# STI-based classes
|
21
|
+
class ProcessShard < Shard; end
|
22
|
+
class ThreadShard < Shard; end
|
23
|
+
end
|
24
|
+
end
|