elecnix-workling 0.4.2 → 0.4.9
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.
- data/CHANGES.markdown +10 -0
- data/README.markdown +161 -4
- data/bin/workling_client +28 -0
- metadata +3 -75
- data/lib/rude_q/client.rb +0 -11
- data/lib/workling.rb +0 -150
- data/lib/workling/base.rb +0 -71
- data/lib/workling/clients/amqp_client.rb +0 -56
- data/lib/workling/clients/base.rb +0 -57
- data/lib/workling/clients/memcache_queue_client.rb +0 -83
- data/lib/workling/discovery.rb +0 -14
- data/lib/workling/remote.rb +0 -42
- data/lib/workling/remote/invokers/base.rb +0 -124
- data/lib/workling/remote/invokers/basic_poller.rb +0 -41
- data/lib/workling/remote/invokers/eventmachine_subscriber.rb +0 -41
- data/lib/workling/remote/invokers/threaded_poller.rb +0 -140
- data/lib/workling/remote/runners/backgroundjob_runner.rb +0 -35
- data/lib/workling/remote/runners/base.rb +0 -42
- data/lib/workling/remote/runners/client_runner.rb +0 -45
- data/lib/workling/remote/runners/not_remote_runner.rb +0 -23
- data/lib/workling/remote/runners/rudeq_runner.rb +0 -23
- data/lib/workling/remote/runners/spawn_runner.rb +0 -38
- data/lib/workling/remote/runners/starling_runner.rb +0 -13
- data/lib/workling/return/store/base.rb +0 -42
- data/lib/workling/return/store/iterator.rb +0 -24
- data/lib/workling/return/store/memory_return_store.rb +0 -26
- data/lib/workling/return/store/rudeq_return_store.rb +0 -24
- data/lib/workling/return/store/starling_return_store.rb +0 -31
- data/lib/workling/routing/base.rb +0 -13
- data/lib/workling/routing/class_and_method_routing.rb +0 -55
- data/lib/workling/rudeq.rb +0 -7
- data/lib/workling/rudeq/client.rb +0 -17
- data/lib/workling/rudeq/poller.rb +0 -116
- data/test/class_and_method_routing_test.rb +0 -18
- data/test/clients/memory_queue_client.rb +0 -36
- data/test/discovery_test.rb +0 -13
- data/test/invoker_basic_poller_test.rb +0 -29
- data/test/invoker_eventmachine_subscription_test.rb +0 -26
- data/test/invoker_threaded_poller_test.rb +0 -34
- data/test/memcachequeue_client_test.rb +0 -36
- data/test/memory_return_store_test.rb +0 -32
- data/test/mocks/client.rb +0 -9
- data/test/mocks/logger.rb +0 -5
- data/test/mocks/rude_queue.rb +0 -9
- data/test/mocks/spawn.rb +0 -5
- data/test/not_remote_runner_test.rb +0 -11
- data/test/remote_runner_test.rb +0 -58
- data/test/rescue_test.rb +0 -24
- data/test/return_store_test.rb +0 -24
- data/test/rudeq_client_test.rb +0 -30
- data/test/rudeq_poller_test.rb +0 -14
- data/test/rudeq_return_store_test.rb +0 -20
- data/test/rudeq_runner_test.rb +0 -22
- data/test/runners/thread_runner.rb +0 -22
- data/test/spawn_runner_test.rb +0 -10
- data/test/starling_return_store_test.rb +0 -29
- data/test/starling_runner_test.rb +0 -8
- data/test/test_helper.rb +0 -50
- data/test/workers/analytics/invites.rb +0 -10
- data/test/workers/util.rb +0 -25
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
#
|
|
2
|
-
# Basic interface for getting and setting Data which needs to be passed between Workers and
|
|
3
|
-
# client code.
|
|
4
|
-
#
|
|
5
|
-
module Workling
|
|
6
|
-
module Return
|
|
7
|
-
module Store
|
|
8
|
-
mattr_accessor :instance
|
|
9
|
-
|
|
10
|
-
# set a value in the store with the given key. delegates to the returnstore.
|
|
11
|
-
def self.set(key, value)
|
|
12
|
-
self.instance.set(key, value)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
# get a value from the store. this should be destructive. delegates to the returnstore.
|
|
16
|
-
def self.get(key)
|
|
17
|
-
self.instance.get(key)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
#
|
|
21
|
-
# Base Class for Return Stores. Subclasses need to implement set and get.
|
|
22
|
-
#
|
|
23
|
-
class Base
|
|
24
|
-
|
|
25
|
-
# set a value in the store with the given key.
|
|
26
|
-
def set(key, value)
|
|
27
|
-
raise NotImplementedError.new("set(key, value) not implemented in #{ self.class }")
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# get a value from the store. this should be destructive.
|
|
31
|
-
def get(key)
|
|
32
|
-
raise NotImplementedError.new("get(key) not implemented in #{ self.class }")
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def iterator(key)
|
|
36
|
-
Workling::Return::Store::Iterator.new(key)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
#
|
|
2
|
-
# Iterator class for iterating over return values.
|
|
3
|
-
#
|
|
4
|
-
module Workling
|
|
5
|
-
module Return
|
|
6
|
-
module Store
|
|
7
|
-
class Iterator
|
|
8
|
-
|
|
9
|
-
include Enumerable
|
|
10
|
-
|
|
11
|
-
def initialize(uid)
|
|
12
|
-
@uid = uid
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def each
|
|
16
|
-
while item = Workling.return.get(@uid)
|
|
17
|
-
yield item
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
require 'workling/return/store/base'
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
# Stores directly into memory. This is for tests only - not for production use. aight?
|
|
5
|
-
#
|
|
6
|
-
module Workling
|
|
7
|
-
module Return
|
|
8
|
-
module Store
|
|
9
|
-
class MemoryReturnStore < Base
|
|
10
|
-
attr_accessor :sky
|
|
11
|
-
|
|
12
|
-
def initialize
|
|
13
|
-
self.sky = Hash.new([])
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def set(key, value)
|
|
17
|
-
self.sky[key] << value
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def get(key)
|
|
21
|
-
self.sky[key].shift
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
require 'workling/return/store/base'
|
|
2
|
-
require 'workling/rudeq/client'
|
|
3
|
-
|
|
4
|
-
module Workling
|
|
5
|
-
module Return
|
|
6
|
-
module Store
|
|
7
|
-
class RudeqReturnStore < Base
|
|
8
|
-
cattr_accessor :client
|
|
9
|
-
|
|
10
|
-
def initialize
|
|
11
|
-
self.class.client = Workling::Rudeq::Client.new
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def set(key, value)
|
|
15
|
-
self.class.client.set(key, value)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def get(key)
|
|
19
|
-
self.class.client.get(key)
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
require 'workling/return/store/base'
|
|
2
|
-
require 'workling/clients/memcache_queue_client'
|
|
3
|
-
|
|
4
|
-
#
|
|
5
|
-
# Recommended Return Store if you are using the Starling Runner. This
|
|
6
|
-
# Simply sets and gets values against queues. 'key' is the name of the respective Queue.
|
|
7
|
-
#
|
|
8
|
-
module Workling
|
|
9
|
-
module Return
|
|
10
|
-
module Store
|
|
11
|
-
class StarlingReturnStore < Base
|
|
12
|
-
cattr_accessor :client
|
|
13
|
-
|
|
14
|
-
def initialize
|
|
15
|
-
self.client = Workling::Clients::MemcacheQueueClient.new
|
|
16
|
-
self.client.connect
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# set a value in the queue 'key'.
|
|
20
|
-
def set(key, value)
|
|
21
|
-
self.class.client.set(key, value)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
# get a value from starling queue 'key'.
|
|
25
|
-
def get(key)
|
|
26
|
-
self.class.client.get(key)
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
#
|
|
2
|
-
# Base Class for Routing. Routing takes the worker method TestWorker#something,
|
|
3
|
-
# and serializes the signature in some way.
|
|
4
|
-
#
|
|
5
|
-
module Workling
|
|
6
|
-
module Routing
|
|
7
|
-
class Base < Hash
|
|
8
|
-
def method_name
|
|
9
|
-
raise Exception.new("method_name not implemented.")
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
end
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
require 'workling/routing/base'
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
# Holds a hash of routes. Each Worker method has a corresponding hash entry after building.
|
|
5
|
-
#
|
|
6
|
-
module Workling
|
|
7
|
-
module Routing
|
|
8
|
-
class ClassAndMethodRouting < Base
|
|
9
|
-
|
|
10
|
-
# initializes and builds routing hash.
|
|
11
|
-
def initialize
|
|
12
|
-
super
|
|
13
|
-
|
|
14
|
-
build
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# returns the worker method name, given the routing string.
|
|
18
|
-
def method_name(queue)
|
|
19
|
-
queue.split("__").last
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# returns the routing string, given a class and method. delegating.
|
|
23
|
-
def queue_for(clazz, method)
|
|
24
|
-
ClassAndMethodRouting.queue_for(clazz, method)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# returns the routing string, given a class and method.
|
|
28
|
-
def self.queue_for(clazz, method)
|
|
29
|
-
"#{ clazz.to_s.tableize }/#{ method }".split("/").join("__") # Don't split with : because it messes up memcache stats
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# returns all routed
|
|
33
|
-
def queue_names
|
|
34
|
-
self.keys
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# dare you to remove this! go on!
|
|
38
|
-
def queue_names_routing_class(clazz)
|
|
39
|
-
self.select { |x, y| y.is_a?(clazz) }.map { |x, y| x }
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
def build
|
|
44
|
-
Workling::Discovery.discovered.each do |clazz|
|
|
45
|
-
methods = clazz.instance_methods(false)
|
|
46
|
-
methods.each do |method|
|
|
47
|
-
next if method == 'create' # Skip the create method
|
|
48
|
-
queue = queue_for(clazz, method)
|
|
49
|
-
self[queue] = clazz.new
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
data/lib/workling/rudeq.rb
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
require 'workling/rudeq'
|
|
2
|
-
|
|
3
|
-
module Workling
|
|
4
|
-
module Rudeq
|
|
5
|
-
class Client
|
|
6
|
-
attr_reader :queue
|
|
7
|
-
|
|
8
|
-
def initialize
|
|
9
|
-
@queue = Workling::Rudeq.config[:queue_class].constantize
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def method_missing(method, *args)
|
|
13
|
-
@queue.send(method, *args)
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
require 'workling/rudeq'
|
|
2
|
-
|
|
3
|
-
module Workling
|
|
4
|
-
module Rudeq
|
|
5
|
-
|
|
6
|
-
class Poller
|
|
7
|
-
|
|
8
|
-
cattr_accessor :sleep_time # Seconds to sleep before looping
|
|
9
|
-
cattr_accessor :reset_time # Seconds to wait while resetting connection
|
|
10
|
-
|
|
11
|
-
def initialize(routing)
|
|
12
|
-
Poller.sleep_time = Workling::Rudeq.config[:sleep_time] || 2
|
|
13
|
-
Poller.reset_time = Workling::Rudeq.config[:reset_time] || 30
|
|
14
|
-
|
|
15
|
-
@routing = routing
|
|
16
|
-
@workers = ThreadGroup.new
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def logger
|
|
20
|
-
Workling::Base.logger
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def listen
|
|
24
|
-
|
|
25
|
-
# Allow concurrency for our tasks
|
|
26
|
-
ActiveRecord::Base.allow_concurrency = true
|
|
27
|
-
|
|
28
|
-
# Create a thread for each worker.
|
|
29
|
-
Workling::Discovery.discovered.each do |clazz|
|
|
30
|
-
logger.debug("Discovered listener #{clazz}")
|
|
31
|
-
@workers.add(Thread.new(clazz) { |c| clazz_listen(c) })
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Wait for all workers to complete
|
|
35
|
-
@workers.list.each { |t| t.join }
|
|
36
|
-
|
|
37
|
-
# Clean up all the connections.
|
|
38
|
-
ActiveRecord::Base.verify_active_connections!
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# gracefully stop processing
|
|
42
|
-
def stop
|
|
43
|
-
@workers.list.each { |w| w[:shutdown] = true }
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
##
|
|
47
|
-
## Thread procs
|
|
48
|
-
##
|
|
49
|
-
|
|
50
|
-
# Listen for one worker class
|
|
51
|
-
def clazz_listen(clazz)
|
|
52
|
-
|
|
53
|
-
logger.debug("Listener thread #{clazz.name} started")
|
|
54
|
-
|
|
55
|
-
# Read thread configuration if available
|
|
56
|
-
if Rudeq.config.has_key?(:listeners)
|
|
57
|
-
if Rudeq.config[:listeners].has_key?(clazz.to_s)
|
|
58
|
-
config = Rudeq.config[:listeners][clazz.to_s].symbolize_keys
|
|
59
|
-
thread_sleep_time = config[:sleep_time] if config.has_key?(:sleep_time)
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
hread_sleep_time ||= self.class.sleep_time
|
|
64
|
-
|
|
65
|
-
connection = Workling::Rudeq::Client.new
|
|
66
|
-
puts "** Starting Workling::Rudeq::Client for #{clazz.name} queue"
|
|
67
|
-
|
|
68
|
-
# Start dispatching those messages
|
|
69
|
-
while (!Thread.current[:shutdown]) do
|
|
70
|
-
begin
|
|
71
|
-
|
|
72
|
-
# Keep MySQL connection alive
|
|
73
|
-
unless ActiveRecord::Base.connection.active?
|
|
74
|
-
unless ActiveRecord::Base.connection.reconnect!
|
|
75
|
-
logger.fatal("FAILED - Database not available")
|
|
76
|
-
break
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Dispatch and process the messages
|
|
81
|
-
n = dispatch!(connection, clazz)
|
|
82
|
-
logger.debug("Listener thread #{clazz.name} processed #{n.to_s} queue items") if n > 0
|
|
83
|
-
sleep(self.class.sleep_time) unless n > 0
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
logger.debug("Listener thread #{clazz.name} ended")
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# Dispatcher for one worker class.
|
|
91
|
-
# Returns the number of worker methods called
|
|
92
|
-
def dispatch!(connection, clazz)
|
|
93
|
-
n = 0
|
|
94
|
-
for queue in @routing.queue_names_routing_class(clazz)
|
|
95
|
-
begin
|
|
96
|
-
result = connection.get(queue)
|
|
97
|
-
if result
|
|
98
|
-
n += 1
|
|
99
|
-
handler = @routing[queue]
|
|
100
|
-
method_name = @routing.method_name(queue)
|
|
101
|
-
logger.debug("Calling #{handler.class.to_s}\##{method_name}(#{result.inspect})")
|
|
102
|
-
handler.send(method_name, result)
|
|
103
|
-
end
|
|
104
|
-
rescue
|
|
105
|
-
logger.error("FAILED to connect with queue #{ queue }: #{ e } }")
|
|
106
|
-
raise e
|
|
107
|
-
rescue Object => e
|
|
108
|
-
logger.error("FAILED to process queue #{ queue }. #{ @routing[queue] } could not handle invocation of #{ @routing.method_name(queue) } with #{ result.inspect }: #{ e }.\n#{ e.backtrace.join("\n") }")
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
return n
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
end
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
require File.dirname(__FILE__) + '/test_helper.rb'
|
|
2
|
-
|
|
3
|
-
context "class and method routing" do
|
|
4
|
-
specify "should create a queue called utils:echo for a Util class that subclasses worker and has the method echo" do
|
|
5
|
-
routing = Workling::Routing::ClassAndMethodRouting.new
|
|
6
|
-
routing['utils__echo'].class.to_s.should.equal "Util"
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
specify "should create a queue called analytics:invites:sent for an Analytics::Invites class that subclasses worker and has the method sent" do
|
|
10
|
-
routing = Workling::Routing::ClassAndMethodRouting.new
|
|
11
|
-
routing['analytics__invites__sent'].class.to_s.should.equal "Analytics::Invites"
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
specify "queue_names_routing_class should return all queue names associated with a class" do
|
|
15
|
-
routing = Workling::Routing::ClassAndMethodRouting.new
|
|
16
|
-
routing.queue_names_routing_class(Util).should.include 'utils__echo'
|
|
17
|
-
end
|
|
18
|
-
end
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
require 'workling/clients/base'
|
|
2
|
-
|
|
3
|
-
module Workling
|
|
4
|
-
module Clients
|
|
5
|
-
class MemoryQueueClient < Workling::Clients::Base
|
|
6
|
-
|
|
7
|
-
def initialize
|
|
8
|
-
@subscribers ||= {}
|
|
9
|
-
@queues ||= {}
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
# collects the worker blocks in a hash
|
|
13
|
-
def subscribe(work_type, &block)
|
|
14
|
-
@subscribers[work_type] = block
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# immediately invokes the required worker block
|
|
18
|
-
def request(work_type, arguments)
|
|
19
|
-
if subscription = @subscribers[work_type]
|
|
20
|
-
subscription.call(arguments)
|
|
21
|
-
else
|
|
22
|
-
@queues[work_type] ||= []
|
|
23
|
-
@queues[work_type] << arguments
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def retrieve(work_type)
|
|
28
|
-
queue = @queues[work_type]
|
|
29
|
-
queue.pop if queue
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def connect; true; end
|
|
33
|
-
def close; true; end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
data/test/discovery_test.rb
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
require File.dirname(__FILE__) + '/test_helper.rb'
|
|
2
|
-
|
|
3
|
-
context "discovery" do
|
|
4
|
-
specify "should discover the Util workling, since it subclasses Workling::Base and is on the configured Workling load path." do
|
|
5
|
-
discovered = Workling::Discovery.discovered
|
|
6
|
-
discovered.map(&:to_s).should.include "Util"
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
specify "should not discover non-worker classes" do
|
|
10
|
-
discovered = Workling::Discovery.discovered
|
|
11
|
-
discovered.all? { |clazz| clazz.superclass == Workling::Base }.should.blaming("some discovered classes were not workers").equal true
|
|
12
|
-
end
|
|
13
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
require File.dirname(__FILE__) + '/test_helper.rb'
|
|
2
|
-
|
|
3
|
-
context "the invoker 'basic poller'" do
|
|
4
|
-
setup do
|
|
5
|
-
routing = Workling::Routing::ClassAndMethodRouting.new
|
|
6
|
-
@client = Workling::Clients::MemoryQueueClient.new
|
|
7
|
-
@client.connect
|
|
8
|
-
@invoker = Workling::Remote::Invokers::BasicPoller.new(routing, @client.class)
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
specify "should not explode when listen is called, and stop nicely when stop is called. " do
|
|
12
|
-
connection = mock()
|
|
13
|
-
connection.expects(:active?).at_least_once.returns(true)
|
|
14
|
-
ActiveRecord::Base.expects(:connection).at_least_once.returns(connection)
|
|
15
|
-
|
|
16
|
-
client = mock()
|
|
17
|
-
client.expects(:retrieve).at_least_once.returns("hi")
|
|
18
|
-
client.expects(:connect).at_least_once.returns(true)
|
|
19
|
-
client.expects(:close).at_least_once.returns(true)
|
|
20
|
-
Workling::Clients::MemoryQueueClient.expects(:new).at_least_once.returns(client)
|
|
21
|
-
|
|
22
|
-
# Don't take longer than 10 seconds to shut this down.
|
|
23
|
-
Timeout::timeout(10) do
|
|
24
|
-
listener = Thread.new { @invoker.listen }
|
|
25
|
-
@invoker.stop
|
|
26
|
-
listener.join
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|