cent 3.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.
@@ -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