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.
Files changed (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +217 -0
  3. data/docs/APPLICATION_V1.md +253 -0
  4. data/docs/CAPABILITIES_V1.md +207 -0
  5. data/docs/CONSENSUS_V1.md +477 -0
  6. data/docs/CONTENT_ADDRESSING_V1.md +221 -0
  7. data/docs/DATAFLOW_V1.md +274 -0
  8. data/docs/MESH_V1.md +732 -0
  9. data/docs/NODE_CACHE_V1.md +324 -0
  10. data/docs/PROACTIVE_AGENTS_V1.md +293 -0
  11. data/docs/SERVER_V1.md +200 -1
  12. data/docs/SKILLS_V1.md +213 -0
  13. data/docs/STORE_ADAPTERS.md +41 -13
  14. data/docs/TEMPORAL_V1.md +174 -0
  15. data/docs/TOOLS_V1.md +347 -0
  16. data/docs/TRANSCRIPTION_V1.md +403 -0
  17. data/examples/README.md +37 -0
  18. data/examples/consensus.rb +239 -0
  19. data/examples/dataflow.rb +308 -0
  20. data/examples/elocal_webhook.rb +1 -0
  21. data/examples/llm_tools.rb +237 -0
  22. data/examples/mesh.rb +239 -0
  23. data/examples/mesh_discovery.rb +267 -0
  24. data/examples/mesh_gossip.rb +162 -0
  25. data/examples/ringcentral_routing.rb +1 -1
  26. data/lib/igniter/agents/ai/alert_agent.rb +111 -0
  27. data/lib/igniter/agents/ai/chain_agent.rb +127 -0
  28. data/lib/igniter/agents/ai/critic_agent.rb +163 -0
  29. data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
  30. data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
  31. data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
  32. data/lib/igniter/agents/ai/observer_agent.rb +184 -0
  33. data/lib/igniter/agents/ai/planner_agent.rb +210 -0
  34. data/lib/igniter/agents/ai/router_agent.rb +131 -0
  35. data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
  36. data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
  37. data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
  38. data/lib/igniter/agents/proactive_agent.rb +208 -0
  39. data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
  40. data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
  41. data/lib/igniter/agents.rb +56 -0
  42. data/lib/igniter/application/app_config.rb +32 -0
  43. data/lib/igniter/application/autoloader.rb +18 -0
  44. data/lib/igniter/application/generator.rb +157 -0
  45. data/lib/igniter/application/scheduler.rb +109 -0
  46. data/lib/igniter/application/yml_loader.rb +39 -0
  47. data/lib/igniter/application.rb +174 -0
  48. data/lib/igniter/capabilities.rb +68 -0
  49. data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
  50. data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
  51. data/lib/igniter/consensus/cluster.rb +183 -0
  52. data/lib/igniter/consensus/errors.rb +14 -0
  53. data/lib/igniter/consensus/executors.rb +43 -0
  54. data/lib/igniter/consensus/node.rb +320 -0
  55. data/lib/igniter/consensus/read_query.rb +30 -0
  56. data/lib/igniter/consensus/state_machine.rb +58 -0
  57. data/lib/igniter/consensus.rb +58 -0
  58. data/lib/igniter/content_addressing.rb +133 -0
  59. data/lib/igniter/contract.rb +12 -0
  60. data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
  61. data/lib/igniter/dataflow/aggregate_state.rb +77 -0
  62. data/lib/igniter/dataflow/diff.rb +37 -0
  63. data/lib/igniter/dataflow/diff_state.rb +81 -0
  64. data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
  65. data/lib/igniter/dataflow/window_filter.rb +48 -0
  66. data/lib/igniter/dataflow.rb +65 -0
  67. data/lib/igniter/dsl/contract_builder.rb +71 -7
  68. data/lib/igniter/executor.rb +60 -0
  69. data/lib/igniter/extensions/capabilities.rb +39 -0
  70. data/lib/igniter/extensions/content_addressing.rb +5 -0
  71. data/lib/igniter/extensions/dataflow.rb +117 -0
  72. data/lib/igniter/extensions/mesh.rb +31 -0
  73. data/lib/igniter/fingerprint.rb +43 -0
  74. data/lib/igniter/integrations/llm/config.rb +48 -4
  75. data/lib/igniter/integrations/llm/executor.rb +221 -28
  76. data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
  77. data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
  78. data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
  79. data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
  80. data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
  81. data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
  82. data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
  83. data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
  84. data/lib/igniter/integrations/llm.rb +37 -1
  85. data/lib/igniter/memory/agent_memory.rb +104 -0
  86. data/lib/igniter/memory/episode.rb +29 -0
  87. data/lib/igniter/memory/fact.rb +27 -0
  88. data/lib/igniter/memory/memorable.rb +90 -0
  89. data/lib/igniter/memory/reflection_cycle.rb +96 -0
  90. data/lib/igniter/memory/reflection_record.rb +28 -0
  91. data/lib/igniter/memory/store.rb +115 -0
  92. data/lib/igniter/memory/stores/in_memory.rb +136 -0
  93. data/lib/igniter/memory/stores/sqlite.rb +284 -0
  94. data/lib/igniter/memory.rb +80 -0
  95. data/lib/igniter/mesh/announcer.rb +55 -0
  96. data/lib/igniter/mesh/config.rb +45 -0
  97. data/lib/igniter/mesh/discovery.rb +39 -0
  98. data/lib/igniter/mesh/errors.rb +31 -0
  99. data/lib/igniter/mesh/gossip.rb +47 -0
  100. data/lib/igniter/mesh/peer.rb +21 -0
  101. data/lib/igniter/mesh/peer_registry.rb +51 -0
  102. data/lib/igniter/mesh/poller.rb +77 -0
  103. data/lib/igniter/mesh/router.rb +109 -0
  104. data/lib/igniter/mesh.rb +85 -0
  105. data/lib/igniter/metrics/collector.rb +131 -0
  106. data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
  107. data/lib/igniter/metrics/snapshot.rb +8 -0
  108. data/lib/igniter/metrics.rb +37 -0
  109. data/lib/igniter/model/aggregate_node.rb +34 -0
  110. data/lib/igniter/model/collection_node.rb +3 -2
  111. data/lib/igniter/model/compute_node.rb +13 -0
  112. data/lib/igniter/model/remote_node.rb +18 -2
  113. data/lib/igniter/node_cache.rb +231 -0
  114. data/lib/igniter/replication/bootstrapper.rb +61 -0
  115. data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
  116. data/lib/igniter/replication/bootstrappers/git.rb +39 -0
  117. data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
  118. data/lib/igniter/replication/expansion_plan.rb +38 -0
  119. data/lib/igniter/replication/expansion_planner.rb +142 -0
  120. data/lib/igniter/replication/manifest.rb +45 -0
  121. data/lib/igniter/replication/network_topology.rb +123 -0
  122. data/lib/igniter/replication/node_role.rb +42 -0
  123. data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
  124. data/lib/igniter/replication/replication_agent.rb +87 -0
  125. data/lib/igniter/replication/role_registry.rb +73 -0
  126. data/lib/igniter/replication/ssh_session.rb +77 -0
  127. data/lib/igniter/replication.rb +54 -0
  128. data/lib/igniter/runtime/execution.rb +18 -0
  129. data/lib/igniter/runtime/input_validator.rb +6 -2
  130. data/lib/igniter/runtime/resolver.rb +254 -16
  131. data/lib/igniter/runtime/stores/redis_store.rb +41 -4
  132. data/lib/igniter/server/client.rb +44 -1
  133. data/lib/igniter/server/config.rb +13 -6
  134. data/lib/igniter/server/handlers/event_handler.rb +4 -0
  135. data/lib/igniter/server/handlers/execute_handler.rb +6 -0
  136. data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
  137. data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
  138. data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
  139. data/lib/igniter/server/handlers/peers_handler.rb +115 -0
  140. data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
  141. data/lib/igniter/server/http_server.rb +54 -17
  142. data/lib/igniter/server/router.rb +54 -21
  143. data/lib/igniter/server/server_logger.rb +52 -0
  144. data/lib/igniter/server.rb +6 -0
  145. data/lib/igniter/skill/feedback.rb +116 -0
  146. data/lib/igniter/skill/output_schema.rb +110 -0
  147. data/lib/igniter/skill.rb +218 -0
  148. data/lib/igniter/temporal.rb +84 -0
  149. data/lib/igniter/tool/discoverable.rb +151 -0
  150. data/lib/igniter/tool.rb +52 -0
  151. data/lib/igniter/tool_registry.rb +144 -0
  152. data/lib/igniter/version.rb +1 -1
  153. data/lib/igniter.rb +17 -0
  154. 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