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,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Mesh
|
|
5
|
+
# Configuration for the local mesh node: registered peers and local identity.
|
|
6
|
+
class Config
|
|
7
|
+
attr_accessor :peer_name, :local_capabilities, :seeds, :discovery_interval,
|
|
8
|
+
:auto_announce, :local_url, :gossip_fanout
|
|
9
|
+
attr_reader :peers, :peer_registry
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@peer_name = nil
|
|
13
|
+
@local_capabilities = []
|
|
14
|
+
@peers = []
|
|
15
|
+
@peer_registry = PeerRegistry.new
|
|
16
|
+
@seeds = []
|
|
17
|
+
@discovery_interval = 30
|
|
18
|
+
@auto_announce = true
|
|
19
|
+
@local_url = nil
|
|
20
|
+
@gossip_fanout = 3
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Register a remote peer by name.
|
|
24
|
+
#
|
|
25
|
+
# Igniter::Mesh.configure do |c|
|
|
26
|
+
# c.add_peer "orders-node", url: "http://orders.internal:4567",
|
|
27
|
+
# capabilities: [:orders, :inventory]
|
|
28
|
+
# end
|
|
29
|
+
def add_peer(name, url:, capabilities: [])
|
|
30
|
+
@peers << Peer.new(name: name, url: url, capabilities: capabilities)
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# All static peers that advertise a given capability.
|
|
35
|
+
def peers_with_capability(capability)
|
|
36
|
+
@peers.select { |p| p.capable?(capability) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Find a static peer by its registered name. Returns nil if not found.
|
|
40
|
+
def peer_named(name)
|
|
41
|
+
@peers.find { |p| p.name == name.to_s }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Mesh
|
|
5
|
+
# Orchestrates the full dynamic-discovery lifecycle:
|
|
6
|
+
#
|
|
7
|
+
# 1. Announce self to all seeds (POST /v1/mesh/peers).
|
|
8
|
+
# 2. Immediately poll all seeds for their known peer list (GET /v1/mesh/peers).
|
|
9
|
+
# 3. Start the background Poller to keep the registry up to date.
|
|
10
|
+
#
|
|
11
|
+
# On #stop:
|
|
12
|
+
# 1. Deannounce self from all seeds (DELETE /v1/mesh/peers/:name), best-effort.
|
|
13
|
+
# 2. Stop the background Poller.
|
|
14
|
+
class Discovery
|
|
15
|
+
def initialize(config)
|
|
16
|
+
@config = config
|
|
17
|
+
@announcer = Announcer.new(config)
|
|
18
|
+
@poller = Poller.new(config)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def start
|
|
22
|
+
@announcer.announce_all
|
|
23
|
+
@poller.poll_once
|
|
24
|
+
@poller.start
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stop
|
|
29
|
+
@announcer.deannounce_all
|
|
30
|
+
@poller.stop
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def running?
|
|
35
|
+
@poller.running?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Mesh
|
|
5
|
+
# Raised when a capability-routed remote node has no alive peers.
|
|
6
|
+
# Inherits from PendingDependencyError so the resolver transitions
|
|
7
|
+
# the node to :pending (same as await/distributed workflow nodes).
|
|
8
|
+
class DeferredCapabilityError < Igniter::PendingDependencyError
|
|
9
|
+
attr_reader :capability
|
|
10
|
+
|
|
11
|
+
def initialize(capability, deferred_result, message = nil)
|
|
12
|
+
@capability = capability
|
|
13
|
+
super(deferred_result, message || "No alive peer with capability :#{capability}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Raised when a pinned_to peer is unavailable.
|
|
18
|
+
# Inherits from ResolutionError so the resolver transitions the node
|
|
19
|
+
# to :failed and surfaces it as an operational incident requiring
|
|
20
|
+
# manual intervention.
|
|
21
|
+
class IncidentError < Igniter::ResolutionError
|
|
22
|
+
attr_reader :peer_name
|
|
23
|
+
|
|
24
|
+
def initialize(peer_name, message = nil, context: {})
|
|
25
|
+
@peer_name = peer_name
|
|
26
|
+
super(message || "Pinned peer '#{peer_name}' is unreachable — manual intervention required",
|
|
27
|
+
context: context)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Mesh
|
|
5
|
+
# Executes a single peer-to-peer gossip round.
|
|
6
|
+
#
|
|
7
|
+
# Picks up to config.gossip_fanout random peers from the local PeerRegistry
|
|
8
|
+
# (excluding self), fetches their GET /v1/mesh/peers lists, and registers
|
|
9
|
+
# any newly discovered peers. This decentralises topology exchange: once seeds
|
|
10
|
+
# bootstrap the registry, peers continue spreading topology information among
|
|
11
|
+
# themselves even if seeds become unavailable.
|
|
12
|
+
#
|
|
13
|
+
# Errors from individual peers are swallowed — a dead peer must not abort
|
|
14
|
+
# the round for the remaining candidates.
|
|
15
|
+
class GossipRound
|
|
16
|
+
def initialize(config)
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
pick_candidates.each { |peer| exchange_with(peer) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def pick_candidates
|
|
27
|
+
@config.peer_registry.all
|
|
28
|
+
.reject { |p| p.url == @config.local_url }
|
|
29
|
+
.sample(@config.gossip_fanout)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def exchange_with(peer) # rubocop:disable Metrics/AbcSize
|
|
33
|
+
peers = Igniter::Server::Client.new(peer.url, timeout: 5).list_peers
|
|
34
|
+
peers.each do |pd|
|
|
35
|
+
next if pd[:name].nil? || pd[:url].nil?
|
|
36
|
+
next if pd[:name] == @config.peer_name
|
|
37
|
+
|
|
38
|
+
@config.peer_registry.register(
|
|
39
|
+
Peer.new(name: pd[:name], url: pd[:url], capabilities: pd[:capabilities] || [])
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
rescue Igniter::Server::Client::ConnectionError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Mesh
|
|
5
|
+
# Immutable value object representing a peer in the static mesh.
|
|
6
|
+
class Peer
|
|
7
|
+
attr_reader :name, :url, :capabilities
|
|
8
|
+
|
|
9
|
+
def initialize(name:, url:, capabilities: [])
|
|
10
|
+
@name = name.to_s.freeze
|
|
11
|
+
@url = url.to_s.chomp("/").freeze
|
|
12
|
+
@capabilities = Array(capabilities).map(&:to_sym).freeze
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def capable?(capability)
|
|
17
|
+
@capabilities.include?(capability.to_sym)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Mesh
|
|
5
|
+
# Thread-safe registry for dynamically discovered peers.
|
|
6
|
+
#
|
|
7
|
+
# Stores peers indexed by name so that registering the same peer twice
|
|
8
|
+
# (e.g. from two different seeds) is idempotent and the latest wins.
|
|
9
|
+
class PeerRegistry
|
|
10
|
+
def initialize
|
|
11
|
+
@peers = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Register (or update) a peer. Thread-safe.
|
|
16
|
+
def register(peer)
|
|
17
|
+
@mutex.synchronize { @peers[peer.name] = peer }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Remove a peer by name. No-op if not registered.
|
|
21
|
+
def unregister(name)
|
|
22
|
+
@mutex.synchronize { @peers.delete(name.to_s) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# All currently registered peers (snapshot).
|
|
26
|
+
def all
|
|
27
|
+
@mutex.synchronize { @peers.values.dup }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Peers that advertise a given capability.
|
|
31
|
+
def peers_with_capability(capability)
|
|
32
|
+
all.select { |p| p.capable?(capability) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Find a peer by name. Returns nil if not found.
|
|
36
|
+
def peer_named(name)
|
|
37
|
+
@mutex.synchronize { @peers[name.to_s] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Remove all registered peers. Useful in tests.
|
|
41
|
+
def clear
|
|
42
|
+
@mutex.synchronize { @peers.clear }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Number of registered peers.
|
|
46
|
+
def size
|
|
47
|
+
@mutex.synchronize { @peers.size }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Mesh
|
|
5
|
+
# Background thread that periodically fetches the peer list from every seed
|
|
6
|
+
# and populates the local PeerRegistry with newly discovered peers.
|
|
7
|
+
#
|
|
8
|
+
# - Errors from individual seeds are swallowed (seed may be temporarily down).
|
|
9
|
+
# - Does not remove peers from the registry on failure — the health cache in
|
|
10
|
+
# Router handles routing around dead peers without purging known topology.
|
|
11
|
+
# - Thread is non-daemon: call #stop explicitly on shutdown (Discovery does this).
|
|
12
|
+
class Poller
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config
|
|
15
|
+
@running = false
|
|
16
|
+
@thread = nil
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Start the background polling thread. Idempotent.
|
|
21
|
+
def start
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
return if @running
|
|
24
|
+
|
|
25
|
+
@running = true
|
|
26
|
+
@thread = Thread.new { run_loop }
|
|
27
|
+
@thread.abort_on_exception = false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Stop the background thread. Idempotent.
|
|
32
|
+
def stop
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@running = false
|
|
35
|
+
@thread&.kill
|
|
36
|
+
@thread = nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def running?
|
|
41
|
+
@mutex.synchronize { @running }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Fetch peers from all seeds, then run a gossip round with random registry
|
|
45
|
+
# peers (Phase 3). Synchronous — used at startup and inside the background loop.
|
|
46
|
+
def poll_once
|
|
47
|
+
@config.seeds.each { |url| fetch_peers_from(url) }
|
|
48
|
+
GossipRound.new(@config).run if @config.gossip_fanout.positive?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def run_loop
|
|
54
|
+
loop do
|
|
55
|
+
sleep(@config.discovery_interval)
|
|
56
|
+
break unless @running
|
|
57
|
+
|
|
58
|
+
poll_once
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def fetch_peers_from(seed_url)
|
|
63
|
+
peers = Igniter::Server::Client.new(seed_url, timeout: 5).list_peers
|
|
64
|
+
peers.each do |pd|
|
|
65
|
+
next if pd[:name].nil? || pd[:url].nil?
|
|
66
|
+
next if pd[:name] == @config.peer_name
|
|
67
|
+
|
|
68
|
+
@config.peer_registry.register(
|
|
69
|
+
Peer.new(name: pd[:name], url: pd[:url], capabilities: pd[:capabilities] || [])
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
rescue Igniter::Server::Client::ConnectionError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Mesh
|
|
5
|
+
# Thread-safe capability router with a short-lived health cache.
|
|
6
|
+
#
|
|
7
|
+
# Resolves the URL to call for a given routing mode:
|
|
8
|
+
# - :capability → round-robin among alive peers that advertise the capability;
|
|
9
|
+
# raises DeferredCapabilityError when no alive peer is found.
|
|
10
|
+
# - :pinned → asserts the named peer is alive and returns its URL;
|
|
11
|
+
# raises IncidentError when the peer is unknown or unreachable.
|
|
12
|
+
#
|
|
13
|
+
# Peer pool = static peers (Config#peers) + dynamic peers (Config#peer_registry).
|
|
14
|
+
# Static peers take precedence when a name appears in both sets.
|
|
15
|
+
class Router
|
|
16
|
+
HEALTH_CACHE_TTL = 5 # seconds
|
|
17
|
+
|
|
18
|
+
def initialize(config)
|
|
19
|
+
@config = config
|
|
20
|
+
@health_cache = {}
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@round_robin = Hash.new(0)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Find an alive peer advertising +capability+.
|
|
26
|
+
# Returns the peer URL. Raises DeferredCapabilityError when none are alive.
|
|
27
|
+
def find_peer_for(capability, deferred_result)
|
|
28
|
+
candidates = all_capable_peers(capability).select { |p| alive?(p) }
|
|
29
|
+
|
|
30
|
+
raise DeferredCapabilityError.new(capability, deferred_result) if candidates.empty?
|
|
31
|
+
|
|
32
|
+
url_for_round_robin(capability, candidates)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Resolve the URL of a pinned peer by name.
|
|
36
|
+
# Raises IncidentError if the peer is unknown or unreachable.
|
|
37
|
+
def resolve_pinned(peer_name)
|
|
38
|
+
peer = find_named_peer(peer_name)
|
|
39
|
+
|
|
40
|
+
unless peer
|
|
41
|
+
raise IncidentError.new(
|
|
42
|
+
peer_name,
|
|
43
|
+
"Pinned peer '#{peer_name}' is not registered in Igniter::Mesh"
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
raise IncidentError, peer_name unless alive?(peer)
|
|
48
|
+
|
|
49
|
+
peer.url
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Expire a peer's cached health status (e.g., after a successful or failed request).
|
|
53
|
+
def invalidate_health!(url)
|
|
54
|
+
@mutex.synchronize { @health_cache.delete(url) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Combined static + dynamic peer pool for capability lookup.
|
|
60
|
+
# Static peers take precedence over same-named dynamic peers.
|
|
61
|
+
def all_capable_peers(capability)
|
|
62
|
+
merge_peers(
|
|
63
|
+
@config.peers_with_capability(capability),
|
|
64
|
+
@config.peer_registry.peers_with_capability(capability)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Lookup peer by name across static and dynamic pools.
|
|
69
|
+
def find_named_peer(name)
|
|
70
|
+
@config.peer_named(name) || @config.peer_registry.peer_named(name)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Merge static and dynamic peer lists; static names win on collision.
|
|
74
|
+
def merge_peers(static, dynamic)
|
|
75
|
+
seen = static.each_with_object({}) { |p, h| h[p.name] = true }
|
|
76
|
+
static + dynamic.reject { |p| seen[p.name] }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def url_for_round_robin(capability, candidates)
|
|
80
|
+
idx = @mutex.synchronize do
|
|
81
|
+
i = @round_robin[capability] % candidates.size
|
|
82
|
+
@round_robin[capability] = i + 1
|
|
83
|
+
i
|
|
84
|
+
end
|
|
85
|
+
candidates[idx].url
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def alive?(peer) # rubocop:disable Metrics/MethodLength
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
entry = @health_cache[peer.url]
|
|
91
|
+
return entry[:alive] if entry && (Time.now.utc - entry[:checked_at]) < HEALTH_CACHE_TTL
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
alive = begin
|
|
95
|
+
Igniter::Server::Client.new(peer.url, timeout: 3).health
|
|
96
|
+
true
|
|
97
|
+
rescue Igniter::Server::Client::ConnectionError
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@mutex.synchronize do
|
|
102
|
+
@health_cache[peer.url] = { alive: alive, checked_at: Time.now.utc }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
alive
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/igniter/mesh.rb
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mesh/errors"
|
|
4
|
+
require_relative "mesh/peer"
|
|
5
|
+
require_relative "mesh/peer_registry"
|
|
6
|
+
require_relative "mesh/config"
|
|
7
|
+
require_relative "mesh/router"
|
|
8
|
+
require_relative "mesh/announcer"
|
|
9
|
+
require_relative "mesh/poller"
|
|
10
|
+
require_relative "mesh/discovery"
|
|
11
|
+
require_relative "mesh/gossip"
|
|
12
|
+
|
|
13
|
+
module Igniter
|
|
14
|
+
# Mesh routing for remote: nodes.
|
|
15
|
+
#
|
|
16
|
+
# Phase 1 — Static Mesh:
|
|
17
|
+
# Declare peer topology via add_peer. capability: and pinned_to: routing
|
|
18
|
+
# modes select alive peers at resolution time.
|
|
19
|
+
#
|
|
20
|
+
# Phase 2 — Dynamic Discovery:
|
|
21
|
+
# Configure seed URLs and call start_discovery!. The local node announces
|
|
22
|
+
# itself to seeds and polls them for the current peer list in the background.
|
|
23
|
+
#
|
|
24
|
+
# Usage:
|
|
25
|
+
#
|
|
26
|
+
# require "igniter/extensions/mesh"
|
|
27
|
+
#
|
|
28
|
+
# Igniter::Mesh.configure do |c|
|
|
29
|
+
# c.peer_name = "api-node"
|
|
30
|
+
# c.local_url = "http://api.internal:4567"
|
|
31
|
+
# c.local_capabilities = [:api]
|
|
32
|
+
# c.seeds = %w[http://orders.internal:4567 http://audit.internal:4567]
|
|
33
|
+
# c.discovery_interval = 30 # seconds (default)
|
|
34
|
+
#
|
|
35
|
+
# # Static peers still work alongside dynamic discovery:
|
|
36
|
+
# c.add_peer "legacy-node",
|
|
37
|
+
# url: "http://legacy.internal:4567",
|
|
38
|
+
# capabilities: [:billing]
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# Igniter::Mesh.start_discovery! # announce + poll + background thread
|
|
42
|
+
# # …
|
|
43
|
+
# Igniter::Mesh.stop_discovery! # deannounce + stop thread (on shutdown)
|
|
44
|
+
module Mesh
|
|
45
|
+
class << self
|
|
46
|
+
def config
|
|
47
|
+
@config ||= Config.new
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def configure
|
|
51
|
+
yield config
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def router
|
|
56
|
+
@router ||= Router.new(config)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Start dynamic discovery: announce self to seeds, do an immediate poll,
|
|
60
|
+
# then begin background polling at config.discovery_interval.
|
|
61
|
+
def start_discovery!
|
|
62
|
+
discovery.start
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Stop dynamic discovery: deannounce self from seeds, stop background thread.
|
|
67
|
+
def stop_discovery!
|
|
68
|
+
@discovery&.stop
|
|
69
|
+
@discovery = nil
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def discovery
|
|
74
|
+
@discovery ||= Discovery.new(config)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Reset all singletons (config, router, discovery). Useful in tests.
|
|
78
|
+
def reset!
|
|
79
|
+
stop_discovery!
|
|
80
|
+
@config = nil
|
|
81
|
+
@router = nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Metrics
|
|
5
|
+
# Thread-safe event subscriber that collects execution-level metrics.
|
|
6
|
+
#
|
|
7
|
+
# Subscribes to an Igniter::Events::Bus (via the Events::Bus#subscribe interface)
|
|
8
|
+
# and maintains in-memory counters and histograms for:
|
|
9
|
+
# - Total executions (by graph, by status)
|
|
10
|
+
# - Execution duration histogram (by graph)
|
|
11
|
+
# - HTTP request counts and durations (recorded directly by the server)
|
|
12
|
+
#
|
|
13
|
+
# All state is protected by a Mutex. Snapshot returns a frozen copy
|
|
14
|
+
# safe to read outside the lock.
|
|
15
|
+
class Collector
|
|
16
|
+
HISTOGRAM_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0].freeze
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@counters = Hash.new(0) # String(metric{labels}) → Integer
|
|
21
|
+
@histograms = {} # String(metric_name) → Hash(label_key → histogram_entry)
|
|
22
|
+
@exec_start = {} # execution_id → Time (for duration tracking)
|
|
23
|
+
@exec_graph = {} # execution_id → graph_name
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Called by Events::Bus for every event emitted during an execution.
|
|
27
|
+
def call(event) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
28
|
+
case event.type
|
|
29
|
+
when :execution_started then on_execution_started(event)
|
|
30
|
+
when :execution_finished then on_execution_finished(event, "succeeded")
|
|
31
|
+
when :execution_failed then on_execution_finished(event, "failed")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Record an HTTP request (called directly by the server/router).
|
|
36
|
+
def record_http(method:, path:, status:, duration:)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
inc("igniter_http_requests_total",
|
|
39
|
+
method: method, path: normalized_path(path), status: status.to_s)
|
|
40
|
+
observe_locked("igniter_http_request_duration_seconds", duration,
|
|
41
|
+
method: method, path: normalized_path(path))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Snapshot returns frozen copies of counters and histograms.
|
|
46
|
+
def snapshot
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
Snapshot.new(
|
|
49
|
+
counters: @counters.dup.freeze,
|
|
50
|
+
histograms: deep_freeze(@histograms)
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def on_execution_started(event)
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
@exec_start[event.execution_id] = event.timestamp
|
|
60
|
+
@exec_graph[event.execution_id] = event.payload[:graph].to_s
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def on_execution_finished(event, status)
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
graph = @exec_graph.delete(event.execution_id) || event.payload[:graph].to_s
|
|
67
|
+
started_at = @exec_start.delete(event.execution_id)
|
|
68
|
+
|
|
69
|
+
inc("igniter_executions_total", graph: graph, status: status)
|
|
70
|
+
|
|
71
|
+
if started_at
|
|
72
|
+
duration = event.timestamp - started_at
|
|
73
|
+
observe_locked("igniter_execution_duration_seconds", duration, graph: graph)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def inc(name, labels)
|
|
79
|
+
@counters[metric_key(name, labels)] += 1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def observe_locked(name, value, labels)
|
|
83
|
+
lkey = label_key(labels)
|
|
84
|
+
@histograms[name] ||= {}
|
|
85
|
+
entry = @histograms[name][lkey] ||= new_histogram_entry(labels)
|
|
86
|
+
HISTOGRAM_BUCKETS.each { |b| entry[:buckets][b] += 1 if value <= b }
|
|
87
|
+
entry[:sum] += value
|
|
88
|
+
entry[:count] += 1
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def new_histogram_entry(labels)
|
|
92
|
+
{ labels: labels, buckets: Hash.new(0), sum: 0.0, count: 0 }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def metric_key(name, labels)
|
|
96
|
+
"#{name}#{label_selector(labels)}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def label_key(labels)
|
|
100
|
+
label_selector(labels)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def label_selector(labels)
|
|
104
|
+
return "" if labels.empty?
|
|
105
|
+
|
|
106
|
+
pairs = labels.map { |k, v| "#{k}=\"#{v}\"" }.join(",")
|
|
107
|
+
"{#{pairs}}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalized_path(path)
|
|
111
|
+
# Collapse dynamic path segments to avoid high-cardinality labels
|
|
112
|
+
path.to_s
|
|
113
|
+
.gsub(%r{/v1/contracts/[^/]+/}, "/v1/contracts/:name/")
|
|
114
|
+
.gsub(%r{/v1/executions/[^/]+}, "/v1/executions/:id")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def deep_freeze(hash)
|
|
118
|
+
hash.each_with_object({}) do |(name, by_label), memo|
|
|
119
|
+
memo[name] = by_label.each_with_object({}) do |(lkey, entry), inner|
|
|
120
|
+
inner[lkey] = {
|
|
121
|
+
labels: entry[:labels].dup.freeze,
|
|
122
|
+
buckets: entry[:buckets].dup.freeze,
|
|
123
|
+
sum: entry[:sum],
|
|
124
|
+
count: entry[:count]
|
|
125
|
+
}.freeze
|
|
126
|
+
end.freeze
|
|
127
|
+
end.freeze
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|