cent 4.0.0 → 5.0.0
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/CHANGELOG.md +93 -0
- data/MIGRATING.md +305 -0
- data/README.md +702 -155
- data/lib/cent/auto_batch.rb +305 -0
- data/lib/cent/batch.rb +209 -0
- data/lib/cent/broadcast.rb +117 -0
- data/lib/cent/client.rb +602 -216
- data/lib/cent/command.rb +64 -0
- data/lib/cent/endpoints.rb +191 -0
- data/lib/cent/errors.rb +392 -0
- data/lib/cent/generated/methods.rb +4226 -0
- data/lib/cent/generated/proxy_types.rb +1834 -0
- data/lib/cent/generated/types.rb +2844 -0
- data/lib/cent/handler.rb +179 -0
- data/lib/cent/http.rb +251 -0
- data/lib/cent/message.rb +52 -0
- data/lib/cent/pages.rb +165 -0
- data/lib/cent/payload.rb +93 -0
- data/lib/cent/proxy.rb +254 -0
- data/lib/cent/reply.rb +109 -0
- data/lib/cent/retry.rb +53 -0
- data/lib/cent/shared_poll.rb +61 -0
- data/lib/cent/token.rb +560 -0
- data/lib/cent/version.rb +2 -1
- data/lib/cent/wire.rb +222 -0
- data/lib/cent.rb +33 -5
- metadata +34 -44
- data/Gemfile +0 -15
- data/Rakefile +0 -12
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/cent.gemspec +0 -36
- data/lib/cent/error.rb +0 -46
- data/lib/cent/notary.rb +0 -105
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cent
|
|
4
|
+
# Lets a client send concurrent calls together, as one batch request, once
|
|
5
|
+
# enough of them are in flight:
|
|
6
|
+
#
|
|
7
|
+
# client = Cent::Client.new(api_endpoint:, api_key:, auto_batch: Cent::AutoBatch.new(max_in_flight: 8))
|
|
8
|
+
#
|
|
9
|
+
# Nothing waits for a batch to fill. While fewer than max_in_flight requests
|
|
10
|
+
# are in flight, every call is sent at once, alone, exactly as without
|
|
11
|
+
# AutoBatch. Only a call made when max_in_flight requests are already in
|
|
12
|
+
# flight waits - for one of them to finish, not for a timer. Then all the
|
|
13
|
+
# calls waiting at that moment go out together, at most max_batch_size of
|
|
14
|
+
# them. The busier the client - Puma threads, Sidekiq workers, fibers sharing
|
|
15
|
+
# it - the larger the batches, and the fewer requests Centrifugo handles for
|
|
16
|
+
# the same work.
|
|
17
|
+
#
|
|
18
|
+
# Every call gets its own command's reply. Calls made one after another keep
|
|
19
|
+
# their order: a call waits for its reply, so the next one joins a later
|
|
20
|
+
# batch. Calls sharing a batch were made concurrently, with no order between
|
|
21
|
+
# them, and Centrifugo runs them in parallel.
|
|
22
|
+
#
|
|
23
|
+
# Calls sharing a batch share its request, though: when it fails as a whole -
|
|
24
|
+
# the network, the credentials, a body too large for a proxy in front of
|
|
25
|
+
# Centrifugo (nginx refuses bodies over 1 MB by default) - every call in it
|
|
26
|
+
# fails with that error. Each call keeps its own timeout: the batch lasts
|
|
27
|
+
# until the latest of its calls'.
|
|
28
|
+
#
|
|
29
|
+
# It is for processes which publish a lot at once - a Sidekiq process at
|
|
30
|
+
# concurrency 10 or more, an app on Falcon - and does nothing for a Puma web
|
|
31
|
+
# process of a few threads. Set max_in_flight to about half the threads or
|
|
32
|
+
# fibers which call the client at once: above that, it never batches; just
|
|
33
|
+
# below, it batches calls one at a time, and only limits them.
|
|
34
|
+
#
|
|
35
|
+
# @!attribute [r] max_in_flight
|
|
36
|
+
# @return [Integer] how many requests may be in flight before calls start
|
|
37
|
+
# to wait and be batched
|
|
38
|
+
# @!attribute [r] max_batch_size
|
|
39
|
+
# @return [Integer] the most calls one batch carries, 1000 by default
|
|
40
|
+
class AutoBatch < Data.define(:max_in_flight, :max_batch_size)
|
|
41
|
+
# @raise [InvalidConfigError]
|
|
42
|
+
def initialize(max_in_flight:, max_batch_size: 1000)
|
|
43
|
+
unless max_in_flight.is_a?(Integer) && max_in_flight >= 1
|
|
44
|
+
raise InvalidConfigError, "AutoBatch max_in_flight must be 1 or more; leave auto_batch unset to disable it"
|
|
45
|
+
end
|
|
46
|
+
unless max_batch_size.is_a?(Integer) && max_batch_size >= 2
|
|
47
|
+
raise InvalidConfigError, "AutoBatch max_batch_size must be 2 or more: a batch of 1 never batches"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A call waiting to be sent in a batch. Until done, its fields belong to
|
|
54
|
+
# the batcher; afterwards, to the caller.
|
|
55
|
+
#
|
|
56
|
+
# @api private
|
|
57
|
+
class Queued
|
|
58
|
+
attr_accessor :reply, :error, :taken, :gone, :index
|
|
59
|
+
attr_reader :command, :deadline, :done, :storage
|
|
60
|
+
|
|
61
|
+
def initialize(command, deadline)
|
|
62
|
+
# Encoded before it queued: the batch never reads the caller's request.
|
|
63
|
+
@command = command
|
|
64
|
+
@deadline = deadline
|
|
65
|
+
@done = Thread::Queue.new
|
|
66
|
+
@reply = {}
|
|
67
|
+
@error = nil
|
|
68
|
+
# taken once in a batch; gone once its caller gave up before that.
|
|
69
|
+
@taken = false
|
|
70
|
+
@gone = false
|
|
71
|
+
# Its place in the batch which carried it, for error messages.
|
|
72
|
+
@index = 0
|
|
73
|
+
# The caller's fiber storage, for batch_context.
|
|
74
|
+
@storage = Fiber.current.storage
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# AutoBatch for a Client.
|
|
79
|
+
#
|
|
80
|
+
# @api private
|
|
81
|
+
class Batcher
|
|
82
|
+
# Seconds a thread sending batches waits for the next before it ends.
|
|
83
|
+
WORKER_IDLE = 10.0
|
|
84
|
+
|
|
85
|
+
class << self
|
|
86
|
+
# WORKER_IDLE, unless the tests set another.
|
|
87
|
+
#
|
|
88
|
+
# @api private
|
|
89
|
+
attr_writer :worker_idle
|
|
90
|
+
|
|
91
|
+
# @api private
|
|
92
|
+
def worker_idle = @worker_idle || WORKER_IDLE
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def initialize(client, opts, batch_context)
|
|
96
|
+
@client = client
|
|
97
|
+
@batch_context = batch_context
|
|
98
|
+
@max_in_flight = opts.max_in_flight
|
|
99
|
+
@max_batch = opts.max_batch_size
|
|
100
|
+
@lock = Mutex.new
|
|
101
|
+
@in_flight = 0
|
|
102
|
+
@queue = []
|
|
103
|
+
# The threads sending batches, started as needed and kept while busy:
|
|
104
|
+
# at most one per slot, since a batch holds one, each ending after
|
|
105
|
+
# WORKER_IDLE seconds without a batch. Starting a thread per batch cost
|
|
106
|
+
# more than a small batch saved.
|
|
107
|
+
@jobs = Thread::Queue.new
|
|
108
|
+
@workers = 0
|
|
109
|
+
@idle = 0
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Stops the threads sending batches, once those in progress are sent.
|
|
113
|
+
def close
|
|
114
|
+
n = @lock.synchronize { @workers }
|
|
115
|
+
n.times { @jobs << :stop }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# [true, what alone returned] when a slot was free and the call went
|
|
119
|
+
# alone; [false, [its place in the batch, its command's reply]] when it
|
|
120
|
+
# went in a batch.
|
|
121
|
+
def call(method, wire, deadline, alone)
|
|
122
|
+
command = nil
|
|
123
|
+
q = nil
|
|
124
|
+
@lock.lock
|
|
125
|
+
begin
|
|
126
|
+
loop do
|
|
127
|
+
drop_gone
|
|
128
|
+
if @in_flight < @max_in_flight && @queue.empty?
|
|
129
|
+
@in_flight += 1
|
|
130
|
+
break
|
|
131
|
+
end
|
|
132
|
+
if command
|
|
133
|
+
q = Queued.new(command, deadline)
|
|
134
|
+
@queue << q
|
|
135
|
+
break
|
|
136
|
+
end
|
|
137
|
+
# The call will queue: encode its command first, outside the lock,
|
|
138
|
+
# then look again - a slot may have freed meanwhile, and a call must
|
|
139
|
+
# never queue while one is free.
|
|
140
|
+
@lock.unlock
|
|
141
|
+
begin
|
|
142
|
+
command = "{\"#{method}\":#{Client.encode(wire)}}"
|
|
143
|
+
ensure
|
|
144
|
+
@lock.lock
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
ensure
|
|
148
|
+
@lock.unlock
|
|
149
|
+
end
|
|
150
|
+
if q.nil?
|
|
151
|
+
begin
|
|
152
|
+
return [true, alone.call]
|
|
153
|
+
ensure
|
|
154
|
+
release
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
[false, wait(q, method, deadline)]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def wait(q, method, deadline)
|
|
163
|
+
if q.done.pop(timeout: [deadline - HTTP.now, 0].max).nil?
|
|
164
|
+
taken = @lock.synchronize do
|
|
165
|
+
q.gone = true unless q.taken
|
|
166
|
+
q.taken
|
|
167
|
+
end
|
|
168
|
+
# The reply may have come as the deadline ended: it is the call's
|
|
169
|
+
# outcome.
|
|
170
|
+
if q.done.empty?
|
|
171
|
+
raise TimeoutError, "#{method}: timed out" if taken
|
|
172
|
+
|
|
173
|
+
raise TimeoutError, "#{method}: timed out before it was sent"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
# A copy for each caller: raised in each caller's thread, one shared
|
|
177
|
+
# error would gather all their backtraces.
|
|
178
|
+
raise q.error.dup if q.error
|
|
179
|
+
|
|
180
|
+
[q.index, q.reply]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def drop_gone
|
|
184
|
+
@queue.shift while @queue.first&.gone
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Hands the slot of a finished request to the calls waiting, if any, and
|
|
188
|
+
# frees it otherwise.
|
|
189
|
+
def release
|
|
190
|
+
items = @lock.synchronize do
|
|
191
|
+
drop_gone
|
|
192
|
+
if @queue.empty?
|
|
193
|
+
@in_flight -= 1
|
|
194
|
+
next nil
|
|
195
|
+
end
|
|
196
|
+
take
|
|
197
|
+
end
|
|
198
|
+
return if items.nil?
|
|
199
|
+
|
|
200
|
+
# Sent from a thread of the batcher's, so that no caller's thread does
|
|
201
|
+
# work for others. Counted as a call: close waits for it.
|
|
202
|
+
@client.calls.add
|
|
203
|
+
dispatch(items)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def dispatch(items)
|
|
207
|
+
start = @lock.synchronize do
|
|
208
|
+
# Pushed under the lock which a sender ending idle takes: it sees
|
|
209
|
+
# the batch, and stays for it.
|
|
210
|
+
@jobs << items
|
|
211
|
+
next false unless @idle.zero? && @workers < @max_in_flight
|
|
212
|
+
|
|
213
|
+
@workers += 1
|
|
214
|
+
@idle += 1
|
|
215
|
+
end
|
|
216
|
+
Thread.new { work } if start
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def work
|
|
220
|
+
loop do
|
|
221
|
+
items = @jobs.pop(timeout: self.class.worker_idle)
|
|
222
|
+
if items.nil?
|
|
223
|
+
done = @lock.synchronize do
|
|
224
|
+
next false unless @jobs.empty?
|
|
225
|
+
|
|
226
|
+
@workers -= 1
|
|
227
|
+
@idle -= 1
|
|
228
|
+
true
|
|
229
|
+
end
|
|
230
|
+
break if done
|
|
231
|
+
|
|
232
|
+
next
|
|
233
|
+
end
|
|
234
|
+
if items == :stop
|
|
235
|
+
@lock.synchronize do
|
|
236
|
+
@workers -= 1
|
|
237
|
+
@idle -= 1
|
|
238
|
+
end
|
|
239
|
+
break
|
|
240
|
+
end
|
|
241
|
+
@lock.synchronize { @idle -= 1 }
|
|
242
|
+
# With an empty fiber storage: a batch is no one caller's.
|
|
243
|
+
Fiber.new(storage: {}) { flush(items) }.resume
|
|
244
|
+
@lock.synchronize { @idle += 1 }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Takes up to max_batch calls off the head of the queue, skipping those
|
|
249
|
+
# gone.
|
|
250
|
+
def take
|
|
251
|
+
items = []
|
|
252
|
+
until items.size >= @max_batch || @queue.empty?
|
|
253
|
+
q = @queue.shift
|
|
254
|
+
next if q.gone
|
|
255
|
+
|
|
256
|
+
q.taken = true
|
|
257
|
+
q.index = items.size
|
|
258
|
+
items << q
|
|
259
|
+
end
|
|
260
|
+
items
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def flush(items)
|
|
264
|
+
send_items(items) unless items.empty?
|
|
265
|
+
ensure
|
|
266
|
+
release
|
|
267
|
+
@client.calls.leave
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def send_items(items)
|
|
271
|
+
# Parallel: the calls of an automatic batch were made concurrently, so
|
|
272
|
+
# nothing orders them, and Centrifugo pipelines their broker commands.
|
|
273
|
+
body = "{\"commands\":[#{items.map(&:command).join(",")}],\"parallel\":true}"
|
|
274
|
+
# The latest deadline of the calls, never less than request_timeout
|
|
275
|
+
# from now.
|
|
276
|
+
timeout = [@client.request_timeout, *items.map { it.deadline - HTTP.now }].max
|
|
277
|
+
replies =
|
|
278
|
+
if @batch_context
|
|
279
|
+
out = nil
|
|
280
|
+
called = false
|
|
281
|
+
@batch_context.call(items.map(&:storage)) do
|
|
282
|
+
called = true
|
|
283
|
+
out = @client.send_auto_batch(body, items.size, timeout)
|
|
284
|
+
end
|
|
285
|
+
# Without the batch sent, its calls fail - not the batcher.
|
|
286
|
+
raise InvalidConfigError, "batch_context must call its block once: the batch was not sent" unless called
|
|
287
|
+
|
|
288
|
+
out
|
|
289
|
+
else
|
|
290
|
+
@client.send_auto_batch(body, items.size, timeout)
|
|
291
|
+
end
|
|
292
|
+
rescue StandardError => e
|
|
293
|
+
items.each do |q|
|
|
294
|
+
q.error = e
|
|
295
|
+
q.done << true
|
|
296
|
+
end
|
|
297
|
+
else
|
|
298
|
+
items.zip(replies) do |q, r|
|
|
299
|
+
q.reply = r
|
|
300
|
+
q.done << true
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
data/lib/cent/batch.rb
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cent
|
|
4
|
+
# The outcome of a sent batch as a whole. Each command's own outcome is in the
|
|
5
|
+
# Pending its method returned.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute [r] replied
|
|
8
|
+
# @return [Boolean] true when Centrifugo replied to every command, the failed
|
|
9
|
+
# ones included; false when the batch failed as a whole. True for an empty
|
|
10
|
+
# batch.
|
|
11
|
+
# @!attribute [r] failed
|
|
12
|
+
# @return [Array<FailedCommand>] the commands which failed, with their
|
|
13
|
+
# errors, in the order they were added
|
|
14
|
+
class BatchResult < Data.define(:replied, :failed)
|
|
15
|
+
include Message
|
|
16
|
+
|
|
17
|
+
# @return [Boolean] alias of replied
|
|
18
|
+
def replied? = replied
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The outcome of one command of a batch, available once the batch is sent.
|
|
22
|
+
class Pending
|
|
23
|
+
# @api private
|
|
24
|
+
def initialize(batch, index)
|
|
25
|
+
@batch = batch
|
|
26
|
+
@index = index
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The command's result, or its error raised: an ApiError for this command,
|
|
30
|
+
# the error which failed the whole batch, or BatchNotSentError. A broadcast
|
|
31
|
+
# raises BroadcastFailedChannelsError, carrying every channel's outcome,
|
|
32
|
+
# when any channel failed.
|
|
33
|
+
#
|
|
34
|
+
# @return [Object] the result of the command's method
|
|
35
|
+
# @raise [Error]
|
|
36
|
+
def value = @batch.outcome(@index)
|
|
37
|
+
|
|
38
|
+
# A Pending of no batch, whose {#value} returns +value+ or raises +error+:
|
|
39
|
+
# for test doubles of code which reads a batch's outcomes.
|
|
40
|
+
#
|
|
41
|
+
# Cent::Pending.resolved(Cent::PublishResult.new(offset: 1))
|
|
42
|
+
# Cent::Pending.resolved(error: Cent::ApiError.for_code(102, "unknown channel"))
|
|
43
|
+
#
|
|
44
|
+
# @param value [Object]
|
|
45
|
+
# @param error [Exception, nil]
|
|
46
|
+
# @return [Pending]
|
|
47
|
+
def self.resolved(value = nil, error: nil) = new(Resolved.new(value, error), 0)
|
|
48
|
+
|
|
49
|
+
# The outcome of a resolved Pending.
|
|
50
|
+
Resolved = Struct.new(:value, :error) do
|
|
51
|
+
# @api private
|
|
52
|
+
def outcome(_) = error ? raise(error) : value
|
|
53
|
+
end
|
|
54
|
+
private_constant :Resolved
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Commands sent to Centrifugo in one request. Create one with
|
|
58
|
+
# {Client#new_batch}, add commands with its methods - one per API method, each
|
|
59
|
+
# taking the method's fields and returning a {Pending} - then send it:
|
|
60
|
+
#
|
|
61
|
+
# batch = client.new_batch
|
|
62
|
+
# pub = batch.publish(channel: "news", data: { text: "hi" })
|
|
63
|
+
# stats = batch.presence_stats(channel: "news")
|
|
64
|
+
# batch.send # raises unless every command succeeded
|
|
65
|
+
# pub.value.offset
|
|
66
|
+
#
|
|
67
|
+
# A batch is sent once, and is not safe for concurrent use.
|
|
68
|
+
class Batch
|
|
69
|
+
include BatchMethods
|
|
70
|
+
|
|
71
|
+
# @api private
|
|
72
|
+
def initialize(client, parallel)
|
|
73
|
+
@client = client
|
|
74
|
+
@parallel = parallel
|
|
75
|
+
@commands = []
|
|
76
|
+
@methods = []
|
|
77
|
+
# Per command: an error which fails the whole batch before sending - a
|
|
78
|
+
# request the client can tell is invalid.
|
|
79
|
+
@invalid = []
|
|
80
|
+
# Per command, filled when sent: its outcome, a result or an error.
|
|
81
|
+
@outcomes = []
|
|
82
|
+
@replies = nil
|
|
83
|
+
@error = nil
|
|
84
|
+
@sent = false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Integer] how many commands the batch has
|
|
88
|
+
def size = @commands.size
|
|
89
|
+
alias length size
|
|
90
|
+
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def empty? = @commands.empty?
|
|
93
|
+
|
|
94
|
+
# Sends the batch and waits for Centrifugo's reply to every command.
|
|
95
|
+
#
|
|
96
|
+
# It returns when every command succeeded. BatchFailedCommandsError means
|
|
97
|
+
# one or more commands failed, up to all: every command got its own reply,
|
|
98
|
+
# each Pending holds its outcome. Any other error means the batch failed as
|
|
99
|
+
# a whole: no command got a reply, and every Pending raises that error. A
|
|
100
|
+
# command the client can tell is invalid fails the whole batch before
|
|
101
|
+
# anything is sent. An empty batch sends nothing.
|
|
102
|
+
#
|
|
103
|
+
# Called with a method name, it is Object#send.
|
|
104
|
+
#
|
|
105
|
+
# @param timeout [Numeric, nil] seconds bounding the call: request_timeout
|
|
106
|
+
# when nil
|
|
107
|
+
# @return [BatchResult]
|
|
108
|
+
# @raise [Error]
|
|
109
|
+
def send(*args, timeout: nil, **, &)
|
|
110
|
+
return __send__(*args, **, &) unless args.empty?
|
|
111
|
+
|
|
112
|
+
raise BatchSentError, "batch already sent" if @sent
|
|
113
|
+
|
|
114
|
+
@sent = true
|
|
115
|
+
begin
|
|
116
|
+
if @commands.empty?
|
|
117
|
+
@client.send_batch(nil, 0, timeout)
|
|
118
|
+
@replies = []
|
|
119
|
+
return BatchResult.new(replied: true, failed: [])
|
|
120
|
+
end
|
|
121
|
+
@invalid.each_with_index do |err, i|
|
|
122
|
+
raise InvalidRequestError, "batch command ##{i}: #{err.message}" if err
|
|
123
|
+
end
|
|
124
|
+
parallel = @parallel ? ',"parallel":true' : ""
|
|
125
|
+
body = "{\"commands\":[#{@commands.join(",")}]#{parallel}}"
|
|
126
|
+
@replies = @client.send_batch(body, @commands.size, timeout)
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
@error = e
|
|
129
|
+
raise
|
|
130
|
+
end
|
|
131
|
+
failed = []
|
|
132
|
+
@methods.each_with_index do |method, i|
|
|
133
|
+
out = resolve(i)
|
|
134
|
+
failed << FailedCommand.new(index: i, method:, error: out) if out.is_a?(Exception)
|
|
135
|
+
end
|
|
136
|
+
res = BatchResult.new(replied: true, failed:)
|
|
137
|
+
raise BatchFailedCommandsError.new(failed, @commands.size, res) unless failed.empty?
|
|
138
|
+
|
|
139
|
+
res
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @api private
|
|
143
|
+
def outcome(index)
|
|
144
|
+
raise BatchNotSentError, "batch not sent yet" unless @sent
|
|
145
|
+
raise @error if @error
|
|
146
|
+
|
|
147
|
+
out = resolve(index)
|
|
148
|
+
raise out if out.is_a?(Exception)
|
|
149
|
+
|
|
150
|
+
out
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def add(method, result_class, &)
|
|
156
|
+
append(method, ->(r, i) { result_class.from_wire(r[method] || {}, "replies[#{i}].#{method}") }, &)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def add_broadcast
|
|
160
|
+
channels = []
|
|
161
|
+
key = ""
|
|
162
|
+
decode = lambda do |r, i|
|
|
163
|
+
BroadcastResult.from_wire(r["broadcast"] || {}, channels, key).check!
|
|
164
|
+
rescue DecodeError => e
|
|
165
|
+
raise DecodeError, "replies[#{i}]: #{e.message}"
|
|
166
|
+
end
|
|
167
|
+
append("broadcast", decode) do
|
|
168
|
+
w = yield
|
|
169
|
+
channels = w["channels"] || []
|
|
170
|
+
key = w["idempotency_key"] || ""
|
|
171
|
+
w
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def append(method, decode)
|
|
176
|
+
raise FrozenError.new("cent: command added to a batch that was already sent", receiver: self) if @sent
|
|
177
|
+
|
|
178
|
+
text = ""
|
|
179
|
+
invalid = nil
|
|
180
|
+
begin
|
|
181
|
+
w = Reply.validate(method, yield)
|
|
182
|
+
text = "{\"#{method}\":#{Client.encode(w)}}"
|
|
183
|
+
rescue InvalidRequestError => e
|
|
184
|
+
invalid = InvalidRequestError.new("invalid #{method} request: #{e.message}")
|
|
185
|
+
end
|
|
186
|
+
@commands << text
|
|
187
|
+
@methods << method
|
|
188
|
+
@invalid << invalid
|
|
189
|
+
@outcomes << decode
|
|
190
|
+
Pending.new(self, @commands.size - 1)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Decodes command i's outcome once, so that send's BatchFailedCommandsError
|
|
194
|
+
# holds the very error its Pending raises.
|
|
195
|
+
def resolve(i)
|
|
196
|
+
out = @outcomes[i]
|
|
197
|
+
return out unless out.is_a?(Proc)
|
|
198
|
+
|
|
199
|
+
reply = @replies[i]
|
|
200
|
+
err = Reply.command_error(reply, i, @methods[i])
|
|
201
|
+
@outcomes[i] =
|
|
202
|
+
(err || begin
|
|
203
|
+
out.call(reply, i)
|
|
204
|
+
rescue Error => e
|
|
205
|
+
e
|
|
206
|
+
end)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cent
|
|
4
|
+
# The outcome of a broadcast in one channel.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] channel
|
|
7
|
+
# @return [String] the channel, as the request named it
|
|
8
|
+
# @!attribute [r] result
|
|
9
|
+
# @return [PublishResult, nil] the publication's position in the channel's
|
|
10
|
+
# history, when it succeeded
|
|
11
|
+
# @!attribute [r] error
|
|
12
|
+
# @return [Error, nil] nil when the publication succeeded; the channel's
|
|
13
|
+
# ApiError, or the error of the whole broadcast when it failed as a whole
|
|
14
|
+
# @!attribute [r] attempts
|
|
15
|
+
# @return [Integer] how many times the channel was sent: more than 1 when
|
|
16
|
+
# broadcast_with_retry retried it
|
|
17
|
+
class ChannelResult < Data.define(:channel, :result, :error, :attempts)
|
|
18
|
+
include Message
|
|
19
|
+
|
|
20
|
+
def initialize(channel:, result: nil, error: nil, attempts: 0) = super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The outcome of a broadcast: one entry per channel, in the order the request
|
|
24
|
+
# listed them.
|
|
25
|
+
#
|
|
26
|
+
# @!attribute [r] channels
|
|
27
|
+
# @return [Array<ChannelResult>]
|
|
28
|
+
# @!attribute [r] idempotency_key
|
|
29
|
+
# @return [String] the key the broadcast was sent with: the request's, or the
|
|
30
|
+
# one broadcast_with_retry made when the request had none. A later retry
|
|
31
|
+
# with it does not publish again into the channels which got the
|
|
32
|
+
# publication.
|
|
33
|
+
# @!attribute [r] attempts
|
|
34
|
+
# @return [Integer] how many times the broadcast was tried: 1 from broadcast,
|
|
35
|
+
# up to max_attempts from broadcast_with_retry, 0 when it was refused
|
|
36
|
+
# before sending
|
|
37
|
+
# @!attribute [r] replied
|
|
38
|
+
# @return [Boolean] true when Centrifugo carried the broadcast out and
|
|
39
|
+
# replied for every channel, the failed ones included; false when it
|
|
40
|
+
# failed as a whole
|
|
41
|
+
class BroadcastResult < Data.define(:channels, :idempotency_key, :attempts, :replied)
|
|
42
|
+
include Message
|
|
43
|
+
|
|
44
|
+
def initialize(channels: [], idempotency_key: "", attempts: 0, replied: false) = super
|
|
45
|
+
|
|
46
|
+
# The channels which did not get the publication, with their errors, in the
|
|
47
|
+
# order of the request.
|
|
48
|
+
#
|
|
49
|
+
# @return [Array<ChannelResult>]
|
|
50
|
+
def failed = channels.select(&:error)
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] alias of replied
|
|
53
|
+
def replied? = replied
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
def self.failed_as_whole(channels, err, attempts = 0)
|
|
57
|
+
new(channels: channels.map { ChannelResult.new(channel: it, error: err, attempts:) }, attempts:)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The result of a broadcast Centrifugo replied to. The reply holds one
|
|
61
|
+
# response per channel, in the request's order.
|
|
62
|
+
#
|
|
63
|
+
# @api private
|
|
64
|
+
def self.from_wire(v, channels, key)
|
|
65
|
+
d = Wire.obj(v, "broadcast result")
|
|
66
|
+
responses = d["responses"] || []
|
|
67
|
+
raise DecodeError, "broadcast result.responses: expected an array" unless responses.is_a?(Array)
|
|
68
|
+
if responses.size != channels.size
|
|
69
|
+
raise DecodeError, "broadcast: got #{responses.size} replies for #{channels.size} channels"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
results = responses.each_with_index.map do |item, i|
|
|
73
|
+
where = "broadcast result.responses[#{i}]"
|
|
74
|
+
r = Wire.obj(item, where)
|
|
75
|
+
if r["error"].nil?
|
|
76
|
+
ChannelResult.new(channel: channels[i], result: PublishResult.from_wire(r["result"] || {}, "#{where}.result"),
|
|
77
|
+
attempts: 1)
|
|
78
|
+
else
|
|
79
|
+
ChannelResult.new(channel: channels[i], error: Reply.api_error(r["error"], "#{where}.error"), attempts: 1)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
new(channels: results, idempotency_key: key, attempts: 1, replied: true)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The result, or BroadcastFailedChannelsError carrying it when any channel
|
|
86
|
+
# failed.
|
|
87
|
+
#
|
|
88
|
+
# @api private
|
|
89
|
+
def check!
|
|
90
|
+
f = failed
|
|
91
|
+
raise BroadcastFailedChannelsError.new(f, channels.size, self) unless f.empty?
|
|
92
|
+
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A failed attempt of broadcast_with_retry, given to its block.
|
|
98
|
+
#
|
|
99
|
+
# @!attribute [r] attempt
|
|
100
|
+
# @return [Integer] the attempt which failed, from 1
|
|
101
|
+
# @!attribute [r] error
|
|
102
|
+
# @return [Error] its error: BroadcastFailedChannelsError when Centrifugo
|
|
103
|
+
# replied and some channels failed, otherwise the error of the request as a
|
|
104
|
+
# whole
|
|
105
|
+
# @!attribute [r] retry
|
|
106
|
+
# @return [Array<ChannelResult>] the channels the next attempt sends, with
|
|
107
|
+
# the errors this attempt failed them with. A broadcast to many channels
|
|
108
|
+
# which all failed lists every one: log a count or a few.
|
|
109
|
+
# @!attribute [r] result
|
|
110
|
+
# @return [BroadcastResult] every channel's outcome so far, as the call
|
|
111
|
+
# would return it if it stopped now
|
|
112
|
+
# @!attribute [r] idempotency_key
|
|
113
|
+
# @return [String] the key every attempt is sent with
|
|
114
|
+
class BroadcastAttempt < Data.define(:attempt, :error, :retry, :result, :idempotency_key)
|
|
115
|
+
include Message
|
|
116
|
+
end
|
|
117
|
+
end
|