nats-async 0.1.3 → 0.1.5
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 +4 -4
- data/.github/workflows/docs.yml +36 -0
- data/README.md +18 -4
- data/Rakefile +26 -0
- data/examples/backend_detection.rb +16 -0
- data/examples/core_lifecycle.rb +20 -0
- data/examples/headers_and_binary.rb +23 -0
- data/examples/jetstream_management.rb +18 -0
- data/examples/jetstream_publish.rb +18 -0
- data/examples/jetstream_pull_consumer.rb +25 -0
- data/examples/jetstream_roundtrip.rb +22 -40
- data/examples/queue_group.rb +21 -0
- data/examples/request_reply.rb +15 -0
- data/lib/nats-async.rb +2 -1
- data/lib/nats_async/{simple_connector.rb → client.rb} +243 -32
- data/lib/nats_async/jetstream.rb +253 -0
- data/lib/version.rb +1 -1
- data/spec/examples_spec.rb +20 -17
- data/spec/nats_async_spec.rb +2 -2
- metadata +12 -3
- data/examples/basic_pub_sub.rb +0 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b1258fa795e1c8f2c02e93508469053ceac348404b146b56d9a158f86cdb864
|
|
4
|
+
data.tar.gz: 89f6419e4e219c23a4b2c3a266cd9d13adedd17604cebd1ff5426a1b68f19270
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56005624768a4523e441dc2a2e36438f4f911fbc21f73cb08b686bc9b5fbb7e6edbacaf022ddaa7eb8655c2211877e9f942b5321b8fb048ff31216380e463301
|
|
7
|
+
data.tar.gz: 915d66b577c8c9f332c5134e065460547b36ea07a7b7755e31813d3f2a84f5397f2f459bd918045fe893ae64c860ce55d965029fd168b411bc5349d71e92bdf1
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: docs
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
pages: write
|
|
11
|
+
id-token: write
|
|
12
|
+
|
|
13
|
+
env:
|
|
14
|
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
build:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v5
|
|
21
|
+
- uses: withastro/action@v5
|
|
22
|
+
with:
|
|
23
|
+
path: docs-site
|
|
24
|
+
node-version: 24
|
|
25
|
+
package-manager: npm@11.6.2
|
|
26
|
+
|
|
27
|
+
deploy:
|
|
28
|
+
needs: build
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
environment:
|
|
31
|
+
name: github-pages
|
|
32
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
33
|
+
steps:
|
|
34
|
+
- name: Deploy to GitHub Pages
|
|
35
|
+
id: deployment
|
|
36
|
+
uses: actions/deploy-pages@v4
|
data/README.md
CHANGED
|
@@ -21,14 +21,18 @@ gem install nats-async
|
|
|
21
21
|
```ruby
|
|
22
22
|
require "nats-async"
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
Async do |task|
|
|
25
|
+
client = NatsAsync::Client.new(url: "nats://127.0.0.1:4222", verbose: false)
|
|
26
|
+
client.start(task: task)
|
|
27
|
+
|
|
26
28
|
client.subscribe("demo.subject") do |message|
|
|
27
29
|
puts "received: #{message.data}"
|
|
28
|
-
task.stop
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
client.publish("demo.subject", "hello")
|
|
33
|
+
client.flush
|
|
34
|
+
ensure
|
|
35
|
+
client&.close
|
|
32
36
|
end
|
|
33
37
|
```
|
|
34
38
|
|
|
@@ -37,7 +41,7 @@ end
|
|
|
37
41
|
Core pub/sub:
|
|
38
42
|
|
|
39
43
|
```bash
|
|
40
|
-
bundle exec ruby examples/
|
|
44
|
+
bundle exec ruby examples/core_lifecycle.rb
|
|
41
45
|
```
|
|
42
46
|
|
|
43
47
|
JetStream publish and pull:
|
|
@@ -48,6 +52,16 @@ bundle exec ruby examples/jetstream_roundtrip.rb
|
|
|
48
52
|
|
|
49
53
|
The integration spec boots the bundled [`bin/nats-server`](bin/nats-server) and runs these examples locally.
|
|
50
54
|
|
|
55
|
+
## Documentation
|
|
56
|
+
|
|
57
|
+
The documentation site is an Astro/Starlight project in [`docs-site`](docs-site).
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm --prefix docs-site install
|
|
61
|
+
bundle exec rake docs:dev
|
|
62
|
+
npm --prefix docs-site run build
|
|
63
|
+
```
|
|
64
|
+
|
|
51
65
|
## Development
|
|
52
66
|
|
|
53
67
|
```bash
|
data/Rakefile
CHANGED
|
@@ -25,6 +25,32 @@ task :readme do
|
|
|
25
25
|
File.write("./README.md", renderer.result)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def npm_bin
|
|
29
|
+
return ENV["NPM_BIN"] if ENV["NPM_BIN"] && !ENV["NPM_BIN"].empty?
|
|
30
|
+
|
|
31
|
+
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |path|
|
|
32
|
+
candidate = File.join(path, "npm")
|
|
33
|
+
return candidate if File.executable?(candidate)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Dir[File.join(Dir.home, ".nvm/versions/node/*/bin/npm")].sort.last
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
namespace :docs do
|
|
40
|
+
desc "Run documentation development server"
|
|
41
|
+
task :dev do
|
|
42
|
+
npm = npm_bin
|
|
43
|
+
abort "npm was not found. Install Node.js or run with NPM_BIN=/path/to/npm." unless npm
|
|
44
|
+
|
|
45
|
+
npm_path = File.dirname(npm)
|
|
46
|
+
env = {"PATH" => [npm_path, ENV["PATH"]].compact.join(File::PATH_SEPARATOR)}
|
|
47
|
+
|
|
48
|
+
Dir.chdir("docs-site") do
|
|
49
|
+
exec env, npm, "run", "dev"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
28
54
|
desc "Build&push new version"
|
|
29
55
|
task push: %i[spec readme] do
|
|
30
56
|
puts "Build&push new version"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "nats-async"
|
|
6
|
+
|
|
7
|
+
Async do |task|
|
|
8
|
+
client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
|
|
9
|
+
client.start(task: task)
|
|
10
|
+
|
|
11
|
+
client.jetstream.add_stream?("inference", subjects: ["jobs.>"])
|
|
12
|
+
backend = client.resolve_backend(mode: :auto, stream: "inference")
|
|
13
|
+
puts "using #{backend}"
|
|
14
|
+
ensure
|
|
15
|
+
client&.close
|
|
16
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "nats-async"
|
|
6
|
+
|
|
7
|
+
Async do |task|
|
|
8
|
+
client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
|
|
9
|
+
client.start(task: task)
|
|
10
|
+
done = Async::Condition.new
|
|
11
|
+
|
|
12
|
+
client.subscribe("demo.>") do |message|
|
|
13
|
+
puts "#{message.subject}: #{message.data}"
|
|
14
|
+
done.signal
|
|
15
|
+
end
|
|
16
|
+
client.publish("demo.created", "hello")
|
|
17
|
+
done.wait
|
|
18
|
+
ensure
|
|
19
|
+
client&.close
|
|
20
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "nats-async"
|
|
6
|
+
|
|
7
|
+
headers = {"traceparent" => "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00"}
|
|
8
|
+
payload = "\x00frame\xFF".b
|
|
9
|
+
|
|
10
|
+
Async do |task|
|
|
11
|
+
client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
|
|
12
|
+
client.start(task: task)
|
|
13
|
+
done = Async::Condition.new
|
|
14
|
+
|
|
15
|
+
client.subscribe("tcp.frames") do |message|
|
|
16
|
+
puts "#{message.headers["traceparent"]}: #{message.data.bytesize} bytes"
|
|
17
|
+
done.signal
|
|
18
|
+
end
|
|
19
|
+
client.publish("tcp.frames", payload, headers: headers)
|
|
20
|
+
done.wait
|
|
21
|
+
ensure
|
|
22
|
+
client&.close
|
|
23
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "nats-async"
|
|
6
|
+
|
|
7
|
+
Async do |task|
|
|
8
|
+
client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
|
|
9
|
+
client.start(task: task)
|
|
10
|
+
|
|
11
|
+
js = client.jetstream
|
|
12
|
+
js.add_stream?("inference", subjects: ["jobs.>"])
|
|
13
|
+
puts js.stream_exists?("inference")
|
|
14
|
+
js.add_consumer?("inference", durable_name: "worker", ack_policy: "explicit", filter_subject: "jobs.>")
|
|
15
|
+
puts js.consumer_info("inference", "worker").dig(:config, :durable_name)
|
|
16
|
+
ensure
|
|
17
|
+
client&.close
|
|
18
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "nats-async"
|
|
6
|
+
|
|
7
|
+
Async do |task|
|
|
8
|
+
client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
|
|
9
|
+
client.start(task: task)
|
|
10
|
+
|
|
11
|
+
js = client.jetstream
|
|
12
|
+
js.add_stream?("jobs", subjects: ["jobs.>"])
|
|
13
|
+
|
|
14
|
+
ack = js.publish("jobs.render", "payload", headers: {"x-id" => "42"})
|
|
15
|
+
puts "stored in #{ack.stream}##{ack.seq}"
|
|
16
|
+
ensure
|
|
17
|
+
client&.close
|
|
18
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "nats-async"
|
|
6
|
+
|
|
7
|
+
Async do |task|
|
|
8
|
+
client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
|
|
9
|
+
client.start(task: task)
|
|
10
|
+
|
|
11
|
+
js = client.jetstream
|
|
12
|
+
js.add_stream?("inference", subjects: ["jobs.>"])
|
|
13
|
+
sub = js.pull_subscribe("jobs.>", stream: "inference", durable: "worker")
|
|
14
|
+
js.publish("jobs.render", "payload")
|
|
15
|
+
|
|
16
|
+
sub.fetch(batch: 5, timeout: 1).each do |message|
|
|
17
|
+
puts message.data
|
|
18
|
+
message.ack
|
|
19
|
+
rescue StandardError
|
|
20
|
+
message.nak
|
|
21
|
+
end
|
|
22
|
+
ensure
|
|
23
|
+
sub&.unsubscribe
|
|
24
|
+
client&.close
|
|
25
|
+
end
|
|
@@ -11,51 +11,33 @@ consumer = ENV.fetch("JS_CONSUMER", "example_consumer")
|
|
|
11
11
|
payload = ENV.fetch("JS_PAYLOAD", "hello jetstream")
|
|
12
12
|
js_api_prefix = ENV.fetch("JS_API_PREFIX", "$JS.API")
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
client.request(
|
|
22
|
-
client.js_api_subject("STREAM.CREATE", stream_name),
|
|
23
|
-
{
|
|
24
|
-
name: stream_name,
|
|
25
|
-
subjects: [subject]
|
|
26
|
-
},
|
|
27
|
-
timeout: 2
|
|
14
|
+
Async do |task|
|
|
15
|
+
client = NatsAsync::Client.new(
|
|
16
|
+
url: url,
|
|
17
|
+
verbose: true,
|
|
18
|
+
js_api_prefix: js_api_prefix,
|
|
19
|
+
ping_interval: 1,
|
|
20
|
+
ping_timeout: 1
|
|
28
21
|
)
|
|
22
|
+
client.start(task: task)
|
|
29
23
|
|
|
30
|
-
client.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
stream_name: stream_name,
|
|
34
|
-
config: {
|
|
35
|
-
name: consumer,
|
|
36
|
-
durable_name: consumer,
|
|
37
|
-
ack_policy: "explicit",
|
|
38
|
-
deliver_policy: "new",
|
|
39
|
-
filter_subject: subject
|
|
40
|
-
}
|
|
41
|
-
},
|
|
42
|
-
timeout: 2
|
|
43
|
-
)
|
|
24
|
+
js = client.jetstream
|
|
25
|
+
js.add_stream?(stream_name, subjects: [subject])
|
|
26
|
+
sub = js.pull_subscribe(subject, stream: stream_name, durable: consumer)
|
|
44
27
|
|
|
45
|
-
pub_ack =
|
|
46
|
-
puts "published seq=#{pub_ack
|
|
28
|
+
pub_ack = js.publish(subject, payload)
|
|
29
|
+
puts "published seq=#{pub_ack.seq} stream=#{pub_ack.stream}"
|
|
47
30
|
|
|
48
|
-
message =
|
|
49
|
-
client.js_api_subject("CONSUMER.MSG.NEXT", stream_name, consumer),
|
|
50
|
-
{batch: 1, expires: 1_000_000_000},
|
|
51
|
-
timeout: 2,
|
|
52
|
-
parse_json: false
|
|
53
|
-
)
|
|
31
|
+
message = sub.fetch(batch: 1, timeout: 2).first
|
|
54
32
|
|
|
55
33
|
puts "received subject=#{message.subject} data=#{message.data.inspect}"
|
|
56
34
|
puts "metadata=#{message.metadata.inspect}"
|
|
57
|
-
message.ack
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
35
|
+
message.ack
|
|
36
|
+
|
|
37
|
+
sub.unsubscribe
|
|
38
|
+
js.delete_consumer(stream_name, consumer)
|
|
39
|
+
js.delete_stream(stream_name)
|
|
40
|
+
ensure
|
|
41
|
+
sub&.unsubscribe
|
|
42
|
+
client&.close
|
|
61
43
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "nats-async"
|
|
6
|
+
|
|
7
|
+
Async do |task|
|
|
8
|
+
client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
|
|
9
|
+
client.start(task: task)
|
|
10
|
+
done = Async::Condition.new
|
|
11
|
+
|
|
12
|
+
client.subscribe("jobs.render", queue: "renderers") do |message|
|
|
13
|
+
puts "worker got #{message.data}"
|
|
14
|
+
done.signal
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
client.publish("jobs.render", "job-1")
|
|
18
|
+
done.wait
|
|
19
|
+
ensure
|
|
20
|
+
client&.drain(timeout: 5)
|
|
21
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "nats-async"
|
|
6
|
+
|
|
7
|
+
Async do |task|
|
|
8
|
+
client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
|
|
9
|
+
client.start(task: task)
|
|
10
|
+
|
|
11
|
+
client.subscribe("math.double") { |message| client.publish(message.reply, message.data.to_i * 2) }
|
|
12
|
+
puts client.request("math.double", "21", timeout: 1)
|
|
13
|
+
ensure
|
|
14
|
+
client&.close
|
|
15
|
+
end
|
data/lib/nats-async.rb
CHANGED
|
@@ -15,8 +15,9 @@ require "base64"
|
|
|
15
15
|
require "openssl"
|
|
16
16
|
|
|
17
17
|
module NatsAsync
|
|
18
|
-
class
|
|
18
|
+
class Client
|
|
19
19
|
CR_LF = "\r\n"
|
|
20
|
+
HEADER_LINE = "NATS/1.0"
|
|
20
21
|
|
|
21
22
|
class AckError < StandardError; end
|
|
22
23
|
class MsgAlreadyAcked < AckError; end
|
|
@@ -25,19 +26,34 @@ module NatsAsync
|
|
|
25
26
|
class ResponseParseError < RequestError; end
|
|
26
27
|
class ProtocolError < StandardError; end
|
|
27
28
|
|
|
29
|
+
class Headers < Hash
|
|
30
|
+
def self.wrap(values)
|
|
31
|
+
new.tap { |headers| values.each { |key, value| headers[key] = value } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def [](key)
|
|
35
|
+
return super if key?(key)
|
|
36
|
+
|
|
37
|
+
match = keys.find { |existing| existing.to_s.casecmp?(key.to_s) }
|
|
38
|
+
match ? super(match) : nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
28
42
|
class Message
|
|
29
43
|
ACK = "+ACK"
|
|
30
44
|
NAK = "-NAK"
|
|
31
45
|
TERM = "+TERM"
|
|
32
46
|
WPI = "+WPI"
|
|
33
47
|
|
|
34
|
-
attr_reader :subject, :sid, :reply, :data
|
|
48
|
+
attr_reader :subject, :sid, :reply, :data, :headers
|
|
49
|
+
alias header headers
|
|
35
50
|
|
|
36
|
-
def initialize(subject:, sid:, reply:, data:, connector:)
|
|
51
|
+
def initialize(subject:, sid:, reply:, data:, connector:, headers: {})
|
|
37
52
|
@subject = subject
|
|
38
53
|
@sid = sid
|
|
39
54
|
@reply = reply
|
|
40
55
|
@data = data
|
|
56
|
+
@headers = Headers.wrap(headers)
|
|
41
57
|
@connector = connector
|
|
42
58
|
@acked = false
|
|
43
59
|
end
|
|
@@ -58,6 +74,14 @@ module NatsAsync
|
|
|
58
74
|
publish_ack(WPI, timeout: timeout)
|
|
59
75
|
end
|
|
60
76
|
|
|
77
|
+
def ackable?
|
|
78
|
+
!reply.to_s.empty?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def acked?
|
|
82
|
+
@acked
|
|
83
|
+
end
|
|
84
|
+
|
|
61
85
|
def metadata
|
|
62
86
|
return unless reply&.start_with?("$JS.ACK.")
|
|
63
87
|
|
|
@@ -119,6 +143,8 @@ module NatsAsync
|
|
|
119
143
|
url: "nats://127.0.0.1:4222",
|
|
120
144
|
verbose: true,
|
|
121
145
|
js_api_prefix: "$JS.API",
|
|
146
|
+
ping_interval: 30,
|
|
147
|
+
ping_timeout: 5,
|
|
122
148
|
tls: nil,
|
|
123
149
|
tls_verify: true,
|
|
124
150
|
tls_ca_file: nil,
|
|
@@ -134,6 +160,8 @@ module NatsAsync
|
|
|
134
160
|
@url = URI(url)
|
|
135
161
|
@verbose = verbose
|
|
136
162
|
@js_api_prefix = normalize_subject_prefix(js_api_prefix)
|
|
163
|
+
@ping_interval = ping_interval
|
|
164
|
+
@ping_timeout = ping_timeout
|
|
137
165
|
@tls_enabled = tls.nil? ? %w[tls nats+tls].include?(@url.scheme) : tls
|
|
138
166
|
@tls_verify = tls_verify
|
|
139
167
|
@tls_ca_file = presence(tls_ca_file)
|
|
@@ -154,7 +182,10 @@ module NatsAsync
|
|
|
154
182
|
@server_info = nil
|
|
155
183
|
|
|
156
184
|
@read_task = nil
|
|
185
|
+
@ping_task = nil
|
|
157
186
|
@read_error = nil
|
|
187
|
+
@started = false
|
|
188
|
+
@closed = true
|
|
158
189
|
@write_lock = Async::Semaphore.new(1)
|
|
159
190
|
@pong_condition = Async::Condition.new
|
|
160
191
|
@sid_seq = 0
|
|
@@ -163,32 +194,89 @@ module NatsAsync
|
|
|
163
194
|
|
|
164
195
|
attr_reader :received_pings, :received_pongs, :sent_pings, :server_info, :js_api_prefix
|
|
165
196
|
|
|
166
|
-
def
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
197
|
+
def start(task:)
|
|
198
|
+
return self if connected?
|
|
199
|
+
|
|
200
|
+
connect!
|
|
201
|
+
read_initial_info!
|
|
202
|
+
send_connect!
|
|
203
|
+
@read_task = task.async { read_loop }
|
|
204
|
+
@started = true
|
|
205
|
+
@closed = false
|
|
206
|
+
ping!(timeout: @ping_timeout)
|
|
207
|
+
start_ping_loop(task)
|
|
208
|
+
self
|
|
209
|
+
rescue StandardError
|
|
210
|
+
stop
|
|
211
|
+
@started = false
|
|
212
|
+
@closed = true
|
|
213
|
+
raise
|
|
214
|
+
end
|
|
171
215
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
yield self, task if block_given?
|
|
216
|
+
def close
|
|
217
|
+
return true if closed?
|
|
175
218
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
219
|
+
stop
|
|
220
|
+
@started = false
|
|
221
|
+
@closed = true
|
|
222
|
+
true
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def resolve_backend(mode: :auto, stream: nil)
|
|
226
|
+
mode = mode.to_sym
|
|
227
|
+
return :core if mode == :core
|
|
228
|
+
|
|
229
|
+
raise ArgumentError, "stream is required for #{mode} backend" if stream.to_s.empty?
|
|
230
|
+
|
|
231
|
+
case mode
|
|
232
|
+
when :jetstream
|
|
233
|
+
jetstream.stream_info(stream)
|
|
234
|
+
:jetstream
|
|
235
|
+
when :auto
|
|
236
|
+
jetstream.stream_exists?(stream) ? :jetstream : :core
|
|
237
|
+
else
|
|
238
|
+
raise ArgumentError, "unsupported backend mode: #{mode.inspect}"
|
|
239
|
+
end
|
|
240
|
+
rescue JetStream::Error
|
|
241
|
+
raise if mode == :jetstream
|
|
242
|
+
|
|
243
|
+
:core
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def drain(timeout: 5)
|
|
247
|
+
@ping_task&.stop
|
|
248
|
+
@ping_task = nil
|
|
249
|
+
flush(timeout: timeout) if connected?
|
|
250
|
+
true
|
|
251
|
+
ensure
|
|
252
|
+
close
|
|
184
253
|
end
|
|
185
254
|
|
|
186
255
|
def stop
|
|
256
|
+
@ping_task&.stop
|
|
187
257
|
@read_task&.stop
|
|
188
258
|
safe_close_stream
|
|
259
|
+
@ping_task = nil
|
|
189
260
|
@read_task = nil
|
|
190
261
|
end
|
|
191
262
|
|
|
263
|
+
def flush(timeout: 2)
|
|
264
|
+
ping!(timeout: timeout)
|
|
265
|
+
true
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def connected?
|
|
269
|
+
@started && !@closed && !@stream.nil?
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def closed?
|
|
273
|
+
@closed
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def last_error
|
|
277
|
+
@read_error
|
|
278
|
+
end
|
|
279
|
+
|
|
192
280
|
def ping!(timeout: 2)
|
|
193
281
|
expected_pongs = @received_pongs + 1
|
|
194
282
|
|
|
@@ -200,8 +288,10 @@ module NatsAsync
|
|
|
200
288
|
})
|
|
201
289
|
end
|
|
202
290
|
|
|
203
|
-
def publish(subject, payload = "", reply: nil)
|
|
291
|
+
def publish(subject, payload = "", reply: nil, headers: nil)
|
|
204
292
|
payload = payload.to_s
|
|
293
|
+
return publish_with_headers(subject, payload, headers, reply: reply) if headers && !headers.empty?
|
|
294
|
+
|
|
205
295
|
command = build_pub_command(subject, payload.bytesize, reply: reply)
|
|
206
296
|
@logger.debug("C->S #{command}")
|
|
207
297
|
|
|
@@ -230,20 +320,20 @@ module NatsAsync
|
|
|
230
320
|
true
|
|
231
321
|
end
|
|
232
322
|
|
|
233
|
-
def request(subject, payload = "", timeout: 0.5, parse_json:
|
|
234
|
-
response = request_message(subject, payload, timeout: timeout)
|
|
235
|
-
result = parse_json ? parse_json_response(subject, response.data) : response
|
|
323
|
+
def request(subject, payload = "", timeout: 0.5, parse_json: false, headers: nil)
|
|
324
|
+
response = request_message(subject, payload, timeout: timeout, headers: headers)
|
|
325
|
+
result = parse_json ? parse_json_response(subject, response.data) : response.data
|
|
236
326
|
ensure_request_ok!(subject, result) if parse_json
|
|
237
327
|
block_given? ? yield(result) : result
|
|
238
328
|
end
|
|
239
329
|
|
|
240
|
-
def request_message(subject, payload = "", timeout: 0.5)
|
|
330
|
+
def request_message(subject, payload = "", timeout: 0.5, headers: nil)
|
|
241
331
|
inbox = "_INBOX.#{rand(1 << 30)}.#{next_sid}"
|
|
242
332
|
response = nil
|
|
243
333
|
condition = Async::Condition.new
|
|
244
334
|
on_response = ->(msg) { response = msg; condition.signal }
|
|
245
335
|
with_temp_subscription(inbox, handler: on_response) do
|
|
246
|
-
publish(subject, request_payload(payload), reply: inbox)
|
|
336
|
+
publish(subject, request_payload(payload), reply: inbox, headers: headers)
|
|
247
337
|
await(timeout: timeout, condition: condition, timeout_message: "request timeout after #{timeout}s", predicate: lambda {
|
|
248
338
|
raise @read_error if @read_error
|
|
249
339
|
!response.nil?
|
|
@@ -257,8 +347,29 @@ module NatsAsync
|
|
|
257
347
|
[js_api_prefix, *tokens.flatten].compact.map(&:to_s).reject(&:empty?).join(".")
|
|
258
348
|
end
|
|
259
349
|
|
|
350
|
+
def jetstream
|
|
351
|
+
@jetstream ||= JetStream.new(self)
|
|
352
|
+
end
|
|
353
|
+
|
|
260
354
|
private
|
|
261
355
|
|
|
356
|
+
def start_ping_loop(task)
|
|
357
|
+
return unless @ping_interval && @ping_interval.positive?
|
|
358
|
+
|
|
359
|
+
@ping_task = task.async { |ping_task| ping_loop(ping_task) }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def ping_loop(task)
|
|
363
|
+
loop do
|
|
364
|
+
task.sleep(@ping_interval)
|
|
365
|
+
ping!(timeout: @ping_timeout)
|
|
366
|
+
end
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
@read_error ||= e
|
|
369
|
+
@logger.error("ping loop error: #{e.class}: #{e.message}")
|
|
370
|
+
safe_close_stream
|
|
371
|
+
end
|
|
372
|
+
|
|
262
373
|
def connect!
|
|
263
374
|
host = @url.host || "127.0.0.1"
|
|
264
375
|
port = @url.port || 4222
|
|
@@ -286,6 +397,7 @@ module NatsAsync
|
|
|
286
397
|
lang: "ruby",
|
|
287
398
|
version: "nats-async-playground",
|
|
288
399
|
protocol: 1,
|
|
400
|
+
headers: true,
|
|
289
401
|
echo: true
|
|
290
402
|
}
|
|
291
403
|
payload.merge!(auth_connect_fields)
|
|
@@ -307,6 +419,8 @@ module NatsAsync
|
|
|
307
419
|
raise ProtocolError, "server error: #{line}"
|
|
308
420
|
when /\AINFO\s+/
|
|
309
421
|
@server_info = parse_info_line(line)
|
|
422
|
+
when /\AHMSG\s+/
|
|
423
|
+
dispatch_hmsg(line)
|
|
310
424
|
when /\AMSG\s+/
|
|
311
425
|
dispatch_msg(line)
|
|
312
426
|
end
|
|
@@ -390,17 +504,36 @@ module NatsAsync
|
|
|
390
504
|
suffix = @stream.read_exactly(CR_LF.bytesize)
|
|
391
505
|
raise ProtocolError, "malformed MSG payload ending: #{suffix.inspect}" unless suffix == CR_LF
|
|
392
506
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
507
|
+
dispatch_message(Message.new(subject: subject, sid: sid, reply: reply, data: payload, connector: self))
|
|
508
|
+
rescue StandardError => e
|
|
509
|
+
@logger.error("message dispatch error: #{e.class}: #{e.message}")
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def dispatch_hmsg(control_line)
|
|
513
|
+
subject, sid, reply, header_size, total_size = parse_hmsg_control_line(control_line)
|
|
514
|
+
raise ProtocolError, "HMSG header size exceeds total size" if header_size > total_size
|
|
515
|
+
|
|
516
|
+
data = @stream.read_exactly(total_size)
|
|
517
|
+
suffix = @stream.read_exactly(CR_LF.bytesize)
|
|
518
|
+
raise ProtocolError, "malformed HMSG payload ending: #{suffix.inspect}" unless suffix == CR_LF
|
|
519
|
+
|
|
520
|
+
header_block = data.byteslice(0, header_size) || +""
|
|
521
|
+
payload = data.byteslice(header_size, total_size - header_size) || +""
|
|
522
|
+
headers = parse_header_block(header_block)
|
|
523
|
+
dispatch_message(Message.new(subject: subject, sid: sid, reply: reply, data: payload, connector: self, headers: headers))
|
|
524
|
+
rescue StandardError => e
|
|
525
|
+
@logger.error("header message dispatch error: #{e.class}: #{e.message}")
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def dispatch_message(message)
|
|
529
|
+
handler = @subscriptions[message.sid]
|
|
530
|
+
protocol_payload_in(message.data)
|
|
396
531
|
|
|
397
532
|
if handler
|
|
398
|
-
handler.call(
|
|
533
|
+
handler.call(message)
|
|
399
534
|
else
|
|
400
|
-
@logger.warn("no subscription handler for sid=#{sid}")
|
|
535
|
+
@logger.warn("no subscription handler for sid=#{message.sid}")
|
|
401
536
|
end
|
|
402
|
-
rescue StandardError => e
|
|
403
|
-
@logger.error("message dispatch error: #{e.class}: #{e.message}")
|
|
404
537
|
end
|
|
405
538
|
|
|
406
539
|
def parse_msg_control_line(control_line)
|
|
@@ -419,6 +552,80 @@ module NatsAsync
|
|
|
419
552
|
raise ProtocolError, "invalid MSG control values: #{e.message}"
|
|
420
553
|
end
|
|
421
554
|
|
|
555
|
+
def parse_hmsg_control_line(control_line)
|
|
556
|
+
tokens = control_line.split(" ")
|
|
557
|
+
raise ProtocolError, "malformed HMSG line: #{control_line.inspect}" unless tokens.first == "HMSG"
|
|
558
|
+
|
|
559
|
+
case tokens.length
|
|
560
|
+
when 5
|
|
561
|
+
[tokens[1], Integer(tokens[2]), nil, Integer(tokens[3]), Integer(tokens[4])]
|
|
562
|
+
when 6
|
|
563
|
+
[tokens[1], Integer(tokens[2]), tokens[3], Integer(tokens[4]), Integer(tokens[5])]
|
|
564
|
+
else
|
|
565
|
+
raise ProtocolError, "unexpected HMSG control tokens: #{tokens.length}"
|
|
566
|
+
end
|
|
567
|
+
rescue ArgumentError => e
|
|
568
|
+
raise ProtocolError, "invalid HMSG control values: #{e.message}"
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def publish_with_headers(subject, payload, headers, reply: nil)
|
|
572
|
+
header_block = build_header_block(headers)
|
|
573
|
+
command = build_hpub_command(subject, header_block.bytesize, header_block.bytesize + payload.bytesize, reply: reply)
|
|
574
|
+
@logger.debug("C->S #{command}")
|
|
575
|
+
|
|
576
|
+
@write_lock.acquire do
|
|
577
|
+
@stream.write("#{command}#{CR_LF}", flush: false)
|
|
578
|
+
@stream.write(header_block, flush: false)
|
|
579
|
+
@stream.write(payload, flush: false)
|
|
580
|
+
@stream.write(CR_LF, flush: true)
|
|
581
|
+
end
|
|
582
|
+
protocol_payload_out(payload)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def build_header_block(headers)
|
|
586
|
+
lines = [HEADER_LINE]
|
|
587
|
+
headers.each do |key, value|
|
|
588
|
+
header_name = validate_header_name(key)
|
|
589
|
+
Array(value).each do |item|
|
|
590
|
+
lines << "#{header_name}: #{validate_header_value(item)}"
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
"#{lines.join(CR_LF)}#{CR_LF}#{CR_LF}".b
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def parse_header_block(block)
|
|
598
|
+
lines = block.split(CR_LF)
|
|
599
|
+
status = lines.shift
|
|
600
|
+
raise ProtocolError, "invalid header block status: #{status.inspect}" unless status&.start_with?(HEADER_LINE)
|
|
601
|
+
|
|
602
|
+
lines.each_with_object({}) do |line, headers|
|
|
603
|
+
next if line.empty?
|
|
604
|
+
|
|
605
|
+
key, value = line.split(":", 2)
|
|
606
|
+
raise ProtocolError, "malformed header line: #{line.inspect}" unless key && value
|
|
607
|
+
|
|
608
|
+
value = value.sub(/\A[ \t]/, "")
|
|
609
|
+
existing = headers[key]
|
|
610
|
+
headers[key] = existing ? Array(existing).push(value) : value
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def validate_header_name(key)
|
|
615
|
+
name = key.to_s
|
|
616
|
+
raise ArgumentError, "header name cannot be empty" if name.empty?
|
|
617
|
+
raise ArgumentError, "invalid header name: #{name.inspect}" if name.match?(/[:\r\n]/)
|
|
618
|
+
|
|
619
|
+
name
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def validate_header_value(value)
|
|
623
|
+
string = value.to_s
|
|
624
|
+
raise ArgumentError, "invalid header value: #{string.inspect}" if string.match?(/[\r\n]/)
|
|
625
|
+
|
|
626
|
+
string
|
|
627
|
+
end
|
|
628
|
+
|
|
422
629
|
def protocol_payload_out(payload)
|
|
423
630
|
return if payload.empty?
|
|
424
631
|
|
|
@@ -442,6 +649,10 @@ module NatsAsync
|
|
|
442
649
|
reply ? "PUB #{subject} #{reply} #{size}" : "PUB #{subject} #{size}"
|
|
443
650
|
end
|
|
444
651
|
|
|
652
|
+
def build_hpub_command(subject, header_size, total_size, reply: nil)
|
|
653
|
+
reply ? "HPUB #{subject} #{reply} #{header_size} #{total_size}" : "HPUB #{subject} #{header_size} #{total_size}"
|
|
654
|
+
end
|
|
655
|
+
|
|
445
656
|
def nkey_connect_fields
|
|
446
657
|
return {} unless nkey_auth?
|
|
447
658
|
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsAsync
|
|
4
|
+
class JetStream
|
|
5
|
+
class Error < Client::RequestError
|
|
6
|
+
attr_reader :code, :err_code, :description
|
|
7
|
+
|
|
8
|
+
def initialize(message, code: nil, err_code: nil, description: nil)
|
|
9
|
+
super(message)
|
|
10
|
+
@code = code
|
|
11
|
+
@err_code = err_code
|
|
12
|
+
@description = description
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class NotFound < Error; end
|
|
17
|
+
class ConsumerError < Error; end
|
|
18
|
+
|
|
19
|
+
PublishAck = Struct.new(:stream, :seq, :duplicate, keyword_init: true) do
|
|
20
|
+
def duplicate?
|
|
21
|
+
!!duplicate
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class PullSubscription
|
|
26
|
+
def initialize(client:, stream:, consumer:)
|
|
27
|
+
@client = client
|
|
28
|
+
@stream = stream
|
|
29
|
+
@consumer = consumer
|
|
30
|
+
@inbox = "_INBOX.#{rand(1 << 30)}.#{object_id.abs}"
|
|
31
|
+
@sid = @client.subscribe(@inbox) { |message| receive(message) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fetch(batch: 1, timeout: 1)
|
|
35
|
+
@messages = []
|
|
36
|
+
@done = false
|
|
37
|
+
@batch = batch
|
|
38
|
+
@condition = Async::Condition.new
|
|
39
|
+
|
|
40
|
+
@client.publish(next_subject, JSON.generate({batch: batch, expires: seconds_to_nanoseconds(timeout)}), reply: @inbox)
|
|
41
|
+
wait_for_messages(timeout)
|
|
42
|
+
@messages
|
|
43
|
+
ensure
|
|
44
|
+
@messages = nil
|
|
45
|
+
@condition = nil
|
|
46
|
+
@batch = nil
|
|
47
|
+
@done = false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def unsubscribe
|
|
51
|
+
return true unless @sid
|
|
52
|
+
|
|
53
|
+
@client.unsubscribe(@sid)
|
|
54
|
+
@sid = nil
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def receive(message)
|
|
61
|
+
return unless @condition
|
|
62
|
+
|
|
63
|
+
if status_message?(message)
|
|
64
|
+
@done = true
|
|
65
|
+
else
|
|
66
|
+
@messages << message
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@condition.signal if @done || @messages.size >= @batch
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def wait_for_messages(timeout)
|
|
73
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
74
|
+
|
|
75
|
+
until @done || @messages.size >= @batch
|
|
76
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
77
|
+
break if remaining <= 0
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
Async::Task.current.with_timeout(remaining) { @condition.wait }
|
|
81
|
+
rescue Timeout::Error, Async::TimeoutError
|
|
82
|
+
break
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def status_message?(message)
|
|
88
|
+
!message.headers["Status"].to_s.empty?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def next_subject
|
|
92
|
+
@client.js_api_subject("CONSUMER.MSG.NEXT", @stream, @consumer)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def seconds_to_nanoseconds(value)
|
|
96
|
+
(value.to_f * 1_000_000_000).to_i
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def initialize(client)
|
|
101
|
+
@client = client
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def stream_info(name)
|
|
105
|
+
api_request("STREAM.INFO", name)
|
|
106
|
+
rescue Error => e
|
|
107
|
+
raise not_found_error(e) if not_found?(e)
|
|
108
|
+
|
|
109
|
+
raise
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def available?
|
|
113
|
+
account_info
|
|
114
|
+
true
|
|
115
|
+
rescue Error
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def account_info
|
|
120
|
+
api_request("INFO")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def stream_exists?(name)
|
|
124
|
+
stream_info(name)
|
|
125
|
+
true
|
|
126
|
+
rescue NotFound
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def add_stream(name, config = nil, **options)
|
|
131
|
+
config = merge_config(config, options).merge(name: name)
|
|
132
|
+
api_request("STREAM.CREATE", name, config)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def add_stream?(name, config = nil, **options)
|
|
136
|
+
return false if stream_exists?(name)
|
|
137
|
+
|
|
138
|
+
add_stream(name, config, **options)
|
|
139
|
+
true
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def delete_stream(name)
|
|
143
|
+
api_request("STREAM.DELETE", name, {})
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def consumer_info(stream, consumer)
|
|
147
|
+
api_request("CONSUMER.INFO", stream, consumer)
|
|
148
|
+
rescue Error => e
|
|
149
|
+
raise not_found_error(e) if not_found?(e)
|
|
150
|
+
|
|
151
|
+
raise
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def consumer_exists?(stream, consumer)
|
|
155
|
+
consumer_info(stream, consumer)
|
|
156
|
+
true
|
|
157
|
+
rescue NotFound
|
|
158
|
+
false
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def add_consumer(stream, config = nil, **options)
|
|
162
|
+
config = merge_config(config, options)
|
|
163
|
+
consumer = consumer_name(config)
|
|
164
|
+
subject = consumer ? @client.js_api_subject("CONSUMER.CREATE", stream, consumer) : @client.js_api_subject("CONSUMER.CREATE", stream)
|
|
165
|
+
api_request_subject(subject, {stream_name: stream, config: config})
|
|
166
|
+
rescue Error => e
|
|
167
|
+
raise ConsumerError.new(e.message, code: e.code, err_code: e.err_code, description: e.description)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def add_consumer?(stream, config = nil, **options)
|
|
171
|
+
config = merge_config(config, options)
|
|
172
|
+
consumer = consumer_name(config)
|
|
173
|
+
return false if consumer && consumer_exists?(stream, consumer)
|
|
174
|
+
|
|
175
|
+
add_consumer(stream, config)
|
|
176
|
+
true
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def delete_consumer(stream, consumer)
|
|
180
|
+
api_request("CONSUMER.DELETE", stream, consumer, {})
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def publish(subject, payload = "", headers: nil, timeout: 2)
|
|
184
|
+
response = @client.request_message(subject, payload, timeout: timeout, headers: headers)
|
|
185
|
+
result = JSON.parse(response.data, symbolize_names: true)
|
|
186
|
+
raise_api_error(subject, result[:error]) if result[:error]
|
|
187
|
+
|
|
188
|
+
PublishAck.new(stream: result[:stream], seq: result[:seq], duplicate: result[:duplicate])
|
|
189
|
+
rescue JSON::ParserError => e
|
|
190
|
+
raise Error, "JetStream publish returned invalid JSON for #{subject}: #{e.message}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def pull_subscribe(subject, stream:, durable: nil, consumer: nil, config: {}, create: true)
|
|
194
|
+
config = merge_config(config, {})
|
|
195
|
+
config[:filter_subject] ||= subject
|
|
196
|
+
config[:ack_policy] ||= "explicit"
|
|
197
|
+
config[:durable_name] ||= durable if durable
|
|
198
|
+
config[:name] ||= consumer if consumer
|
|
199
|
+
|
|
200
|
+
consumer_name = consumer_name(config)
|
|
201
|
+
raise ArgumentError, "durable, consumer, config[:name], or config[:durable_name] is required" if consumer_name.to_s.empty?
|
|
202
|
+
|
|
203
|
+
add_consumer?(stream, config) if create
|
|
204
|
+
PullSubscription.new(client: @client, stream: stream, consumer: consumer_name)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def api_request(*tokens)
|
|
210
|
+
payload = tokens.last.is_a?(Hash) ? tokens.pop : {}
|
|
211
|
+
api_request_subject(@client.js_api_subject(*tokens), payload)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def api_request_subject(subject, payload)
|
|
215
|
+
response = @client.request_message(subject, payload, timeout: 2)
|
|
216
|
+
result = JSON.parse(response.data, symbolize_names: true)
|
|
217
|
+
raise_api_error(subject, result[:error]) if result[:error]
|
|
218
|
+
|
|
219
|
+
result
|
|
220
|
+
rescue JSON::ParserError => e
|
|
221
|
+
raise Error, "JetStream API returned invalid JSON for #{subject}: #{e.message}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def raise_api_error(subject, error)
|
|
225
|
+
details = error.is_a?(Hash) ? error : {description: error.to_s}
|
|
226
|
+
code = details[:code]
|
|
227
|
+
err_code = details[:err_code]
|
|
228
|
+
description = details[:description] || details[:message] || details.inspect
|
|
229
|
+
klass = not_found_code?(code, description) ? NotFound : Error
|
|
230
|
+
raise klass.new("JetStream API request failed for #{subject}: #{description}", code: code, err_code: err_code, description: description)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def not_found?(error)
|
|
234
|
+
error.is_a?(NotFound) || not_found_code?(error.code, error.description)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def not_found_code?(code, description)
|
|
238
|
+
code.to_i == 404 || description.to_s.match?(/not found/i)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def not_found_error(error)
|
|
242
|
+
NotFound.new(error.message, code: error.code, err_code: error.err_code, description: error.description)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def merge_config(config, options)
|
|
246
|
+
(config || {}).transform_keys(&:to_sym).merge(options)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def consumer_name(config)
|
|
250
|
+
config[:name] || config[:durable_name]
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
data/lib/version.rb
CHANGED
data/spec/examples_spec.rb
CHANGED
|
@@ -11,20 +11,6 @@ require "tempfile"
|
|
|
11
11
|
require "tmpdir"
|
|
12
12
|
|
|
13
13
|
RSpec.describe "example scripts" do
|
|
14
|
-
def report_server_start_failure(reason, log_path)
|
|
15
|
-
log = File.exist?(log_path) ? File.read(log_path) : "(log file missing)"
|
|
16
|
-
message = <<~MSG
|
|
17
|
-
nats-server example integration skipped: #{reason}
|
|
18
|
-
--- nats-server log ---
|
|
19
|
-
#{log}
|
|
20
|
-
--- end nats-server log ---
|
|
21
|
-
MSG
|
|
22
|
-
|
|
23
|
-
warn(message)
|
|
24
|
-
warn("::warning::#{message.gsub("\n", "%0A")}") if ENV["GITHUB_ACTIONS"] == "true"
|
|
25
|
-
skip(message)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
14
|
def project_path = File.expand_path("..", __dir__)
|
|
29
15
|
def server_path = File.join(project_path, "bin", "nats-server")
|
|
30
16
|
|
|
@@ -44,11 +30,21 @@ RSpec.describe "example scripts" do
|
|
|
44
30
|
return
|
|
45
31
|
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
|
|
46
32
|
if Process.waitpid(server_pid, Process::WNOHANG)
|
|
47
|
-
|
|
33
|
+
raise <<~MSG
|
|
34
|
+
bundled nats-server exited before becoming ready
|
|
35
|
+
--- nats-server log ---
|
|
36
|
+
#{File.exist?(log_path) ? File.read(log_path) : "(log file missing)"}
|
|
37
|
+
--- end nats-server log ---
|
|
38
|
+
MSG
|
|
48
39
|
end
|
|
49
40
|
|
|
50
41
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
51
|
-
|
|
42
|
+
raise <<~MSG
|
|
43
|
+
bundled nats-server did not start on port #{port}
|
|
44
|
+
--- nats-server log ---
|
|
45
|
+
#{File.exist?(log_path) ? File.read(log_path) : "(log file missing)"}
|
|
46
|
+
--- end nats-server log ---
|
|
47
|
+
MSG
|
|
52
48
|
end
|
|
53
49
|
|
|
54
50
|
sleep 0.1
|
|
@@ -92,7 +88,14 @@ RSpec.describe "example scripts" do
|
|
|
92
88
|
|
|
93
89
|
begin
|
|
94
90
|
wait_for_server(port, server, log_path)
|
|
95
|
-
run_example("examples/
|
|
91
|
+
run_example("examples/core_lifecycle.rb", {"NATS_URL" => url})
|
|
92
|
+
run_example("examples/queue_group.rb", {"NATS_URL" => url})
|
|
93
|
+
run_example("examples/request_reply.rb", {"NATS_URL" => url})
|
|
94
|
+
run_example("examples/headers_and_binary.rb", {"NATS_URL" => url})
|
|
95
|
+
run_example("examples/jetstream_management.rb", {"NATS_URL" => url})
|
|
96
|
+
run_example("examples/jetstream_publish.rb", {"NATS_URL" => url})
|
|
97
|
+
run_example("examples/backend_detection.rb", {"NATS_URL" => url})
|
|
98
|
+
run_example("examples/jetstream_pull_consumer.rb", {"NATS_URL" => url})
|
|
96
99
|
run_example(
|
|
97
100
|
"examples/jetstream_roundtrip.rb",
|
|
98
101
|
{
|
data/spec/nats_async_spec.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: nats-async
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Artem Borodkin
|
|
@@ -190,15 +190,24 @@ executables: []
|
|
|
190
190
|
extensions: []
|
|
191
191
|
extra_rdoc_files: []
|
|
192
192
|
files:
|
|
193
|
+
- ".github/workflows/docs.yml"
|
|
193
194
|
- ".github/workflows/release.yml"
|
|
194
195
|
- ".rspec"
|
|
195
196
|
- ".rubocop.yml"
|
|
196
197
|
- README.md
|
|
197
198
|
- Rakefile
|
|
198
|
-
- examples/
|
|
199
|
+
- examples/backend_detection.rb
|
|
200
|
+
- examples/core_lifecycle.rb
|
|
201
|
+
- examples/headers_and_binary.rb
|
|
202
|
+
- examples/jetstream_management.rb
|
|
203
|
+
- examples/jetstream_publish.rb
|
|
204
|
+
- examples/jetstream_pull_consumer.rb
|
|
199
205
|
- examples/jetstream_roundtrip.rb
|
|
206
|
+
- examples/queue_group.rb
|
|
207
|
+
- examples/request_reply.rb
|
|
200
208
|
- lib/nats-async.rb
|
|
201
|
-
- lib/nats_async/
|
|
209
|
+
- lib/nats_async/client.rb
|
|
210
|
+
- lib/nats_async/jetstream.rb
|
|
202
211
|
- lib/version.rb
|
|
203
212
|
- spec/examples_spec.rb
|
|
204
213
|
- spec/nats_async_spec.rb
|
data/examples/basic_pub_sub.rb
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require "bundler/setup"
|
|
5
|
-
require "nats-async"
|
|
6
|
-
|
|
7
|
-
url = ENV.fetch("NATS_URL", "nats://127.0.0.1:4222")
|
|
8
|
-
subject = ENV.fetch("NATS_SUBJECT", "demo.subject")
|
|
9
|
-
payload = ENV.fetch("NATS_PAYLOAD", "hello from nats-async")
|
|
10
|
-
|
|
11
|
-
connector = NatsAsync::SimpleConnector.new(url: url, verbose: true)
|
|
12
|
-
|
|
13
|
-
connector.run(duration: 1.5, ping_every: 1, ping_timeout: 1) do |client, task|
|
|
14
|
-
sid = client.subscribe(subject) do |message|
|
|
15
|
-
puts "received subject=#{message.subject} data=#{message.data.inspect}"
|
|
16
|
-
client.unsubscribe(sid)
|
|
17
|
-
task.stop
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
client.publish(subject, payload)
|
|
21
|
-
end
|