igniter 0.4.5 → 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/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/mesh.rb +31 -0
- data/lib/igniter/fingerprint.rb +43 -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/execution.rb +18 -0
- data/lib/igniter/runtime/input_validator.rb +6 -2
- data/lib/igniter/runtime/resolver.rb +254 -16
- 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 +122 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Consensus
|
|
5
|
+
# Manages a consensus cluster: node lifecycle, leader discovery,
|
|
6
|
+
# high-level read/write, quorum checks, and Igniter::Contract integration.
|
|
7
|
+
#
|
|
8
|
+
# == Quick start (default KV state machine)
|
|
9
|
+
#
|
|
10
|
+
# cluster = Igniter::Consensus::Cluster.start(nodes: %i[n1 n2 n3 n4 n5])
|
|
11
|
+
# cluster.wait_for_leader
|
|
12
|
+
# cluster.write(key: :price, value: 99)
|
|
13
|
+
# cluster.read(:price) # => 99
|
|
14
|
+
# cluster.stop!
|
|
15
|
+
#
|
|
16
|
+
# == Custom state machine
|
|
17
|
+
#
|
|
18
|
+
# class OrderBook < Igniter::Consensus::StateMachine
|
|
19
|
+
# apply :add do |state, cmd| state.merge(cmd[:id] => cmd[:data]) end
|
|
20
|
+
# apply :cancel do |state, cmd| state.reject { |k, _| k == cmd[:id] } end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# cluster = Igniter::Consensus::Cluster.start(
|
|
24
|
+
# nodes: %i[n1 n2 n3 n4 n5],
|
|
25
|
+
# state_machine: OrderBook,
|
|
26
|
+
# )
|
|
27
|
+
# cluster.write(type: :add, id: "o1", data: { price: 42 })
|
|
28
|
+
# cluster.read("o1") # => { price: 42 }
|
|
29
|
+
#
|
|
30
|
+
# == Contract integration
|
|
31
|
+
#
|
|
32
|
+
# q = cluster.read_contract(key: :price)
|
|
33
|
+
# q.resolve_all
|
|
34
|
+
# q.result.value # => 99
|
|
35
|
+
class Cluster
|
|
36
|
+
attr_reader :node_ids, :state_machine_class
|
|
37
|
+
|
|
38
|
+
# Start a cluster. Does NOT wait for leader election.
|
|
39
|
+
# Call +wait_for_leader+ if you need a leader before proceeding.
|
|
40
|
+
#
|
|
41
|
+
# @param nodes [Array<Symbol>] Registry names for each node
|
|
42
|
+
# @param state_machine [Class] StateMachine subclass (default: StateMachine)
|
|
43
|
+
# @param verbose [Boolean] print Raft events to stdout
|
|
44
|
+
def self.start(nodes:, state_machine: nil, verbose: false)
|
|
45
|
+
new(nodes: nodes, state_machine: state_machine, verbose: verbose).tap(&:start!)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(nodes:, state_machine: nil, verbose: false)
|
|
49
|
+
@node_ids = nodes.freeze
|
|
50
|
+
@state_machine_class = state_machine || StateMachine
|
|
51
|
+
@verbose = verbose
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Start all nodes.
|
|
55
|
+
def start!
|
|
56
|
+
@node_ids.each do |nid|
|
|
57
|
+
Node.start(
|
|
58
|
+
name: nid,
|
|
59
|
+
peers: @node_ids.reject { |id| id == nid },
|
|
60
|
+
state_machine: @state_machine_class,
|
|
61
|
+
verbose: @verbose,
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Stop all nodes gracefully.
|
|
68
|
+
def stop!(timeout: 2)
|
|
69
|
+
@node_ids.each do |nid|
|
|
70
|
+
begin
|
|
71
|
+
Igniter::Registry.find(nid)&.stop(timeout: timeout)
|
|
72
|
+
Igniter::Registry.unregister(nid)
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns the Ref for the current leader, or +nil+ if none is available.
|
|
81
|
+
def leader
|
|
82
|
+
@node_ids.each do |nid|
|
|
83
|
+
ref = Igniter::Registry.find(nid)
|
|
84
|
+
next unless ref&.alive?
|
|
85
|
+
return ref if ref.state[:role] == :leader
|
|
86
|
+
end
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Block until a leader is elected or +timeout+ seconds elapse.
|
|
91
|
+
#
|
|
92
|
+
# @param timeout [Float] seconds to wait (default: ~2s, covers max election jitter)
|
|
93
|
+
# @return [Agent::Ref] the leader Ref
|
|
94
|
+
# @raise [NoLeaderError] if no leader is elected within the timeout
|
|
95
|
+
def wait_for_leader(timeout: ELECTION_TIMEOUT_BASE + ELECTION_TIMEOUT_JITTER + 0.5)
|
|
96
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
97
|
+
loop do
|
|
98
|
+
ref = leader
|
|
99
|
+
return ref if ref
|
|
100
|
+
|
|
101
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
102
|
+
raise NoLeaderError, "No leader elected within #{timeout}s — cluster may lack quorum" if remaining <= 0
|
|
103
|
+
|
|
104
|
+
sleep 0.05
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Submit a command to the consensus log via the current leader.
|
|
109
|
+
#
|
|
110
|
+
# Default KV protocol: +cluster.write(key: :x, value: 42)+
|
|
111
|
+
# Custom state machine: +cluster.write(type: :add_order, id: "o1", data: {...})+
|
|
112
|
+
#
|
|
113
|
+
# @raise [NoLeaderError] if no leader is currently available
|
|
114
|
+
def write(command = {})
|
|
115
|
+
ref = leader
|
|
116
|
+
raise NoLeaderError, "No leader available — cluster may be electing or lacks quorum" unless ref
|
|
117
|
+
ref.send(:client_write, command: command)
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Read a key from the current leader's committed state machine.
|
|
122
|
+
#
|
|
123
|
+
# @raise [NoLeaderError] if no leader is available
|
|
124
|
+
def read(key)
|
|
125
|
+
ref = leader
|
|
126
|
+
raise NoLeaderError, "No leader available" unless ref
|
|
127
|
+
ref.state[:state_machine][key]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Return a snapshot of the full state machine from the current leader.
|
|
131
|
+
#
|
|
132
|
+
# @raise [NoLeaderError] if no leader is available
|
|
133
|
+
def state_machine_snapshot
|
|
134
|
+
ref = leader
|
|
135
|
+
raise NoLeaderError, "No leader available" unless ref
|
|
136
|
+
ref.state[:state_machine].dup
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Number of alive nodes.
|
|
140
|
+
def alive_count
|
|
141
|
+
@node_ids.count { |nid| Igniter::Registry.find(nid)&.alive? }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Minimum votes required for any Raft decision (⌊N/2⌋ + 1).
|
|
145
|
+
def quorum_size
|
|
146
|
+
(@node_ids.size / 2) + 1
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Returns +true+ if enough nodes are alive to elect a leader.
|
|
150
|
+
def has_quorum?
|
|
151
|
+
alive_count >= quorum_size
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Returns a +ReadQuery+ contract pre-configured for this cluster and key.
|
|
155
|
+
# Resolve it like any Igniter::Contract:
|
|
156
|
+
#
|
|
157
|
+
# q = cluster.read_contract(key: :price)
|
|
158
|
+
# q.resolve_all
|
|
159
|
+
# q.result.value # => 99
|
|
160
|
+
def read_contract(key:)
|
|
161
|
+
ReadQuery.new(cluster: self, key: key)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Status snapshot for every alive node in the cluster.
|
|
165
|
+
# @return [Array<Hash>] one hash per alive node
|
|
166
|
+
def status
|
|
167
|
+
@node_ids.filter_map do |nid|
|
|
168
|
+
ref = Igniter::Registry.find(nid)
|
|
169
|
+
next unless ref&.alive?
|
|
170
|
+
s = ref.state
|
|
171
|
+
{
|
|
172
|
+
node_id: s[:node_id],
|
|
173
|
+
role: s[:role],
|
|
174
|
+
term: s[:term],
|
|
175
|
+
commit_index: s[:commit_index],
|
|
176
|
+
log_size: s[:log].size,
|
|
177
|
+
state_machine: s[:state_machine],
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Consensus
|
|
5
|
+
# Base class for all consensus errors.
|
|
6
|
+
class Error < Igniter::Error; end
|
|
7
|
+
|
|
8
|
+
# Raised when no leader is available — cluster may be electing or lacks quorum.
|
|
9
|
+
class NoLeaderError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when the cluster loses quorum mid-operation.
|
|
12
|
+
class QuorumLostError < Error; end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Consensus
|
|
5
|
+
# Finds the current leader in a Cluster.
|
|
6
|
+
# Raises +Igniter::ResolutionError+ if no leader is available —
|
|
7
|
+
# the Resolver will enrich the error with graph/node context.
|
|
8
|
+
class FindLeader < Igniter::Executor
|
|
9
|
+
def call(cluster:)
|
|
10
|
+
ref = cluster.leader
|
|
11
|
+
raise Igniter::ResolutionError, "No leader in cluster — retry later" unless ref
|
|
12
|
+
s = ref.state
|
|
13
|
+
{ ref: ref, term: s[:term], node_id: s[:node_id] }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Reads a single key from the leader's committed state machine.
|
|
18
|
+
class ReadValue < Igniter::Executor
|
|
19
|
+
def call(leader:, key:)
|
|
20
|
+
leader[:ref].state[:state_machine][key]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Submits an arbitrary command to the consensus log and returns the command.
|
|
25
|
+
# Useful for fan-out patterns (e.g., multiple parallel writes in a Contract).
|
|
26
|
+
#
|
|
27
|
+
# Receives +cluster:+ plus any additional named keyword arguments; those
|
|
28
|
+
# extra kwargs become the command body:
|
|
29
|
+
#
|
|
30
|
+
# compute :write1, with: [:cluster, :cmd1], call: SubmitCommand
|
|
31
|
+
#
|
|
32
|
+
# The executor forwards +:cluster+ as the Cluster ref and treats all other
|
|
33
|
+
# keyword arguments as the command payload.
|
|
34
|
+
class SubmitCommand < Igniter::Executor
|
|
35
|
+
def call(cluster:, **command_kwargs)
|
|
36
|
+
ref = cluster.leader
|
|
37
|
+
raise Igniter::ResolutionError, "No leader — cannot submit command" unless ref
|
|
38
|
+
ref.send(:client_write, command: command_kwargs)
|
|
39
|
+
command_kwargs
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Consensus
|
|
5
|
+
# Value objects for sync-reply handlers.
|
|
6
|
+
# Must be non-Hash so the Agent runner sends them as replies rather than
|
|
7
|
+
# treating them as new state.
|
|
8
|
+
NodeStatus = Struct.new(:node_id, :role, :term, :commit_index,
|
|
9
|
+
:log_size, :state_machine, :leader, keyword_init: true)
|
|
10
|
+
NodeReadResult = Struct.new(:data, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# Timing constants — 1:20 heartbeat-to-election ratio for Ruby scheduling jitter.
|
|
13
|
+
ELECTION_CHECK_INTERVAL = 0.05 # how often to poll for timeout (s)
|
|
14
|
+
ELECTION_TIMEOUT_BASE = 1.0 # minimum idle time before election (s)
|
|
15
|
+
ELECTION_TIMEOUT_JITTER = 0.5 # random addition to prevent split votes (s)
|
|
16
|
+
HEARTBEAT_INTERVAL = 0.05 # leader heartbeat cadence (s)
|
|
17
|
+
|
|
18
|
+
# Raft consensus agent — internal implementation.
|
|
19
|
+
# Users should interact with +Cluster+ rather than +Node+ directly.
|
|
20
|
+
#
|
|
21
|
+
# The full Raft protocol (leader election, log replication, quorum-based
|
|
22
|
+
# commit) is encapsulated here. The user-defined state machine is stored in
|
|
23
|
+
# the agent state as +:state_machine_class+ and invoked on every commit.
|
|
24
|
+
class Node < Igniter::Agent
|
|
25
|
+
mailbox_size 2000
|
|
26
|
+
mailbox_overflow :drop_oldest
|
|
27
|
+
|
|
28
|
+
# Convenience factory — builds the correct initial_state automatically.
|
|
29
|
+
#
|
|
30
|
+
# @param name [Symbol] Registry key for this node
|
|
31
|
+
# @param peers [Array<Symbol>] Registry keys of all other nodes
|
|
32
|
+
# @param state_machine [Class] StateMachine subclass (default: StateMachine)
|
|
33
|
+
# @param verbose [Boolean] print protocol events to stdout
|
|
34
|
+
def self.start(name:, peers:, state_machine: StateMachine, verbose: false)
|
|
35
|
+
super(
|
|
36
|
+
name: name,
|
|
37
|
+
initial_state: {
|
|
38
|
+
node_id: name,
|
|
39
|
+
peers: peers,
|
|
40
|
+
state_machine_class: state_machine,
|
|
41
|
+
verbose: verbose,
|
|
42
|
+
role: :follower,
|
|
43
|
+
term: 0,
|
|
44
|
+
voted_for: nil,
|
|
45
|
+
log: [],
|
|
46
|
+
commit_index: -1,
|
|
47
|
+
last_applied: -1,
|
|
48
|
+
votes_received: [],
|
|
49
|
+
state_machine: {},
|
|
50
|
+
next_index: {},
|
|
51
|
+
match_index: {},
|
|
52
|
+
current_leader: nil,
|
|
53
|
+
last_heartbeat_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
54
|
+
election_timeout: ELECTION_TIMEOUT_BASE + rand * ELECTION_TIMEOUT_JITTER,
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Minimum votes required for a decision in a cluster of +size+ nodes.
|
|
60
|
+
def self.quorum(size) = (size / 2) + 1
|
|
61
|
+
|
|
62
|
+
# ── Internal helpers (called from schedule/on blocks) ────────────────────
|
|
63
|
+
|
|
64
|
+
def self.find_peer(id) = Igniter::Registry.find(id)
|
|
65
|
+
|
|
66
|
+
def self.log_msg(state, msg)
|
|
67
|
+
$stdout.puts " [#{state[:node_id]}] #{msg}" if state[:verbose]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Apply committed log entries to the state machine and return the updated
|
|
71
|
+
# state_machine hash + last_applied index.
|
|
72
|
+
def self.apply_entries(sm, last_applied, log, commit_index, sm_class)
|
|
73
|
+
sm = sm.dup
|
|
74
|
+
while last_applied < commit_index
|
|
75
|
+
last_applied += 1
|
|
76
|
+
cmd = log[last_applied]&.dig(:command)
|
|
77
|
+
sm = sm_class.call(sm, cmd) if cmd
|
|
78
|
+
end
|
|
79
|
+
[sm, last_applied]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ── Timer: election check ──────────────────────────────────────────────
|
|
83
|
+
schedule :election_check, every: ELECTION_CHECK_INTERVAL do |state:|
|
|
84
|
+
next state if state[:role] == :leader
|
|
85
|
+
|
|
86
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - state[:last_heartbeat_at]
|
|
87
|
+
next state if elapsed < state[:election_timeout]
|
|
88
|
+
|
|
89
|
+
new_term = state[:term] + 1
|
|
90
|
+
nid = state[:node_id]
|
|
91
|
+
lli = state[:log].size - 1
|
|
92
|
+
llt = state[:log].empty? ? -1 : state[:log].last[:term]
|
|
93
|
+
|
|
94
|
+
Node.log_msg(state, "timeout #{elapsed.round(2)}s → Candidate term=#{new_term}")
|
|
95
|
+
|
|
96
|
+
state[:peers].each do |pid|
|
|
97
|
+
Node.find_peer(pid)&.send(:request_vote, {
|
|
98
|
+
term: new_term, candidate_id: nid,
|
|
99
|
+
last_log_index: lli, last_log_term: llt,
|
|
100
|
+
})
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
state.merge(
|
|
104
|
+
role: :candidate, term: new_term,
|
|
105
|
+
voted_for: nid, votes_received: [nid],
|
|
106
|
+
last_heartbeat_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
107
|
+
election_timeout: ELECTION_TIMEOUT_BASE + rand * ELECTION_TIMEOUT_JITTER,
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ── Timer: heartbeat (leader only) ────────────────────────────────────
|
|
112
|
+
schedule :heartbeat, every: HEARTBEAT_INTERVAL do |state:|
|
|
113
|
+
next state unless state[:role] == :leader
|
|
114
|
+
|
|
115
|
+
state[:peers].each do |pid|
|
|
116
|
+
peer = Node.find_peer(pid)
|
|
117
|
+
next unless peer
|
|
118
|
+
|
|
119
|
+
mi = state[:match_index][pid] || -1
|
|
120
|
+
next_idx = mi + 1
|
|
121
|
+
prev_idx = next_idx - 1
|
|
122
|
+
prev_term = state[:log][prev_idx]&.dig(:term) || -1
|
|
123
|
+
entries = state[:log][next_idx..] || []
|
|
124
|
+
|
|
125
|
+
peer.send(:append_entries, {
|
|
126
|
+
term: state[:term], leader_id: state[:node_id],
|
|
127
|
+
prev_log_index: prev_idx, prev_log_term: prev_term,
|
|
128
|
+
entries: entries, leader_commit: state[:commit_index],
|
|
129
|
+
})
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
state
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# ── RequestVote ──────────────────────────────────────────────────────
|
|
136
|
+
on :request_vote do |state:, payload:|
|
|
137
|
+
msg = payload
|
|
138
|
+
base = msg[:term] > state[:term] ?
|
|
139
|
+
state.merge(term: msg[:term], role: :follower, voted_for: nil) : state
|
|
140
|
+
|
|
141
|
+
our_lt = base[:log].empty? ? -1 : base[:log].last[:term]
|
|
142
|
+
our_li = base[:log].size - 1
|
|
143
|
+
log_ok = msg[:last_log_term] > our_lt ||
|
|
144
|
+
(msg[:last_log_term] == our_lt && msg[:last_log_index] >= our_li)
|
|
145
|
+
|
|
146
|
+
can_vote = msg[:term] >= base[:term] && log_ok &&
|
|
147
|
+
(base[:voted_for].nil? || base[:voted_for] == msg[:candidate_id])
|
|
148
|
+
|
|
149
|
+
sender = Node.find_peer(msg[:candidate_id])
|
|
150
|
+
|
|
151
|
+
if can_vote
|
|
152
|
+
Node.log_msg(state, "votes for #{msg[:candidate_id]} (term #{msg[:term]})")
|
|
153
|
+
sender&.send(:vote_response, {
|
|
154
|
+
term: msg[:term], vote_granted: true, voter_id: state[:node_id],
|
|
155
|
+
})
|
|
156
|
+
base.merge(
|
|
157
|
+
voted_for: msg[:candidate_id],
|
|
158
|
+
last_heartbeat_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
159
|
+
)
|
|
160
|
+
else
|
|
161
|
+
sender&.send(:vote_response, {
|
|
162
|
+
term: base[:term], vote_granted: false, voter_id: state[:node_id],
|
|
163
|
+
})
|
|
164
|
+
base
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# ── VoteResponse ────────────────────────────────────────────────────
|
|
169
|
+
on :vote_response do |state:, payload:|
|
|
170
|
+
msg = payload
|
|
171
|
+
next state unless state[:role] == :candidate && msg[:term] == state[:term]
|
|
172
|
+
|
|
173
|
+
if msg[:term] > state[:term]
|
|
174
|
+
next state.merge(role: :follower, term: msg[:term], voted_for: nil)
|
|
175
|
+
end
|
|
176
|
+
next state unless msg[:vote_granted]
|
|
177
|
+
|
|
178
|
+
votes = (state[:votes_received] + [msg[:voter_id]]).uniq
|
|
179
|
+
quorum = Node.quorum(state[:peers].size + 1) # full cluster = peers + self
|
|
180
|
+
|
|
181
|
+
if votes.size >= quorum
|
|
182
|
+
ni = state[:peers].each_with_object({}) { |p, h| h[p] = state[:log].size }
|
|
183
|
+
mi = state[:peers].each_with_object({}) { |p, h| h[p] = -1 }
|
|
184
|
+
Node.log_msg(state, "*** LEADER (term=#{state[:term]}, votes=#{votes.size}/#{state[:peers].size + 1}) ***")
|
|
185
|
+
state.merge(role: :leader, votes_received: votes,
|
|
186
|
+
next_index: ni, match_index: mi)
|
|
187
|
+
else
|
|
188
|
+
state.merge(votes_received: votes)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# ── AppendEntries ───────────────────────────────────────────────────
|
|
193
|
+
on :append_entries do |state:, payload:|
|
|
194
|
+
msg = payload
|
|
195
|
+
sender = Node.find_peer(msg[:leader_id])
|
|
196
|
+
|
|
197
|
+
if msg[:term] < state[:term]
|
|
198
|
+
sender&.send(:append_entries_response, {
|
|
199
|
+
term: state[:term], success: false,
|
|
200
|
+
follower_id: state[:node_id], match_index: -1,
|
|
201
|
+
})
|
|
202
|
+
next state
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
s = state.merge(
|
|
206
|
+
term: [state[:term], msg[:term]].max,
|
|
207
|
+
role: :follower,
|
|
208
|
+
voted_for: state[:term] == msg[:term] ? state[:voted_for] : nil,
|
|
209
|
+
current_leader: msg[:leader_id],
|
|
210
|
+
last_heartbeat_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if msg[:prev_log_index] >= 0
|
|
214
|
+
ok = s[:log].size > msg[:prev_log_index] &&
|
|
215
|
+
s[:log][msg[:prev_log_index]]&.dig(:term) == msg[:prev_log_term]
|
|
216
|
+
unless ok
|
|
217
|
+
sender&.send(:append_entries_response, {
|
|
218
|
+
term: s[:term], success: false,
|
|
219
|
+
follower_id: state[:node_id], match_index: s[:log].size - 1,
|
|
220
|
+
})
|
|
221
|
+
next s
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
new_log = s[:log].take(msg[:prev_log_index] + 1).concat(msg[:entries])
|
|
226
|
+
new_commit = [[msg[:leader_commit], new_log.size - 1].min, s[:commit_index]].max
|
|
227
|
+
|
|
228
|
+
sm, la = Node.apply_entries(s[:state_machine], s[:last_applied],
|
|
229
|
+
new_log, new_commit, s[:state_machine_class])
|
|
230
|
+
|
|
231
|
+
sender&.send(:append_entries_response, {
|
|
232
|
+
term: s[:term], success: true,
|
|
233
|
+
follower_id: state[:node_id], match_index: new_log.size - 1,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
s.merge(log: new_log, commit_index: new_commit,
|
|
237
|
+
last_applied: la, state_machine: sm)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# ── AppendEntriesResponse (leader) ──────────────────────────────────
|
|
241
|
+
on :append_entries_response do |state:, payload:|
|
|
242
|
+
msg = payload
|
|
243
|
+
next state unless state[:role] == :leader
|
|
244
|
+
|
|
245
|
+
if msg[:term] > state[:term]
|
|
246
|
+
next state.merge(role: :follower, term: msg[:term], voted_for: nil)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
pid = msg[:follower_id]
|
|
250
|
+
new_mi = state[:match_index].dup
|
|
251
|
+
new_ni = state[:next_index].dup
|
|
252
|
+
|
|
253
|
+
if msg[:success]
|
|
254
|
+
new_mi[pid] = [new_mi[pid] || -1, msg[:match_index]].max
|
|
255
|
+
new_ni[pid] = new_mi[pid] + 1
|
|
256
|
+
else
|
|
257
|
+
new_ni[pid] = [(new_ni[pid] || 1) - 1, 0].max
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
new_log = state[:log]
|
|
261
|
+
new_commit = state[:commit_index]
|
|
262
|
+
quorum = Node.quorum(state[:peers].size + 1)
|
|
263
|
+
|
|
264
|
+
((new_commit + 1)...new_log.size).each do |n|
|
|
265
|
+
next unless new_log[n][:term] == state[:term]
|
|
266
|
+
replicated = new_mi.values.count { |m| m >= n } + 1 # +1 for leader
|
|
267
|
+
new_commit = n if replicated >= quorum
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
sm, la = Node.apply_entries(state[:state_machine], state[:last_applied],
|
|
271
|
+
new_log, new_commit, state[:state_machine_class])
|
|
272
|
+
|
|
273
|
+
if new_commit > state[:commit_index]
|
|
274
|
+
Node.log_msg(state, "committed idx=#{new_commit} → #{sm.inspect}")
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
state.merge(match_index: new_mi, next_index: new_ni,
|
|
278
|
+
commit_index: new_commit, last_applied: la, state_machine: sm)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# ── client_write — appends to log (leader) or forwards ──────────────
|
|
282
|
+
on :client_write do |state:, payload:|
|
|
283
|
+
unless state[:role] == :leader
|
|
284
|
+
ref = state[:current_leader] && Node.find_peer(state[:current_leader])
|
|
285
|
+
if ref
|
|
286
|
+
ref.send(:client_write, payload)
|
|
287
|
+
else
|
|
288
|
+
Node.log_msg(state, "no leader known — write dropped")
|
|
289
|
+
end
|
|
290
|
+
next state
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
entry = { term: state[:term], command: payload[:command] }
|
|
294
|
+
new_log = state[:log] + [entry]
|
|
295
|
+
Node.log_msg(state, "Leader appends log[#{new_log.size - 1}]: #{payload[:command].inspect}")
|
|
296
|
+
state.merge(log: new_log)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# ── Sync query: status ───────────────────────────────────────────────
|
|
300
|
+
# Returns NodeStatus (non-Hash) → runner sends as sync reply, NOT new state.
|
|
301
|
+
on :status do |state:, payload:|
|
|
302
|
+
NodeStatus.new(
|
|
303
|
+
node_id: state[:node_id],
|
|
304
|
+
role: state[:role],
|
|
305
|
+
term: state[:term],
|
|
306
|
+
commit_index: state[:commit_index],
|
|
307
|
+
log_size: state[:log].size,
|
|
308
|
+
state_machine: state[:state_machine],
|
|
309
|
+
leader: state[:current_leader],
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# ── Sync query: read full state machine snapshot ─────────────────────
|
|
314
|
+
# Returns NodeReadResult (non-Hash) so the runner sends it as sync reply.
|
|
315
|
+
on :read do |state:, payload:|
|
|
316
|
+
NodeReadResult.new(data: state[:state_machine].dup)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Consensus
|
|
5
|
+
# Built-in single-shot read Contract. Dependency graph: find_leader → read_value.
|
|
6
|
+
#
|
|
7
|
+
# Prefer +Cluster#read_contract+ for convenience:
|
|
8
|
+
#
|
|
9
|
+
# q = cluster.read_contract(key: :price)
|
|
10
|
+
# q.resolve_all
|
|
11
|
+
# q.result.value # => 99
|
|
12
|
+
#
|
|
13
|
+
# Or instantiate directly:
|
|
14
|
+
#
|
|
15
|
+
# q = Igniter::Consensus::ReadQuery.new(cluster: my_cluster, key: :price)
|
|
16
|
+
# q.resolve_all
|
|
17
|
+
# q.result.value # => 99
|
|
18
|
+
class ReadQuery < Igniter::Contract
|
|
19
|
+
define do
|
|
20
|
+
input :cluster # Igniter::Consensus::Cluster
|
|
21
|
+
input :key # key to read from the state machine
|
|
22
|
+
|
|
23
|
+
compute :leader, with: :cluster, call: FindLeader
|
|
24
|
+
compute :value, with: [:leader, :key], call: ReadValue
|
|
25
|
+
|
|
26
|
+
output :value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Consensus
|
|
5
|
+
# Base class for user-defined consensus state machines.
|
|
6
|
+
#
|
|
7
|
+
# Subclass and declare command handlers with +apply+. Each handler receives
|
|
8
|
+
# the current state (Hash) and a command Hash and must return the NEW state —
|
|
9
|
+
# without mutating the original.
|
|
10
|
+
#
|
|
11
|
+
# class PriceStore < Igniter::Consensus::StateMachine
|
|
12
|
+
# apply :set do |state, cmd| state.merge(cmd[:key] => cmd[:value]) end
|
|
13
|
+
# apply :delete do |state, cmd| state.reject { |k, _| k == cmd[:key] } end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Passing no subclass to Cluster uses the default KV protocol:
|
|
17
|
+
# +{ key:, value: }+ sets a key; +{ key:, op: :delete }+ removes it.
|
|
18
|
+
class StateMachine
|
|
19
|
+
class << self
|
|
20
|
+
# Declare a reducer for a named command type.
|
|
21
|
+
# The block receives +(state, command)+ and must return the new state Hash.
|
|
22
|
+
def apply(type, &block)
|
|
23
|
+
reducers[type.to_sym] = block
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @api private
|
|
27
|
+
def reducers
|
|
28
|
+
@reducers ||= {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Apply +command+ to +state+ and return the resulting state.
|
|
32
|
+
# Dispatches to the registered reducer for +command[:type]+, or falls back
|
|
33
|
+
# to the built-in KV protocol when no reducer is found.
|
|
34
|
+
#
|
|
35
|
+
# @param state [Hash] current state machine snapshot
|
|
36
|
+
# @param command [Hash, nil] command to apply
|
|
37
|
+
# @return [Hash] new state
|
|
38
|
+
def call(state, command)
|
|
39
|
+
return state unless command
|
|
40
|
+
|
|
41
|
+
type = command[:type]&.to_sym
|
|
42
|
+
if type && (reducer = reducers[type])
|
|
43
|
+
reducer.call(state, command)
|
|
44
|
+
else
|
|
45
|
+
# Default KV protocol: { key:, value: } → set; { key:, op: :delete } → remove
|
|
46
|
+
return state unless command.key?(:key)
|
|
47
|
+
|
|
48
|
+
if command[:op] == :delete
|
|
49
|
+
state.reject { |k, _| k == command[:key] }
|
|
50
|
+
else
|
|
51
|
+
state.merge(command[:key] => command[:value])
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|