igniter 0.3.1 → 0.4.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/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
- data/examples/distributed_workflow.rb +52 -0
- data/lib/igniter/compiler/compiled_graph.rb +12 -0
- data/lib/igniter/compiler/validation_pipeline.rb +3 -1
- data/lib/igniter/compiler/validators/await_validator.rb +53 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +41 -1
- data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
- data/lib/igniter/compiler.rb +2 -0
- data/lib/igniter/contract.rb +59 -8
- data/lib/igniter/dsl/contract_builder.rb +42 -4
- data/lib/igniter/errors.rb +6 -1
- data/lib/igniter/integrations/llm/config.rb +69 -0
- data/lib/igniter/integrations/llm/context.rb +74 -0
- data/lib/igniter/integrations/llm/executor.rb +159 -0
- data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
- data/lib/igniter/integrations/llm/providers/base.rb +33 -0
- data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
- data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
- data/lib/igniter/integrations/llm.rb +59 -0
- data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
- data/lib/igniter/integrations/rails/contract_job.rb +76 -0
- data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
- data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
- data/lib/igniter/integrations/rails/railtie.rb +25 -0
- data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
- data/lib/igniter/integrations/rails.rb +12 -0
- data/lib/igniter/model/await_node.rb +21 -0
- data/lib/igniter/model/remote_node.rb +26 -0
- data/lib/igniter/model.rb +2 -0
- data/lib/igniter/runtime/execution.rb +2 -2
- data/lib/igniter/runtime/input_validator.rb +5 -3
- data/lib/igniter/runtime/resolver.rb +43 -1
- data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
- data/lib/igniter/runtime/stores/file_store.rb +50 -2
- data/lib/igniter/runtime/stores/memory_store.rb +55 -2
- data/lib/igniter/runtime/stores/redis_store.rb +13 -1
- data/lib/igniter/server/client.rb +123 -0
- data/lib/igniter/server/config.rb +27 -0
- data/lib/igniter/server/handlers/base.rb +105 -0
- data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
- data/lib/igniter/server/handlers/event_handler.rb +28 -0
- data/lib/igniter/server/handlers/execute_handler.rb +37 -0
- data/lib/igniter/server/handlers/health_handler.rb +32 -0
- data/lib/igniter/server/handlers/status_handler.rb +27 -0
- data/lib/igniter/server/http_server.rb +109 -0
- data/lib/igniter/server/rack_app.rb +35 -0
- data/lib/igniter/server/registry.rb +56 -0
- data/lib/igniter/server/router.rb +75 -0
- data/lib/igniter/server.rb +67 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +4 -0
- metadata +36 -2
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Rails
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
desc "Creates an Igniter initializer in your application."
|
|
11
|
+
|
|
12
|
+
def copy_initializer
|
|
13
|
+
template "igniter.rb.tt", "config/initializers/igniter.rb"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create_contracts_directory
|
|
17
|
+
empty_directory "app/contracts"
|
|
18
|
+
create_file "app/contracts/.keep"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def show_readme
|
|
22
|
+
say "", :green
|
|
23
|
+
say "✓ Igniter installed!", :green
|
|
24
|
+
say ""
|
|
25
|
+
say "Next steps:"
|
|
26
|
+
say " 1. Configure your store in config/initializers/igniter.rb"
|
|
27
|
+
say " 2. Generate a contract: rails g igniter:contract YourContractName"
|
|
28
|
+
say ""
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Rails
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
initializer "igniter.configure_store" do
|
|
7
|
+
# Auto-configure store based on available adapters unless already set
|
|
8
|
+
next if Igniter.instance_variable_defined?(:@execution_store)
|
|
9
|
+
|
|
10
|
+
Igniter.execution_store =
|
|
11
|
+
if defined?(Redis) && ::Rails.application.config.respond_to?(:redis)
|
|
12
|
+
Igniter::Runtime::Stores::RedisStore.new(::Redis.current)
|
|
13
|
+
else
|
|
14
|
+
Igniter::Runtime::Stores::MemoryStore.new
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
initializer "igniter.load_contracts" do
|
|
19
|
+
::Rails.autoloaders.main.on_load("ApplicationContract") do
|
|
20
|
+
# Hook point for future eager loading of compiled contracts
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Rails
|
|
5
|
+
# Controller mixin for delivering external events to running contracts.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# class WebhooksController < ApplicationController
|
|
9
|
+
# include Igniter::Rails::WebhookHandler
|
|
10
|
+
#
|
|
11
|
+
# def stripe
|
|
12
|
+
# deliver_event_for(
|
|
13
|
+
# OrderContract,
|
|
14
|
+
# event: :stripe_payment_succeeded,
|
|
15
|
+
# correlation_from: { order_id: params[:metadata][:order_id] },
|
|
16
|
+
# payload: params.to_unsafe_h
|
|
17
|
+
# )
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
module WebhookHandler
|
|
21
|
+
def deliver_event_for(contract_class, event:, correlation_from:, payload: nil, store: nil) # rubocop:disable Metrics/MethodLength
|
|
22
|
+
payload_data = payload || (respond_to?(:params) ? params.to_unsafe_h : {})
|
|
23
|
+
correlation = extract_correlation(correlation_from)
|
|
24
|
+
|
|
25
|
+
contract_class.deliver_event(
|
|
26
|
+
event,
|
|
27
|
+
correlation: correlation,
|
|
28
|
+
payload: payload_data,
|
|
29
|
+
store: store || Igniter.execution_store
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
head :ok
|
|
33
|
+
rescue Igniter::ResolutionError => e
|
|
34
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def extract_correlation(source)
|
|
40
|
+
case source
|
|
41
|
+
when Hash then source.transform_keys(&:to_sym)
|
|
42
|
+
when Symbol then { source => params[source] }
|
|
43
|
+
when Array then source.each_with_object({}) { |k, h| h[k.to_sym] = params[k] }
|
|
44
|
+
else source.to_h.transform_keys(&:to_sym)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require_relative "rails/railtie" if defined?(::Rails::Railtie)
|
|
5
|
+
require_relative "rails/contract_job"
|
|
6
|
+
require_relative "rails/webhook_concern"
|
|
7
|
+
require_relative "rails/cable_adapter"
|
|
8
|
+
|
|
9
|
+
module Igniter
|
|
10
|
+
module Rails
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Model
|
|
5
|
+
class AwaitNode < Node
|
|
6
|
+
attr_reader :event_name
|
|
7
|
+
|
|
8
|
+
def initialize(id:, name:, event_name:, path: nil, metadata: {})
|
|
9
|
+
super(
|
|
10
|
+
id: id,
|
|
11
|
+
kind: :await,
|
|
12
|
+
name: name,
|
|
13
|
+
path: path || name.to_s,
|
|
14
|
+
dependencies: [],
|
|
15
|
+
metadata: metadata
|
|
16
|
+
)
|
|
17
|
+
@event_name = event_name.to_sym
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Model
|
|
5
|
+
# Represents a node that executes a contract on a remote igniter-server node.
|
|
6
|
+
# The result is the outputs hash returned by the remote contract.
|
|
7
|
+
class RemoteNode < Node
|
|
8
|
+
attr_reader :contract_name, :node_url, :input_mapping, :timeout
|
|
9
|
+
|
|
10
|
+
def initialize(id:, name:, contract_name:, node_url:, input_mapping:, timeout: 30, path: nil, metadata: {})
|
|
11
|
+
super(
|
|
12
|
+
id: id,
|
|
13
|
+
kind: :remote,
|
|
14
|
+
name: name,
|
|
15
|
+
path: path || name.to_s,
|
|
16
|
+
dependencies: input_mapping.values.map(&:to_sym),
|
|
17
|
+
metadata: metadata
|
|
18
|
+
)
|
|
19
|
+
@contract_name = contract_name.to_s
|
|
20
|
+
@node_url = node_url.to_s
|
|
21
|
+
@input_mapping = input_mapping.transform_keys(&:to_sym).transform_values(&:to_sym).freeze
|
|
22
|
+
@timeout = Integer(timeout)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/igniter/model.rb
CHANGED
|
@@ -8,6 +8,8 @@ require_relative "model/composition_node"
|
|
|
8
8
|
require_relative "model/branch_node"
|
|
9
9
|
require_relative "model/collection_node"
|
|
10
10
|
require_relative "model/output_node"
|
|
11
|
+
require_relative "model/await_node"
|
|
12
|
+
require_relative "model/remote_node"
|
|
11
13
|
|
|
12
14
|
module Igniter
|
|
13
15
|
module Model
|
|
@@ -11,10 +11,10 @@ module Igniter
|
|
|
11
11
|
@runner_strategy = runner
|
|
12
12
|
@max_workers = max_workers
|
|
13
13
|
@store = store
|
|
14
|
-
@
|
|
14
|
+
@events = Events::Bus.new
|
|
15
|
+
@input_validator = InputValidator.new(compiled_graph, execution_id: @events.execution_id)
|
|
15
16
|
@inputs = @input_validator.normalize_initial_inputs(inputs)
|
|
16
17
|
@cache = Cache.new
|
|
17
|
-
@events = Events::Bus.new
|
|
18
18
|
@audit = Extensions::Auditing::Timeline.new(self)
|
|
19
19
|
@events.subscribe(@audit)
|
|
20
20
|
@resolver = Resolver.new(self)
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
module Igniter
|
|
4
4
|
module Runtime
|
|
5
5
|
class InputValidator
|
|
6
|
-
def initialize(compiled_graph)
|
|
6
|
+
def initialize(compiled_graph, execution_id: nil)
|
|
7
7
|
@compiled_graph = compiled_graph
|
|
8
|
+
@execution_id = execution_id
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def normalize_initial_inputs(raw_inputs)
|
|
@@ -106,7 +107,7 @@ module Igniter
|
|
|
106
107
|
hash.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = value }
|
|
107
108
|
end
|
|
108
109
|
|
|
109
|
-
def input_error(input_node, message)
|
|
110
|
+
def input_error(input_node, message) # rubocop:disable Metrics/MethodLength
|
|
110
111
|
InputError.new(
|
|
111
112
|
message,
|
|
112
113
|
context: {
|
|
@@ -114,7 +115,8 @@ module Igniter
|
|
|
114
115
|
node_id: input_node.id,
|
|
115
116
|
node_name: input_node.name,
|
|
116
117
|
node_path: input_node.path,
|
|
117
|
-
source_location: input_node.source_location
|
|
118
|
+
source_location: input_node.source_location,
|
|
119
|
+
execution_id: @execution_id
|
|
118
120
|
}
|
|
119
121
|
)
|
|
120
122
|
end
|
|
@@ -25,6 +25,10 @@ module Igniter
|
|
|
25
25
|
resolve_branch(node)
|
|
26
26
|
when :collection
|
|
27
27
|
resolve_collection(node)
|
|
28
|
+
when :await
|
|
29
|
+
resolve_await(node)
|
|
30
|
+
when :remote
|
|
31
|
+
resolve_remote(node)
|
|
28
32
|
else
|
|
29
33
|
raise ResolutionError, "Unsupported node kind: #{node.kind}"
|
|
30
34
|
end
|
|
@@ -59,6 +63,43 @@ module Igniter
|
|
|
59
63
|
NodeState.new(node: node, status: :succeeded, value: @execution.fetch_input!(node.name))
|
|
60
64
|
end
|
|
61
65
|
|
|
66
|
+
def resolve_await(node)
|
|
67
|
+
deferred = Runtime::DeferredResult.build(
|
|
68
|
+
payload: { event: node.event_name },
|
|
69
|
+
source_node: node.name,
|
|
70
|
+
waiting_on: node.name
|
|
71
|
+
)
|
|
72
|
+
raise PendingDependencyError.new(deferred, "Waiting for external event '#{node.event_name}'")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve_remote(node) # rubocop:disable Metrics/MethodLength
|
|
76
|
+
unless defined?(Igniter::Server::Client)
|
|
77
|
+
raise ResolutionError,
|
|
78
|
+
"remote: nodes require `require 'igniter/server'` (server integration not loaded)"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
inputs = node.input_mapping.each_with_object({}) do |(child_input, dep_name), memo|
|
|
82
|
+
memo[child_input] = resolve_dependency_value(dep_name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
client = Igniter::Server::Client.new(node.node_url, timeout: node.timeout)
|
|
86
|
+
response = client.execute(node.contract_name, inputs: inputs)
|
|
87
|
+
|
|
88
|
+
case response[:status]
|
|
89
|
+
when :succeeded
|
|
90
|
+
NodeState.new(node: node, status: :succeeded, value: response[:outputs])
|
|
91
|
+
when :failed
|
|
92
|
+
error_message = response.dig(:error, :message) || response.dig(:error, "message")
|
|
93
|
+
raise ResolutionError,
|
|
94
|
+
"Remote #{node.contract_name}@#{node.node_url}: #{error_message}"
|
|
95
|
+
else
|
|
96
|
+
raise ResolutionError,
|
|
97
|
+
"Remote #{node.contract_name}@#{node.node_url}: unexpected status '#{response[:status]}'"
|
|
98
|
+
end
|
|
99
|
+
rescue Igniter::Server::Client::ConnectionError => e
|
|
100
|
+
raise ResolutionError, "Cannot reach #{node.node_url}: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
|
|
62
103
|
def resolve_compute(node)
|
|
63
104
|
dependencies = node.dependencies.each_with_object({}) do |dependency_name, memo|
|
|
64
105
|
memo[dependency_name] = resolve_dependency_value(dependency_name)
|
|
@@ -310,7 +351,8 @@ module Igniter
|
|
|
310
351
|
node_id: node.id,
|
|
311
352
|
node_name: node.name,
|
|
312
353
|
node_path: node.path,
|
|
313
|
-
source_location: node.source_location
|
|
354
|
+
source_location: node.source_location,
|
|
355
|
+
execution_id: @execution.events.execution_id
|
|
314
356
|
}
|
|
315
357
|
)
|
|
316
358
|
end
|
|
@@ -12,7 +12,7 @@ module Igniter
|
|
|
12
12
|
@snapshot_column = snapshot_column.to_sym
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def save(snapshot)
|
|
15
|
+
def save(snapshot, correlation: nil, graph: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
16
16
|
execution_id = snapshot[:execution_id] || snapshot["execution_id"]
|
|
17
17
|
record = @record_class.find_or_initialize_by(@execution_id_column => execution_id)
|
|
18
18
|
record.public_send(:"#{@snapshot_column}=", JSON.generate(snapshot))
|
|
@@ -20,6 +20,18 @@ module Igniter
|
|
|
20
20
|
execution_id
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def find_by_correlation(graph:, correlation:)
|
|
24
|
+
raise NotImplementedError, "find_by_correlation is not implemented for ActiveRecordStore"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def list_all(graph: nil)
|
|
28
|
+
raise NotImplementedError, "list_all is not implemented for ActiveRecordStore"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def list_pending(graph: nil)
|
|
32
|
+
raise NotImplementedError, "list_pending is not implemented for ActiveRecordStore"
|
|
33
|
+
end
|
|
34
|
+
|
|
23
35
|
def fetch(execution_id)
|
|
24
36
|
record = @record_class.find_by(@execution_id_column => execution_id)
|
|
25
37
|
raise Igniter::ResolutionError, "No execution snapshot found for '#{execution_id}'" unless record
|
|
@@ -12,12 +12,51 @@ module Igniter
|
|
|
12
12
|
FileUtils.mkdir_p(@root)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def save(snapshot)
|
|
15
|
+
def save(snapshot, correlation: nil, graph: nil)
|
|
16
16
|
execution_id = snapshot[:execution_id] || snapshot["execution_id"]
|
|
17
|
-
|
|
17
|
+
data = snapshot.merge(
|
|
18
|
+
_graph: graph,
|
|
19
|
+
_correlation: correlation&.transform_keys(&:to_s)
|
|
20
|
+
).compact
|
|
21
|
+
File.write(path_for(execution_id), JSON.pretty_generate(data))
|
|
18
22
|
execution_id
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
def find_by_correlation(graph:, correlation:)
|
|
26
|
+
normalized = correlation.transform_keys(&:to_s)
|
|
27
|
+
each_snapshot do |data|
|
|
28
|
+
next unless data["_graph"] == graph
|
|
29
|
+
|
|
30
|
+
stored_corr = data["_correlation"] || {}
|
|
31
|
+
return data["execution_id"] if stored_corr == normalized
|
|
32
|
+
end
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def list_all(graph: nil)
|
|
37
|
+
results = []
|
|
38
|
+
each_snapshot do |data|
|
|
39
|
+
next if graph && data["_graph"] != graph
|
|
40
|
+
|
|
41
|
+
results << data["execution_id"]
|
|
42
|
+
end
|
|
43
|
+
results
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def list_pending(graph: nil)
|
|
47
|
+
results = []
|
|
48
|
+
each_snapshot do |data|
|
|
49
|
+
next if graph && data["_graph"] != graph
|
|
50
|
+
|
|
51
|
+
states = data["states"] || {}
|
|
52
|
+
pending = states.any? do |_name, state|
|
|
53
|
+
(state["status"] || state[:status]).to_s == "pending"
|
|
54
|
+
end
|
|
55
|
+
results << data["execution_id"] if pending
|
|
56
|
+
end
|
|
57
|
+
results
|
|
58
|
+
end
|
|
59
|
+
|
|
21
60
|
def fetch(execution_id)
|
|
22
61
|
JSON.parse(File.read(path_for(execution_id)))
|
|
23
62
|
rescue Errno::ENOENT
|
|
@@ -37,6 +76,15 @@ module Igniter
|
|
|
37
76
|
def path_for(execution_id)
|
|
38
77
|
File.join(@root, "#{execution_id}.json")
|
|
39
78
|
end
|
|
79
|
+
|
|
80
|
+
def each_snapshot(&block)
|
|
81
|
+
Dir.glob(File.join(@root, "*.json")).each do |file|
|
|
82
|
+
data = JSON.parse(File.read(file))
|
|
83
|
+
block.call(data)
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
end
|
|
40
88
|
end
|
|
41
89
|
end
|
|
42
90
|
end
|
|
@@ -6,15 +6,68 @@ module Igniter
|
|
|
6
6
|
class MemoryStore
|
|
7
7
|
def initialize
|
|
8
8
|
@snapshots = {}
|
|
9
|
+
@correlation_index = {}
|
|
9
10
|
@mutex = Mutex.new
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
def save(snapshot)
|
|
13
|
+
def save(snapshot, correlation: nil, graph: nil) # rubocop:disable Metrics/MethodLength
|
|
13
14
|
execution_id = snapshot[:execution_id] || snapshot["execution_id"]
|
|
14
|
-
@mutex.synchronize
|
|
15
|
+
@mutex.synchronize do
|
|
16
|
+
@snapshots[execution_id] = deep_copy(snapshot)
|
|
17
|
+
if graph
|
|
18
|
+
@correlation_index[execution_id] = {
|
|
19
|
+
graph: graph,
|
|
20
|
+
correlation: (correlation || {}).transform_keys(&:to_sym)
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
15
24
|
execution_id
|
|
16
25
|
end
|
|
17
26
|
|
|
27
|
+
def find_by_correlation(graph:, correlation:)
|
|
28
|
+
normalized = correlation.transform_keys(&:to_sym)
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
@correlation_index.each do |execution_id, entry|
|
|
31
|
+
next unless entry[:graph] == graph
|
|
32
|
+
return execution_id if entry[:correlation] == normalized
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def list_all(graph: nil)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
if graph
|
|
41
|
+
@correlation_index.select { |_id, entry| entry[:graph] == graph }.keys
|
|
42
|
+
else
|
|
43
|
+
@snapshots.keys
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def list_pending(graph: nil) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
49
|
+
ids = @mutex.synchronize do
|
|
50
|
+
if graph
|
|
51
|
+
@correlation_index.select { |_id, entry| entry[:graph] == graph }.keys
|
|
52
|
+
else
|
|
53
|
+
@snapshots.keys
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
ids.select do |id|
|
|
59
|
+
snapshot = @snapshots[id]
|
|
60
|
+
next false unless snapshot
|
|
61
|
+
|
|
62
|
+
states = snapshot[:states] || snapshot["states"] || {}
|
|
63
|
+
states.any? do |_name, state|
|
|
64
|
+
status = state[:status] || state["status"]
|
|
65
|
+
status.to_s == "pending"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
18
71
|
def fetch(execution_id)
|
|
19
72
|
@mutex.synchronize { deep_copy(@snapshots.fetch(execution_id)) }
|
|
20
73
|
rescue KeyError
|
|
@@ -11,12 +11,24 @@ module Igniter
|
|
|
11
11
|
@namespace = namespace
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def save(snapshot)
|
|
14
|
+
def save(snapshot, correlation: nil, graph: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
15
|
execution_id = snapshot[:execution_id] || snapshot["execution_id"]
|
|
16
16
|
@redis.set(redis_key(execution_id), JSON.generate(snapshot))
|
|
17
17
|
execution_id
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
def find_by_correlation(graph:, correlation:)
|
|
21
|
+
raise NotImplementedError, "find_by_correlation is not implemented for RedisStore"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def list_all(graph: nil)
|
|
25
|
+
raise NotImplementedError, "list_all is not implemented for RedisStore"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def list_pending(graph: nil)
|
|
29
|
+
raise NotImplementedError, "list_pending is not implemented for RedisStore"
|
|
30
|
+
end
|
|
31
|
+
|
|
20
32
|
def fetch(execution_id)
|
|
21
33
|
payload = @redis.get(redis_key(execution_id))
|
|
22
34
|
raise Igniter::ResolutionError, "No execution snapshot found for '#{execution_id}'" unless payload
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Igniter
|
|
8
|
+
module Server
|
|
9
|
+
# HTTP client for calling remote igniter-server nodes.
|
|
10
|
+
# Uses only stdlib (Net::HTTP + JSON), no external gems required.
|
|
11
|
+
class Client
|
|
12
|
+
class Error < Igniter::Server::Error; end
|
|
13
|
+
class ConnectionError < Error; end
|
|
14
|
+
class RemoteError < Error; end
|
|
15
|
+
|
|
16
|
+
def initialize(base_url, timeout: 30)
|
|
17
|
+
@base_url = base_url.chomp("/")
|
|
18
|
+
@timeout = timeout
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Execute a contract on the remote node synchronously.
|
|
22
|
+
#
|
|
23
|
+
# Returns a symbolized hash:
|
|
24
|
+
# { status: :succeeded, execution_id: "uuid", outputs: { result: 42 } }
|
|
25
|
+
# { status: :failed, execution_id: "uuid", error: { message: "..." } }
|
|
26
|
+
# { status: :pending, execution_id: "uuid", waiting_for: ["event"] }
|
|
27
|
+
def execute(contract_name, inputs: {})
|
|
28
|
+
response = post(
|
|
29
|
+
"/v1/contracts/#{uri_encode(contract_name)}/execute",
|
|
30
|
+
{ inputs: inputs }
|
|
31
|
+
)
|
|
32
|
+
symbolize_response(response)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Deliver an event to a pending distributed workflow on the remote node.
|
|
36
|
+
def deliver_event(contract_name, event:, correlation:, payload: {})
|
|
37
|
+
response = post(
|
|
38
|
+
"/v1/contracts/#{uri_encode(contract_name)}/events",
|
|
39
|
+
{ event: event, correlation: correlation, payload: payload }
|
|
40
|
+
)
|
|
41
|
+
symbolize_response(response)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Fetch execution status by ID.
|
|
45
|
+
def status(execution_id)
|
|
46
|
+
symbolize_response(get("/v1/executions/#{uri_encode(execution_id)}"))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check remote node health.
|
|
50
|
+
def health
|
|
51
|
+
get("/v1/health")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def post(path, body)
|
|
57
|
+
uri = build_uri(path)
|
|
58
|
+
http = build_http(uri)
|
|
59
|
+
req = Net::HTTP::Post.new(uri.path, json_headers)
|
|
60
|
+
req.body = JSON.generate(body)
|
|
61
|
+
parse_response(http.request(req))
|
|
62
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e
|
|
63
|
+
raise ConnectionError, "Cannot connect to #{@base_url}: #{e.message}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def get(path)
|
|
67
|
+
uri = build_uri(path)
|
|
68
|
+
http = build_http(uri)
|
|
69
|
+
req = Net::HTTP::Get.new(uri.path, json_headers)
|
|
70
|
+
parse_response(http.request(req))
|
|
71
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e
|
|
72
|
+
raise ConnectionError, "Cannot connect to #{@base_url}: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_uri(path)
|
|
76
|
+
URI.parse("#{@base_url}#{path}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_http(uri)
|
|
80
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
81
|
+
http.use_ssl = uri.scheme == "https"
|
|
82
|
+
http.read_timeout = @timeout
|
|
83
|
+
http.open_timeout = 10
|
|
84
|
+
http
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def json_headers
|
|
88
|
+
{ "Content-Type" => "application/json", "Accept" => "application/json" }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_response(response)
|
|
92
|
+
body = begin
|
|
93
|
+
JSON.parse(response.body.to_s)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
{}
|
|
96
|
+
end
|
|
97
|
+
raise RemoteError, "Remote error #{response.code}: #{body["error"]}" unless response.is_a?(Net::HTTPSuccess)
|
|
98
|
+
|
|
99
|
+
body
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def symbolize_response(hash)
|
|
103
|
+
{
|
|
104
|
+
status: hash["status"]&.to_sym,
|
|
105
|
+
execution_id: hash["execution_id"],
|
|
106
|
+
outputs: symbolize_keys(hash["outputs"] || {}),
|
|
107
|
+
waiting_for: hash["waiting_for"] || [],
|
|
108
|
+
error: hash["error"]
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def symbolize_keys(hash)
|
|
113
|
+
return hash unless hash.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
hash.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def uri_encode(str)
|
|
119
|
+
URI.encode_uri_component(str.to_s)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Server
|
|
5
|
+
class Config
|
|
6
|
+
attr_accessor :host, :port, :store, :logger
|
|
7
|
+
attr_reader :registry
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@host = "0.0.0.0"
|
|
11
|
+
@port = 4567
|
|
12
|
+
@store = Igniter::Runtime::Stores::MemoryStore.new
|
|
13
|
+
@registry = Registry.new
|
|
14
|
+
@logger = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def register(name, contract_class)
|
|
18
|
+
@registry.register(name, contract_class)
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def contracts=(hash)
|
|
23
|
+
hash.each { |name, klass| register(name.to_s, klass) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|