ruby_zmq_framework 0.1.0 → 0.1.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 +4 -4
- data/CHANGELOG.md +14 -0
- data/PROTOCOL.md +93 -0
- data/bin/flowctl +76 -0
- data/lib/ruby_zmq_framework/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5463eeb0f6933758bd516406c8fc45dd1d366089337244760247a87376639174
|
|
4
|
+
data.tar.gz: 90ebc27c4e1635883fc246a3d188ba5cf7c67233a9dc5e219b964cc1e240ff1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a927b2df81d865d478db868c47dccb12674cf775d7211f02d4d2068b0c52fc979a4cb170b87906a2b988091df27bdc8ca7201a203b737346e2ceb529eab748bb
|
|
7
|
+
data.tar.gz: 4a030f0e3bb7715ec8d42de84e14e8a6fe1be1a2148dd3ec0831d6bde0bcdede4a0530306a133b44f9ab4de7138bc39d24d9b00bc616ba7daec00153abbb84d8
|
data/CHANGELOG.md
CHANGED
|
@@ -49,6 +49,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
49
49
|
- ZeroMQ return codes are checked; failures raise
|
|
50
50
|
`RubyZmqFramework::Error` instead of passing silently.
|
|
51
51
|
|
|
52
|
+
## [0.1.1] - 2026-07-24
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
- `bin/flowctl` is now actually packaged with the gem (registered as the
|
|
56
|
+
`flowctl` executable) instead of only existing in the git checkout —
|
|
57
|
+
previously a project that merely depended on the gem had no way to run
|
|
58
|
+
the Quick Start despite the README instructing `bundle exec bin/flowctl`.
|
|
59
|
+
`PROTOCOL.md` is shipped alongside it for the same reason.
|
|
60
|
+
- `bin/flowctl` resolved its nodes' working directory from its own
|
|
61
|
+
install location (`__dir__`) rather than the manifest it was given, so
|
|
62
|
+
a `flow.yml` outside this repo would spawn nodes that couldn't find
|
|
63
|
+
their own relative `cmd` paths. It now `chdir`s to the manifest's
|
|
64
|
+
directory.
|
|
65
|
+
|
|
52
66
|
## [0.1.0] - 2026-07-21
|
|
53
67
|
|
|
54
68
|
- Initial version: `ZeroMQBus`, `StrictContract`, `FrameworkModule`
|
data/PROTOCOL.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Bus Protocol
|
|
2
|
+
|
|
3
|
+
This one page is the entire contract. A node written in any language that
|
|
4
|
+
follows it is a full citizen of the flow — no framework code required.
|
|
5
|
+
It is deliberately small enough to paste into an LLM prompt as the
|
|
6
|
+
complete context for writing a new node.
|
|
7
|
+
|
|
8
|
+
## Transport
|
|
9
|
+
|
|
10
|
+
ZeroMQ PUB/SUB over TCP. Every node owns exactly one PUB socket (bound to
|
|
11
|
+
its own port, where it publishes everything) and one SUB socket
|
|
12
|
+
(connected to each peer's PUB port, subscribed to `""` — all topics —
|
|
13
|
+
with filtering done after receipt).
|
|
14
|
+
|
|
15
|
+
## Wire format
|
|
16
|
+
|
|
17
|
+
Every message is a two-frame ZeroMQ multipart message:
|
|
18
|
+
|
|
19
|
+
| frame | content |
|
|
20
|
+
|-------|--------------------------------------------|
|
|
21
|
+
| 0 | topic — UTF-8 string, `lower_snake_case` |
|
|
22
|
+
| 1 | payload — a JSON **object** (`{...}`), UTF-8 |
|
|
23
|
+
|
|
24
|
+
Anything else (single frames, non-JSON payloads) is dropped by
|
|
25
|
+
well-behaved receivers with a warning. Never crash on a bad message.
|
|
26
|
+
|
|
27
|
+
## Process environment (set by the launcher)
|
|
28
|
+
|
|
29
|
+
A node learns its wiring from four environment variables and must not
|
|
30
|
+
hardcode any topology:
|
|
31
|
+
|
|
32
|
+
| variable | meaning | example |
|
|
33
|
+
|------------------|----------------------------------------------------|---------------------------------|
|
|
34
|
+
| `BUS_PORT` | port to bind the PUB socket on (`0`/unset = any) | `5555` |
|
|
35
|
+
| `BUS_PEERS` | comma-separated `host:port` list to connect SUB to | `127.0.0.1:5556,127.0.0.1:5557` |
|
|
36
|
+
| `BUS_SUBSCRIBES` | comma-separated topics this node should act on | `engine_data,heartbeat` |
|
|
37
|
+
| `NODE_NAME` | this node's identity, used in heartbeats | `ecu` |
|
|
38
|
+
|
|
39
|
+
Unset variables mean: bind any free port, no peers, no subscriptions —
|
|
40
|
+
the node must still start (standalone mode).
|
|
41
|
+
|
|
42
|
+
## Heartbeat
|
|
43
|
+
|
|
44
|
+
Every node publishes on topic `heartbeat` every **5 seconds**:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{ "node_name": "<NODE_NAME>", "status": "ok", "timestamp": 1784796766 }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`timestamp` is Unix seconds. A node that stops heartbeating is presumed
|
|
51
|
+
dead; there is no other liveness mechanism.
|
|
52
|
+
|
|
53
|
+
## Conventions
|
|
54
|
+
|
|
55
|
+
- Payloads are always JSON objects, never bare arrays/scalars.
|
|
56
|
+
- Delivery is fire-and-forget: no acks, no replay. A slow consumer loses
|
|
57
|
+
old messages (ZeroMQ drops at the high-water mark). Design for
|
|
58
|
+
latest-value-wins.
|
|
59
|
+
- PUB/SUB connections take ~a few hundred ms to establish ("slow
|
|
60
|
+
joiner"): wait ~1s after startup before your first meaningful publish,
|
|
61
|
+
or design so early messages are harmless to lose.
|
|
62
|
+
- Request/response is done with a topic pair by convention, e.g. publish
|
|
63
|
+
`request_global_state` → someone publishes `global_state_snapshot`.
|
|
64
|
+
|
|
65
|
+
## Minimal node, any language (Python shown)
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import json, os, time, threading, zmq
|
|
69
|
+
|
|
70
|
+
ctx = zmq.Context()
|
|
71
|
+
pub = ctx.socket(zmq.PUB); pub.bind(f"tcp://127.0.0.1:{os.environ.get('BUS_PORT', '*')}")
|
|
72
|
+
sub = ctx.socket(zmq.SUB); sub.setsockopt_string(zmq.SUBSCRIBE, "")
|
|
73
|
+
for peer in filter(None, os.environ.get("BUS_PEERS", "").split(",")):
|
|
74
|
+
sub.connect(f"tcp://{peer}")
|
|
75
|
+
|
|
76
|
+
name = os.environ.get("NODE_NAME", "python-node")
|
|
77
|
+
topics = set(filter(None, os.environ.get("BUS_SUBSCRIBES", "").split(",")))
|
|
78
|
+
|
|
79
|
+
def heartbeat():
|
|
80
|
+
while True:
|
|
81
|
+
pub.send_multipart([b"heartbeat", json.dumps(
|
|
82
|
+
{"node_name": name, "status": "ok", "timestamp": int(time.time())}).encode()])
|
|
83
|
+
time.sleep(5)
|
|
84
|
+
threading.Thread(target=heartbeat, daemon=True).start()
|
|
85
|
+
|
|
86
|
+
while True:
|
|
87
|
+
topic, payload = sub.recv_multipart()
|
|
88
|
+
if topic.decode() in topics:
|
|
89
|
+
handle(topic.decode(), json.loads(payload)) # your logic here
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Add the node to `flow.yml` with its `cmd`, `publishes`, and `subscribes`,
|
|
93
|
+
and `bin/flowctl` wires it in.
|
data/bin/flowctl
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Runs a flow manifest: assigns each node a free port, computes the peer
|
|
3
|
+
# wiring, spawns every node with that wiring in its environment, and
|
|
4
|
+
# streams their output with a [name] prefix. Ctrl-C stops everything.
|
|
5
|
+
#
|
|
6
|
+
# bundle exec bin/flowctl # runs ./flow.yml
|
|
7
|
+
# bundle exec bin/flowctl other.yml
|
|
8
|
+
# bundle exec bin/flowctl --plan # print computed wiring, run nothing
|
|
9
|
+
|
|
10
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
11
|
+
require 'ruby_zmq_framework'
|
|
12
|
+
require 'socket'
|
|
13
|
+
require 'optparse'
|
|
14
|
+
|
|
15
|
+
plan_only = false
|
|
16
|
+
OptionParser.new do |opts|
|
|
17
|
+
opts.banner = 'Usage: bin/flowctl [--plan] [flow.yml]'
|
|
18
|
+
opts.on('--plan', 'Print the computed wiring and exit') { plan_only = true }
|
|
19
|
+
end.parse!
|
|
20
|
+
|
|
21
|
+
manifest = ARGV.first || 'flow.yml'
|
|
22
|
+
manifest_path = File.expand_path(manifest, Dir.pwd)
|
|
23
|
+
root = File.dirname(manifest_path)
|
|
24
|
+
flow = RubyZmqFramework::Flow.load(manifest_path)
|
|
25
|
+
|
|
26
|
+
def free_port
|
|
27
|
+
server = TCPServer.new('127.0.0.1', 0)
|
|
28
|
+
port = server.addr[1]
|
|
29
|
+
server.close
|
|
30
|
+
port
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
ports = flow.nodes.to_h { |node| [node.name, free_port] }
|
|
34
|
+
wiring = flow.wiring(ports)
|
|
35
|
+
|
|
36
|
+
if plan_only
|
|
37
|
+
wiring.each do |name, env|
|
|
38
|
+
puts name
|
|
39
|
+
env.each { |k, v| puts " #{k}=#{v}" }
|
|
40
|
+
end
|
|
41
|
+
exit
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
children = {}
|
|
45
|
+
flow.nodes.each do |node|
|
|
46
|
+
reader, writer = IO.pipe
|
|
47
|
+
pid = spawn(wiring[node.name], node.cmd, out: writer, err: writer, chdir: root)
|
|
48
|
+
writer.close
|
|
49
|
+
children[pid] = node.name
|
|
50
|
+
|
|
51
|
+
Thread.new do
|
|
52
|
+
reader.each_line { |line| puts "[#{node.name}] #{line}" }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
puts "[flowctl] started #{children.size} nodes: #{children.values.join(', ')}"
|
|
57
|
+
|
|
58
|
+
begin
|
|
59
|
+
until children.empty?
|
|
60
|
+
pid = Process.wait
|
|
61
|
+
puts "[flowctl] #{children.delete(pid)} exited with status #{$?.exitstatus.inspect}"
|
|
62
|
+
end
|
|
63
|
+
puts '[flowctl] all nodes exited'
|
|
64
|
+
rescue Interrupt
|
|
65
|
+
puts "\n[flowctl] shutting down #{children.size} nodes"
|
|
66
|
+
children.each_key do |pid|
|
|
67
|
+
Process.kill('TERM', pid)
|
|
68
|
+
rescue Errno::ESRCH
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
children.each_key do |pid|
|
|
72
|
+
Process.wait(pid)
|
|
73
|
+
rescue Errno::ECHILD
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_zmq_framework
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Paul Daniel
|
|
@@ -58,13 +58,16 @@ description: 'Blackbox node processes wired together by pub/sub topics: the grap
|
|
|
58
58
|
transport with strict runtime module contracts.'
|
|
59
59
|
email:
|
|
60
60
|
- paulgdan@gmail.com
|
|
61
|
-
executables:
|
|
61
|
+
executables:
|
|
62
|
+
- flowctl
|
|
62
63
|
extensions: []
|
|
63
64
|
extra_rdoc_files: []
|
|
64
65
|
files:
|
|
65
66
|
- CHANGELOG.md
|
|
66
67
|
- LICENSE.txt
|
|
68
|
+
- PROTOCOL.md
|
|
67
69
|
- README.md
|
|
70
|
+
- bin/flowctl
|
|
68
71
|
- lib/ruby_zmq_framework.rb
|
|
69
72
|
- lib/ruby_zmq_framework/can_bridge.rb
|
|
70
73
|
- lib/ruby_zmq_framework/flow.rb
|