google-cloud-pubsub 0.20.0 → 2.6.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.yardopts +18 -0
- data/AUTHENTICATION.md +178 -0
- data/CHANGELOG.md +659 -0
- data/CODE_OF_CONDUCT.md +40 -0
- data/CONTRIBUTING.md +187 -0
- data/EMULATOR.md +37 -0
- data/LICENSE +201 -0
- data/LOGGING.md +32 -0
- data/OVERVIEW.md +528 -0
- data/TROUBLESHOOTING.md +31 -0
- data/lib/google/cloud/pubsub/async_publisher/batch.rb +310 -0
- data/lib/google/cloud/pubsub/async_publisher.rb +402 -0
- data/lib/google/cloud/pubsub/batch_publisher.rb +100 -0
- data/lib/google/cloud/pubsub/convert.rb +91 -0
- data/lib/google/cloud/pubsub/credentials.rb +26 -10
- data/lib/google/cloud/pubsub/errors.rb +85 -0
- data/lib/google/cloud/pubsub/message.rb +82 -20
- data/lib/google/cloud/pubsub/policy.rb +40 -61
- data/lib/google/cloud/pubsub/project.rb +405 -265
- data/lib/google/cloud/pubsub/publish_result.rb +103 -0
- data/lib/google/cloud/pubsub/received_message.rb +165 -30
- data/lib/google/cloud/pubsub/retry_policy.rb +88 -0
- data/lib/google/cloud/pubsub/schema/list.rb +180 -0
- data/lib/google/cloud/pubsub/schema.rb +310 -0
- data/lib/google/cloud/pubsub/service.rb +304 -162
- data/lib/google/cloud/pubsub/snapshot/list.rb +178 -0
- data/lib/google/cloud/pubsub/snapshot.rb +205 -0
- data/lib/google/cloud/pubsub/subscriber/enumerator_queue.rb +54 -0
- data/lib/google/cloud/pubsub/subscriber/inventory.rb +173 -0
- data/lib/google/cloud/pubsub/subscriber/sequencer.rb +115 -0
- data/lib/google/cloud/pubsub/subscriber/stream.rb +400 -0
- data/lib/google/cloud/pubsub/subscriber/timed_unary_buffer.rb +230 -0
- data/lib/google/cloud/pubsub/subscriber.rb +417 -0
- data/lib/google/cloud/pubsub/subscription/list.rb +38 -43
- data/lib/google/cloud/pubsub/subscription/push_config.rb +268 -0
- data/lib/google/cloud/pubsub/subscription.rb +1040 -210
- data/lib/google/cloud/pubsub/topic/list.rb +32 -37
- data/lib/google/cloud/pubsub/topic.rb +726 -177
- data/lib/google/cloud/pubsub/version.rb +6 -4
- data/lib/google/cloud/pubsub.rb +138 -413
- data/lib/google-cloud-pubsub.rb +60 -42
- metadata +88 -39
- data/lib/google/cloud/pubsub/topic/publisher.rb +0 -87
- data/lib/google/iam/v1/iam_policy.rb +0 -33
- data/lib/google/iam/v1/iam_policy_services.rb +0 -30
- data/lib/google/iam/v1/policy.rb +0 -25
- data/lib/google/pubsub/v1/pubsub_pb.rb +0 -129
- data/lib/google/pubsub/v1/pubsub_services_pb.rb +0 -117
@@ -0,0 +1,115 @@
|
|
1
|
+
# Copyright 2019 Google LLC
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
|
16
|
+
require "monitor"
|
17
|
+
|
18
|
+
module Google
|
19
|
+
module Cloud
|
20
|
+
module PubSub
|
21
|
+
class Subscriber
|
22
|
+
##
|
23
|
+
# @private The sequencer's job is simple, keep track of all the
|
24
|
+
# streams's recieved message and deliver the messages with an
|
25
|
+
# ordering_key in the order they were recieved. The sequencer ensures
|
26
|
+
# only one callback can be performed at a time per ordering_key.
|
27
|
+
class Sequencer
|
28
|
+
include MonitorMixin
|
29
|
+
|
30
|
+
##
|
31
|
+
# @private Create an empty Subscriber::Sequencer object.
|
32
|
+
def initialize &block
|
33
|
+
raise ArgumentError if block.nil?
|
34
|
+
|
35
|
+
super() # to init MonitorMixin
|
36
|
+
|
37
|
+
@seq_hash = Hash.new { |hash, key| hash[key] = [] }
|
38
|
+
@process_callback = block
|
39
|
+
end
|
40
|
+
|
41
|
+
##
|
42
|
+
# @private Add a ReceivedMessage to the sequencer.
|
43
|
+
def add message
|
44
|
+
# Messages without ordering_key are not managed by the sequencer
|
45
|
+
if message.ordering_key.empty?
|
46
|
+
@process_callback.call message
|
47
|
+
return
|
48
|
+
end
|
49
|
+
|
50
|
+
perform_callback = synchronize do
|
51
|
+
# The purpose of this block is to add the message to the
|
52
|
+
# sequencer, and to return whether the message should be processed
|
53
|
+
# immediately, or whether it will be processed later by #next. We
|
54
|
+
# want to ensure that these operations happen atomically.
|
55
|
+
|
56
|
+
@seq_hash[message.ordering_key].push message
|
57
|
+
@seq_hash[message.ordering_key].count == 1
|
58
|
+
end
|
59
|
+
|
60
|
+
@process_callback.call message if perform_callback
|
61
|
+
end
|
62
|
+
|
63
|
+
##
|
64
|
+
# @private Indicate a ReceivedMessage was processed, and the next in
|
65
|
+
# the queue can now be processed.
|
66
|
+
def next message
|
67
|
+
# Messages without ordering_key are not managed by the sequencer
|
68
|
+
return if message.ordering_key.empty?
|
69
|
+
|
70
|
+
next_message = synchronize do
|
71
|
+
# The purpose of this block is to remove the message that was
|
72
|
+
# processed from the sequencer, and to return the next message to
|
73
|
+
# be processed. We want to ensure that these operations happen
|
74
|
+
# atomically.
|
75
|
+
|
76
|
+
# The message should be at index 0, so this should be a very quick
|
77
|
+
# operation.
|
78
|
+
if @seq_hash[message.ordering_key].first != message
|
79
|
+
# Raising this error will stop the other messages with this
|
80
|
+
# ordering key from being processed by the callback (delivered).
|
81
|
+
raise OrderedMessageDeliveryError, message
|
82
|
+
end
|
83
|
+
|
84
|
+
# Remove the message
|
85
|
+
@seq_hash[message.ordering_key].shift
|
86
|
+
|
87
|
+
# Retrieve the next message to be processed, or nil if empty
|
88
|
+
next_msg = @seq_hash[message.ordering_key].first
|
89
|
+
|
90
|
+
# Remove the ordering_key from hash when empty
|
91
|
+
@seq_hash.delete message.ordering_key if next_msg.nil?
|
92
|
+
|
93
|
+
# Return the next message to be processed, or nil if empty
|
94
|
+
next_msg
|
95
|
+
end
|
96
|
+
|
97
|
+
@process_callback.call next_message unless next_message.nil?
|
98
|
+
end
|
99
|
+
|
100
|
+
# @private
|
101
|
+
def to_s
|
102
|
+
"#{@seq_hash.count}/#{@seq_hash.values.sum(&:count)}"
|
103
|
+
end
|
104
|
+
|
105
|
+
# @private
|
106
|
+
def inspect
|
107
|
+
"#<#{self.class.name} (#{self})>"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
Pubsub = PubSub unless const_defined? :Pubsub
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,400 @@
|
|
1
|
+
# Copyright 2017 Google LLC
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
|
16
|
+
require "google/cloud/pubsub/subscriber/sequencer"
|
17
|
+
require "google/cloud/pubsub/subscriber/enumerator_queue"
|
18
|
+
require "google/cloud/pubsub/subscriber/inventory"
|
19
|
+
require "google/cloud/pubsub/service"
|
20
|
+
require "google/cloud/errors"
|
21
|
+
require "monitor"
|
22
|
+
require "concurrent"
|
23
|
+
|
24
|
+
module Google
|
25
|
+
module Cloud
|
26
|
+
module PubSub
|
27
|
+
class Subscriber
|
28
|
+
##
|
29
|
+
# @private
|
30
|
+
class Stream
|
31
|
+
include MonitorMixin
|
32
|
+
|
33
|
+
##
|
34
|
+
# @private Implementation attributes.
|
35
|
+
attr_reader :callback_thread_pool
|
36
|
+
|
37
|
+
##
|
38
|
+
# @private Subscriber attributes.
|
39
|
+
attr_reader :subscriber
|
40
|
+
|
41
|
+
##
|
42
|
+
# @private Inventory.
|
43
|
+
attr_reader :inventory
|
44
|
+
|
45
|
+
##
|
46
|
+
# @private Sequencer.
|
47
|
+
attr_reader :sequencer
|
48
|
+
|
49
|
+
##
|
50
|
+
# @private Create an empty Subscriber::Stream object.
|
51
|
+
def initialize subscriber
|
52
|
+
super() # to init MonitorMixin
|
53
|
+
|
54
|
+
@subscriber = subscriber
|
55
|
+
|
56
|
+
@request_queue = nil
|
57
|
+
@stopped = nil
|
58
|
+
@paused = nil
|
59
|
+
@pause_cond = new_cond
|
60
|
+
|
61
|
+
@inventory = Inventory.new self, **@subscriber.stream_inventory
|
62
|
+
|
63
|
+
@sequencer = Sequencer.new(&method(:perform_callback_async)) if subscriber.message_ordering
|
64
|
+
|
65
|
+
@callback_thread_pool = Concurrent::ThreadPoolExecutor.new max_threads: @subscriber.callback_threads
|
66
|
+
|
67
|
+
@stream_keepalive_task = Concurrent::TimerTask.new(
|
68
|
+
execution_interval: 30
|
69
|
+
) do
|
70
|
+
# push empty request every 30 seconds to keep stream alive
|
71
|
+
push Google::Cloud::PubSub::V1::StreamingPullRequest.new unless inventory.empty?
|
72
|
+
end.execute
|
73
|
+
end
|
74
|
+
|
75
|
+
def start
|
76
|
+
synchronize do
|
77
|
+
break if @background_thread
|
78
|
+
|
79
|
+
@inventory.start
|
80
|
+
|
81
|
+
start_streaming!
|
82
|
+
end
|
83
|
+
|
84
|
+
self
|
85
|
+
end
|
86
|
+
|
87
|
+
def stop
|
88
|
+
synchronize do
|
89
|
+
break if @stopped
|
90
|
+
|
91
|
+
# Close the stream by pushing the sentinel value.
|
92
|
+
# The unary pusher does not use the stream, so it can close here.
|
93
|
+
@request_queue&.push self
|
94
|
+
|
95
|
+
# Signal to the background thread that we are stopped.
|
96
|
+
@stopped = true
|
97
|
+
@pause_cond.broadcast
|
98
|
+
|
99
|
+
# Now that the reception thread is stopped, immediately stop the
|
100
|
+
# callback thread pool. All queued callbacks will see the stream
|
101
|
+
# is stopped and perform a noop.
|
102
|
+
@callback_thread_pool.shutdown
|
103
|
+
|
104
|
+
# Once all the callbacks are stopped, we can stop the inventory.
|
105
|
+
@inventory.stop
|
106
|
+
end
|
107
|
+
|
108
|
+
self
|
109
|
+
end
|
110
|
+
|
111
|
+
def stopped?
|
112
|
+
synchronize { @stopped }
|
113
|
+
end
|
114
|
+
|
115
|
+
def paused?
|
116
|
+
synchronize { @paused }
|
117
|
+
end
|
118
|
+
|
119
|
+
def running?
|
120
|
+
!stopped?
|
121
|
+
end
|
122
|
+
|
123
|
+
def wait! timeout = nil
|
124
|
+
# Wait for all queued callbacks to be processed.
|
125
|
+
@callback_thread_pool.wait_for_termination timeout
|
126
|
+
|
127
|
+
self
|
128
|
+
end
|
129
|
+
|
130
|
+
##
|
131
|
+
# @private
|
132
|
+
def acknowledge *messages
|
133
|
+
ack_ids = coerce_ack_ids messages
|
134
|
+
return true if ack_ids.empty?
|
135
|
+
|
136
|
+
synchronize do
|
137
|
+
@inventory.remove ack_ids
|
138
|
+
@subscriber.buffer.acknowledge ack_ids
|
139
|
+
end
|
140
|
+
|
141
|
+
true
|
142
|
+
end
|
143
|
+
|
144
|
+
##
|
145
|
+
# @private
|
146
|
+
def modify_ack_deadline deadline, *messages
|
147
|
+
mod_ack_ids = coerce_ack_ids messages
|
148
|
+
return true if mod_ack_ids.empty?
|
149
|
+
|
150
|
+
synchronize do
|
151
|
+
@inventory.remove mod_ack_ids
|
152
|
+
@subscriber.buffer.modify_ack_deadline deadline, mod_ack_ids
|
153
|
+
end
|
154
|
+
|
155
|
+
true
|
156
|
+
end
|
157
|
+
|
158
|
+
##
|
159
|
+
# @private
|
160
|
+
def release *messages
|
161
|
+
ack_ids = coerce_ack_ids messages
|
162
|
+
return if ack_ids.empty?
|
163
|
+
|
164
|
+
synchronize do
|
165
|
+
# Remove from inventory if the message was not explicitly acked or
|
166
|
+
# nacked in the callback
|
167
|
+
@inventory.remove ack_ids
|
168
|
+
# Check whether to unpause the stream only after the callback is
|
169
|
+
# completed and the thread is being reclaimed.
|
170
|
+
unpause_streaming!
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
def push request
|
175
|
+
synchronize { @request_queue.push request }
|
176
|
+
end
|
177
|
+
|
178
|
+
##
|
179
|
+
# @private
|
180
|
+
def renew_lease!
|
181
|
+
synchronize do
|
182
|
+
return true if @inventory.empty?
|
183
|
+
|
184
|
+
@inventory.remove_expired!
|
185
|
+
@subscriber.buffer.renew_lease @subscriber.deadline, @inventory.ack_ids
|
186
|
+
unpause_streaming!
|
187
|
+
end
|
188
|
+
|
189
|
+
true
|
190
|
+
end
|
191
|
+
|
192
|
+
# @private
|
193
|
+
def to_s
|
194
|
+
seq_str = "sequenced: #{sequencer}, " if sequencer
|
195
|
+
"(inventory: #{@inventory.count}, #{seq_str}status: #{status}, thread: #{thread_status})"
|
196
|
+
end
|
197
|
+
|
198
|
+
# @private
|
199
|
+
def inspect
|
200
|
+
"#<#{self.class.name} #{self}>"
|
201
|
+
end
|
202
|
+
|
203
|
+
protected
|
204
|
+
|
205
|
+
# @private
|
206
|
+
class RestartStream < StandardError; end
|
207
|
+
|
208
|
+
# rubocop:disable all
|
209
|
+
|
210
|
+
def background_run
|
211
|
+
synchronize do
|
212
|
+
# Don't allow a stream to restart if already stopped
|
213
|
+
return if @stopped
|
214
|
+
|
215
|
+
@stopped = false
|
216
|
+
@paused = false
|
217
|
+
|
218
|
+
# signal to the previous queue to shut down
|
219
|
+
old_queue = []
|
220
|
+
old_queue = @request_queue.quit_and_dump_queue if @request_queue
|
221
|
+
|
222
|
+
# Always create a new request queue
|
223
|
+
@request_queue = EnumeratorQueue.new self
|
224
|
+
@request_queue.push initial_input_request
|
225
|
+
old_queue.each { |obj| @request_queue.push obj }
|
226
|
+
end
|
227
|
+
|
228
|
+
# Call the StreamingPull API to get the response enumerator
|
229
|
+
enum = @subscriber.service.streaming_pull @request_queue.each
|
230
|
+
|
231
|
+
loop do
|
232
|
+
synchronize do
|
233
|
+
if @paused && !@stopped
|
234
|
+
@pause_cond.wait
|
235
|
+
next
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
# Break loop, close thread if stopped
|
240
|
+
break if synchronize { @stopped }
|
241
|
+
|
242
|
+
begin
|
243
|
+
# Cannot syncronize the enumerator, causes deadlock
|
244
|
+
response = enum.next
|
245
|
+
|
246
|
+
# Use synchronize so both changes happen atomically
|
247
|
+
synchronize do
|
248
|
+
# Create receipt of received messages reception
|
249
|
+
@subscriber.buffer.modify_ack_deadline @subscriber.deadline, response.received_messages.map(&:ack_id)
|
250
|
+
|
251
|
+
# Add received messages to inventory
|
252
|
+
@inventory.add response.received_messages
|
253
|
+
end
|
254
|
+
|
255
|
+
response.received_messages.each do |rec_msg_grpc|
|
256
|
+
rec_msg = ReceivedMessage.from_grpc(rec_msg_grpc, self)
|
257
|
+
# No need to synchronize the callback future
|
258
|
+
register_callback rec_msg
|
259
|
+
end
|
260
|
+
synchronize { pause_streaming! }
|
261
|
+
rescue StopIteration
|
262
|
+
break
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
# Has the loop broken but we aren't stopped?
|
267
|
+
# Could be GRPC has thrown an internal error, so restart.
|
268
|
+
raise RestartStream unless synchronize { @stopped }
|
269
|
+
|
270
|
+
# We must be stopped, tell the stream to quit.
|
271
|
+
stop
|
272
|
+
rescue GRPC::Cancelled, GRPC::DeadlineExceeded, GRPC::Internal,
|
273
|
+
GRPC::ResourceExhausted, GRPC::Unauthenticated,
|
274
|
+
GRPC::Unavailable
|
275
|
+
# Restart the stream with an incremental back for a retriable error.
|
276
|
+
|
277
|
+
retry
|
278
|
+
rescue RestartStream
|
279
|
+
retry
|
280
|
+
rescue StandardError => e
|
281
|
+
@subscriber.error! e
|
282
|
+
|
283
|
+
retry
|
284
|
+
end
|
285
|
+
|
286
|
+
# rubocop:enable all
|
287
|
+
|
288
|
+
def register_callback rec_msg
|
289
|
+
if @sequencer
|
290
|
+
# Add the message to the sequencer to invoke the callback.
|
291
|
+
@sequencer.add rec_msg
|
292
|
+
else
|
293
|
+
# Call user provided code for received message
|
294
|
+
perform_callback_async rec_msg
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def perform_callback_async rec_msg
|
299
|
+
return unless callback_thread_pool.running?
|
300
|
+
|
301
|
+
Concurrent::Promises.future_on(
|
302
|
+
callback_thread_pool, rec_msg, &method(:perform_callback_sync)
|
303
|
+
)
|
304
|
+
end
|
305
|
+
|
306
|
+
def perform_callback_sync rec_msg
|
307
|
+
@subscriber.callback.call rec_msg unless stopped?
|
308
|
+
rescue StandardError => e
|
309
|
+
@subscriber.error! e
|
310
|
+
ensure
|
311
|
+
release rec_msg
|
312
|
+
if @sequencer && running?
|
313
|
+
begin
|
314
|
+
@sequencer.next rec_msg
|
315
|
+
rescue OrderedMessageDeliveryError => e
|
316
|
+
@subscriber.error! e
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
def start_streaming!
|
322
|
+
# A Stream will only ever have one background thread. If the thread
|
323
|
+
# dies because it was stopped, or because of an unhandled error that
|
324
|
+
# could not be recovered from, so be it.
|
325
|
+
return if @background_thread
|
326
|
+
|
327
|
+
# create new background thread to handle new enumerator
|
328
|
+
@background_thread = Thread.new { background_run }
|
329
|
+
end
|
330
|
+
|
331
|
+
def pause_streaming!
|
332
|
+
return unless pause_streaming?
|
333
|
+
|
334
|
+
@paused = true
|
335
|
+
end
|
336
|
+
|
337
|
+
def pause_streaming?
|
338
|
+
return if @stopped
|
339
|
+
return if @paused
|
340
|
+
|
341
|
+
@inventory.full?
|
342
|
+
end
|
343
|
+
|
344
|
+
def unpause_streaming!
|
345
|
+
return unless unpause_streaming?
|
346
|
+
|
347
|
+
@paused = nil
|
348
|
+
# signal to the background thread that we are unpaused
|
349
|
+
@pause_cond.broadcast
|
350
|
+
end
|
351
|
+
|
352
|
+
def unpause_streaming?
|
353
|
+
return if @stopped
|
354
|
+
return if @paused.nil?
|
355
|
+
|
356
|
+
@inventory.count < @inventory.limit * 0.8
|
357
|
+
end
|
358
|
+
|
359
|
+
def initial_input_request
|
360
|
+
Google::Cloud::PubSub::V1::StreamingPullRequest.new.tap do |req|
|
361
|
+
req.subscription = @subscriber.subscription_name
|
362
|
+
req.stream_ack_deadline_seconds = @subscriber.deadline
|
363
|
+
req.modify_deadline_ack_ids += @inventory.ack_ids
|
364
|
+
req.modify_deadline_seconds += @inventory.ack_ids.map { @subscriber.deadline }
|
365
|
+
req.client_id = @subscriber.service.client_id
|
366
|
+
req.max_outstanding_messages = @inventory.use_legacy_flow_control ? 0 : @inventory.limit
|
367
|
+
req.max_outstanding_bytes = @inventory.use_legacy_flow_control ? 0 : @inventory.bytesize
|
368
|
+
end
|
369
|
+
end
|
370
|
+
|
371
|
+
##
|
372
|
+
# Makes sure the values are the `ack_id`. If given several
|
373
|
+
# {ReceivedMessage} objects extract the `ack_id` values.
|
374
|
+
def coerce_ack_ids messages
|
375
|
+
Array(messages).flatten.map do |msg|
|
376
|
+
msg.respond_to?(:ack_id) ? msg.ack_id : msg.to_s
|
377
|
+
end
|
378
|
+
end
|
379
|
+
|
380
|
+
def status
|
381
|
+
return "stopped" if stopped?
|
382
|
+
return "paused" if paused?
|
383
|
+
"running"
|
384
|
+
end
|
385
|
+
|
386
|
+
def thread_status
|
387
|
+
return "not started" if @background_thread.nil?
|
388
|
+
|
389
|
+
status = @background_thread.status
|
390
|
+
return "error" if status.nil?
|
391
|
+
return "stopped" if status == false
|
392
|
+
status
|
393
|
+
end
|
394
|
+
end
|
395
|
+
end
|
396
|
+
end
|
397
|
+
|
398
|
+
Pubsub = PubSub unless const_defined? :Pubsub
|
399
|
+
end
|
400
|
+
end
|