igniter 0.4.3 → 0.5.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.
- checksums.yaml +4 -4
- data/README.md +217 -0
- data/docs/APPLICATION_V1.md +253 -0
- data/docs/CAPABILITIES_V1.md +207 -0
- data/docs/CONSENSUS_V1.md +477 -0
- data/docs/CONTENT_ADDRESSING_V1.md +221 -0
- data/docs/DATAFLOW_V1.md +274 -0
- data/docs/MESH_V1.md +732 -0
- data/docs/NODE_CACHE_V1.md +324 -0
- data/docs/PROACTIVE_AGENTS_V1.md +293 -0
- data/docs/SERVER_V1.md +200 -1
- data/docs/SKILLS_V1.md +213 -0
- data/docs/STORE_ADAPTERS.md +41 -13
- data/docs/TEMPORAL_V1.md +174 -0
- data/docs/TOOLS_V1.md +347 -0
- data/docs/TRANSCRIPTION_V1.md +403 -0
- data/examples/README.md +37 -0
- data/examples/consensus.rb +239 -0
- data/examples/dataflow.rb +308 -0
- data/examples/elocal_webhook.rb +1 -0
- data/examples/incremental.rb +142 -0
- data/examples/llm_tools.rb +237 -0
- data/examples/mesh.rb +239 -0
- data/examples/mesh_discovery.rb +267 -0
- data/examples/mesh_gossip.rb +162 -0
- data/examples/ringcentral_routing.rb +1 -1
- data/lib/igniter/agents/ai/alert_agent.rb +111 -0
- data/lib/igniter/agents/ai/chain_agent.rb +127 -0
- data/lib/igniter/agents/ai/critic_agent.rb +163 -0
- data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
- data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
- data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
- data/lib/igniter/agents/ai/observer_agent.rb +184 -0
- data/lib/igniter/agents/ai/planner_agent.rb +210 -0
- data/lib/igniter/agents/ai/router_agent.rb +131 -0
- data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
- data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
- data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
- data/lib/igniter/agents/proactive_agent.rb +208 -0
- data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
- data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
- data/lib/igniter/agents.rb +56 -0
- data/lib/igniter/application/app_config.rb +32 -0
- data/lib/igniter/application/autoloader.rb +18 -0
- data/lib/igniter/application/generator.rb +157 -0
- data/lib/igniter/application/scheduler.rb +109 -0
- data/lib/igniter/application/yml_loader.rb +39 -0
- data/lib/igniter/application.rb +174 -0
- data/lib/igniter/capabilities.rb +68 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
- data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
- data/lib/igniter/consensus/cluster.rb +183 -0
- data/lib/igniter/consensus/errors.rb +14 -0
- data/lib/igniter/consensus/executors.rb +43 -0
- data/lib/igniter/consensus/node.rb +320 -0
- data/lib/igniter/consensus/read_query.rb +30 -0
- data/lib/igniter/consensus/state_machine.rb +58 -0
- data/lib/igniter/consensus.rb +58 -0
- data/lib/igniter/content_addressing.rb +133 -0
- data/lib/igniter/contract.rb +12 -0
- data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
- data/lib/igniter/dataflow/aggregate_state.rb +77 -0
- data/lib/igniter/dataflow/diff.rb +37 -0
- data/lib/igniter/dataflow/diff_state.rb +81 -0
- data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
- data/lib/igniter/dataflow/window_filter.rb +48 -0
- data/lib/igniter/dataflow.rb +65 -0
- data/lib/igniter/dsl/contract_builder.rb +71 -7
- data/lib/igniter/executor.rb +60 -0
- data/lib/igniter/extensions/capabilities.rb +39 -0
- data/lib/igniter/extensions/content_addressing.rb +5 -0
- data/lib/igniter/extensions/dataflow.rb +117 -0
- data/lib/igniter/extensions/incremental.rb +50 -0
- data/lib/igniter/extensions/mesh.rb +31 -0
- data/lib/igniter/fingerprint.rb +43 -0
- data/lib/igniter/incremental/formatter.rb +81 -0
- data/lib/igniter/incremental/result.rb +69 -0
- data/lib/igniter/incremental/tracker.rb +108 -0
- data/lib/igniter/incremental.rb +50 -0
- data/lib/igniter/integrations/llm/config.rb +48 -4
- data/lib/igniter/integrations/llm/executor.rb +221 -28
- data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
- data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
- data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
- data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
- data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
- data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
- data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
- data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
- data/lib/igniter/integrations/llm.rb +37 -1
- data/lib/igniter/memory/agent_memory.rb +104 -0
- data/lib/igniter/memory/episode.rb +29 -0
- data/lib/igniter/memory/fact.rb +27 -0
- data/lib/igniter/memory/memorable.rb +90 -0
- data/lib/igniter/memory/reflection_cycle.rb +96 -0
- data/lib/igniter/memory/reflection_record.rb +28 -0
- data/lib/igniter/memory/store.rb +115 -0
- data/lib/igniter/memory/stores/in_memory.rb +136 -0
- data/lib/igniter/memory/stores/sqlite.rb +284 -0
- data/lib/igniter/memory.rb +80 -0
- data/lib/igniter/mesh/announcer.rb +55 -0
- data/lib/igniter/mesh/config.rb +45 -0
- data/lib/igniter/mesh/discovery.rb +39 -0
- data/lib/igniter/mesh/errors.rb +31 -0
- data/lib/igniter/mesh/gossip.rb +47 -0
- data/lib/igniter/mesh/peer.rb +21 -0
- data/lib/igniter/mesh/peer_registry.rb +51 -0
- data/lib/igniter/mesh/poller.rb +77 -0
- data/lib/igniter/mesh/router.rb +109 -0
- data/lib/igniter/mesh.rb +85 -0
- data/lib/igniter/metrics/collector.rb +131 -0
- data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
- data/lib/igniter/metrics/snapshot.rb +8 -0
- data/lib/igniter/metrics.rb +37 -0
- data/lib/igniter/model/aggregate_node.rb +34 -0
- data/lib/igniter/model/collection_node.rb +3 -2
- data/lib/igniter/model/compute_node.rb +13 -0
- data/lib/igniter/model/remote_node.rb +18 -2
- data/lib/igniter/node_cache.rb +231 -0
- data/lib/igniter/replication/bootstrapper.rb +61 -0
- data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
- data/lib/igniter/replication/bootstrappers/git.rb +39 -0
- data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
- data/lib/igniter/replication/expansion_plan.rb +38 -0
- data/lib/igniter/replication/expansion_planner.rb +142 -0
- data/lib/igniter/replication/manifest.rb +45 -0
- data/lib/igniter/replication/network_topology.rb +123 -0
- data/lib/igniter/replication/node_role.rb +42 -0
- data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
- data/lib/igniter/replication/replication_agent.rb +87 -0
- data/lib/igniter/replication/role_registry.rb +73 -0
- data/lib/igniter/replication/ssh_session.rb +77 -0
- data/lib/igniter/replication.rb +54 -0
- data/lib/igniter/runtime/cache.rb +35 -6
- data/lib/igniter/runtime/execution.rb +26 -2
- data/lib/igniter/runtime/input_validator.rb +6 -2
- data/lib/igniter/runtime/node_state.rb +7 -2
- data/lib/igniter/runtime/resolver.rb +323 -31
- data/lib/igniter/runtime/stores/redis_store.rb +41 -4
- data/lib/igniter/server/client.rb +44 -1
- data/lib/igniter/server/config.rb +13 -6
- data/lib/igniter/server/handlers/event_handler.rb +4 -0
- data/lib/igniter/server/handlers/execute_handler.rb +6 -0
- data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
- data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
- data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
- data/lib/igniter/server/handlers/peers_handler.rb +115 -0
- data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
- data/lib/igniter/server/http_server.rb +54 -17
- data/lib/igniter/server/router.rb +54 -21
- data/lib/igniter/server/server_logger.rb +52 -0
- data/lib/igniter/server.rb +6 -0
- data/lib/igniter/skill/feedback.rb +116 -0
- data/lib/igniter/skill/output_schema.rb +110 -0
- data/lib/igniter/skill.rb +218 -0
- data/lib/igniter/temporal.rb +84 -0
- data/lib/igniter/tool/discoverable.rb +151 -0
- data/lib/igniter/tool.rb +52 -0
- data/lib/igniter/tool_registry.rb +144 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +17 -0
- metadata +128 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Capability-Based Security — v1
|
|
2
|
+
|
|
3
|
+
Capabilities let executors declare what external resources they need, and let operators
|
|
4
|
+
enforce policies that deny specific capabilities at runtime. This makes the security surface
|
|
5
|
+
of every graph visible and auditable at a glance.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
require "igniter/capabilities"
|
|
11
|
+
|
|
12
|
+
# 1. Declare capabilities on executors
|
|
13
|
+
class DatabaseLookup < Igniter::Executor
|
|
14
|
+
capabilities :database
|
|
15
|
+
|
|
16
|
+
def call(id:)
|
|
17
|
+
DB.find(id)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class PureCalculator < Igniter::Executor
|
|
22
|
+
pure # shorthand for capabilities(:pure)
|
|
23
|
+
|
|
24
|
+
def call(x:, y:)
|
|
25
|
+
x + y
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 2. Enforce a policy
|
|
30
|
+
policy = Igniter::Capabilities::Policy.new(denied: [:database])
|
|
31
|
+
Igniter::Capabilities.policy = policy
|
|
32
|
+
|
|
33
|
+
# 3. Execution raises CapabilityViolationError for denied nodes
|
|
34
|
+
class MyContract < Igniter::Contract
|
|
35
|
+
define do
|
|
36
|
+
input :id
|
|
37
|
+
compute :record, depends_on: :id, call: DatabaseLookup
|
|
38
|
+
output :record
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
MyContract.new(id: 42).resolve_all
|
|
43
|
+
# => Igniter::Capabilities::CapabilityViolationError:
|
|
44
|
+
# Node 'record' executor DatabaseLookup uses denied capabilities: database
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Executor DSL
|
|
48
|
+
|
|
49
|
+
### `capabilities(*caps)`
|
|
50
|
+
|
|
51
|
+
Declare one or more capabilities required by the executor.
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
class EmailSender < Igniter::Executor
|
|
55
|
+
capabilities :network, :external_api
|
|
56
|
+
|
|
57
|
+
def call(to:, body:)
|
|
58
|
+
Mailer.send(to: to, body: body)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `pure`
|
|
64
|
+
|
|
65
|
+
Shorthand for `capabilities(:pure)`. Marks the executor as having no side effects — its
|
|
66
|
+
output is fully determined by its inputs. `pure` executors participate in
|
|
67
|
+
[content-addressed caching](CONTENT_ADDRESSING_V1.md).
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
class TaxCalculator < Igniter::Executor
|
|
71
|
+
pure
|
|
72
|
+
|
|
73
|
+
def call(amount:, rate:)
|
|
74
|
+
amount * rate
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `pure?`
|
|
80
|
+
|
|
81
|
+
Returns `true` if the executor declares the `:pure` capability.
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
TaxCalculator.pure? # => true
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Accessing declared capabilities
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
EmailSender.declared_capabilities # => [:network, :external_api]
|
|
91
|
+
TaxCalculator.declared_capabilities # => [:pure]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Known Capabilities
|
|
95
|
+
|
|
96
|
+
| Symbol | Meaning |
|
|
97
|
+
|--------|---------|
|
|
98
|
+
| `:pure` | No side effects; output determined by inputs only |
|
|
99
|
+
| `:network` | Makes outbound TCP/HTTP connections |
|
|
100
|
+
| `:database` | Reads or writes a database |
|
|
101
|
+
| `:filesystem` | Reads or writes the local filesystem |
|
|
102
|
+
| `:external_api` | Calls a third-party API |
|
|
103
|
+
| `:messaging` | Publishes to a message queue or event stream |
|
|
104
|
+
| `:queue` | Reads from a job or task queue |
|
|
105
|
+
| `:cache` | Reads or writes a distributed cache |
|
|
106
|
+
|
|
107
|
+
You can also use any custom symbol — the system does not restrict you to this list.
|
|
108
|
+
|
|
109
|
+
## Policy Enforcement
|
|
110
|
+
|
|
111
|
+
### `Igniter::Capabilities::Policy`
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
policy = Igniter::Capabilities::Policy.new(denied: [:network, :filesystem])
|
|
115
|
+
Igniter::Capabilities.policy = policy
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Setting the policy to `nil` disables all capability checks:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
Igniter::Capabilities.policy = nil # no-op, all executors run freely
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The policy is global (process-wide). It is typically set at boot time.
|
|
125
|
+
|
|
126
|
+
### `CapabilityViolationError`
|
|
127
|
+
|
|
128
|
+
Raised by the resolver at the start of `resolve_compute` before the executor is invoked.
|
|
129
|
+
The error includes the node name and the violated capabilities in its message:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
begin
|
|
133
|
+
contract.resolve_all
|
|
134
|
+
rescue Igniter::Capabilities::CapabilityViolationError => e
|
|
135
|
+
puts e.message
|
|
136
|
+
# => "Node 'fetch_data' executor DataFetcher uses denied capabilities: network"
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Graph Introspection
|
|
141
|
+
|
|
142
|
+
After `require "igniter/extensions/capabilities"` (or `require "igniter/capabilities"`) the
|
|
143
|
+
compiled graph gains two introspection methods:
|
|
144
|
+
|
|
145
|
+
### `required_capabilities`
|
|
146
|
+
|
|
147
|
+
Returns a Hash of `{ node_name => [capabilities] }` for every node whose executor
|
|
148
|
+
declares at least one capability.
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
require "igniter/capabilities"
|
|
152
|
+
|
|
153
|
+
class MyContract < Igniter::Contract
|
|
154
|
+
define do
|
|
155
|
+
input :id
|
|
156
|
+
compute :record, depends_on: :id, call: DatabaseLookup
|
|
157
|
+
compute :total, depends_on: :record, call: PureCalculator
|
|
158
|
+
output :total
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
MyContract.compiled_graph.required_capabilities
|
|
163
|
+
# => { record: [:database], total: [:pure] }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### `capabilities_for(node_name)`
|
|
167
|
+
|
|
168
|
+
Returns the declared capabilities of a single node as an array.
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
MyContract.compiled_graph.capabilities_for(:record)
|
|
172
|
+
# => [:database]
|
|
173
|
+
|
|
174
|
+
MyContract.compiled_graph.capabilities_for(:total)
|
|
175
|
+
# => [:pure]
|
|
176
|
+
|
|
177
|
+
MyContract.compiled_graph.capabilities_for(:id)
|
|
178
|
+
# => [] (input nodes have no executor)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Environment-Based Policy Pattern
|
|
182
|
+
|
|
183
|
+
A common pattern is to load the policy from environment configuration so that the
|
|
184
|
+
same codebase enforces different rules in development vs. production:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
# config/igniter.rb
|
|
188
|
+
DENIED_CAPS = case ENV["RAILS_ENV"]
|
|
189
|
+
when "test" then %i[network database filesystem]
|
|
190
|
+
when "production" then []
|
|
191
|
+
else []
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
Igniter::Capabilities.policy = Igniter::Capabilities::Policy.new(denied: DENIED_CAPS)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
This lets your test suite run without real I/O while production executions are unrestricted.
|
|
198
|
+
|
|
199
|
+
## Files
|
|
200
|
+
|
|
201
|
+
| File | Purpose |
|
|
202
|
+
|------|---------|
|
|
203
|
+
| `lib/igniter/capabilities.rb` | `Capabilities` module, `Policy` class, `CapabilityViolationError` |
|
|
204
|
+
| `lib/igniter/extensions/capabilities.rb` | Patches `CompiledGraph` with `required_capabilities` / `capabilities_for` |
|
|
205
|
+
| `lib/igniter/executor.rb` | `capabilities`, `pure`, `pure?`, `declared_capabilities`, `fingerprint` DSL |
|
|
206
|
+
| `lib/igniter/runtime/resolver.rb` | `check_capability_policy!` guard in `resolve_compute` |
|
|
207
|
+
| `spec/igniter/capabilities_spec.rb` | 16 examples |
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
# Distributed Consensus with Igniter — v1
|
|
2
|
+
|
|
3
|
+
`require "igniter/consensus"` provides a Raft-inspired consensus cluster built on
|
|
4
|
+
Igniter's Actor primitives. The Raft protocol is fully encapsulated — users interact
|
|
5
|
+
with the high-level `Cluster` API and an optional `StateMachine` subclass.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require "igniter/consensus"
|
|
13
|
+
|
|
14
|
+
# Start a 5-node cluster with the built-in key-value state machine
|
|
15
|
+
cluster = Igniter::Consensus::Cluster.start(nodes: %i[n1 n2 n3 n4 n5])
|
|
16
|
+
cluster.wait_for_leader
|
|
17
|
+
|
|
18
|
+
cluster.write(key: :price, value: 99) # replicated to all nodes
|
|
19
|
+
cluster.read(:price) # => 99
|
|
20
|
+
|
|
21
|
+
cluster.stop!
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Architecture
|
|
27
|
+
|
|
28
|
+
Two complementary Igniter primitives map naturally to consensus protocols:
|
|
29
|
+
|
|
30
|
+
| Primitive | Role |
|
|
31
|
+
|-----------|------|
|
|
32
|
+
| `Igniter::Consensus::Node` | Raft agent — leader election, log replication (internal) |
|
|
33
|
+
| `Igniter::Consensus::Cluster` | Lifecycle management + high-level read/write API |
|
|
34
|
+
| `Igniter::Consensus::StateMachine` | User-extensible state machine DSL |
|
|
35
|
+
| `Igniter::Consensus::ReadQuery` | Built-in single-shot read Contract |
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
Cluster of 5 Node agents, each registered in Igniter::Registry
|
|
39
|
+
|
|
40
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
41
|
+
│ n1: follower │ n2: follower │ n3: LEADER │ n4: follower │ n5: follower
|
|
42
|
+
└───────────────┴───────────────┴──────────────┴───────────────┴───────────────┘
|
|
43
|
+
│
|
|
44
|
+
┌──────────────────┼──────────────────┐
|
|
45
|
+
heartbeat (50ms) AppendEntries commit on quorum(3/5)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Leader election flow
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
Follower ──(timeout 1–1.5 s)──► Candidate ──(quorum votes)──► Leader
|
|
52
|
+
▲ │
|
|
53
|
+
└────────────────── AppendEntries heartbeat (50 ms) ◄──────────┘
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
1. A follower that receives no heartbeat within its randomised election timeout
|
|
57
|
+
becomes a **Candidate** and broadcasts `RequestVote` to all peers.
|
|
58
|
+
2. A node that hasn't voted in this term grants its vote if the candidate's log
|
|
59
|
+
is at least as up-to-date as its own.
|
|
60
|
+
3. The first candidate to collect **majority votes** (quorum = ⌊N/2⌋ + 1) becomes
|
|
61
|
+
**Leader** and immediately starts heartbeating.
|
|
62
|
+
4. Randomised timeouts (1.0–1.5 s) prevent simultaneous elections (split votes).
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## `Cluster` API
|
|
67
|
+
|
|
68
|
+
### Starting a cluster
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
cluster = Igniter::Consensus::Cluster.start(
|
|
72
|
+
nodes: %i[n1 n2 n3 n4 n5], # Registry names for each node
|
|
73
|
+
state_machine: MyStateMachine, # optional — default is KV store
|
|
74
|
+
verbose: false, # print Raft events to stdout
|
|
75
|
+
)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`start` creates nodes and returns immediately — it does **not** wait for leader
|
|
79
|
+
election. Call `wait_for_leader` if you need a leader before proceeding.
|
|
80
|
+
|
|
81
|
+
### Waiting for a leader
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
leader_ref = cluster.wait_for_leader # blocks up to ~2 s
|
|
85
|
+
leader_ref = cluster.wait_for_leader(timeout: 5) # custom timeout
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Raises `Igniter::Consensus::NoLeaderError` if no leader is elected within the timeout.
|
|
89
|
+
|
|
90
|
+
### Writing
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# Default KV protocol
|
|
94
|
+
cluster.write(key: :price, value: 99)
|
|
95
|
+
|
|
96
|
+
# Custom state machine command
|
|
97
|
+
cluster.write(type: :add_order, id: "o1", data: { price: 42, qty: 10 })
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Raises `NoLeaderError` if no leader is available. Returns `self` (chainable).
|
|
101
|
+
|
|
102
|
+
### Reading
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
cluster.read(:price) # => 99 (reads from leader's committed state)
|
|
106
|
+
cluster.state_machine_snapshot # => { price: 99, ... } (full snapshot)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Quorum and status
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
cluster.quorum_size # => 3 (minimum votes for 5-node cluster)
|
|
113
|
+
cluster.has_quorum? # => true
|
|
114
|
+
cluster.alive_count # => 5
|
|
115
|
+
cluster.status # => [{ node_id: :n1, role: :follower, term: 2, ... }, ...]
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Stopping
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
cluster.stop! # graceful stop of all nodes (timeout: 2s default)
|
|
122
|
+
cluster.stop!(timeout: 5)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Contract integration
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
q = cluster.read_contract(key: :price) # returns ReadQuery instance
|
|
129
|
+
q.resolve_all
|
|
130
|
+
q.result.value # => 99
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## `StateMachine` — custom command reducers
|
|
136
|
+
|
|
137
|
+
Subclass `Igniter::Consensus::StateMachine` and declare handlers with `apply`:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
class OrderBook < Igniter::Consensus::StateMachine
|
|
141
|
+
# Each handler receives (state, command) and must return the NEW state (immutably).
|
|
142
|
+
apply :add_order do |state, cmd|
|
|
143
|
+
state.merge(cmd[:id] => cmd[:order])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
apply :cancel_order do |state, cmd|
|
|
147
|
+
state.reject { |k, _| k == cmd[:id] }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
apply :update_price do |state, cmd|
|
|
151
|
+
return state unless state.key?(cmd[:id])
|
|
152
|
+
state.merge(cmd[:id] => state[cmd[:id]].merge(price: cmd[:price]))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
cluster = Igniter::Consensus::Cluster.start(
|
|
157
|
+
nodes: %i[n1 n2 n3 n4 n5],
|
|
158
|
+
state_machine: OrderBook,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
cluster.write(type: :add_order, id: "ord-1", order: { vendor: "ACME", price: 42.0 })
|
|
162
|
+
cluster.read("ord-1") # => { vendor: "ACME", price: 42.0 }
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Default KV protocol (no subclass needed)
|
|
166
|
+
|
|
167
|
+
When no `state_machine:` is provided, commands use a simple key-value protocol:
|
|
168
|
+
|
|
169
|
+
| Command | Effect |
|
|
170
|
+
|---------|--------|
|
|
171
|
+
| `{ key: :x, value: 42 }` | Set `state_machine[:x] = 42` |
|
|
172
|
+
| `{ key: :x, op: :delete }` | Remove `:x` from state machine |
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## `ReadQuery` — declarative Contract read
|
|
177
|
+
|
|
178
|
+
`ReadQuery` is a built-in `Igniter::Contract` with the dependency graph
|
|
179
|
+
`find_leader → read_value`:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
q = Igniter::Consensus::ReadQuery.new(cluster: cluster, key: :price)
|
|
183
|
+
q.resolve_all
|
|
184
|
+
q.result.value # => 99
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Or use `Cluster#read_contract` for convenience:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
q = cluster.read_contract(key: :price)
|
|
191
|
+
q.resolve_all
|
|
192
|
+
q.result.value # => 99
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Custom read Contract
|
|
196
|
+
|
|
197
|
+
You can build your own Contracts using the bundled executors:
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
class PriceCheck < Igniter::Contract
|
|
201
|
+
define do
|
|
202
|
+
input :cluster
|
|
203
|
+
input :threshold
|
|
204
|
+
|
|
205
|
+
compute :leader, with: :cluster, call: Igniter::Consensus::FindLeader
|
|
206
|
+
compute :current_price, with: [:leader], call: ReadCurrentPrice
|
|
207
|
+
compute :verdict, with: [:current_price, :threshold], call: EvaluatePrice
|
|
208
|
+
|
|
209
|
+
output :verdict
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Practical Example — `BidAuction`
|
|
217
|
+
|
|
218
|
+
Models the auction problem: N vendors submit bids durably to the consensus log
|
|
219
|
+
before the winner is selected. Combines `Igniter::Contract` parallel execution
|
|
220
|
+
with consensus-backed durability.
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
class SubmitBid < Igniter::Executor
|
|
224
|
+
# The dep name varies per compute node (vendor1_bid / vendor2_bid / …).
|
|
225
|
+
# Using ** captures whichever named bid dep is passed.
|
|
226
|
+
def call(cluster:, **bid_kwarg)
|
|
227
|
+
bid = bid_kwarg.values.first # { vendor_id:, price: }
|
|
228
|
+
ref = cluster.leader
|
|
229
|
+
raise Igniter::ResolutionError, "No leader — cannot submit bid" unless ref
|
|
230
|
+
ref.send(:client_write, command: { key: :"bid_#{bid[:vendor_id]}", value: bid[:price] })
|
|
231
|
+
bid
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
class SelectWinner < Igniter::Executor
|
|
236
|
+
def call(bid1:, bid2:, bid3:)
|
|
237
|
+
[bid1, bid2, bid3].min_by { |b| b[:price] }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
class BidAuction < Igniter::Contract
|
|
242
|
+
runner :thread_pool, pool_size: 3 # bid1, bid2, bid3 run concurrently
|
|
243
|
+
|
|
244
|
+
define do
|
|
245
|
+
input :cluster
|
|
246
|
+
input :vendor1_bid # { vendor_id: String, price: Float }
|
|
247
|
+
input :vendor2_bid
|
|
248
|
+
input :vendor3_bid
|
|
249
|
+
|
|
250
|
+
# No deps between bid1/bid2/bid3 → submitted to the consensus log in parallel
|
|
251
|
+
compute :bid1, with: [:cluster, :vendor1_bid], call: SubmitBid
|
|
252
|
+
compute :bid2, with: [:cluster, :vendor2_bid], call: SubmitBid
|
|
253
|
+
compute :bid3, with: [:cluster, :vendor3_bid], call: SubmitBid
|
|
254
|
+
|
|
255
|
+
# Depends on all three → runs only after every bid is committed
|
|
256
|
+
compute :winner, with: [:bid1, :bid2, :bid3], call: SelectWinner
|
|
257
|
+
|
|
258
|
+
output :winner
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Usage:
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
auction = BidAuction.new(
|
|
267
|
+
cluster: cluster,
|
|
268
|
+
vendor1_bid: { vendor_id: "alpha", price: 45.00 },
|
|
269
|
+
vendor2_bid: { vendor_id: "betacor", price: 38.50 },
|
|
270
|
+
vendor3_bid: { vendor_id: "gamma", price: 52.00 },
|
|
271
|
+
)
|
|
272
|
+
auction.resolve_all
|
|
273
|
+
puts auction.result.winner # => { vendor_id: "betacor", price: 38.5 }
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The Igniter dependency graph enforces the correct ordering automatically:
|
|
277
|
+
- `bid1`, `bid2`, `bid3` have no mutual deps → `thread_pool` submits them concurrently.
|
|
278
|
+
- `winner` depends on all three → it never runs before every bid is logged.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Quorum Failure and Safety Guarantees
|
|
283
|
+
|
|
284
|
+
Raft is a **CP system** (Consistent + Partition-tolerant). With fewer than `⌊N/2⌋ + 1`
|
|
285
|
+
nodes alive, no leader can be elected and the cluster is **unavailable** — but it
|
|
286
|
+
never returns stale or conflicting data.
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
# Kill enough nodes to break quorum (3/5 needed, only 2 survive)
|
|
290
|
+
(3).times { cluster_nodes.pop.kill }
|
|
291
|
+
|
|
292
|
+
begin
|
|
293
|
+
cluster.read_contract(key: :price).resolve_all
|
|
294
|
+
rescue Igniter::Error => e
|
|
295
|
+
puts e.message
|
|
296
|
+
# => "No leader in cluster — retry later [graph=ReadQuery, node=leader …]"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Or catch at the Cluster level before even attempting a Contract:
|
|
300
|
+
cluster.has_quorum? # => false
|
|
301
|
+
cluster.write(key: :x, value: 1)
|
|
302
|
+
# => Igniter::Consensus::NoLeaderError: No leader available
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`FindLeader` scans all known nodes, finds no leader, and raises `Igniter::ResolutionError`.
|
|
306
|
+
The resolver enriches it with full context (graph, node, execution_id).
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Critical Implementation Gotchas
|
|
311
|
+
|
|
312
|
+
### 1. `next` not `return` inside `on` blocks
|
|
313
|
+
|
|
314
|
+
`on :type do |state:, payload:| … end` registers a **Proc** (not a lambda).
|
|
315
|
+
`return` inside a Proc raises `LocalJumpError` at runtime.
|
|
316
|
+
Use `next value` for early exits:
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
# ✗ LocalJumpError at runtime
|
|
320
|
+
on :vote_response do |state:, payload:|
|
|
321
|
+
return state unless msg[:vote_granted]
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# ✓ correct
|
|
325
|
+
on :vote_response do |state:, payload:|
|
|
326
|
+
next state unless msg[:vote_granted]
|
|
327
|
+
end
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### 2. Sync-reply handlers must return a non-Hash
|
|
331
|
+
|
|
332
|
+
The Agent runner applies this logic to the handler's return value:
|
|
333
|
+
|
|
334
|
+
```
|
|
335
|
+
Hash → @state_holder.set(result) # treated as NEW STATE; caller gets nil
|
|
336
|
+
other → send as reply to call()
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
For synchronous queries, wrap the result in a Struct:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
StatusInfo = Struct.new(:role, :term, :node_id, keyword_init: true)
|
|
343
|
+
|
|
344
|
+
on :status do |state:, payload:|
|
|
345
|
+
StatusInfo.new(role: state[:role], term: state[:term], node_id: state[:node_id])
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
ref.call(:status).role # => :leader
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`Igniter::Consensus::Node` uses `NodeStatus` and `NodeReadResult` structs internally.
|
|
352
|
+
|
|
353
|
+
### 3. `ref.state` vs `ref.call()`
|
|
354
|
+
|
|
355
|
+
| Method | Mechanism | Use when |
|
|
356
|
+
|--------|-----------|----------|
|
|
357
|
+
| `ref.state` | Reads `StateHolder` directly (Mutex, no mailbox) | Polling from the main thread; leader discovery |
|
|
358
|
+
| `ref.call(:type)` | Goes through mailbox, blocks until reply | Need agent-thread consistency; sync queries |
|
|
359
|
+
|
|
360
|
+
`Cluster#leader` and `Cluster#read` use `ref.state` for performance-sensitive
|
|
361
|
+
leader polling.
|
|
362
|
+
|
|
363
|
+
### 4. Class-method helpers must NOT be `private_class_method`
|
|
364
|
+
|
|
365
|
+
Helpers called from `schedule`/`on` blocks (which run in the Agent's Runner thread)
|
|
366
|
+
must be accessible via explicit class reference (`Node.find_peer`, etc.). Making them
|
|
367
|
+
`private_class_method` blocks these calls since the blocks use explicit receiver form.
|
|
368
|
+
|
|
369
|
+
### 5. Heartbeat : election timeout ratio
|
|
370
|
+
|
|
371
|
+
Raft recommends heartbeat interval be **10× smaller** than the minimum election
|
|
372
|
+
timeout. With Ruby's green-thread scheduling jitter, a 1:20 ratio is more reliable:
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
HEARTBEAT_INTERVAL = 0.05 # 50 ms
|
|
376
|
+
ELECTION_TIMEOUT_BASE = 1.0 # 1000 ms minimum (1:20 ratio)
|
|
377
|
+
ELECTION_TIMEOUT_JITTER = 0.5 # + random 0–500 ms
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
With a tighter ratio (e.g., 1:3) followers can time out before they receive the
|
|
381
|
+
first heartbeat from a freshly elected leader, causing cascading elections.
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## Extending the Pattern
|
|
386
|
+
|
|
387
|
+
### Custom state machine commands
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
class InventoryMachine < Igniter::Consensus::StateMachine
|
|
391
|
+
apply :set do |state, cmd| state.merge(cmd[:key] => cmd[:value]) end
|
|
392
|
+
apply :incr do |state, cmd| state.merge(cmd[:key] => (state[cmd[:key]] || 0) + cmd[:by]) end
|
|
393
|
+
apply :delete do |state, cmd| state.reject { |k, _| k == cmd[:key] } end
|
|
394
|
+
end
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### Distributed lock
|
|
398
|
+
|
|
399
|
+
```ruby
|
|
400
|
+
class AcquireLock < Igniter::Executor
|
|
401
|
+
def call(cluster:, lock_key:, owner:)
|
|
402
|
+
ref = cluster.leader
|
|
403
|
+
raise Igniter::ResolutionError, "No leader" unless ref
|
|
404
|
+
current = ref.state[:state_machine][lock_key]
|
|
405
|
+
raise Igniter::ResolutionError, "Lock held by #{current}" if current
|
|
406
|
+
ref.send(:client_write, command: { key: lock_key, value: owner })
|
|
407
|
+
:acquired
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Multi-Raft / partitioned keyspace
|
|
413
|
+
|
|
414
|
+
Start separate clusters per shard and route writes by key hash:
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
SHARD_CLUSTERS = {
|
|
418
|
+
0 => Igniter::Consensus::Cluster.start(nodes: %i[n1a n2a n3a]),
|
|
419
|
+
1 => Igniter::Consensus::Cluster.start(nodes: %i[n1b n2b n3b]),
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
def shard_for(key) = key.hash % SHARD_CLUSTERS.size
|
|
423
|
+
def cluster_for(key) = SHARD_CLUSTERS[shard_for(key)]
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Redis-backed log persistence
|
|
427
|
+
|
|
428
|
+
Access the underlying Node agent via `Igniter::Registry` to intercept writes:
|
|
429
|
+
|
|
430
|
+
```ruby
|
|
431
|
+
# Subscribe to writes via a custom state machine that persists to Redis:
|
|
432
|
+
class RedisBackedMachine < Igniter::Consensus::StateMachine
|
|
433
|
+
apply :set do |state, cmd|
|
|
434
|
+
$redis.rpush("raft:log", { key: cmd[:key], value: cmd[:value] }.to_json)
|
|
435
|
+
state.merge(cmd[:key] => cmd[:value])
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## Running the Demo
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
bundle exec ruby examples/consensus.rb
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Output covers 10 steps:
|
|
449
|
+
|
|
450
|
+
| Step | Description |
|
|
451
|
+
|------|-------------|
|
|
452
|
+
| 1 | Start 5-node cluster |
|
|
453
|
+
| 2 | Leader elected via `wait_for_leader` |
|
|
454
|
+
| 3 | Two writes committed to the log |
|
|
455
|
+
| 4 | Full cluster status snapshot |
|
|
456
|
+
| 5 | `ReadQuery` contract reads `:price` |
|
|
457
|
+
| 6 | Leader crash simulation |
|
|
458
|
+
| 7 | New leader elected; write + read after failover |
|
|
459
|
+
| 8 | Custom `CounterMachine` in a 3-node cluster |
|
|
460
|
+
| 9 | `BidAuction` — three vendors bid in parallel; bids replicated before winner selected |
|
|
461
|
+
| 10 | Quorum failure — `ReadQuery` raises `ResolutionError` (CP guarantee) |
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## Files
|
|
466
|
+
|
|
467
|
+
| File | Purpose |
|
|
468
|
+
|------|---------|
|
|
469
|
+
| `lib/igniter/consensus.rb` | Entry point (`require "igniter/consensus"`) |
|
|
470
|
+
| `lib/igniter/consensus/cluster.rb` | **Public API** — lifecycle, read, write, status |
|
|
471
|
+
| `lib/igniter/consensus/state_machine.rb` | User DSL — `apply :type do \|state, cmd\| end` |
|
|
472
|
+
| `lib/igniter/consensus/node.rb` | Internal Raft agent (full protocol) |
|
|
473
|
+
| `lib/igniter/consensus/executors.rb` | `FindLeader`, `ReadValue`, `SubmitCommand` |
|
|
474
|
+
| `lib/igniter/consensus/read_query.rb` | `ReadQuery` built-in Contract |
|
|
475
|
+
| `lib/igniter/consensus/errors.rb` | `NoLeaderError`, `QuorumLostError` |
|
|
476
|
+
| `examples/consensus.rb` | Full working demo (10 steps) |
|
|
477
|
+
| `spec/igniter/consensus_spec.rb` | Test suite (35 examples) |
|