service_mesh 0.4.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fef05ef9dacd492215d76304b8f1938056e862e35c0e0450f7b120213bf7e8d7
4
+ data.tar.gz: 97cb3e42dc4797d618d3eacf61aa3d2ba994887aa94ee88d37d90b6b04211fb6
5
+ SHA512:
6
+ metadata.gz: c7da5185432f80a50b73ec3942c736367455bc337918b7afba2b8393a1575353e54a9fbdb54d98aeaf03c7b0922766490a8da075c8daf4d2a58087044e1e448b
7
+ data.tar.gz: 4418d34275bc4dd1d532b4b84f48361967601275181c64c50c5cb7ca7177ddecd9ebdedbb773d813d07e55307efeb24b63c2753a8c6881ea66803e1e619f3127
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paymentbox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # service-mesh-ruby
2
+
3
+ The Ruby contract for the
4
+ [Service Mesh API Specification](https://github.com/Paymentbox-com/service-mesh-api),
5
+ packaged as the gem `service_mesh`. It holds what every transport and every
6
+ caller must agree on, and nothing that moves bytes. Transports are separate
7
+ gems that depend on it and implement `Client` and `Runtime`.
8
+
9
+ The [gRPC Service Mesh API](https://github.com/Paymentbox-com/grpc-service-mesh-api) is a protocol layer that generates code against this contract from protobuf
10
+ definitions, through its Ruby library [grpc-service-mesh-ruby](https://github.com/Paymentbox-com/grpc-service-mesh-ruby). Other protocol layers may be implemented
11
+ to do the same.
12
+
13
+ ## Install
14
+
15
+ ```ruby
16
+ # Gemfile
17
+ gem "service_mesh"
18
+ ```
19
+
20
+ Requires Ruby 3.3 or newer. The gem has no runtime dependencies.
21
+
22
+ ## What it Implements
23
+
24
+ - The value types from the specification: `ServiceMesh::Target`, `ServiceMesh::ServiceMap`,
25
+ `ServiceMesh::Message`, `ServiceMesh::Endpoint` and `ServiceMesh::Subscriber`.
26
+ - Target Kinds are implemented as `:route` and `:topic`. A `Target` built with a kind outside the two
27
+ raises `KindMismatch`.
28
+ - `Message#payload` is always `Encoding::BINARY`.
29
+ - `Target#same_channel?` compares segments and kind and ignores metadata.
30
+ - The configuration keys the specification defines: `DEPLOYMENT_GROUP_KEY`,
31
+ `CONSUMER_GROUP_KEY`, and the value `CONSUMER_GROUP_NONE`.
32
+ - The errors defined by the specification as `ServiceMesh::Error`: `ServiceMesh::KindMismatch`,
33
+ `ServiceMesh::InvalidTarget`, `ServiceMesh::NoDeploymentGroup`.
34
+ - A conformance suite a transport runs against its own `Client` and
35
+ `Runtime`.
36
+
37
+ `Client` and `Runtime` are duck types. The specification names their methods;
38
+ a transport satisfies the contract by responding to them and by passing the
39
+ conformance suite.
40
+
41
+ ## Transports
42
+
43
+ - NATS: [service-mesh-nats-ruby](https://github.com/Paymentbox-com/service-mesh-nats-ruby),
44
+ gem `service_mesh_nats`.
45
+
46
+ The Go counterpart of this gem is
47
+ [service-mesh-go](https://github.com/Paymentbox-com/service-mesh-go).
48
+
49
+ ## Usage
50
+
51
+ A transport that implements Client and Runtime according to the specification will used the
52
+ types defined here.
53
+
54
+ ```ruby
55
+ require "service_mesh"
56
+
57
+ TARGET = ServiceMesh::Target.new(segments: %w[accounts lookup], kind: :route)
58
+
59
+ def lookup(client, id)
60
+ reply = client.request(ServiceMesh::Message.new(target: TARGET, payload: id))
61
+ reply.payload
62
+ end
63
+ ```
64
+
65
+ ## Conformance
66
+
67
+ A transport implemented to use these types should include the shared examples defined in this
68
+ library in its own spec suite and supply its own constructors and a pair of valid targets:
69
+
70
+ ```ruby
71
+ require "service_mesh/rspec"
72
+
73
+ RSpec.describe MyTransport do
74
+ it_behaves_like "a service mesh transport" do
75
+ let(:new_runtime) do
76
+ ->(client, config, endpoints:, subscribers:) { MyTransport::Runtime.new(client, config, endpoints:, subscribers:) }
77
+ end
78
+ let(:new_client) { ->(config, map) { MyTransport::Client.new(config, map) } }
79
+ let(:runtime_config) { {"deployment_group" => "test", "url" => url} }
80
+ let(:client_config) { {"url" => url} }
81
+ let(:route_target) { ServiceMesh::Target.new(segments: %w[test echo], kind: :route) }
82
+ let(:topic_target) { ServiceMesh::Target.new(segments: %w[test event], kind: :topic) }
83
+ end
84
+ end
85
+ ```
86
+
87
+ The suite covers kind checks, the deployment group requirement, request and
88
+ reply with metadata both ways, publish, the consumer-group delivery
89
+ permutations across two runtimes, a standalone client refusing requests after
90
+ close, the runtime's client being the one it was given and closed by stop, the lifecycle state machine, and drain completing and expiring.
91
+ Anything that names a transport's own errors, config keys, or address syntax
92
+ stays in the transport's specs.
93
+
94
+ ## Development
95
+
96
+ ```
97
+ mise install
98
+ just install
99
+ just check # lint, test, build
100
+ ```
101
+
102
+ ## Tests
103
+
104
+ ```
105
+ just test
106
+ ```
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ServiceMesh
4
+ class Error < StandardError; end
5
+
6
+ # Raised for misuse of the contract. Transport errors pass through as the
7
+ # transport library's own exceptions.
8
+ class KindMismatch < Error; end
9
+
10
+ class InvalidTarget < Error; end
11
+
12
+ class NoDeploymentGroup < Error
13
+ def initialize(msg = "config #{DEPLOYMENT_GROUP_KEY} is required") = super
14
+ end
15
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec"
4
+ require "timeout"
5
+ require_relative "../service_mesh"
6
+
7
+ # Conformance suite. A transport includes it from its own specs:
8
+ #
9
+ # require "service_mesh/rspec"
10
+ #
11
+ # RSpec.describe MyTransport do
12
+ # it_behaves_like "a service mesh transport" do
13
+ # let(:new_runtime) { ->(client, config, endpoints:, subscribers:) { MyTransport::Runtime.new(client, config, endpoints:, subscribers:) } }
14
+ # let(:new_client) { ->(config, map) { MyTransport::Client.new(config, map) } }
15
+ # let(:runtime_config) { {"deployment_group" => "test", ...transport keys...} }
16
+ # let(:client_config) { {...transport keys...} }
17
+ # let(:route_target) { ServiceMesh::Target.new(segments: %w[test echo], kind: :route) }
18
+ # let(:topic_target) { ServiceMesh::Target.new(segments: %w[test event], kind: :topic) }
19
+ # end
20
+ # end
21
+ #
22
+ # Every example here follows from the specification alone. Anything that
23
+ # names a transport's own errors, config keys, or address syntax belongs in
24
+ # the transport's specs.
25
+ RSpec.shared_examples "a service mesh transport" do
26
+ let(:service_map) { ServiceMesh::ServiceMap.new }
27
+ let(:wait) { 3 }
28
+
29
+ def endpoint(target, handler, metadata: {})
30
+ ServiceMesh::Endpoint.new(target: target, handler: handler, metadata: metadata)
31
+ end
32
+
33
+ def subscriber(target, handler, metadata: {})
34
+ ServiceMesh::Subscriber.new(target: target, handler: handler, metadata: metadata)
35
+ end
36
+
37
+ def message(target, metadata: {}, payload: "")
38
+ ServiceMesh::Message.new(target: target, metadata: metadata, payload: payload)
39
+ end
40
+
41
+ def config_for(group)
42
+ runtime_config.merge(ServiceMesh::DEPLOYMENT_GROUP_KEY => group)
43
+ end
44
+
45
+ def build(config = runtime_config, endpoints: [], subscribers: [])
46
+ new_runtime.call(new_client.call(client_config, service_map), config, endpoints: endpoints, subscribers: subscribers)
47
+ end
48
+
49
+ def serve(config = runtime_config, endpoints: [], subscribers: [])
50
+ rt = build(config, endpoints: endpoints, subscribers: subscribers)
51
+ rt.start
52
+ @conformance_runtimes << rt
53
+ rt
54
+ end
55
+
56
+ def wait_until
57
+ Timeout.timeout(wait) { sleep 0.01 until yield }
58
+ end
59
+
60
+ before do
61
+ @conformance_runtimes = []
62
+ @conformance_threads = []
63
+ end
64
+
65
+ after do
66
+ @conformance_threads.each(&:kill)
67
+ @conformance_runtimes.each { |rt| rt.stop(wait) }
68
+ end
69
+
70
+ let(:client) { new_client.call(client_config, service_map) }
71
+ after { client.close }
72
+
73
+ describe "kind checks" do
74
+ it "rejects a request to a topic" do
75
+ expect { client.request(message(topic_target)) }.to raise_error(ServiceMesh::KindMismatch)
76
+ end
77
+
78
+ it "rejects a publish to a route" do
79
+ expect { client.publish(message(route_target)) }.to raise_error(ServiceMesh::KindMismatch)
80
+ end
81
+
82
+ it "rejects an endpoint on a topic" do
83
+ expect { build(endpoints: [endpoint(topic_target, ->(m) { m })]) }.to raise_error(ServiceMesh::KindMismatch)
84
+ end
85
+
86
+ it "rejects a subscriber on a route" do
87
+ expect { build(subscribers: [subscriber(route_target, ->(m) { m })]) }.to raise_error(ServiceMesh::KindMismatch)
88
+ end
89
+ end
90
+
91
+ describe "configuration" do
92
+ it "requires a deployment group" do
93
+ without = runtime_config.reject { |k, _| k == ServiceMesh::DEPLOYMENT_GROUP_KEY }
94
+ expect { build(without) }.to raise_error(ServiceMesh::NoDeploymentGroup)
95
+ end
96
+
97
+ it "rejects an empty deployment group" do
98
+ expect { build(config_for("")) }.to raise_error(ServiceMesh::NoDeploymentGroup)
99
+ end
100
+ end
101
+
102
+ describe "request and reply" do
103
+ it "round-trips payload and metadata both ways and ignores the reply's target" do
104
+ seen = nil
105
+ serve(endpoints: [endpoint(route_target, lambda { |m|
106
+ seen = m
107
+ message(topic_target, metadata: {"Reply-Key" => "reply-value"}, payload: m.payload.upcase)
108
+ })])
109
+
110
+ reply = client.request(message(route_target, metadata: {"Request-Key" => "request-value"}, payload: "hello"))
111
+
112
+ expect(seen.target.same_channel?(route_target)).to be(true)
113
+ expect(seen.metadata["Request-Key"]).to eq("request-value")
114
+ expect(reply.payload).to eq("HELLO")
115
+ expect(reply.payload.encoding).to eq(Encoding::BINARY)
116
+ expect(reply.metadata["Reply-Key"]).to eq("reply-value")
117
+ expect(reply.target.same_channel?(route_target)).to be(true)
118
+ end
119
+
120
+ it "carries an empty payload with no metadata" do
121
+ serve(endpoints: [endpoint(route_target, ->(m) { m })])
122
+ reply = client.request(message(route_target))
123
+ expect([reply.payload, reply.metadata]).to eq(["", {}])
124
+ end
125
+ end
126
+
127
+ describe "publish" do
128
+ it "reaches a subscriber with payload and metadata" do
129
+ got = Queue.new
130
+ serve(subscribers: [subscriber(topic_target, ->(m) { got << m })])
131
+
132
+ client.publish(message(topic_target, metadata: {"Event-Id" => "42"}, payload: "created"))
133
+
134
+ m = Timeout.timeout(wait) { got.pop }
135
+ expect(m.target.same_channel?(topic_target)).to be(true)
136
+ expect([m.payload, m.metadata["Event-Id"]]).to eq(["created", "42"])
137
+ end
138
+ end
139
+
140
+ describe "consumer groups" do
141
+ none = {ServiceMesh::CONSUMER_GROUP_KEY => ServiceMesh::CONSUMER_GROUP_NONE}
142
+ shared = {ServiceMesh::CONSUMER_GROUP_KEY => "order-consumers"}
143
+
144
+ [
145
+ ["same deployment, key absent: one instance handles it", %w[billing billing], [{}, {}], 1],
146
+ ["different deployments, key absent: each deployment handles it", %w[billing audit], [{}, {}], 2],
147
+ ["same deployment, none: every instance handles it", %w[billing billing], [none, none], 2],
148
+ ["different deployments, shared named group: one instance handles it", %w[billing audit], [shared, shared], 1]
149
+ ].each do |name, groups, metadata, want|
150
+ it name do
151
+ count = Queue.new
152
+ 2.times do |i|
153
+ serve(config_for(groups[i]), subscribers: [subscriber(topic_target, ->(_) { count << true }, metadata: metadata[i])])
154
+ end
155
+
156
+ client.publish(message(topic_target))
157
+
158
+ wait_until { count.size >= want }
159
+ sleep 0.1 # let an unwanted extra delivery show up
160
+ expect(count.size).to eq(want)
161
+ end
162
+ end
163
+
164
+ it "honours a group set on the target rather than the binding" do
165
+ count = Queue.new
166
+ %w[a b].each do |group|
167
+ t = ServiceMesh::Target.new(segments: topic_target.segments, kind: :topic, metadata: {ServiceMesh::CONSUMER_GROUP_KEY => group})
168
+ serve(config_for("same"), subscribers: [subscriber(t, ->(_) { count << true })])
169
+ end
170
+ client.publish(message(topic_target))
171
+ wait_until { count.size == 2 }
172
+ end
173
+ end
174
+
175
+ describe "a standalone client" do
176
+ it "accepts no requests after close" do
177
+ serve(endpoints: [endpoint(route_target, ->(m) { m })])
178
+ own = new_client.call(client_config, service_map)
179
+
180
+ expect(own.request(message(route_target)).payload).to eq("")
181
+ own.close
182
+ expect { own.request(message(route_target)) }.to raise_error(StandardError)
183
+ end
184
+ end
185
+
186
+ describe "the runtime's client" do
187
+ it "is the client the runtime was given, and stop closes it" do
188
+ given = new_client.call(client_config, service_map)
189
+ rt = new_runtime.call(given, runtime_config, endpoints: [endpoint(route_target, ->(m) { m })], subscribers: [])
190
+ @conformance_runtimes << rt
191
+ req = message(route_target)
192
+
193
+ expect(rt.client).to equal(given)
194
+ expect(rt.service_map).to equal(service_map)
195
+ rt.start
196
+ expect(given.request(req).payload).to eq("")
197
+ rt.stop(1)
198
+ expect { given.request(req) }.to raise_error(StandardError)
199
+ end
200
+ end
201
+
202
+ describe "lifecycle" do
203
+ it "moves created -> running -> stopped and does not restart" do
204
+ rt = build
205
+ @conformance_runtimes << rt
206
+
207
+ expect(rt.running?).to be(false)
208
+ expect(rt.stop(0)).to be(true)
209
+ rt.start
210
+ expect(rt.running?).to be(true)
211
+ expect { rt.start }.to raise_error(StandardError)
212
+ expect(rt.stop(1)).to be(true)
213
+ expect(rt.running?).to be(false)
214
+ expect(rt.stop(0)).to be(true)
215
+ expect { rt.start }.to raise_error(StandardError)
216
+ end
217
+
218
+ it "rejects a negative drain" do
219
+ expect { build.stop(-1) }.to raise_error(ArgumentError)
220
+ end
221
+ end
222
+
223
+ describe "stop" do
224
+ it "waits for an in-flight handler and the requester still gets the reply" do
225
+ entered = Queue.new
226
+ release = Queue.new
227
+ rt = serve(endpoints: [endpoint(route_target, lambda { |_|
228
+ entered << true
229
+ release.pop
230
+ message(route_target, payload: "done")
231
+ })])
232
+
233
+ reply = Thread.new { client.request(message(route_target)) }
234
+ @conformance_threads << reply
235
+ Timeout.timeout(wait) { entered.pop }
236
+
237
+ stopped = Thread.new { rt.stop(wait) }
238
+ sleep 0.2
239
+ expect(stopped.alive?).to be(true)
240
+
241
+ release << true
242
+ expect(stopped.value).to be(true)
243
+ expect(reply.value.payload).to eq("done")
244
+ end
245
+
246
+ it "returns false when the drain expires with a handler still running" do
247
+ entered = Queue.new
248
+ release = Queue.new
249
+ rt = serve(endpoints: [endpoint(route_target, lambda { |_|
250
+ entered << true
251
+ release.pop
252
+ message(route_target)
253
+ })])
254
+
255
+ @conformance_threads << Thread.new { client.request(message(route_target)) rescue nil } # rubocop:disable Style/RescueModifier
256
+ Timeout.timeout(wait) { entered.pop }
257
+
258
+ expect(rt.stop(0.1)).to be(false)
259
+ release << true
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ServiceMesh
4
+ KINDS = %i[route topic].freeze
5
+
6
+ # Configuration keys the specification defines. Everything else belongs to
7
+ # a transport.
8
+ DEPLOYMENT_GROUP_KEY = "deployment_group"
9
+ CONSUMER_GROUP_KEY = "consumer_group"
10
+ CONSUMER_GROUP_NONE = "none"
11
+
12
+ # Identifies a receiving channel on the mesh. A transport assembles the
13
+ # segments into its own address; that string never leaves the transport.
14
+ Target = Data.define(:segments, :kind, :metadata) do
15
+ def initialize(segments:, kind:, metadata: {})
16
+ raise KindMismatch, "kind must be one of #{KINDS.inspect}, got #{kind.inspect}" unless KINDS.include?(kind)
17
+
18
+ super(segments: Array(segments).map(&:to_s).freeze, kind: kind, metadata: metadata.to_h.freeze)
19
+ end
20
+
21
+ # Same channel: same segments and kind. Metadata is configuration.
22
+ def same_channel?(other)
23
+ segments == other.segments && kind == other.kind
24
+ end
25
+ end
26
+
27
+ # Every Target reachable over one transport.
28
+ ServiceMap = Data.define(:targets) do
29
+ def initialize(targets: [])
30
+ super(targets: Array(targets).freeze)
31
+ end
32
+ end
33
+
34
+ # What travels between services. Payload is always BINARY encoded.
35
+ Message = Data.define(:target, :metadata, :payload) do
36
+ def initialize(target:, metadata: {}, payload: "")
37
+ super(target: target, metadata: metadata.to_h.freeze, payload: payload.to_s.b.freeze)
38
+ end
39
+ end
40
+
41
+ # A route target paired with a handler that returns a Message.
42
+ Endpoint = Data.define(:target, :metadata, :handler) do
43
+ def initialize(target:, handler:, metadata: {})
44
+ super(target: target, metadata: metadata.to_h.freeze, handler: handler)
45
+ end
46
+ end
47
+
48
+ # A topic target paired with a handler whose return value is ignored.
49
+ Subscriber = Data.define(:target, :metadata, :handler) do
50
+ def initialize(target:, handler:, metadata: {})
51
+ super(target: target, metadata: metadata.to_h.freeze, handler: handler)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ServiceMesh
4
+ VERSION = "0.4.1"
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "service_mesh/version"
4
+ require_relative "service_mesh/types"
5
+ require_relative "service_mesh/errors"
6
+
7
+ # Ruby contract for the Service Mesh API Specification. Transports implement
8
+ # Client and Runtime against these types; see service_mesh/rspec for the
9
+ # conformance suite a transport runs.
10
+ module ServiceMesh
11
+ end
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: service_mesh
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.1
5
+ platform: ruby
6
+ authors:
7
+ - Paymentbox
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-25 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - lib/service_mesh.rb
22
+ - lib/service_mesh/errors.rb
23
+ - lib/service_mesh/rspec.rb
24
+ - lib/service_mesh/types.rb
25
+ - lib/service_mesh/version.rb
26
+ homepage: https://github.com/Paymentbox-com/service-mesh-ruby
27
+ licenses:
28
+ - MIT
29
+ metadata:
30
+ homepage_uri: https://github.com/Paymentbox-com/service-mesh-ruby
31
+ source_code_uri: https://github.com/Paymentbox-com/service-mesh-ruby
32
+ rubygems_mfa_required: 'true'
33
+ post_install_message:
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '3.3'
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubygems_version: 3.5.22
49
+ signing_key:
50
+ specification_version: 4
51
+ summary: Ruby contract for the Service Mesh API Specification
52
+ test_files: []