omq-backend-rust 0.2.0 → 0.3.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.
@@ -0,0 +1,664 @@
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
+ return if mech.nil? || mech.instance_of?(Protocol::ZMTP::Mechanism::Null)
430
+
431
+ unless defined?(Protocol::ZMTP::Mechanism::Curve) && mech.instance_of?(Protocol::ZMTP::Mechanism::Curve)
432
+ raise ArgumentError, "Rust backend does not support security mechanism #{mech.class}"
433
+ end
434
+
435
+ require "protocol/zmtp/z85"
436
+
437
+ keypair = CurveKeypair.new(
438
+ z85_key(mech.instance_variable_get(:@permanent_public), "public key"),
439
+ z85_key(mech.instance_variable_get(:@permanent_secret), "secret key"),
440
+ )
441
+
442
+ if mech.instance_variable_get(:@as_server)
443
+ if (authenticator = mech.instance_variable_get(:@authenticator))
444
+ validate_authenticator_classpath!
445
+ crypto = mech.instance_variable_get(:@crypto)
446
+ callback = lambda do |peer|
447
+ public_key = crypto::PublicKey.new(Protocol::ZMTP::Z85.decode(peer.publicKey.orElse(nil)))
448
+ !!authenticator.call(Protocol::ZMTP::PeerInfo.new(public_key: public_key, identity: ""))
449
+ end
450
+ builder.curveServer(keypair, callback)
451
+ else
452
+ builder.curveServer(keypair)
453
+ end
454
+ else
455
+ builder.curveClient(keypair, z85_key(mech.instance_variable_get(:@server_public), "server key"))
456
+ end
457
+ end
458
+
459
+
460
+ # OMQ.java's JNI callback threads resolve PeerInfo through the JVM
461
+ # system class loader, which cannot see JRuby's dynamically loaded jars.
462
+ def validate_authenticator_classpath!
463
+ ::Java::JavaLang::Class.forName("io.omq.PeerInfo", false, ::Java::JavaLang::ClassLoader.getSystemClassLoader)
464
+ rescue ::Java::JavaLang::ClassNotFoundException
465
+ raise ArgumentError, "JRuby CURVE authenticators require the OMQ.java jar on the JVM CLASSPATH"
466
+ end
467
+
468
+
469
+ def z85_key(key, label)
470
+ raw = key&.to_s&.b
471
+ raise ArgumentError, "#{label} must be exactly 32 bytes" unless raw&.bytesize == 32
472
+
473
+ Protocol::ZMTP::Z85.encode(raw)
474
+ end
475
+
476
+
477
+ def on_mute
478
+ case @options.on_mute&.to_sym
479
+ when :drop_newest, :drop
480
+ OnMute::DROP_NEWEST
481
+ when :drop_oldest
482
+ OnMute::DROP_OLDEST
483
+ else
484
+ OnMute::BLOCK
485
+ end
486
+ end
487
+
488
+
489
+ def duration_from_seconds(label, value)
490
+ seconds = Float(value)
491
+ raise ArgumentError, "#{label} must be finite and non-negative" unless seconds.finite? && !seconds.negative?
492
+
493
+ Duration.ofNanos((seconds * 1_000_000_000).round)
494
+ end
495
+
496
+
497
+ def nonnegative_integer(label, value)
498
+ value = integer_option(label, value)
499
+ raise ArgumentError, "#{label} must be non-negative" if value.negative?
500
+
501
+ value
502
+ end
503
+
504
+
505
+ def integer_option(label, value)
506
+ Integer(value)
507
+ rescue ArgumentError, TypeError
508
+ raise ArgumentError, "#{label} must be an Integer"
509
+ end
510
+
511
+
512
+ def bytes(value)
513
+ value.to_s.b.to_java_bytes
514
+ end
515
+
516
+
517
+ def apply_endpoint_options!(opts)
518
+ compression = extract_endpoint_compression_options(opts)
519
+ return if compression.empty?
520
+
521
+ if @materialized
522
+ existing = compression.keys.to_h do |key|
523
+ [key, @compression_options.fetch(key, default_compression_option(key))]
524
+ end
525
+ return if compression == existing
526
+
527
+ raise ArgumentError,
528
+ "Rust backend compression options must be set before first bind/connect"
529
+ end
530
+
531
+ @compression_options.merge!(compression)
532
+ end
533
+
534
+
535
+ def extract_endpoint_compression_options(opts)
536
+ out = {}
537
+
538
+ if opts.key?(:level)
539
+ validate_zstd_level!(opts[:level])
540
+ out["compression_level"] = opts[:level]
541
+ end
542
+ out["compression_dict"] = opts[:dict].b if opts.key?(:dict) && opts[:dict]
543
+
544
+ if opts.key?(:auto_dict)
545
+ auto_dict = opts[:auto_dict]
546
+ if auto_dict && opts[:dict]
547
+ raise ArgumentError, "cannot combine auto_dict: and dict:"
548
+ end
549
+
550
+ case auto_dict
551
+ when nil, false
552
+ out["compression_auto_train"] = false
553
+ when true
554
+ out["compression_auto_train"] = true
555
+ when Hash
556
+ if auto_dict.key?(:trigger)
557
+ raise ArgumentError,
558
+ "Rust backend does not support auto_dict: trigger"
559
+ end
560
+ validate_positive!("auto_dict capacity", auto_dict[:capacity]) if auto_dict[:capacity]
561
+ out["compression_auto_train"] = true
562
+ out["compression_dict_capacity"] = auto_dict[:capacity] if auto_dict[:capacity]
563
+ else
564
+ raise TypeError, "auto_dict: must be true, false, or a Hash; got #{auto_dict.class}"
565
+ end
566
+ end
567
+
568
+ out["compression_threshold"] = opts[:compression_threshold] if opts.key?(:compression_threshold)
569
+ out["max_recv_dict_size"] = opts[:max_recv_dict_size] if opts.key?(:max_recv_dict_size)
570
+ if opts.key?(:compression_offload_threshold)
571
+ out["compression_offload_threshold"] = opts[:compression_offload_threshold] || -1
572
+ end
573
+
574
+ out
575
+ end
576
+
577
+
578
+ def default_compression_option(key)
579
+ key == "compression_auto_train" ? false : nil
580
+ end
581
+
582
+
583
+ def validate_positive!(label, value)
584
+ return if value.respond_to?(:positive?) && value.positive?
585
+
586
+ raise ArgumentError, "#{label} must be positive"
587
+ end
588
+
589
+
590
+ def validate_zstd_level!(level)
591
+ return if level.is_a?(Integer) && (-8..4).cover?(level)
592
+
593
+ raise ArgumentError, "zstd compression level must be -8..4, got #{level.inspect}"
594
+ end
595
+
596
+
597
+ def with_java_errors
598
+ yield
599
+ rescue ::Java::IoOmq::TimeoutException => error
600
+ raise IO::TimeoutError, error.message
601
+ rescue ::Java::IoOmq::ClosedException => error
602
+ raise IOError, error.message
603
+ rescue ::Java::IoOmq::InvalidEndpointException => error
604
+ raise ArgumentError, error.message
605
+ rescue ::Java::IoOmq::OMQException => error
606
+ raise RuntimeError, error.message
607
+ end
608
+
609
+ class RoutingStub
610
+ def initialize(engine)
611
+ @engine = engine
612
+ @pending_subscribe = []
613
+ @pending_join = []
614
+ end
615
+
616
+
617
+ def subscriber_joined
618
+ @engine.subscriber_joined
619
+ end
620
+
621
+
622
+ def subscribe(prefix)
623
+ native = @engine.instance_variable_get(:@native)
624
+ if @engine.instance_variable_get(:@materialized)
625
+ native.subscribe(prefix.b.to_java_bytes)
626
+ else
627
+ @pending_subscribe << prefix.b
628
+ end
629
+ end
630
+
631
+
632
+ def unsubscribe(prefix)
633
+ @engine.instance_variable_get(:@native).unsubscribe(prefix.b.to_java_bytes)
634
+ end
635
+
636
+
637
+ def join(group)
638
+ native = @engine.instance_variable_get(:@native)
639
+ if @engine.instance_variable_get(:@materialized)
640
+ native.join(group.b.to_java_bytes)
641
+ else
642
+ @pending_join << group.b
643
+ end
644
+ end
645
+
646
+
647
+ def leave(group)
648
+ @engine.instance_variable_get(:@native).leave(group.b.to_java_bytes)
649
+ end
650
+
651
+
652
+ def replay_pending(native)
653
+ @pending_subscribe.each { |prefix| native.subscribe(prefix.to_java_bytes) }
654
+ @pending_subscribe.clear
655
+ @pending_join.each { |group| native.join(group.to_java_bytes) }
656
+ @pending_join.clear
657
+ end
658
+ end
659
+ end
660
+ end
661
+
662
+ Engine = Java::Engine
663
+ end
664
+ end