omq 0.1.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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/LICENSE +15 -0
  4. data/README.md +145 -0
  5. data/lib/omq/pair.rb +13 -0
  6. data/lib/omq/pub_sub.rb +77 -0
  7. data/lib/omq/push_pull.rb +21 -0
  8. data/lib/omq/req_rep.rb +23 -0
  9. data/lib/omq/router_dealer.rb +36 -0
  10. data/lib/omq/socket.rb +178 -0
  11. data/lib/omq/version.rb +5 -0
  12. data/lib/omq/zmtp/codec/command.rb +207 -0
  13. data/lib/omq/zmtp/codec/frame.rb +104 -0
  14. data/lib/omq/zmtp/codec/greeting.rb +96 -0
  15. data/lib/omq/zmtp/codec.rb +18 -0
  16. data/lib/omq/zmtp/connection.rb +233 -0
  17. data/lib/omq/zmtp/engine.rb +339 -0
  18. data/lib/omq/zmtp/mechanism/null.rb +70 -0
  19. data/lib/omq/zmtp/options.rb +57 -0
  20. data/lib/omq/zmtp/reactor.rb +142 -0
  21. data/lib/omq/zmtp/readable.rb +29 -0
  22. data/lib/omq/zmtp/routing/dealer.rb +57 -0
  23. data/lib/omq/zmtp/routing/fan_out.rb +89 -0
  24. data/lib/omq/zmtp/routing/pair.rb +68 -0
  25. data/lib/omq/zmtp/routing/pub.rb +62 -0
  26. data/lib/omq/zmtp/routing/pull.rb +48 -0
  27. data/lib/omq/zmtp/routing/push.rb +57 -0
  28. data/lib/omq/zmtp/routing/rep.rb +83 -0
  29. data/lib/omq/zmtp/routing/req.rb +70 -0
  30. data/lib/omq/zmtp/routing/round_robin.rb +69 -0
  31. data/lib/omq/zmtp/routing/router.rb +88 -0
  32. data/lib/omq/zmtp/routing/sub.rb +80 -0
  33. data/lib/omq/zmtp/routing/xpub.rb +74 -0
  34. data/lib/omq/zmtp/routing/xsub.rb +80 -0
  35. data/lib/omq/zmtp/routing.rb +38 -0
  36. data/lib/omq/zmtp/transport/inproc.rb +299 -0
  37. data/lib/omq/zmtp/transport/ipc.rb +114 -0
  38. data/lib/omq/zmtp/transport/tcp.rb +98 -0
  39. data/lib/omq/zmtp/valid_peers.rb +21 -0
  40. data/lib/omq/zmtp/writable.rb +44 -0
  41. data/lib/omq/zmtp.rb +47 -0
  42. data/lib/omq.rb +19 -0
  43. metadata +110 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f11ba4b6d749acef6ad97b6f328aec61aba341ba5759660e23c896af2d96054d
4
+ data.tar.gz: cf91d6bc140f04d000b5add87322ba9f39ea3b5790f75e4b22144b429284b4c7
5
+ SHA512:
6
+ metadata.gz: 4ecc0a3992c75679ea9cebd9872bdb7a7c9d3f3e86eaccd82dccc7717a84591c5d8493ab0629b14dfdaea35e30b0bae27c6c142841e2f1c61a04a4ef09805b54
7
+ data.tar.gz: 7789734d032134883b3ae71ba0af36a7f89d67625d4e879e03f1be81d3ae83b3f1afcda0610bb203ff25a62ecdfac90a4f56b08b5a8127cbab94b1e305261278
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-03-25
4
+
5
+ Initial release. Pure Ruby implementation of ZMTP 3.1 (ZeroMQ) using Async.
6
+
7
+ ### Socket types
8
+
9
+ - REQ, REP, DEALER, ROUTER
10
+ - PUB, SUB, XPUB, XSUB
11
+ - PUSH, PULL
12
+ - PAIR
13
+
14
+ ### Transports
15
+
16
+ - TCP (with ephemeral port support and IPv6)
17
+ - IPC (Unix domain sockets, including Linux abstract namespace)
18
+ - inproc (in-process, lock-free direct pipes)
19
+
20
+ ### Features
21
+
22
+ - Buffered I/O via io-stream (read-ahead buffering, automatic TCP_NODELAY)
23
+ - Heartbeat (PING/PONG) with configurable interval and timeout
24
+ - Automatic reconnection with exponential backoff
25
+ - Per-socket send/receive HWM (high-water mark)
26
+ - Linger on close (drain send queue before closing)
27
+ - `max_message_size` enforcement
28
+ - `connect_timeout` for TCP
29
+ - Works inside Async reactors or standalone (shared IO thread)
30
+ - Optional CURVE encryption via the `omq-curve` gem
data/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025-2026, Patrik Wenger
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # OMQ! Where did the C dependency go?!
2
+
3
+ [![CI](https://github.com/paddor/omq/actions/workflows/ci.yml/badge.svg)](https://github.com/paddor/omq/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/omq?color=e9573f)](https://rubygems.org/gems/omq)
5
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
6
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.3-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
7
+
8
+ Pure Ruby implementation of the [ZMTP 3.1](https://rfc.zeromq.org/spec/23/) wire protocol ([ZeroMQ](https://zeromq.org/)) using the [Async](https://github.com/socketry/async) gem. No native libraries required.
9
+
10
+ > **186k msg/s** inproc throughput | **12 µs** req/rep roundtrip latency | pure Ruby + YJIT
11
+
12
+ ---
13
+
14
+ ## Highlights
15
+
16
+ - **Pure Ruby** — no C extensions, no FFI, no libzmq/libczmq dependency
17
+ - **All socket types** — req/rep, pub/sub, push/pull, dealer/router, xpub/xsub, pair
18
+ - **Async-native** — built on [Async](https://github.com/socketry/async) fibers, also works with plain threads
19
+ - **Ruby-idiomatic API** — messages as `Array<String>`, errors as exceptions, timeouts as `IO::TimeoutError`
20
+ - **All transports** — tcp, ipc, inproc
21
+
22
+ ## Why pure Ruby?
23
+
24
+ Modern Ruby has closed the gap:
25
+
26
+ - **YJIT** — JIT-compiled hot paths close the throughput gap with C extensions
27
+ - **Fiber Scheduler** — non-blocking I/O without callbacks or threads (`Async` builds on this)
28
+ - **`IO::Buffer`** — zero-copy binary reads/writes, no manual `String#b` packing
29
+
30
+ When [CZTop](https://github.com/paddor/cztop) was written, none of this existed. Today, a pure Ruby ZMTP implementation is fast enough for production use — and you get `gem install` with no compiler toolchain, no system packages, and no segfaults.
31
+
32
+ ## Install
33
+
34
+ No system libraries needed — just Ruby:
35
+
36
+ ```sh
37
+ gem install omq
38
+ # or in Gemfile
39
+ gem 'omq'
40
+ ```
41
+
42
+ ## Learning ZeroMQ
43
+
44
+ New to ZeroMQ? See [ZGUIDE_SUMMARY.md](ZGUIDE_SUMMARY.md) — a ~30 min read covering all major patterns with working OMQ code examples.
45
+
46
+ ## Quick Start
47
+
48
+ ### Request / Reply
49
+
50
+ ```ruby
51
+ require 'omq'
52
+ require 'async'
53
+
54
+ Async do |task|
55
+ rep = OMQ::REP.bind('inproc://example')
56
+ req = OMQ::REQ.connect('inproc://example')
57
+
58
+ task.async do
59
+ msg = rep.receive
60
+ rep << msg.map(&:upcase)
61
+ end
62
+
63
+ req << 'hello'
64
+ puts req.receive.inspect # => ["HELLO"]
65
+ ensure
66
+ req&.close
67
+ rep&.close
68
+ end
69
+ ```
70
+
71
+ ### Pub / Sub
72
+
73
+ ```ruby
74
+ Async do |task|
75
+ pub = OMQ::PUB.bind('inproc://pubsub')
76
+ sub = OMQ::SUB.connect('inproc://pubsub')
77
+ sub.subscribe('') # subscribe to all
78
+
79
+ task.async { pub << 'news flash' }
80
+ puts sub.receive.inspect # => ["news flash"]
81
+ ensure
82
+ pub&.close
83
+ sub&.close
84
+ end
85
+ ```
86
+
87
+ ### Push / Pull (Pipeline)
88
+
89
+ ```ruby
90
+ Async do
91
+ push = OMQ::PUSH.connect('inproc://pipeline')
92
+ pull = OMQ::PULL.bind('inproc://pipeline')
93
+
94
+ push << 'work item'
95
+ puts pull.receive.inspect # => ["work item"]
96
+ ensure
97
+ push&.close
98
+ pull&.close
99
+ end
100
+ ```
101
+
102
+ ## Socket Types
103
+
104
+ | Pattern | Classes | Direction |
105
+ |---------|---------|-----------|
106
+ | Request/Reply | `REQ`, `REP` | bidirectional |
107
+ | Publish/Subscribe | `PUB`, `SUB`, `XPUB`, `XSUB` | unidirectional |
108
+ | Pipeline | `PUSH`, `PULL` | unidirectional |
109
+ | Routing | `DEALER`, `ROUTER` | bidirectional |
110
+ | Exclusive pair | `PAIR` | bidirectional |
111
+
112
+ All classes live under `OMQ::`.
113
+
114
+ ## Performance
115
+
116
+ Benchmarked with benchmark-ips on Linux x86_64 (Ruby 4.0.1 +YJIT):
117
+
118
+ #### Throughput (push/pull, 64 B messages)
119
+
120
+ | inproc | ipc | tcp |
121
+ |--------|-----|-----|
122
+ | 184k/s | 35k/s | 18k/s |
123
+
124
+ #### Latency (req/rep roundtrip)
125
+
126
+ | inproc | ipc | tcp |
127
+ |--------|-----|-----|
128
+ | 13 µs | 70 µs | 97 µs |
129
+
130
+ See [`bench/`](bench/) for full results and scripts.
131
+
132
+ ## Interop with native ZMQ
133
+
134
+ OMQ speaks ZMTP 3.1 on the wire and interoperates with libzmq, CZMQ, pyzmq, etc. over **tcp** and **ipc**. The `inproc://` transport is OMQ-internal (in-process Ruby queues) and is not visible to native ZMQ running in the same process — use `ipc://` to talk across library boundaries.
135
+
136
+ ## Development
137
+
138
+ ```sh
139
+ bundle install
140
+ bundle exec rake
141
+ ```
142
+
143
+ ## License
144
+
145
+ [ISC](LICENSE)
data/lib/omq/pair.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class PAIR < Socket
5
+ include ZMTP::Readable
6
+ include ZMTP::Writable
7
+
8
+ def initialize(endpoints = nil, linger: 0)
9
+ _init_engine(:PAIR, linger: linger)
10
+ _attach(endpoints, default: :connect)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class PUB < Socket
5
+ include ZMTP::Writable
6
+
7
+ def initialize(endpoints = nil, linger: 0)
8
+ _init_engine(:PUB, linger: linger)
9
+ _attach(endpoints, default: :bind)
10
+ end
11
+ end
12
+
13
+ # SUB socket.
14
+ #
15
+ class SUB < Socket
16
+ include ZMTP::Readable
17
+
18
+ # @return [String] subscription prefix to subscribe to everything
19
+ #
20
+ EVERYTHING = ''
21
+
22
+ # @param endpoints [String, nil]
23
+ # @param linger [Integer]
24
+ # @param prefix [String, nil] subscription prefix; +nil+ (default)
25
+ # means no subscription — call {#subscribe} explicitly.
26
+ #
27
+ def initialize(endpoints = nil, linger: 0, prefix: nil)
28
+ _init_engine(:SUB, linger: linger)
29
+ _attach(endpoints, default: :connect)
30
+ subscribe(prefix) unless prefix.nil?
31
+ end
32
+
33
+ # Subscribes to a topic prefix.
34
+ #
35
+ # @param prefix [String]
36
+ # @return [void]
37
+ #
38
+ def subscribe(prefix = EVERYTHING)
39
+ @engine.routing.subscribe(prefix)
40
+ end
41
+
42
+ # Unsubscribes from a topic prefix.
43
+ #
44
+ # @param prefix [String]
45
+ # @return [void]
46
+ #
47
+ def unsubscribe(prefix)
48
+ @engine.routing.unsubscribe(prefix)
49
+ end
50
+ end
51
+
52
+ class XPUB < Socket
53
+ include ZMTP::Readable
54
+ include ZMTP::Writable
55
+
56
+ def initialize(endpoints = nil, linger: 0)
57
+ _init_engine(:XPUB, linger: linger)
58
+ _attach(endpoints, default: :bind)
59
+ end
60
+ end
61
+
62
+ class XSUB < Socket
63
+ include ZMTP::Readable
64
+ include ZMTP::Writable
65
+
66
+ # @param endpoints [String, nil]
67
+ # @param linger [Integer]
68
+ # @param prefix [String, nil] subscription prefix; +nil+ (default)
69
+ # means no subscription — send a subscribe frame explicitly.
70
+ #
71
+ def initialize(endpoints = nil, linger: 0, prefix: nil)
72
+ _init_engine(:XSUB, linger: linger)
73
+ _attach(endpoints, default: :connect)
74
+ send("\x01#{prefix}".b) unless prefix.nil?
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class PUSH < Socket
5
+ include ZMTP::Writable
6
+
7
+ def initialize(endpoints = nil, linger: 0)
8
+ _init_engine(:PUSH, linger: linger)
9
+ _attach(endpoints, default: :connect)
10
+ end
11
+ end
12
+
13
+ class PULL < Socket
14
+ include ZMTP::Readable
15
+
16
+ def initialize(endpoints = nil, linger: 0)
17
+ _init_engine(:PULL, linger: linger)
18
+ _attach(endpoints, default: :bind)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class REQ < Socket
5
+ include ZMTP::Readable
6
+ include ZMTP::Writable
7
+
8
+ def initialize(endpoints = nil, linger: 0)
9
+ _init_engine(:REQ, linger: linger)
10
+ _attach(endpoints, default: :connect)
11
+ end
12
+ end
13
+
14
+ class REP < Socket
15
+ include ZMTP::Readable
16
+ include ZMTP::Writable
17
+
18
+ def initialize(endpoints = nil, linger: 0)
19
+ _init_engine(:REP, linger: linger)
20
+ _attach(endpoints, default: :bind)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class DEALER < Socket
5
+ include ZMTP::Readable
6
+ include ZMTP::Writable
7
+
8
+ def initialize(endpoints = nil, linger: 0)
9
+ _init_engine(:DEALER, linger: linger)
10
+ _attach(endpoints, default: :connect)
11
+ end
12
+ end
13
+
14
+ # ROUTER socket.
15
+ #
16
+ class ROUTER < Socket
17
+ include ZMTP::Readable
18
+ include ZMTP::Writable
19
+
20
+ def initialize(endpoints = nil, linger: 0)
21
+ _init_engine(:ROUTER, linger: linger)
22
+ _attach(endpoints, default: :bind)
23
+ end
24
+
25
+ # Sends a message to a specific peer by identity.
26
+ #
27
+ # @param receiver [String] peer identity
28
+ # @param message [String, Array<String>]
29
+ # @return [self]
30
+ #
31
+ def send_to(receiver, message)
32
+ parts = message.is_a?(Array) ? message : [message]
33
+ send([receiver, '', *parts])
34
+ end
35
+ end
36
+ end
data/lib/omq/socket.rb ADDED
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ # Socket base class.
5
+ #
6
+ class Socket
7
+ # @return [ZMTP::Options]
8
+ #
9
+ attr_reader :options
10
+
11
+ # @return [Integer, nil] last auto-selected TCP port
12
+ #
13
+ attr_reader :last_tcp_port
14
+
15
+ # Delegate socket option accessors to @options.
16
+ #
17
+ %i[
18
+ send_hwm send_hwm=
19
+ recv_hwm recv_hwm=
20
+ linger linger=
21
+ identity identity=
22
+ recv_timeout recv_timeout=
23
+ send_timeout send_timeout=
24
+ read_timeout read_timeout=
25
+ write_timeout write_timeout=
26
+ router_mandatory router_mandatory=
27
+ router_mandatory?
28
+ reconnect_interval reconnect_interval=
29
+ heartbeat_interval heartbeat_interval=
30
+ heartbeat_ttl heartbeat_ttl=
31
+ heartbeat_timeout heartbeat_timeout=
32
+ max_message_size max_message_size=
33
+ connect_timeout connect_timeout=
34
+ mechanism mechanism=
35
+ curve_server curve_server=
36
+ curve_server_key curve_server_key=
37
+ curve_public_key curve_public_key=
38
+ curve_secret_key curve_secret_key=
39
+ curve_authenticator curve_authenticator=
40
+ ].each do |method|
41
+ define_method(method) { |*args| @options.public_send(method, *args) }
42
+ end
43
+
44
+ # Creates a new socket and binds it to the given endpoint.
45
+ #
46
+ # @param endpoint [String]
47
+ # @param opts [Hash] keyword arguments forwarded to {#initialize}
48
+ # @return [Socket]
49
+ #
50
+ def self.bind(endpoint, **opts)
51
+ new(nil, **opts).tap { |s| s.bind(endpoint) }
52
+ end
53
+
54
+ # Creates a new socket and connects it to the given endpoint.
55
+ #
56
+ # @param endpoint [String]
57
+ # @param opts [Hash] keyword arguments forwarded to {#initialize}
58
+ # @return [Socket]
59
+ #
60
+ def self.connect(endpoint, **opts)
61
+ new(nil, **opts).tap { |s| s.connect(endpoint) }
62
+ end
63
+
64
+ def initialize(endpoints = nil, linger: 0); end
65
+
66
+ # Binds to an endpoint.
67
+ #
68
+ # @param endpoint [String]
69
+ # @return [void]
70
+ #
71
+ def bind(endpoint)
72
+ @engine.bind(endpoint)
73
+ @last_tcp_port = @engine.last_tcp_port
74
+ end
75
+
76
+ # Connects to an endpoint.
77
+ #
78
+ # @param endpoint [String]
79
+ # @return [void]
80
+ #
81
+ def connect(endpoint)
82
+ @engine.connect(endpoint)
83
+ end
84
+
85
+ # Disconnects from an endpoint.
86
+ #
87
+ # @param endpoint [String]
88
+ # @return [void]
89
+ #
90
+ def disconnect(endpoint)
91
+ @engine.disconnect(endpoint)
92
+ end
93
+
94
+ # Unbinds from an endpoint.
95
+ #
96
+ # @param endpoint [String]
97
+ # @return [void]
98
+ #
99
+ def unbind(endpoint)
100
+ @engine.unbind(endpoint)
101
+ end
102
+
103
+ # @return [String, nil] last bound endpoint
104
+ #
105
+ def last_endpoint
106
+ @engine.last_endpoint
107
+ end
108
+
109
+ # Closes the socket.
110
+ #
111
+ # @return [void]
112
+ #
113
+ def close
114
+ @engine.close
115
+ nil
116
+ end
117
+
118
+ # Set socket to use unbounded pipes (HWM=0).
119
+ #
120
+ def set_unbounded
121
+ @options.send_hwm = 0
122
+ @options.recv_hwm = 0
123
+ nil
124
+ end
125
+
126
+ # @return [String]
127
+ #
128
+ def inspect
129
+ format("#<%s last_endpoint=%p>", self.class, last_endpoint)
130
+ end
131
+
132
+ private
133
+
134
+ # Runs a block with a timeout. Uses Async's with_timeout if inside
135
+ # a reactor, otherwise falls back to Timeout.timeout.
136
+ #
137
+ # @param seconds [Numeric]
138
+ # @raise [IO::TimeoutError]
139
+ #
140
+ def with_timeout(seconds, &block)
141
+ return yield if seconds.nil?
142
+ if Async::Task.current?
143
+ Async::Task.current.with_timeout(seconds, &block)
144
+ else
145
+ Timeout.timeout(seconds, &block)
146
+ end
147
+ rescue Async::TimeoutError, Timeout::Error
148
+ raise IO::TimeoutError, "timed out"
149
+ end
150
+
151
+ # Connects or binds based on endpoint prefix convention.
152
+ #
153
+ # @param endpoints [String, nil]
154
+ # @param default [Symbol] :connect or :bind
155
+ #
156
+ def _attach(endpoints, default:)
157
+ return unless endpoints
158
+ case endpoints
159
+ when /\A@(.+)\z/
160
+ bind($1)
161
+ when /\A>(.+)\z/
162
+ connect($1)
163
+ else
164
+ __send__(default, endpoints)
165
+ end
166
+ end
167
+
168
+ # Initializes engine and options for a socket type.
169
+ #
170
+ # @param socket_type [Symbol]
171
+ # @param linger [Integer]
172
+ #
173
+ def _init_engine(socket_type, linger:)
174
+ @options = ZMTP::Options.new(linger: linger)
175
+ @engine = ZMTP::Engine.new(socket_type, @options)
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ VERSION = "0.1.0"
5
+ end