girl_friday 0.9.6 → 0.9.7
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/.gitignore +2 -0
- data/History.md +7 -0
- data/README.md +2 -2
- data/examples/batch.rb +4 -1
- data/girl_friday.gemspec +1 -1
- data/lib/girl_friday/batch.rb +31 -8
- data/lib/girl_friday/persistence.rb +4 -6
- data/lib/girl_friday/server.rb +1 -1
- data/lib/girl_friday/version.rb +1 -1
- data/lib/girl_friday/work_queue.rb +25 -25
- data/test/helper.rb +9 -2
- data/test/test_batch.rb +32 -1
- data/test/test_girl_friday_queue.rb +16 -1
- metadata +31 -23
data/History.md
CHANGED
data/README.md
CHANGED
|
@@ -36,13 +36,13 @@ In your Rails app, create a `config/initializers/girl_friday.rb` which defines y
|
|
|
36
36
|
:size is the number of workers to spin up and defaults to 5. Keep in mind, ActiveRecord defaults to a connection pool size of 5 so if your workers are accessing the database you'll want to ensure that the connection pool is large enough by modifying `config/database.yml`.
|
|
37
37
|
|
|
38
38
|
In order to use the Redis backend, you must use a connection pool to share a set of Redis connections with
|
|
39
|
-
other threads and GirlFriday queues using the `
|
|
39
|
+
other threads and GirlFriday queues using the `connection_pool` gem:
|
|
40
40
|
|
|
41
41
|
require 'connection_pool'
|
|
42
42
|
|
|
43
43
|
redis_pool = ConnectionPool.new(:size => 5, :timeout => 5) { Redis.new }
|
|
44
44
|
|
|
45
|
-
CLEAN_FILTER_QUEUE = GirlFriday::WorkQueue.new(:clean_filter, :store => GirlFriday::Store::Redis, :store_config =>
|
|
45
|
+
CLEAN_FILTER_QUEUE = GirlFriday::WorkQueue.new(:clean_filter, :store => GirlFriday::Store::Redis, :store_config => { :pool => redis_pool }) do |msg|
|
|
46
46
|
Filter.clean(msg)
|
|
47
47
|
end
|
|
48
48
|
|
data/examples/batch.rb
CHANGED
|
@@ -7,11 +7,14 @@ class UrlProcessor
|
|
|
7
7
|
URLS = %w(http://www.bing.com http://www.google.com http://www.yahoo.com)
|
|
8
8
|
|
|
9
9
|
def parallel
|
|
10
|
-
batch = GirlFriday::Batch.new(
|
|
10
|
+
batch = GirlFriday::Batch.new(nil, :size => 3) do |url|
|
|
11
11
|
html = open(url)
|
|
12
12
|
doc = Nokogiri::HTML(html.read)
|
|
13
13
|
doc.css('span').count
|
|
14
14
|
end
|
|
15
|
+
URLS.each do |url|
|
|
16
|
+
batch << url
|
|
17
|
+
end
|
|
15
18
|
p URLS.zip(batch.results)
|
|
16
19
|
end
|
|
17
20
|
|
data/girl_friday.gemspec
CHANGED
|
@@ -17,6 +17,6 @@ Gem::Specification.new do |s|
|
|
|
17
17
|
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
|
18
18
|
s.require_paths = ["lib"]
|
|
19
19
|
s.add_dependency 'connection_pool', '~> 0.1.0'
|
|
20
|
-
s.add_development_dependency 'sinatra', '~> 1.
|
|
20
|
+
s.add_development_dependency 'sinatra', '~> 1.3'
|
|
21
21
|
s.add_development_dependency 'rake'
|
|
22
22
|
end
|
data/lib/girl_friday/batch.rb
CHANGED
|
@@ -12,17 +12,23 @@ module GirlFriday
|
|
|
12
12
|
#
|
|
13
13
|
# TODO Errors are not handled well at all.
|
|
14
14
|
class Batch
|
|
15
|
-
def initialize(enumerable, options, &block)
|
|
15
|
+
def initialize(enumerable=nil, options={}, &block)
|
|
16
16
|
@queue = GirlFriday::Queue.new(:batch, options, &block)
|
|
17
17
|
@complete = 0
|
|
18
|
-
@size =
|
|
19
|
-
@results =
|
|
18
|
+
@size = 0
|
|
19
|
+
@results = []
|
|
20
|
+
if enumerable
|
|
21
|
+
@size = enumerable.count
|
|
22
|
+
@results = Array.new(@size)
|
|
23
|
+
end
|
|
20
24
|
@lock = Mutex.new
|
|
21
25
|
@condition = ConditionVariable.new
|
|
26
|
+
@frozen = false
|
|
22
27
|
start(enumerable)
|
|
23
28
|
end
|
|
24
29
|
|
|
25
30
|
def results(timeout=nil)
|
|
31
|
+
@frozen = true
|
|
26
32
|
@lock.synchronize do
|
|
27
33
|
@condition.wait(@lock, timeout) if @complete != @size
|
|
28
34
|
@queue.shutdown
|
|
@@ -30,17 +36,34 @@ module GirlFriday
|
|
|
30
36
|
end
|
|
31
37
|
end
|
|
32
38
|
|
|
39
|
+
def push(msg)
|
|
40
|
+
raise ArgumentError, "Batch is frozen, you cannot push more items into it" if @frozen
|
|
41
|
+
@lock.synchronize do
|
|
42
|
+
@results << nil
|
|
43
|
+
@size += 1
|
|
44
|
+
index = @results.size - 1
|
|
45
|
+
@queue.push(msg) do |result|
|
|
46
|
+
completion(result, index)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
alias_method :<<, :push
|
|
51
|
+
|
|
33
52
|
private
|
|
34
53
|
|
|
35
54
|
def start(operations)
|
|
36
55
|
operations.each_with_index do |packet, index|
|
|
37
56
|
@queue.push(packet) do |result|
|
|
38
|
-
|
|
39
|
-
@complete += 1
|
|
40
|
-
@results[index] = result
|
|
41
|
-
@condition.signal if @complete == @size
|
|
42
|
-
end
|
|
57
|
+
completion(result, index)
|
|
43
58
|
end
|
|
59
|
+
end if operations
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def completion(result, index)
|
|
63
|
+
@lock.synchronize do
|
|
64
|
+
@complete += 1
|
|
65
|
+
@results[index] = result
|
|
66
|
+
@condition.signal if @complete == @size
|
|
44
67
|
end
|
|
45
68
|
end
|
|
46
69
|
|
|
@@ -23,25 +23,23 @@ module GirlFriday
|
|
|
23
23
|
class Redis
|
|
24
24
|
def initialize(name, options)
|
|
25
25
|
@opts = options
|
|
26
|
-
unless @opts[:pool]
|
|
27
|
-
raise ArgumentError, "you must pass in a :pool"
|
|
28
|
-
end
|
|
26
|
+
raise ArgumentError, "you must pass in a :pool" unless @opts[:pool]
|
|
29
27
|
@key = "girl_friday-#{name}-#{environment}"
|
|
30
28
|
end
|
|
31
29
|
|
|
32
30
|
def push(work)
|
|
33
31
|
val = Marshal.dump(work)
|
|
34
|
-
redis{ |r| r.rpush(@key, val) }
|
|
32
|
+
redis { |r| r.rpush(@key, val) }
|
|
35
33
|
end
|
|
36
34
|
alias_method :<<, :push
|
|
37
35
|
|
|
38
36
|
def pop
|
|
39
|
-
val = redis{ |r| r.lpop(@key) }
|
|
37
|
+
val = redis { |r| r.lpop(@key) }
|
|
40
38
|
Marshal.load(val) if val
|
|
41
39
|
end
|
|
42
40
|
|
|
43
41
|
def size
|
|
44
|
-
redis.llen(@key)
|
|
42
|
+
redis { |r| r.llen(@key) }
|
|
45
43
|
end
|
|
46
44
|
|
|
47
45
|
private
|
data/lib/girl_friday/server.rb
CHANGED
data/lib/girl_friday/version.rb
CHANGED
|
@@ -11,7 +11,7 @@ module GirlFriday
|
|
|
11
11
|
@name = name.to_s
|
|
12
12
|
@size = options[:size] || 5
|
|
13
13
|
@processor = block
|
|
14
|
-
@error_handlers = (Array(options[:error_handler]
|
|
14
|
+
@error_handlers = (Array(options[:error_handler] || ErrorHandler.default)).map(&:new)
|
|
15
15
|
|
|
16
16
|
@shutdown = false
|
|
17
17
|
@busy_workers = []
|
|
@@ -70,17 +70,26 @@ module GirlFriday
|
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
def shutdown
|
|
73
|
+
def shutdown(&block)
|
|
74
74
|
# Runtime state should never be modified by caller thread,
|
|
75
75
|
# only the Supervisor thread.
|
|
76
|
-
@supervisor << Shutdown[
|
|
76
|
+
@supervisor << Shutdown[block]
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
private
|
|
80
80
|
|
|
81
|
+
def running?
|
|
82
|
+
!@shutdown
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def handle_error(ex)
|
|
86
|
+
# Redis network error? Log and ignore.
|
|
87
|
+
@error_handlers.each { |handler| handler.handle(ex) }
|
|
88
|
+
end
|
|
89
|
+
|
|
81
90
|
def on_ready(who)
|
|
82
91
|
@total_processed += 1
|
|
83
|
-
if
|
|
92
|
+
if running? && work = @persister.pop
|
|
84
93
|
who.this << work
|
|
85
94
|
drain
|
|
86
95
|
else
|
|
@@ -88,22 +97,21 @@ module GirlFriday
|
|
|
88
97
|
ready_workers << who.this
|
|
89
98
|
end
|
|
90
99
|
rescue => ex
|
|
91
|
-
|
|
92
|
-
@error_handlers.each { |handler| handler.handle(ex) }
|
|
100
|
+
handle_error(ex)
|
|
93
101
|
end
|
|
94
102
|
|
|
95
103
|
def shutdown_complete
|
|
96
104
|
begin
|
|
97
105
|
@when_shutdown.call(self) if @when_shutdown
|
|
98
106
|
rescue Exception => ex
|
|
99
|
-
|
|
107
|
+
handle_error(ex)
|
|
100
108
|
end
|
|
101
109
|
end
|
|
102
110
|
|
|
103
111
|
def on_work(work)
|
|
104
112
|
@total_queued += 1
|
|
105
113
|
|
|
106
|
-
if
|
|
114
|
+
if running? && worker = ready_workers.pop
|
|
107
115
|
@busy_workers << worker
|
|
108
116
|
worker << work
|
|
109
117
|
drain
|
|
@@ -111,19 +119,12 @@ module GirlFriday
|
|
|
111
119
|
@persister << work
|
|
112
120
|
end
|
|
113
121
|
rescue => ex
|
|
114
|
-
|
|
115
|
-
@error_handlers.each { |handler| handler.handle(ex) }
|
|
122
|
+
handle_error(ex)
|
|
116
123
|
end
|
|
117
124
|
|
|
118
125
|
def ready_workers
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
@size.times do
|
|
122
|
-
# start N workers
|
|
123
|
-
workers << Actor.spawn_link(&@work_loop)
|
|
124
|
-
end
|
|
125
|
-
workers
|
|
126
|
-
end
|
|
126
|
+
# start N workers
|
|
127
|
+
@ready_workers ||= Array.new(@size) { Actor.spawn_link(&@work_loop) }
|
|
127
128
|
end
|
|
128
129
|
|
|
129
130
|
def start
|
|
@@ -132,9 +133,9 @@ module GirlFriday
|
|
|
132
133
|
supervisor = Actor.current
|
|
133
134
|
@work_loop = Proc.new do
|
|
134
135
|
Thread.current[:label] = "#{name}-worker"
|
|
135
|
-
while
|
|
136
|
+
while running? do
|
|
136
137
|
work = Actor.receive
|
|
137
|
-
if
|
|
138
|
+
if running?
|
|
138
139
|
result = @processor.call(work.msg)
|
|
139
140
|
work.callback.call(result) if work.callback
|
|
140
141
|
supervisor << Ready[Actor.current]
|
|
@@ -155,8 +156,7 @@ module GirlFriday
|
|
|
155
156
|
|
|
156
157
|
def drain
|
|
157
158
|
# give as much work to as many ready workers as possible
|
|
158
|
-
|
|
159
|
-
todo = ready_workers.size < ps ? ready_workers.size : ps
|
|
159
|
+
todo = [@persister.size, ready_workers.size].min
|
|
160
160
|
todo.times do
|
|
161
161
|
worker = ready_workers.pop
|
|
162
162
|
@busy_workers << worker
|
|
@@ -183,18 +183,18 @@ module GirlFriday
|
|
|
183
183
|
return
|
|
184
184
|
end
|
|
185
185
|
f.when(Actor::DeadActorError) do |ex|
|
|
186
|
-
if
|
|
186
|
+
if running?
|
|
187
187
|
# TODO Provide current message contents as error context
|
|
188
188
|
@total_errors += 1
|
|
189
189
|
@busy_workers.delete(ex.actor)
|
|
190
190
|
ready_workers << Actor.spawn_link(&@work_loop)
|
|
191
|
-
|
|
191
|
+
handle_error(ex.reason)
|
|
192
192
|
end
|
|
193
193
|
end
|
|
194
194
|
end
|
|
195
195
|
end
|
|
196
196
|
end
|
|
197
|
-
|
|
198
197
|
end
|
|
198
|
+
|
|
199
199
|
Queue = WorkQueue
|
|
200
200
|
end
|
data/test/helper.rb
CHANGED
|
@@ -2,12 +2,16 @@ $testing = true
|
|
|
2
2
|
puts RUBY_DESCRIPTION
|
|
3
3
|
|
|
4
4
|
at_exit do
|
|
5
|
+
# queue.shutdown is async so sleep a little to minimize
|
|
6
|
+
# race conditions between us and other threads
|
|
7
|
+
# that haven't yet been GC'd.
|
|
8
|
+
sleep 0.1
|
|
5
9
|
if Thread.list.size > 1
|
|
6
10
|
Thread.list.each do |thread|
|
|
7
11
|
next if thread.status == 'run'
|
|
8
12
|
puts "WARNING: lingering threads found. All threads should be shutdown and garbage collected."
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
puts "This is normal if a test failed so a queue did not get shutdown."
|
|
14
|
+
p [thread, thread[:label]]
|
|
11
15
|
end
|
|
12
16
|
end
|
|
13
17
|
end
|
|
@@ -30,6 +34,9 @@ class MiniTest::Unit::TestCase
|
|
|
30
34
|
q = TimedQueue.new
|
|
31
35
|
yield Proc.new { q << nil }
|
|
32
36
|
q.timed_pop(time)
|
|
37
|
+
ensure
|
|
38
|
+
count = GirlFriday.shutdown!(1)
|
|
39
|
+
puts "Unable to shutdown queue (#{count})" if count != 0
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
end
|
data/test/test_batch.rb
CHANGED
|
@@ -16,7 +16,7 @@ class TestBatch < MiniTest::Unit::TestCase
|
|
|
16
16
|
# asking for the results should block
|
|
17
17
|
results = batch.results(1.0)
|
|
18
18
|
c = Time.now
|
|
19
|
-
assert_in_delta(0.5, (c - b), 0.
|
|
19
|
+
assert_in_delta(0.5, (c - b), 0.3)
|
|
20
20
|
|
|
21
21
|
assert_equal 10, results.size
|
|
22
22
|
assert_kind_of Time, results[0]
|
|
@@ -39,4 +39,35 @@ class TestBatch < MiniTest::Unit::TestCase
|
|
|
39
39
|
# http://redmine.ruby-lang.org/issues/5342
|
|
40
40
|
sleep 0.1
|
|
41
41
|
end
|
|
42
|
+
|
|
43
|
+
def test_empty_batch
|
|
44
|
+
batch = GirlFriday::Batch.new(nil, :size => 4) do |msg|
|
|
45
|
+
sleep msg
|
|
46
|
+
'x'
|
|
47
|
+
end
|
|
48
|
+
values = batch.results
|
|
49
|
+
values.must_be_kind_of Array
|
|
50
|
+
values.must_equal []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_streaming_batch_api
|
|
54
|
+
batch = GirlFriday::Batch.new(nil, :size => 4) do |msg|
|
|
55
|
+
sleep msg
|
|
56
|
+
'x'
|
|
57
|
+
end
|
|
58
|
+
a = Time.now
|
|
59
|
+
batch << 0.1
|
|
60
|
+
batch << 0.1
|
|
61
|
+
batch << 0.1
|
|
62
|
+
batch << 0.1
|
|
63
|
+
values = batch.results
|
|
64
|
+
b = Time.now
|
|
65
|
+
values.must_be_kind_of Array
|
|
66
|
+
values.must_equal %w(x x x x)
|
|
67
|
+
assert_in_delta 0.2, (b - a), 0.1
|
|
68
|
+
|
|
69
|
+
assert_raises ArgumentError do
|
|
70
|
+
batch << 0.1
|
|
71
|
+
end
|
|
72
|
+
end
|
|
42
73
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
require 'helper'
|
|
2
2
|
|
|
3
|
-
class
|
|
3
|
+
class TestGirlFridayQueue < MiniTest::Unit::TestCase
|
|
4
4
|
|
|
5
5
|
class TestErrorHandler
|
|
6
6
|
include MiniTest::Assertions
|
|
@@ -36,6 +36,20 @@ class TestGirlFriday < MiniTest::Unit::TestCase
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def test_should_use_a_default_error_handler_when_none_specified
|
|
40
|
+
async_test do |cb|
|
|
41
|
+
queue = GirlFriday::WorkQueue.new('error') do |msg|
|
|
42
|
+
end
|
|
43
|
+
queue.shutdown do
|
|
44
|
+
cb.call
|
|
45
|
+
end
|
|
46
|
+
queue.push(:text => 'foo') # Redundant
|
|
47
|
+
|
|
48
|
+
# Not an ideal method, but I can't see a better way without complex stubbing.
|
|
49
|
+
assert queue.instance_eval { @error_handlers }.length > 0
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
39
53
|
def test_should_call_callback_when_complete
|
|
40
54
|
async_test do |cb|
|
|
41
55
|
queue = GirlFriday::WorkQueue.new('callback', :size => 1) do |msg|
|
|
@@ -118,6 +132,7 @@ class TestGirlFriday < MiniTest::Unit::TestCase
|
|
|
118
132
|
total.times do
|
|
119
133
|
queue.push(:text => 'foo')
|
|
120
134
|
end
|
|
135
|
+
refute_nil queue.status['redis-pool'][:backlog]
|
|
121
136
|
end
|
|
122
137
|
end
|
|
123
138
|
|
metadata
CHANGED
|
@@ -1,49 +1,50 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: girl_friday
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
prerelease:
|
|
5
|
+
version: 0.9.7
|
|
6
6
|
platform: ruby
|
|
7
7
|
authors:
|
|
8
8
|
- Mike Perham
|
|
9
|
-
autorequire:
|
|
9
|
+
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2011-
|
|
12
|
+
date: 2011-11-28 00:00:00.000000000 -08:00
|
|
13
|
+
default_executable:
|
|
13
14
|
dependencies:
|
|
14
15
|
- !ruby/object:Gem::Dependency
|
|
15
16
|
name: connection_pool
|
|
16
|
-
|
|
17
|
-
none: false
|
|
17
|
+
version_requirements: &2078 !ruby/object:Gem::Requirement
|
|
18
18
|
requirements:
|
|
19
19
|
- - ~>
|
|
20
20
|
- !ruby/object:Gem::Version
|
|
21
21
|
version: 0.1.0
|
|
22
|
-
|
|
22
|
+
none: false
|
|
23
|
+
requirement: *2078
|
|
23
24
|
prerelease: false
|
|
24
|
-
|
|
25
|
+
type: :runtime
|
|
25
26
|
- !ruby/object:Gem::Dependency
|
|
26
27
|
name: sinatra
|
|
27
|
-
|
|
28
|
-
none: false
|
|
28
|
+
version_requirements: &2096 !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ~>
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '1.
|
|
33
|
-
|
|
32
|
+
version: '1.3'
|
|
33
|
+
none: false
|
|
34
|
+
requirement: *2096
|
|
34
35
|
prerelease: false
|
|
35
|
-
|
|
36
|
+
type: :development
|
|
36
37
|
- !ruby/object:Gem::Dependency
|
|
37
38
|
name: rake
|
|
38
|
-
|
|
39
|
-
none: false
|
|
39
|
+
version_requirements: &2114 !ruby/object:Gem::Requirement
|
|
40
40
|
requirements:
|
|
41
41
|
- - ! '>='
|
|
42
42
|
- !ruby/object:Gem::Version
|
|
43
43
|
version: '0'
|
|
44
|
-
|
|
44
|
+
none: false
|
|
45
|
+
requirement: *2114
|
|
45
46
|
prerelease: false
|
|
46
|
-
|
|
47
|
+
type: :development
|
|
47
48
|
description: Background processing, simplified
|
|
48
49
|
email:
|
|
49
50
|
- mperham@gmail.com
|
|
@@ -83,28 +84,35 @@ files:
|
|
|
83
84
|
- test/test_girl_friday.rb
|
|
84
85
|
- test/test_girl_friday_immediately.rb
|
|
85
86
|
- test/test_girl_friday_queue.rb
|
|
87
|
+
has_rdoc: true
|
|
86
88
|
homepage: http://github.com/mperham/girl_friday
|
|
87
89
|
licenses: []
|
|
88
|
-
post_install_message:
|
|
90
|
+
post_install_message:
|
|
89
91
|
rdoc_options: []
|
|
90
92
|
require_paths:
|
|
91
93
|
- lib
|
|
92
94
|
required_ruby_version: !ruby/object:Gem::Requirement
|
|
93
|
-
none: false
|
|
94
95
|
requirements:
|
|
95
96
|
- - ! '>='
|
|
96
97
|
- !ruby/object:Gem::Version
|
|
97
98
|
version: '0'
|
|
98
|
-
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
99
99
|
none: false
|
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
100
101
|
requirements:
|
|
101
102
|
- - ! '>='
|
|
102
103
|
- !ruby/object:Gem::Version
|
|
103
104
|
version: '0'
|
|
105
|
+
none: false
|
|
104
106
|
requirements: []
|
|
105
107
|
rubyforge_project: girl_friday
|
|
106
|
-
rubygems_version: 1.
|
|
107
|
-
signing_key:
|
|
108
|
+
rubygems_version: 1.6.2
|
|
109
|
+
signing_key:
|
|
108
110
|
specification_version: 3
|
|
109
111
|
summary: Background processing, simplified
|
|
110
|
-
test_files:
|
|
112
|
+
test_files:
|
|
113
|
+
- test/helper.rb
|
|
114
|
+
- test/test_batch.rb
|
|
115
|
+
- test/test_girl_friday.rb
|
|
116
|
+
- test/test_girl_friday_immediately.rb
|
|
117
|
+
- test/test_girl_friday_queue.rb
|
|
118
|
+
...
|