omq-backend-rust 0.2.0 → 0.3.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,642 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jar_dependencies"
4
+ require "thread"
5
+ require "uri"
6
+ require "java"
7
+
8
+ require_relative "../version"
9
+ require_relative "platform"
10
+
11
+ require_jar "io.github.paddor", "omq-java",
12
+ OMQ::Rust::Java.classifier, OMQ::Rust::OMQ_JAVA_VERSION
13
+
14
+ module OMQ
15
+ module Rust
16
+ module Java
17
+ Duration = ::Java::JavaTime::Duration
18
+ JavaOMQ = ::Java::IoOmq::OMQ
19
+ SocketOptions = ::Java::IoOmq::SocketOptions
20
+ SocketType = ::Java::IoOmq::SocketType
21
+ Message = ::Java::IoOmq::Message
22
+ CurveKeypair = ::Java::IoOmq::CurveKeypair
23
+ OnMute = ::Java::IoOmq::OnMute
24
+ @context_mutex = Mutex.new
25
+
26
+ class << self
27
+ def context
28
+ @context_mutex.synchronize do
29
+ @context ||= JavaOMQ.context(OMQ::Rust.io_threads.to_i).tap do
30
+ at_exit { @context&.close rescue nil } unless @context_at_exit
31
+ @context_at_exit = true
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ class Promise
38
+ def initialize(&resolver)
39
+ @mutex = Mutex.new
40
+ @cv = ConditionVariable.new
41
+ @resolved = false
42
+ @value = nil
43
+ @resolver = resolver
44
+ end
45
+
46
+
47
+ def resolved?
48
+ @mutex.synchronize { @resolved }
49
+ end
50
+
51
+
52
+ def resolve(value = nil)
53
+ @mutex.synchronize do
54
+ return @value if @resolved
55
+
56
+ @resolved = true
57
+ @value = value
58
+ @cv.broadcast
59
+ end
60
+ end
61
+
62
+
63
+ def wait
64
+ until resolved?
65
+ if @resolver
66
+ @resolver.call(self)
67
+ else
68
+ @mutex.synchronize { @cv.wait(@mutex, 0.05) unless @resolved }
69
+ end
70
+ end
71
+
72
+ @value
73
+ end
74
+ end
75
+
76
+ class Engine
77
+ POLL_SECONDS = 0.005
78
+ POLL_DURATION = Duration.ofMillis((POLL_SECONDS * 1000).to_i)
79
+
80
+ attr_reader :options, :connections, :routing, :socket_type
81
+ attr_reader :peer_connected, :all_peers_gone, :parent_task
82
+ attr_reader :on_io_thread
83
+ alias on_io_thread? on_io_thread
84
+ attr_writer :reconnect_enabled
85
+ attr_accessor :subscriber_joined
86
+
87
+
88
+ def initialize(socket_type, options)
89
+ @socket_type = socket_type
90
+ @options = options
91
+ @connections = {}
92
+ @closed = false
93
+ @parent_task = nil
94
+ @on_io_thread = false
95
+ @materialized = false
96
+ @recv_sentinels = 0
97
+ @compression_options = {}
98
+
99
+ @peer_connected = Promise.new do |promise|
100
+ wait_connected(1)
101
+ promise.resolve(true) unless @closed
102
+ end
103
+ @all_peers_gone = Promise.new do |promise|
104
+ wait_all_peers_gone
105
+ promise.resolve(true) unless @closed
106
+ end
107
+ @subscriber_joined = Promise.new do |promise|
108
+ wait_subscribed(1)
109
+ promise.resolve(true) unless @closed
110
+ end
111
+
112
+ @routing = RoutingStub.new(self)
113
+ end
114
+
115
+
116
+ def capture_parent_task(parent: nil)
117
+ @parent_task ||= parent
118
+ end
119
+
120
+
121
+ def bind(endpoint, parent: nil, **opts)
122
+ capture_parent_task(parent: parent)
123
+ apply_endpoint_options!(opts)
124
+ ensure_materialized
125
+ URI.parse(with_java_errors { @native.bind(endpoint) })
126
+ end
127
+
128
+
129
+ def connect(endpoint, parent: nil, **opts)
130
+ capture_parent_task(parent: parent)
131
+ apply_endpoint_options!(opts)
132
+ ensure_materialized
133
+ with_java_errors { @native.connect(endpoint) }
134
+ resolve_peer_connected if endpoint.start_with?("inproc://")
135
+ URI.parse(endpoint)
136
+ end
137
+
138
+
139
+ def disconnect(endpoint)
140
+ ensure_materialized
141
+ with_java_errors { @native.disconnect(endpoint) }
142
+ end
143
+
144
+
145
+ def unbind(endpoint)
146
+ ensure_materialized
147
+ with_java_errors { @native.unbind(endpoint) }
148
+ end
149
+
150
+
151
+ def enqueue_send(parts)
152
+ ensure_materialized
153
+ msg = java_message(parts)
154
+
155
+ if (timeout = @options.write_timeout)
156
+ ok = with_java_errors { @native.send(msg, duration_from_seconds("write_timeout", timeout)) }
157
+ raise IO::TimeoutError, "operation timed out" unless ok
158
+ else
159
+ with_java_errors { @native.send(msg) }
160
+ end
161
+
162
+ nil
163
+ end
164
+
165
+
166
+ def dequeue_recv
167
+ ensure_materialized
168
+ @recv_deadline = monotonic_time + @options.read_timeout.to_f if @options.read_timeout
169
+
170
+ loop do
171
+ return take_recv_sentinel if @recv_sentinels.positive?
172
+
173
+ optional = with_java_errors { @native.tryReceive }
174
+ return ruby_parts(optional.get) if optional.isPresent
175
+
176
+ raise IO::TimeoutError, "operation timed out" if recv_deadline_expired?
177
+
178
+ sleep recv_poll_seconds
179
+ end
180
+ ensure
181
+ @recv_deadline = nil
182
+ end
183
+
184
+
185
+ def dequeue_recv_sentinel
186
+ @recv_sentinels += 1
187
+ nil
188
+ end
189
+
190
+
191
+ def close
192
+ return if @closed
193
+
194
+ @closed = true
195
+ @peer_connected.resolve(nil)
196
+ @all_peers_gone.resolve(nil)
197
+ @subscriber_joined.resolve(nil)
198
+ with_java_errors { @native&.close }
199
+ @connections.clear
200
+ nil
201
+ end
202
+
203
+
204
+ alias stop close
205
+
206
+
207
+ def closed?
208
+ @closed
209
+ end
210
+
211
+
212
+ def subscribe(prefix)
213
+ @routing.subscribe(prefix)
214
+ end
215
+
216
+
217
+ def unsubscribe(prefix)
218
+ @routing.unsubscribe(prefix)
219
+ end
220
+
221
+
222
+ def emit_monitor_event(_type, endpoint: nil, detail: nil)
223
+ end
224
+
225
+
226
+ def monitor_queue=(queue)
227
+ @monitor_queue = queue
228
+ end
229
+
230
+
231
+ def verbose_monitor=(val)
232
+ @verbose_monitor = val
233
+ end
234
+
235
+
236
+ private
237
+
238
+
239
+ def ensure_materialized
240
+ return if @materialized
241
+
242
+ @native = Java.context.socket(SocketType.valueOf(@socket_type.to_s), java_options)
243
+ @materialized = true
244
+ @routing.replay_pending(@native)
245
+ end
246
+
247
+
248
+ def java_message(parts)
249
+ frames = parts.map { |part| part.to_java_bytes }
250
+ return Message.of(frames.first) if frames.size == 1
251
+
252
+ Message.multipart(*frames)
253
+ end
254
+
255
+
256
+ def ruby_parts(message)
257
+ parts = []
258
+ message.partCount.times do |index|
259
+ parts << String.from_java_bytes(message.part(index)).b.freeze
260
+ end
261
+ parts.freeze
262
+ end
263
+
264
+
265
+ def take_recv_sentinel
266
+ @recv_sentinels -= 1
267
+ nil
268
+ end
269
+
270
+
271
+ def recv_deadline_expired?
272
+ return false unless @options.read_timeout
273
+
274
+ monotonic_time >= @recv_deadline
275
+ end
276
+
277
+
278
+ def recv_poll_seconds
279
+ return POLL_SECONDS unless @options.read_timeout
280
+
281
+ [[@recv_deadline - monotonic_time, POLL_SECONDS].min, 0].max
282
+ end
283
+
284
+
285
+ def monotonic_time
286
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
287
+ end
288
+
289
+
290
+ def wait_connected(count)
291
+ ensure_materialized
292
+
293
+ loop do
294
+ result = @native.waitConnected(count, POLL_DURATION)
295
+ resolve_peer_connected if result.to_i.positive?
296
+ return result
297
+ rescue ::Java::IoOmq::TimeoutException
298
+ return nil if @closed
299
+ rescue ::Java::IoOmq::ClosedException
300
+ return nil
301
+ end
302
+ end
303
+
304
+
305
+ def resolve_peer_connected
306
+ @connections[:peer] = true
307
+ @peer_connected.resolve(true) unless @peer_connected.resolved?
308
+ end
309
+
310
+
311
+ def wait_subscribed(count)
312
+ ensure_materialized
313
+
314
+ loop do
315
+ result = @native.waitSubscribed(count, POLL_DURATION)
316
+ return result
317
+ rescue ::Java::IoOmq::TimeoutException
318
+ return nil if @closed
319
+ rescue ::Java::IoOmq::ClosedException
320
+ return nil
321
+ end
322
+ end
323
+
324
+
325
+ def wait_all_peers_gone
326
+ @peer_connected.wait
327
+
328
+ loop do
329
+ @native.waitConnected(1, POLL_DURATION)
330
+ sleep POLL_SECONDS
331
+ rescue ::Java::IoOmq::TimeoutException
332
+ @connections.clear
333
+ return true
334
+ rescue ::Java::IoOmq::ClosedException
335
+ return nil
336
+ end
337
+ end
338
+
339
+
340
+ def java_options
341
+ builder = SocketOptions.builder
342
+ apply_socket_options(builder)
343
+ apply_compression_options(builder)
344
+ apply_mechanism(builder)
345
+ builder.build
346
+ end
347
+
348
+
349
+ def apply_socket_options(builder)
350
+ builder.sendHighWaterMark([integer_option("send_hwm", @options.send_hwm), 0].max)
351
+ builder.receiveHighWaterMark([integer_option("recv_hwm", @options.recv_hwm), 0].max)
352
+
353
+ if @options.linger == Float::INFINITY
354
+ builder.lingerForever
355
+ else
356
+ builder.linger(duration_from_seconds("linger", @options.linger))
357
+ end
358
+
359
+ builder.identity(bytes(@options.identity)) if @options.identity && !@options.identity.empty?
360
+ builder.routerMandatory(!!@options.router_mandatory)
361
+ builder.conflate(!!@options.conflate)
362
+ builder.maxMessageSize(nonnegative_integer("max_message_size", @options.max_message_size)) if @options.max_message_size
363
+ builder.sendBufferSize(nonnegative_integer("sndbuf", @options.sndbuf)) if @options.sndbuf
364
+ builder.receiveBufferSize(nonnegative_integer("rcvbuf", @options.rcvbuf)) if @options.rcvbuf
365
+ builder.onMute(on_mute)
366
+
367
+ apply_duration_option(builder, :heartbeatInterval, "heartbeat_interval", @options.heartbeat_interval)
368
+ apply_duration_option(builder, :heartbeatTtl, "heartbeat_ttl", @options.heartbeat_ttl)
369
+ apply_duration_option(builder, :heartbeatTimeout, "heartbeat_timeout", @options.heartbeat_timeout)
370
+ apply_reconnect_option(builder)
371
+ end
372
+
373
+
374
+ def apply_reconnect_option(builder)
375
+ reconnect = @options.reconnect_interval
376
+
377
+ case reconnect
378
+ when Range
379
+ builder.reconnectExponential(
380
+ duration_from_seconds("reconnect_interval min", reconnect.begin),
381
+ duration_from_seconds("reconnect_interval max", reconnect.end),
382
+ )
383
+ when nil, false
384
+ builder.reconnectDisabled
385
+ else
386
+ builder.reconnectInterval(duration_from_seconds("reconnect_interval", reconnect))
387
+ end
388
+ end
389
+
390
+
391
+ def apply_duration_option(builder, method, label, value)
392
+ builder.public_send(method, duration_from_seconds(label, value)) if value
393
+ end
394
+
395
+
396
+ def apply_compression_options(builder)
397
+ if @compression_options.key?("compression_auto_train")
398
+ builder.compressionAutoTrain(!!@compression_options["compression_auto_train"])
399
+ end
400
+ if (value = @compression_options["compression_threshold"])
401
+ builder.compressionThreshold(nonnegative_integer("compression_threshold", value))
402
+ end
403
+ if (value = @compression_options["compression_level"])
404
+ builder.compressionLevel(integer_option("compression_level", value))
405
+ end
406
+ if (value = @compression_options["compression_dict"])
407
+ builder.compressionDict(bytes(value))
408
+ end
409
+ if (value = @compression_options["compression_dict_capacity"])
410
+ builder.compressionDictCapacity(nonnegative_integer("compression_dict_capacity", value))
411
+ end
412
+ if (value = @compression_options["max_recv_dict_size"])
413
+ builder.maxReceiveDictSize(nonnegative_integer("max_recv_dict_size", value))
414
+ end
415
+
416
+ return unless @compression_options.key?("compression_offload_threshold")
417
+
418
+ value = @compression_options["compression_offload_threshold"]
419
+ if value.nil? || value.to_i.negative?
420
+ builder.noCompressionOffload
421
+ else
422
+ builder.compressionOffloadThreshold(nonnegative_integer("compression_offload_threshold", value))
423
+ end
424
+ end
425
+
426
+
427
+ def apply_mechanism(builder)
428
+ mech = @options.mechanism
429
+ klass = mech.class.name
430
+ return unless klass&.include?("Curve")
431
+
432
+ require "protocol/zmtp/z85"
433
+
434
+ keypair = CurveKeypair.new(
435
+ z85_key(mech.instance_variable_get(:@permanent_public), "public key"),
436
+ z85_key(mech.instance_variable_get(:@permanent_secret), "secret key"),
437
+ )
438
+
439
+ if mech.instance_variable_get(:@as_server)
440
+ builder.curveServer(keypair)
441
+ else
442
+ builder.curveClient(keypair, z85_key(mech.instance_variable_get(:@server_public), "server key"))
443
+ end
444
+ end
445
+
446
+
447
+ def z85_key(key, label)
448
+ raw = key&.to_s&.b
449
+ raise ArgumentError, "#{label} must be exactly 32 bytes" unless raw&.bytesize == 32
450
+
451
+ Protocol::ZMTP::Z85.encode(raw)
452
+ end
453
+
454
+
455
+ def on_mute
456
+ case @options.on_mute&.to_sym
457
+ when :drop_newest, :drop
458
+ OnMute::DROP_NEWEST
459
+ when :drop_oldest
460
+ OnMute::DROP_OLDEST
461
+ else
462
+ OnMute::BLOCK
463
+ end
464
+ end
465
+
466
+
467
+ def duration_from_seconds(label, value)
468
+ seconds = Float(value)
469
+ raise ArgumentError, "#{label} must be finite and non-negative" unless seconds.finite? && !seconds.negative?
470
+
471
+ Duration.ofNanos((seconds * 1_000_000_000).round)
472
+ end
473
+
474
+
475
+ def nonnegative_integer(label, value)
476
+ value = integer_option(label, value)
477
+ raise ArgumentError, "#{label} must be non-negative" if value.negative?
478
+
479
+ value
480
+ end
481
+
482
+
483
+ def integer_option(label, value)
484
+ Integer(value)
485
+ rescue ArgumentError, TypeError
486
+ raise ArgumentError, "#{label} must be an Integer"
487
+ end
488
+
489
+
490
+ def bytes(value)
491
+ value.to_s.b.to_java_bytes
492
+ end
493
+
494
+
495
+ def apply_endpoint_options!(opts)
496
+ compression = extract_endpoint_compression_options(opts)
497
+ return if compression.empty?
498
+
499
+ if @materialized
500
+ existing = compression.keys.to_h do |key|
501
+ [key, @compression_options.fetch(key, default_compression_option(key))]
502
+ end
503
+ return if compression == existing
504
+
505
+ raise ArgumentError,
506
+ "Rust backend compression options must be set before first bind/connect"
507
+ end
508
+
509
+ @compression_options.merge!(compression)
510
+ end
511
+
512
+
513
+ def extract_endpoint_compression_options(opts)
514
+ out = {}
515
+
516
+ if opts.key?(:level)
517
+ validate_zstd_level!(opts[:level])
518
+ out["compression_level"] = opts[:level]
519
+ end
520
+ out["compression_dict"] = opts[:dict].b if opts.key?(:dict) && opts[:dict]
521
+
522
+ if opts.key?(:auto_dict)
523
+ auto_dict = opts[:auto_dict]
524
+ if auto_dict && opts[:dict]
525
+ raise ArgumentError, "cannot combine auto_dict: and dict:"
526
+ end
527
+
528
+ case auto_dict
529
+ when nil, false
530
+ out["compression_auto_train"] = false
531
+ when true
532
+ out["compression_auto_train"] = true
533
+ when Hash
534
+ if auto_dict.key?(:trigger)
535
+ raise ArgumentError,
536
+ "Rust backend does not support auto_dict: trigger"
537
+ end
538
+ validate_positive!("auto_dict capacity", auto_dict[:capacity]) if auto_dict[:capacity]
539
+ out["compression_auto_train"] = true
540
+ out["compression_dict_capacity"] = auto_dict[:capacity] if auto_dict[:capacity]
541
+ else
542
+ raise TypeError, "auto_dict: must be true, false, or a Hash; got #{auto_dict.class}"
543
+ end
544
+ end
545
+
546
+ out["compression_threshold"] = opts[:compression_threshold] if opts.key?(:compression_threshold)
547
+ out["max_recv_dict_size"] = opts[:max_recv_dict_size] if opts.key?(:max_recv_dict_size)
548
+ if opts.key?(:compression_offload_threshold)
549
+ out["compression_offload_threshold"] = opts[:compression_offload_threshold] || -1
550
+ end
551
+
552
+ out
553
+ end
554
+
555
+
556
+ def default_compression_option(key)
557
+ key == "compression_auto_train" ? false : nil
558
+ end
559
+
560
+
561
+ def validate_positive!(label, value)
562
+ return if value.respond_to?(:positive?) && value.positive?
563
+
564
+ raise ArgumentError, "#{label} must be positive"
565
+ end
566
+
567
+
568
+ def validate_zstd_level!(level)
569
+ return if level.is_a?(Integer) && (-8..4).cover?(level)
570
+
571
+ raise ArgumentError, "zstd compression level must be -8..4, got #{level.inspect}"
572
+ end
573
+
574
+
575
+ def with_java_errors
576
+ yield
577
+ rescue ::Java::IoOmq::TimeoutException => error
578
+ raise IO::TimeoutError, error.message
579
+ rescue ::Java::IoOmq::ClosedException => error
580
+ raise IOError, error.message
581
+ rescue ::Java::IoOmq::InvalidEndpointException => error
582
+ raise ArgumentError, error.message
583
+ rescue ::Java::IoOmq::OMQException => error
584
+ raise RuntimeError, error.message
585
+ end
586
+
587
+ class RoutingStub
588
+ def initialize(engine)
589
+ @engine = engine
590
+ @pending_subscribe = []
591
+ @pending_join = []
592
+ end
593
+
594
+
595
+ def subscriber_joined
596
+ @engine.subscriber_joined
597
+ end
598
+
599
+
600
+ def subscribe(prefix)
601
+ native = @engine.instance_variable_get(:@native)
602
+ if @engine.instance_variable_get(:@materialized)
603
+ native.subscribe(prefix.b.to_java_bytes)
604
+ else
605
+ @pending_subscribe << prefix.b
606
+ end
607
+ end
608
+
609
+
610
+ def unsubscribe(prefix)
611
+ @engine.instance_variable_get(:@native).unsubscribe(prefix.b.to_java_bytes)
612
+ end
613
+
614
+
615
+ def join(group)
616
+ native = @engine.instance_variable_get(:@native)
617
+ if @engine.instance_variable_get(:@materialized)
618
+ native.join(group.b.to_java_bytes)
619
+ else
620
+ @pending_join << group.b
621
+ end
622
+ end
623
+
624
+
625
+ def leave(group)
626
+ @engine.instance_variable_get(:@native).leave(group.b.to_java_bytes)
627
+ end
628
+
629
+
630
+ def replay_pending(native)
631
+ @pending_subscribe.each { |prefix| native.subscribe(prefix.to_java_bytes) }
632
+ @pending_subscribe.clear
633
+ @pending_join.each { |group| native.join(group.to_java_bytes) }
634
+ @pending_join.clear
635
+ end
636
+ end
637
+ end
638
+ end
639
+
640
+ Engine = Java::Engine
641
+ end
642
+ end