grpc-dc 0.37.1
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 +7 -0
- data/LICENSE +201 -0
- data/README.md +10 -0
- data/lib/grpc_dc.rb +1 -0
- data/lib/grpcdc/channel.rb +327 -0
- data/lib/grpcdc/codec.rb +262 -0
- data/lib/grpcdc/dynamic.rb +677 -0
- data/lib/grpcdc/endpoint.rb +397 -0
- data/lib/grpcdc/eventbus.rb +250 -0
- data/lib/grpcdc/eventbus_git.rb +132 -0
- data/lib/grpcdc/events_grpc_dc.rb +232 -0
- data/lib/grpcdc/events_pb.rb +432 -0
- data/lib/grpcdc/loopback.rb +43 -0
- data/lib/grpcdc/server.rb +106 -0
- data/lib/grpcdc/sidecar.rb +63 -0
- data/lib/grpcdc/wire_gen.rb +58 -0
- data/lib/grpcdc.rb +12 -0
- metadata +61 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
# @generated by grpc-dc codegen. DO NOT EDIT.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# Endpoint: multiplexes grpc-dc streams over one Transport.
|
|
4
|
+
|
|
5
|
+
require_relative "codec"
|
|
6
|
+
require_relative "wire_gen"
|
|
7
|
+
|
|
8
|
+
module Grpcdc
|
|
9
|
+
EVENT_HEADERS = "headers"
|
|
10
|
+
EVENT_DATA = "data"
|
|
11
|
+
EVENT_HALF_CLOSE = "half-close"
|
|
12
|
+
EVENT_TRAILERS = "trailers"
|
|
13
|
+
EVENT_RST = "rst"
|
|
14
|
+
|
|
15
|
+
# Carries whole DataChannel messages (boundaries preserved).
|
|
16
|
+
#
|
|
17
|
+
# recv returns one message, or raises EOFError when the
|
|
18
|
+
# transport is closed.
|
|
19
|
+
class Transport
|
|
20
|
+
def send(_data)
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def recv
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class StreamEvent
|
|
30
|
+
attr_reader :kind, :headers, :payload, :status
|
|
31
|
+
|
|
32
|
+
def initialize(kind, headers: nil, payload: nil, status: nil)
|
|
33
|
+
@kind = kind
|
|
34
|
+
@headers = headers
|
|
35
|
+
@payload = payload
|
|
36
|
+
@status = status
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Receiving queue of one stream; closed after a terminal event.
|
|
41
|
+
class StreamInput
|
|
42
|
+
CLOSED = Object.new
|
|
43
|
+
|
|
44
|
+
def initialize
|
|
45
|
+
@events = Queue.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def put(event)
|
|
49
|
+
@events << event
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def close
|
|
53
|
+
@events << CLOSED
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
TIMEOUT = Object.new
|
|
57
|
+
|
|
58
|
+
# Next event, or nil once the stream is closed. With a
|
|
59
|
+
# timeout (seconds), returns TIMEOUT when the wait expires.
|
|
60
|
+
def get(timeout = nil)
|
|
61
|
+
return get_blocking if timeout.nil?
|
|
62
|
+
|
|
63
|
+
# Portable timed wait: Queue#pop(timeout:) only exists on
|
|
64
|
+
# Ruby >= 3.2 (3.1 raises immediately), so poll non-blocking.
|
|
65
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
66
|
+
loop do
|
|
67
|
+
begin
|
|
68
|
+
event = @events.pop(true)
|
|
69
|
+
return event.equal?(CLOSED) ? nil : event
|
|
70
|
+
rescue ThreadError
|
|
71
|
+
return TIMEOUT if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
72
|
+
|
|
73
|
+
sleep(0.001)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def get_blocking
|
|
81
|
+
event = @events.pop
|
|
82
|
+
event.equal?(CLOSED) ? nil : event
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
public
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class ResponseSink
|
|
89
|
+
# Wired to the CallContext maps by the endpoint.
|
|
90
|
+
attr_accessor :resp_headers, :resp_trailers
|
|
91
|
+
|
|
92
|
+
def initialize(endpoint, stream_id)
|
|
93
|
+
@endpoint = endpoint
|
|
94
|
+
@id = stream_id
|
|
95
|
+
@resp_headers = {}
|
|
96
|
+
@resp_trailers = {}
|
|
97
|
+
@headers_sent = false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def send_data(payload)
|
|
101
|
+
# A stream the endpoint has forgotten was RST by the peer
|
|
102
|
+
# (or the channel went away): fail the send so a producing
|
|
103
|
+
# handler stops instead of streaming into the void.
|
|
104
|
+
if @endpoint.stream(@id).nil?
|
|
105
|
+
raise StatusError.new(CODE_CANCELLED, "call cancelled")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
flush_headers
|
|
109
|
+
@endpoint.send_frame(Codec.encode_frame(FRAME_DATA, @id, payload))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def flush_headers
|
|
113
|
+
return if @headers_sent
|
|
114
|
+
|
|
115
|
+
@headers_sent = true
|
|
116
|
+
send_headers(@resp_headers)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def send_headers(metadata = nil)
|
|
120
|
+
if @endpoint.stream(@id).nil?
|
|
121
|
+
raise StatusError.new(CODE_CANCELLED, "call cancelled")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
@headers_sent = true
|
|
125
|
+
@endpoint.send_frame(Codec.headers_frame(@id, nil, metadata || @resp_headers))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def send_trailers(status)
|
|
129
|
+
if @resp_trailers && !@resp_trailers.empty?
|
|
130
|
+
merged = @resp_trailers.merge(status.trailers || {})
|
|
131
|
+
status = StatusError.new(status.code, status.message)
|
|
132
|
+
status.trailers = merged
|
|
133
|
+
end
|
|
134
|
+
@endpoint.send_frame(Codec.status_frame(FRAME_TRAILERS, @id, status))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Server sink interface used by Server.
|
|
138
|
+
def send(payload)
|
|
139
|
+
send_data(payload)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Per-call context: metadata sent in HEADERS, plus deadline.
|
|
144
|
+
class CallContext
|
|
145
|
+
attr_reader :metadata, :deadline, :response_headers, :response_trailers
|
|
146
|
+
|
|
147
|
+
# Record response initial metadata (sent lazily, before
|
|
148
|
+
# the first response message).
|
|
149
|
+
def set_headers(md)
|
|
150
|
+
@response_headers.merge!(md)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Record response trailing metadata (rides the terminal
|
|
154
|
+
# status frame).
|
|
155
|
+
def set_trailers(md)
|
|
156
|
+
@response_trailers.merge!(md)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def initialize(metadata = nil, timeout: nil)
|
|
160
|
+
@metadata = metadata || {}
|
|
161
|
+
@response_headers = {}
|
|
162
|
+
@response_trailers = {}
|
|
163
|
+
remaining = Codec.parse_timeout(@metadata["grpc-timeout"])
|
|
164
|
+
@deadline = remaining.nil? ? nil : Process.clock_gettime(Process::CLOCK_MONOTONIC) + remaining
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Seconds left before the deadline, or nil when unbounded.
|
|
168
|
+
def time_remaining
|
|
169
|
+
return nil if @deadline.nil?
|
|
170
|
+
|
|
171
|
+
[@deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.0].max
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def deadline_exceeded?
|
|
175
|
+
time_remaining == 0.0
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Multiplexes streams over one Transport.
|
|
180
|
+
#
|
|
181
|
+
# The side that created the channel allocates odd stream ids,
|
|
182
|
+
# the answerer even.
|
|
183
|
+
class Endpoint
|
|
184
|
+
attr_reader :transport
|
|
185
|
+
attr_accessor :dispatcher
|
|
186
|
+
|
|
187
|
+
def initialize(transport, creator:)
|
|
188
|
+
@transport = transport
|
|
189
|
+
@dispatcher = nil
|
|
190
|
+
@next_id = creator ? 1 : 2
|
|
191
|
+
@send_lock = Mutex.new
|
|
192
|
+
@streams = {}
|
|
193
|
+
@streams_lock = Mutex.new
|
|
194
|
+
@closed = false
|
|
195
|
+
@started = false
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Start the read loop (idempotent). Called after any
|
|
199
|
+
# dispatcher is installed, so an early HEADERS can never race
|
|
200
|
+
# dispatcher setup.
|
|
201
|
+
def start
|
|
202
|
+
return if @started
|
|
203
|
+
|
|
204
|
+
@started = true
|
|
205
|
+
Thread.new { read_loop }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def set_dispatcher(dispatcher)
|
|
209
|
+
@dispatcher = dispatcher
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def send_frame(frame)
|
|
213
|
+
@send_lock.synchronize { @transport.send(frame) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def open_stream
|
|
217
|
+
stream_id = @send_lock.synchronize do
|
|
218
|
+
id = @next_id
|
|
219
|
+
@next_id += 2
|
|
220
|
+
id
|
|
221
|
+
end
|
|
222
|
+
stream_input = StreamInput.new
|
|
223
|
+
@streams_lock.synchronize { @streams[stream_id] = stream_input }
|
|
224
|
+
[stream_id, stream_input]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def close_stream(stream_id)
|
|
228
|
+
@streams_lock.synchronize { @streams.delete(stream_id) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Abort one stream: RST towards the peer; any thread blocked
|
|
232
|
+
# in recv wakes with the cancellation status (the local input
|
|
233
|
+
# is closed with a synthetic RST event).
|
|
234
|
+
def cancel_stream(stream_id)
|
|
235
|
+
status = StatusError.new(CODE_CANCELLED, "call cancelled")
|
|
236
|
+
input = @streams_lock.synchronize { @streams.delete(stream_id) }
|
|
237
|
+
unless input.nil?
|
|
238
|
+
input.put(StreamEvent.new(EVENT_RST, status: status))
|
|
239
|
+
input.close
|
|
240
|
+
end
|
|
241
|
+
begin
|
|
242
|
+
send_frame(Codec.status_frame(FRAME_RST, stream_id, status))
|
|
243
|
+
rescue StandardError
|
|
244
|
+
nil
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def stream(stream_id)
|
|
249
|
+
@streams_lock.synchronize { @streams[stream_id] }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def close
|
|
253
|
+
return if @closed
|
|
254
|
+
|
|
255
|
+
@closed = true
|
|
256
|
+
begin
|
|
257
|
+
@transport.close if @transport.respond_to?(:close)
|
|
258
|
+
rescue StandardError
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
fail_all
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
private
|
|
265
|
+
|
|
266
|
+
def fail_all
|
|
267
|
+
pending = @streams_lock.synchronize do
|
|
268
|
+
items = @streams.to_a
|
|
269
|
+
@streams.clear
|
|
270
|
+
items
|
|
271
|
+
end
|
|
272
|
+
pending.each do |_stream_id, stream_input|
|
|
273
|
+
stream_input.put(StreamEvent.new(
|
|
274
|
+
EVENT_RST,
|
|
275
|
+
status: StatusError.new(CODE_UNAVAILABLE, "data channel closed")
|
|
276
|
+
))
|
|
277
|
+
stream_input.close
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def read_loop
|
|
282
|
+
loop do
|
|
283
|
+
begin
|
|
284
|
+
data = @transport.recv
|
|
285
|
+
rescue StandardError
|
|
286
|
+
fail_all
|
|
287
|
+
return
|
|
288
|
+
end
|
|
289
|
+
return if @closed
|
|
290
|
+
|
|
291
|
+
on_bytes(data)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def on_bytes(data)
|
|
296
|
+
begin
|
|
297
|
+
frame_type, stream_id, payload = Codec.decode_frame(data)
|
|
298
|
+
rescue StatusError
|
|
299
|
+
return # malformed frame: drop, keep the channel up
|
|
300
|
+
end
|
|
301
|
+
case frame_type
|
|
302
|
+
when 1 then on_headers(stream_id, payload)
|
|
303
|
+
when 2 then push_event(stream_id, StreamEvent.new(EVENT_DATA, payload: payload))
|
|
304
|
+
when 3 then on_terminal(stream_id, payload, EVENT_TRAILERS)
|
|
305
|
+
when 4 then push_event(stream_id, StreamEvent.new(EVENT_HALF_CLOSE))
|
|
306
|
+
when 5 then on_terminal(stream_id, payload, EVENT_RST)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def on_terminal(stream_id, payload, kind)
|
|
312
|
+
stream_input = stream(stream_id)
|
|
313
|
+
return unless stream_input
|
|
314
|
+
|
|
315
|
+
stream_input.put(StreamEvent.new(kind, status: Codec.parse_status(payload)))
|
|
316
|
+
stream_input.close
|
|
317
|
+
close_stream(stream_id)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def push_event(stream_id, event)
|
|
321
|
+
stream_input = stream(stream_id)
|
|
322
|
+
stream_input&.put(event)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def on_headers(stream_id, payload)
|
|
326
|
+
begin
|
|
327
|
+
headers = Codec.parse_headers(payload)
|
|
328
|
+
rescue StatusError => e
|
|
329
|
+
begin
|
|
330
|
+
send_frame(Codec.status_frame(FRAME_RST, stream_id, Grpcdc.to_status(e)))
|
|
331
|
+
rescue StandardError
|
|
332
|
+
nil
|
|
333
|
+
end
|
|
334
|
+
return
|
|
335
|
+
end
|
|
336
|
+
if headers.path.nil?
|
|
337
|
+
# Server response headers for one of our outbound streams.
|
|
338
|
+
stream_input = stream(stream_id)
|
|
339
|
+
stream_input&.put(StreamEvent.new(EVENT_HEADERS, headers: headers))
|
|
340
|
+
return
|
|
341
|
+
end
|
|
342
|
+
if stream(stream_id)
|
|
343
|
+
begin
|
|
344
|
+
send_frame(Codec.status_frame(
|
|
345
|
+
FRAME_RST, stream_id,
|
|
346
|
+
StatusError.new(CODE_INTERNAL, "HEADERS for stream already in use")
|
|
347
|
+
))
|
|
348
|
+
rescue StandardError
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
return
|
|
352
|
+
end
|
|
353
|
+
if @dispatcher.nil?
|
|
354
|
+
begin
|
|
355
|
+
send_frame(Codec.status_frame(
|
|
356
|
+
FRAME_TRAILERS, stream_id,
|
|
357
|
+
StatusError.new(CODE_UNIMPLEMENTED, "unknown method #{headers.path}")
|
|
358
|
+
))
|
|
359
|
+
rescue StandardError
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
return
|
|
363
|
+
end
|
|
364
|
+
stream_input = StreamInput.new
|
|
365
|
+
@streams_lock.synchronize { @streams[stream_id] = stream_input }
|
|
366
|
+
sink = ResponseSink.new(self, stream_id)
|
|
367
|
+
dispatcher = @dispatcher
|
|
368
|
+
Thread.new do
|
|
369
|
+
begin
|
|
370
|
+
# No eager HEADERS: initial metadata flushes lazily
|
|
371
|
+
# via the sink (first DATA, or below on a DATA-less
|
|
372
|
+
# success) so handlers can set_headers first. Errors
|
|
373
|
+
# end trailers-only, as gRPC allows.
|
|
374
|
+
ctx = CallContext.new(headers.metadata)
|
|
375
|
+
sink.resp_headers = ctx.response_headers
|
|
376
|
+
sink.resp_trailers = ctx.response_trailers
|
|
377
|
+
status = dispatcher.call(ctx, headers, stream_input, sink)
|
|
378
|
+
if status.nil?
|
|
379
|
+
status = StatusError.new(CODE_OK, "")
|
|
380
|
+
begin
|
|
381
|
+
sink.flush_headers
|
|
382
|
+
rescue StandardError
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
begin
|
|
387
|
+
sink.send_trailers(status)
|
|
388
|
+
rescue StandardError
|
|
389
|
+
nil
|
|
390
|
+
end
|
|
391
|
+
ensure
|
|
392
|
+
close_stream(stream_id)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# @generated by grpc-dc codegen. DO NOT EDIT.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# Event bus (grpcdc.events.v1.EventBus): in-memory broker,
|
|
4
|
+
# client helpers, and tag matching. Wire messages and stubs
|
|
5
|
+
# are generated from proto/events.proto (events_pb,
|
|
6
|
+
# events_grpc_dc); the broker policy comes from the shared
|
|
7
|
+
# event-bus spec.
|
|
8
|
+
#
|
|
9
|
+
# Delivery is at-most-once: each subscriber has a bounded
|
|
10
|
+
# queue (SUBSCRIBER_QUEUE_CAPACITY) and drops its oldest
|
|
11
|
+
# events when it falls behind; stats reports the drops.
|
|
12
|
+
|
|
13
|
+
require "thread"
|
|
14
|
+
|
|
15
|
+
require_relative "events_pb"
|
|
16
|
+
require_relative "events_grpc_dc"
|
|
17
|
+
|
|
18
|
+
module Grpcdc
|
|
19
|
+
module Eventbus
|
|
20
|
+
SUBSCRIBER_QUEUE_CAPACITY = 128
|
|
21
|
+
|
|
22
|
+
# Canonical identity of one tag value. The oneof kind is
|
|
23
|
+
# part of the identity (int 5 != "5" != enum 5). Kinds are
|
|
24
|
+
# told apart by value, so an all-default TagValue reads as
|
|
25
|
+
# the empty string value; numbers compare bit-exact, never
|
|
26
|
+
# via decimal text.
|
|
27
|
+
def self.value_key(tv)
|
|
28
|
+
return "s:#{tv.string_value}" unless tv.string_value.empty?
|
|
29
|
+
return "i:#{tv.int_value}" unless tv.int_value.zero?
|
|
30
|
+
return "d:#{[tv.double_value].pack("G").unpack1("H*")}" unless tv.double_value.zero?
|
|
31
|
+
return "b:true" if tv.bool_value
|
|
32
|
+
return "e:#{tv.enum_value}" unless tv.enum_value.zero?
|
|
33
|
+
|
|
34
|
+
"s:"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Stable key for one TagValue, e.g. "1|s:orders".
|
|
38
|
+
def self.tag_value_key(tv)
|
|
39
|
+
"#{tv.tag}|#{value_key(tv)}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Containment match: the event is delivered when its tag
|
|
43
|
+
# list contains every (tag, value) pair of the filter. An
|
|
44
|
+
# empty filter matches everything.
|
|
45
|
+
def self.tags_match(event_tags, filter_tags)
|
|
46
|
+
return true if filter_tags.nil? || filter_tags.empty?
|
|
47
|
+
|
|
48
|
+
have = {}
|
|
49
|
+
(event_tags || []).each { |tv| have[tag_value_key(tv)] = true }
|
|
50
|
+
filter_tags.all? { |f| have.key?(tag_value_key(f)) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.tag_string(tag, value)
|
|
54
|
+
EventsPb::TagValue.new(tag: tag, string_value: value.to_s)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.tag_int(tag, value)
|
|
58
|
+
EventsPb::TagValue.new(tag: tag, int_value: Integer(value))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.tag_double(tag, value)
|
|
62
|
+
EventsPb::TagValue.new(tag: tag, double_value: Float(value))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.tag_bool(tag, value)
|
|
66
|
+
EventsPb::TagValue.new(tag: tag, bool_value: value ? true : false)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.tag_enum(tag, value)
|
|
70
|
+
EventsPb::TagValue.new(tag: tag, enum_value: Integer(value))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class Subscriber
|
|
74
|
+
attr_reader :filter, :dropped
|
|
75
|
+
|
|
76
|
+
def initialize(filter_tags, capacity)
|
|
77
|
+
@filter = filter_tags || []
|
|
78
|
+
@capacity = capacity
|
|
79
|
+
@queue = []
|
|
80
|
+
@mutex = Mutex.new
|
|
81
|
+
@cond = ConditionVariable.new
|
|
82
|
+
@dropped = 0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def push(event)
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
dropped = false
|
|
88
|
+
if @queue.length >= @capacity
|
|
89
|
+
@queue.shift
|
|
90
|
+
@dropped += 1
|
|
91
|
+
dropped = true
|
|
92
|
+
end
|
|
93
|
+
@queue << event
|
|
94
|
+
@cond.broadcast
|
|
95
|
+
dropped
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Next queued event, or nil when the wait times out. The
|
|
100
|
+
# broker re-checks every tick; a cancelled call is reaped
|
|
101
|
+
# when its next matching send fails (the runtime surfaces
|
|
102
|
+
# cancellation only at send time).
|
|
103
|
+
def take(timeout)
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
@cond.wait(@mutex, timeout) if @queue.empty?
|
|
106
|
+
@queue.empty? ? nil : @queue.shift
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# In-memory grpcdc.events.v1.EventBus broker. Any peer can
|
|
112
|
+
# host it: register() it on a Grpcdc::Server next to your
|
|
113
|
+
# own services, or call publish_event directly in-process.
|
|
114
|
+
class EventBusBroker
|
|
115
|
+
def initialize(queue_capacity: SUBSCRIBER_QUEUE_CAPACITY)
|
|
116
|
+
@queue_capacity = queue_capacity
|
|
117
|
+
@mutex = Mutex.new
|
|
118
|
+
@subscribers = []
|
|
119
|
+
@seq = 0
|
|
120
|
+
@published = 0
|
|
121
|
+
@dropped = 0
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Accept an event: assign its sequence number and
|
|
125
|
+
# timestamp and fan it out to matching subscribers.
|
|
126
|
+
# Returns the stamped EventsPb::Event.
|
|
127
|
+
def publish_event(tags, payload, publisher_id = "")
|
|
128
|
+
event = nil
|
|
129
|
+
subscribers = nil
|
|
130
|
+
@mutex.synchronize do
|
|
131
|
+
@seq += 1
|
|
132
|
+
event = EventsPb::Event.new(
|
|
133
|
+
tags: tags || [],
|
|
134
|
+
payload: (payload || "".b).to_s.b,
|
|
135
|
+
seq: @seq,
|
|
136
|
+
publisher_id: publisher_id || "",
|
|
137
|
+
timestamp_unix_ms: (Time.now.to_f * 1000).to_i
|
|
138
|
+
)
|
|
139
|
+
@published += 1
|
|
140
|
+
subscribers = @subscribers.dup
|
|
141
|
+
end
|
|
142
|
+
subscribers.each do |sub|
|
|
143
|
+
next unless Eventbus.tags_match(event.tags, sub.filter)
|
|
144
|
+
|
|
145
|
+
if sub.push(event)
|
|
146
|
+
@mutex.synchronize { @dropped += 1 }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
event
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def stats
|
|
153
|
+
@mutex.synchronize do
|
|
154
|
+
{ "published" => @published, "subscribers" => @subscribers.length, "dropped" => @dropped }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# grpcdc.events.v1.EventBus service implementation
|
|
159
|
+
# (register calls these).
|
|
160
|
+
def Publish(_ctx, req)
|
|
161
|
+
event = publish_event(req.tags, req.payload, req.publisher_id)
|
|
162
|
+
EventsPb::PublishReply.new(seq: event.seq)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def Subscribe(req, stream)
|
|
166
|
+
sub = Subscriber.new(req.filter, @queue_capacity)
|
|
167
|
+
@mutex.synchronize { @subscribers << sub }
|
|
168
|
+
begin
|
|
169
|
+
loop do
|
|
170
|
+
event = sub.take(0.1)
|
|
171
|
+
stream.send(event) unless event.nil?
|
|
172
|
+
end
|
|
173
|
+
ensure
|
|
174
|
+
@mutex.synchronize { @subscribers.delete(sub) }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# In-process subscription: events fan out to it exactly
|
|
179
|
+
# like an RPC subscriber, without a call. Useful embedded
|
|
180
|
+
# and for tests; close() unregisters.
|
|
181
|
+
def subscribe_local(filter_tags)
|
|
182
|
+
sub = Subscriber.new(filter_tags, @queue_capacity)
|
|
183
|
+
@mutex.synchronize { @subscribers << sub }
|
|
184
|
+
LocalSubscription.new(self, sub)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def remove_subscriber(sub)
|
|
188
|
+
@mutex.synchronize { @subscribers.delete(sub) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def register(server)
|
|
192
|
+
EventsGrpcDc.register_event_bus_server(server, self)
|
|
193
|
+
self
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Handle for EventBusBroker#subscribe_local: pull events
|
|
198
|
+
# in-process. take returns the next event (waiting up to
|
|
199
|
+
# the timeout, in seconds), or nil; dropped counts this
|
|
200
|
+
# subscription's drop-oldest losses.
|
|
201
|
+
class LocalSubscription
|
|
202
|
+
def initialize(broker, sub)
|
|
203
|
+
@broker = broker
|
|
204
|
+
@sub = sub
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def dropped
|
|
208
|
+
@sub.dropped
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def take(timeout = 1.0)
|
|
212
|
+
deadline = Time.now + timeout
|
|
213
|
+
loop do
|
|
214
|
+
remaining = deadline - Time.now
|
|
215
|
+
return @sub.take(0) if remaining <= 0
|
|
216
|
+
|
|
217
|
+
event = @sub.take([remaining, 0.1].min)
|
|
218
|
+
return event unless event.nil?
|
|
219
|
+
return nil if Time.now >= deadline
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def close
|
|
224
|
+
@broker.remove_subscriber(@sub)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Publish through a generated EventBusClient; returns the
|
|
229
|
+
# broker-assigned sequence number.
|
|
230
|
+
def self.publish_event(client, tags, payload, publisher_id = "")
|
|
231
|
+
reply = client.Publish(EventsPb::PublishRequest.new(
|
|
232
|
+
tags: tags || [],
|
|
233
|
+
payload: (payload || "".b).to_s.b,
|
|
234
|
+
publisher_id: publisher_id || ""
|
|
235
|
+
))
|
|
236
|
+
reply.seq
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Subscribe through a generated EventBusClient; returns the
|
|
240
|
+
# receiving stream (enumerate it, or close() it to
|
|
241
|
+
# unsubscribe).
|
|
242
|
+
def self.subscribe_events(client, filter_tags)
|
|
243
|
+
client.Subscribe(EventsPb::SubscribeRequest.new(filter: filter_tags || []))
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Re-exported so users can build a client without requiring
|
|
247
|
+
# the stub module directly.
|
|
248
|
+
EventBusClient = EventsGrpcDc::EventBusClient
|
|
249
|
+
end
|
|
250
|
+
end
|