igniter 0.4.0 → 0.4.3
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/CHANGELOG.md +25 -0
- data/README.md +238 -218
- data/docs/LLM_V1.md +335 -0
- data/docs/PATTERNS.md +189 -0
- data/docs/SERVER_V1.md +313 -0
- data/examples/README.md +129 -0
- data/examples/agents.rb +150 -0
- data/examples/differential.rb +161 -0
- data/examples/distributed_server.rb +94 -0
- data/examples/effects.rb +184 -0
- data/examples/invariants.rb +179 -0
- data/examples/order_pipeline.rb +163 -0
- data/examples/provenance.rb +122 -0
- data/examples/saga.rb +110 -0
- data/lib/igniter/agent/mailbox.rb +96 -0
- data/lib/igniter/agent/message.rb +21 -0
- data/lib/igniter/agent/ref.rb +86 -0
- data/lib/igniter/agent/runner.rb +129 -0
- data/lib/igniter/agent/state_holder.rb +23 -0
- data/lib/igniter/agent.rb +155 -0
- data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
- data/lib/igniter/differential/divergence.rb +29 -0
- data/lib/igniter/differential/formatter.rb +96 -0
- data/lib/igniter/differential/report.rb +86 -0
- data/lib/igniter/differential/runner.rb +130 -0
- data/lib/igniter/differential.rb +51 -0
- data/lib/igniter/dsl/contract_builder.rb +32 -0
- data/lib/igniter/effect.rb +91 -0
- data/lib/igniter/effect_registry.rb +78 -0
- data/lib/igniter/errors.rb +11 -1
- data/lib/igniter/execution_report/builder.rb +54 -0
- data/lib/igniter/execution_report/formatter.rb +50 -0
- data/lib/igniter/execution_report/node_entry.rb +24 -0
- data/lib/igniter/execution_report/report.rb +65 -0
- data/lib/igniter/execution_report.rb +32 -0
- data/lib/igniter/extensions/differential.rb +114 -0
- data/lib/igniter/extensions/execution_report.rb +27 -0
- data/lib/igniter/extensions/invariants.rb +116 -0
- data/lib/igniter/extensions/provenance.rb +45 -0
- data/lib/igniter/extensions/saga.rb +74 -0
- data/lib/igniter/integrations/agents.rb +18 -0
- data/lib/igniter/invariant.rb +50 -0
- data/lib/igniter/model/effect_node.rb +37 -0
- data/lib/igniter/model.rb +1 -0
- data/lib/igniter/property_testing/formatter.rb +66 -0
- data/lib/igniter/property_testing/generators.rb +115 -0
- data/lib/igniter/property_testing/result.rb +45 -0
- data/lib/igniter/property_testing/run.rb +43 -0
- data/lib/igniter/property_testing/runner.rb +47 -0
- data/lib/igniter/property_testing.rb +64 -0
- data/lib/igniter/provenance/builder.rb +97 -0
- data/lib/igniter/provenance/lineage.rb +82 -0
- data/lib/igniter/provenance/node_trace.rb +65 -0
- data/lib/igniter/provenance/text_formatter.rb +70 -0
- data/lib/igniter/provenance.rb +29 -0
- data/lib/igniter/registry.rb +67 -0
- data/lib/igniter/runtime/resolver.rb +15 -0
- data/lib/igniter/saga/compensation.rb +31 -0
- data/lib/igniter/saga/compensation_record.rb +20 -0
- data/lib/igniter/saga/executor.rb +85 -0
- data/lib/igniter/saga/formatter.rb +49 -0
- data/lib/igniter/saga/result.rb +47 -0
- data/lib/igniter/saga.rb +56 -0
- data/lib/igniter/stream_loop.rb +80 -0
- data/lib/igniter/supervisor.rb +167 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +10 -0
- metadata +57 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Order Pipeline — guard + collection + branch + export
|
|
4
|
+
#
|
|
5
|
+
# Flow:
|
|
6
|
+
# 1. guard — require in_stock == true before proceeding
|
|
7
|
+
# 2. collection — compute subtotal per line item (LineItemContract per item)
|
|
8
|
+
# 3. compute — aggregate item subtotals into order_subtotal
|
|
9
|
+
# 4. branch — choose domestic or international shipping strategy
|
|
10
|
+
# 5. export — pull shipping_cost and eta out of the selected branch
|
|
11
|
+
# 6. output — order_subtotal, shipping_cost, eta, grand_total
|
|
12
|
+
#
|
|
13
|
+
# Run: ruby examples/order_pipeline.rb
|
|
14
|
+
|
|
15
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
16
|
+
require "igniter"
|
|
17
|
+
|
|
18
|
+
# ── Line Item ────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
class LineItemContract < Igniter::Contract
|
|
21
|
+
define do
|
|
22
|
+
input :sku, type: :string
|
|
23
|
+
input :quantity, type: :numeric
|
|
24
|
+
input :unit_price, type: :numeric
|
|
25
|
+
|
|
26
|
+
compute :subtotal, depends_on: %i[quantity unit_price] do |quantity:, unit_price:|
|
|
27
|
+
(quantity * unit_price).round(2)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
output :sku
|
|
31
|
+
output :subtotal
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# ── Shipping Strategies ──────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
class DomesticShippingContract < Igniter::Contract
|
|
38
|
+
define do
|
|
39
|
+
input :country, type: :string
|
|
40
|
+
input :subtotal, type: :numeric
|
|
41
|
+
|
|
42
|
+
compute :shipping_cost, depends_on: :subtotal do |subtotal:|
|
|
43
|
+
subtotal >= 100 ? 0.0 : 9.99
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
compute :eta, depends_on: :country do |country:|
|
|
47
|
+
country == "US" ? "2-3 business days" : "3-5 business days"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
output :shipping_cost
|
|
51
|
+
output :eta
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class InternationalShippingContract < Igniter::Contract
|
|
56
|
+
define do
|
|
57
|
+
input :country, type: :string
|
|
58
|
+
input :subtotal, type: :numeric
|
|
59
|
+
|
|
60
|
+
compute :shipping_cost, depends_on: :subtotal do |subtotal:|
|
|
61
|
+
(subtotal * 0.15).round(2)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
compute :eta, depends_on: :country do |**|
|
|
65
|
+
"7-14 business days"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
output :shipping_cost
|
|
69
|
+
output :eta
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ── Order Pipeline ────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
class OrderPipelineContract < Igniter::Contract
|
|
76
|
+
define do # rubocop:disable Metrics/BlockLength
|
|
77
|
+
input :line_items, type: :array
|
|
78
|
+
input :country, type: :string
|
|
79
|
+
input :in_stock, type: :boolean
|
|
80
|
+
|
|
81
|
+
# Guard fires before anything else is computed
|
|
82
|
+
guard :stock_available,
|
|
83
|
+
with: :in_stock,
|
|
84
|
+
eq: true,
|
|
85
|
+
message: "Order cannot be placed: items are out of stock"
|
|
86
|
+
|
|
87
|
+
# Gate node: depends on the guard — if guard fails, this node fails too,
|
|
88
|
+
# which blocks the collection and all downstream nodes from running.
|
|
89
|
+
compute :gated_items, depends_on: %i[line_items stock_available] do |line_items:, **|
|
|
90
|
+
line_items
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Fan-out: one LineItemContract per item hash
|
|
94
|
+
collection :items,
|
|
95
|
+
with: :gated_items,
|
|
96
|
+
each: LineItemContract,
|
|
97
|
+
key: :sku,
|
|
98
|
+
mode: :collect
|
|
99
|
+
|
|
100
|
+
# Sum succeeded item subtotals
|
|
101
|
+
compute :order_subtotal, depends_on: :items do |items:|
|
|
102
|
+
items.successes.values.sum { |item| item.result.subtotal }.round(2)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Route to the right shipping strategy based on country
|
|
106
|
+
branch :shipping,
|
|
107
|
+
with: :country,
|
|
108
|
+
inputs: { country: :country, subtotal: :order_subtotal } do
|
|
109
|
+
on "US", contract: DomesticShippingContract
|
|
110
|
+
on "CA", contract: DomesticShippingContract
|
|
111
|
+
default contract: InternationalShippingContract
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Lift shipping outputs up to the top-level graph
|
|
115
|
+
# export calls output internally, so no separate output declarations needed
|
|
116
|
+
export :shipping_cost, :eta, from: :shipping
|
|
117
|
+
|
|
118
|
+
compute :grand_total, depends_on: %i[order_subtotal shipping_cost] do |order_subtotal:, shipping_cost:|
|
|
119
|
+
(order_subtotal + shipping_cost).round(2)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
output :items
|
|
123
|
+
output :order_subtotal
|
|
124
|
+
output :grand_total
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ── Run ───────────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
line_items = [
|
|
131
|
+
{ sku: "ruby-book", quantity: 2, unit_price: 29.99 },
|
|
132
|
+
{ sku: "rails-course", quantity: 1, unit_price: 49.99 },
|
|
133
|
+
{ sku: "keyboard", quantity: 1, unit_price: 89.99 }
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
# US order — free shipping over $100
|
|
137
|
+
us_order = OrderPipelineContract.new(line_items: line_items, country: "US", in_stock: true)
|
|
138
|
+
us_order.resolve_all
|
|
139
|
+
|
|
140
|
+
puts "=== US Order ==="
|
|
141
|
+
puts "items_summary=#{us_order.result.items.summary.inspect}"
|
|
142
|
+
puts "order_subtotal=#{us_order.result.order_subtotal}"
|
|
143
|
+
puts "shipping_cost=#{us_order.result.shipping_cost}"
|
|
144
|
+
puts "eta=#{us_order.result.eta}"
|
|
145
|
+
puts "grand_total=#{us_order.result.grand_total}"
|
|
146
|
+
|
|
147
|
+
# International order — 15% shipping
|
|
148
|
+
intl_order = OrderPipelineContract.new(line_items: line_items, country: "DE", in_stock: true)
|
|
149
|
+
intl_order.resolve_all
|
|
150
|
+
|
|
151
|
+
puts "\n=== International Order (DE) ==="
|
|
152
|
+
puts "shipping_cost=#{intl_order.result.shipping_cost}"
|
|
153
|
+
puts "eta=#{intl_order.result.eta}"
|
|
154
|
+
puts "grand_total=#{intl_order.result.grand_total}"
|
|
155
|
+
|
|
156
|
+
# Out of stock — guard fires and raises ResolutionError
|
|
157
|
+
oos_order = OrderPipelineContract.new(line_items: line_items, country: "US", in_stock: false)
|
|
158
|
+
begin
|
|
159
|
+
oos_order.resolve_all
|
|
160
|
+
rescue Igniter::ResolutionError => e
|
|
161
|
+
puts "\n=== Out of Stock ==="
|
|
162
|
+
puts "error=#{e.message.split(" [").first}"
|
|
163
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Provenance — data lineage for Igniter contracts
|
|
4
|
+
#
|
|
5
|
+
# After a contract resolves, provenance answers:
|
|
6
|
+
# "How was this output computed, and which inputs influenced it?"
|
|
7
|
+
#
|
|
8
|
+
# Run: ruby examples/provenance.rb
|
|
9
|
+
|
|
10
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
11
|
+
require "igniter"
|
|
12
|
+
require "igniter/extensions/provenance"
|
|
13
|
+
|
|
14
|
+
# ── Contracts ─────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
class TierDiscountContract < Igniter::Contract
|
|
17
|
+
TIERS = { bronze: 0, silver: 10, gold: 20, platinum: 30 }.freeze
|
|
18
|
+
|
|
19
|
+
define do
|
|
20
|
+
input :user_tier, type: :symbol
|
|
21
|
+
|
|
22
|
+
compute :discount_pct, depends_on: :user_tier do |user_tier:|
|
|
23
|
+
TierDiscountContract::TIERS.fetch(user_tier, 0)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
output :discount_pct
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class PricingContract < Igniter::Contract
|
|
31
|
+
define do
|
|
32
|
+
input :base_price, type: :numeric
|
|
33
|
+
input :quantity, type: :numeric
|
|
34
|
+
input :user_tier, type: :symbol
|
|
35
|
+
|
|
36
|
+
compute :unit_price, depends_on: :base_price do |base_price:|
|
|
37
|
+
(base_price * 1.08).round(2) # +8% margin
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
compute :subtotal, depends_on: %i[unit_price quantity] do |unit_price:, quantity:|
|
|
41
|
+
(unit_price * quantity).round(2)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
compose :tier_info, contract: TierDiscountContract,
|
|
45
|
+
inputs: { user_tier: :user_tier }
|
|
46
|
+
|
|
47
|
+
compute :discount_amount, depends_on: %i[subtotal tier_info] do |subtotal:, tier_info:|
|
|
48
|
+
(subtotal * tier_info.discount_pct / 100.0).round(2)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
compute :grand_total, depends_on: %i[subtotal discount_amount] do |subtotal:, discount_amount:|
|
|
52
|
+
(subtotal - discount_amount).round(2)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
output :unit_price
|
|
56
|
+
output :subtotal
|
|
57
|
+
output :discount_amount
|
|
58
|
+
output :grand_total
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# ── Run ───────────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
puts "=== Provenance Demo ==="
|
|
65
|
+
puts
|
|
66
|
+
|
|
67
|
+
contract = PricingContract.new(
|
|
68
|
+
base_price: 100.0,
|
|
69
|
+
quantity: 3,
|
|
70
|
+
user_tier: :gold
|
|
71
|
+
)
|
|
72
|
+
contract.resolve_all
|
|
73
|
+
|
|
74
|
+
puts "grand_total=#{contract.result.grand_total}"
|
|
75
|
+
puts
|
|
76
|
+
|
|
77
|
+
# ── ASCII tree ────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
puts "--- explain(:grand_total) ---"
|
|
80
|
+
puts contract.explain(:grand_total)
|
|
81
|
+
puts
|
|
82
|
+
|
|
83
|
+
# ── Query API ─────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
lin = contract.lineage(:grand_total)
|
|
86
|
+
|
|
87
|
+
puts "--- contributing_inputs ---"
|
|
88
|
+
lin.contributing_inputs.each do |input_name, val|
|
|
89
|
+
puts " #{input_name} = #{val.inspect}"
|
|
90
|
+
end
|
|
91
|
+
puts
|
|
92
|
+
|
|
93
|
+
puts "--- sensitive_to? ---"
|
|
94
|
+
puts "sensitive_to?(:base_price) = #{lin.sensitive_to?(:base_price)}"
|
|
95
|
+
puts "sensitive_to?(:quantity) = #{lin.sensitive_to?(:quantity)}"
|
|
96
|
+
puts "sensitive_to?(:user_tier) = #{lin.sensitive_to?(:user_tier)}"
|
|
97
|
+
puts "sensitive_to?(:unknown_key) = #{lin.sensitive_to?(:unknown_key)}"
|
|
98
|
+
puts
|
|
99
|
+
|
|
100
|
+
puts "--- path_to(:base_price) ---"
|
|
101
|
+
puts lin.path_to(:base_price).inspect
|
|
102
|
+
puts
|
|
103
|
+
|
|
104
|
+
puts "--- path_to(:user_tier) ---"
|
|
105
|
+
puts lin.path_to(:user_tier).inspect
|
|
106
|
+
puts
|
|
107
|
+
|
|
108
|
+
# ── Composition output lineage ────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
puts "--- explain(:discount_amount) ---"
|
|
111
|
+
puts contract.explain(:discount_amount)
|
|
112
|
+
puts
|
|
113
|
+
|
|
114
|
+
# ── Structured lineage (to_h) ─────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
puts "--- lineage(:subtotal).to_h (keys only) ---"
|
|
117
|
+
h = contract.lineage(:subtotal).to_h
|
|
118
|
+
puts "node=#{h[:node]}, kind=#{h[:kind]}, value=#{h[:value]}"
|
|
119
|
+
puts "contributing=#{h[:contributing].keys.inspect}"
|
|
120
|
+
puts
|
|
121
|
+
|
|
122
|
+
puts "done=true"
|
data/examples/saga.rb
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Saga Pattern — compensating transactions for Igniter contracts.
|
|
4
|
+
#
|
|
5
|
+
# When a step fails mid-execution, the saga system automatically runs
|
|
6
|
+
# compensating actions for all previously completed steps in reverse order.
|
|
7
|
+
#
|
|
8
|
+
# Run: ruby examples/saga.rb
|
|
9
|
+
|
|
10
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
11
|
+
require "igniter"
|
|
12
|
+
require "igniter/extensions/saga"
|
|
13
|
+
require "igniter/extensions/execution_report"
|
|
14
|
+
|
|
15
|
+
# ── Contracts ─────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
class OrderWorkflow < Igniter::Contract
|
|
18
|
+
define do
|
|
19
|
+
input :order_id, type: :string
|
|
20
|
+
input :amount, type: :numeric
|
|
21
|
+
|
|
22
|
+
compute :reserve_stock, depends_on: :order_id do |order_id:|
|
|
23
|
+
puts " [forward] reserve_stock for order #{order_id}"
|
|
24
|
+
{ reservation_id: "rsv-#{order_id}" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
compute :charge_card, depends_on: %i[order_id amount reserve_stock] do |order_id:, amount:, **|
|
|
28
|
+
puts " [forward] charge_card: $#{amount}"
|
|
29
|
+
raise "Card declined: amount $#{amount} exceeds limit" if amount > 500
|
|
30
|
+
|
|
31
|
+
{ charge_id: "chg-#{order_id}", amount: amount }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
compute :send_confirmation, depends_on: %i[reserve_stock charge_card] do |charge_card:, **|
|
|
35
|
+
puts " [forward] send_confirmation for charge #{charge_card[:charge_id]}"
|
|
36
|
+
{ sent: true }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
output :reserve_stock
|
|
40
|
+
output :charge_card
|
|
41
|
+
output :send_confirmation
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
compensate :charge_card do |_inputs:, value:|
|
|
45
|
+
puts " [compensate] refunding charge #{value[:charge_id]}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
compensate :reserve_stock do |_inputs:, value:|
|
|
49
|
+
puts " [compensate] releasing reservation #{value[:reservation_id]}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
puts "=== Saga Demo ==="
|
|
54
|
+
puts
|
|
55
|
+
|
|
56
|
+
# ── 1. Successful saga ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
puts "--- 1. Successful execution (amount=$100) ---"
|
|
59
|
+
result_ok = OrderWorkflow.new(order_id: "ord-001", amount: 100.0).resolve_saga
|
|
60
|
+
puts result_ok.explain
|
|
61
|
+
puts
|
|
62
|
+
|
|
63
|
+
# ── 2. Failed saga — compensations triggered ───────────────────────────────────
|
|
64
|
+
|
|
65
|
+
puts "--- 2. Failed execution (amount=$999 — exceeds limit) ---"
|
|
66
|
+
result_fail = OrderWorkflow.new(order_id: "ord-002", amount: 999.0).resolve_saga
|
|
67
|
+
puts result_fail.explain
|
|
68
|
+
puts
|
|
69
|
+
|
|
70
|
+
# ── 3. Result query API ────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
puts "--- 3. Result query API ---"
|
|
73
|
+
puts "success? = #{result_fail.success?}"
|
|
74
|
+
puts "failed_node = #{result_fail.failed_node.inspect}"
|
|
75
|
+
puts "error = #{result_fail.error.message}"
|
|
76
|
+
puts "compensations_ran = #{result_fail.compensations.map(&:node_name).inspect}"
|
|
77
|
+
puts "all compensations ok? #{result_fail.compensations.all?(&:success?)}"
|
|
78
|
+
puts
|
|
79
|
+
|
|
80
|
+
# ── 4. Execution report ────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
puts "--- 4. execution_report (after failed saga) ---"
|
|
83
|
+
failed_contract = OrderWorkflow.new(order_id: "ord-003", amount: 999.0)
|
|
84
|
+
begin
|
|
85
|
+
failed_contract.resolve_all
|
|
86
|
+
rescue Igniter::Error
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
puts failed_contract.execution_report.explain
|
|
90
|
+
puts
|
|
91
|
+
|
|
92
|
+
# ── 5. Execution report on successful contract ────────────────────────────────
|
|
93
|
+
|
|
94
|
+
puts "--- 5. execution_report (successful) ---"
|
|
95
|
+
ok_contract = OrderWorkflow.new(order_id: "ord-004", amount: 50.0)
|
|
96
|
+
ok_contract.resolve_all
|
|
97
|
+
puts ok_contract.execution_report.explain
|
|
98
|
+
puts
|
|
99
|
+
|
|
100
|
+
# ── 6. to_h / structured output ───────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
puts "--- 6. result.to_h ---"
|
|
103
|
+
h = result_fail.to_h
|
|
104
|
+
puts "success: #{h[:success]}"
|
|
105
|
+
puts "failed_node: #{h[:failed_node].inspect}"
|
|
106
|
+
puts "error: #{h[:error]}"
|
|
107
|
+
puts "compensations: #{h[:compensations].map { |c| [c[:node], c[:success]] }.inspect}"
|
|
108
|
+
puts
|
|
109
|
+
|
|
110
|
+
puts "done=true"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
class Agent
|
|
5
|
+
# Thread-safe bounded queue for passing Messages between threads.
|
|
6
|
+
#
|
|
7
|
+
# Overflow policies (applied when capacity is reached):
|
|
8
|
+
# :block — block the caller until space is available (default)
|
|
9
|
+
# :drop_oldest — discard the oldest message and enqueue the new one
|
|
10
|
+
# :drop_newest — discard the incoming message silently
|
|
11
|
+
# :error — raise Igniter::Agent::MailboxFullError
|
|
12
|
+
class Mailbox
|
|
13
|
+
DEFAULT_CAPACITY = 256
|
|
14
|
+
|
|
15
|
+
def initialize(capacity: DEFAULT_CAPACITY, overflow: :block)
|
|
16
|
+
@capacity = capacity
|
|
17
|
+
@overflow = overflow
|
|
18
|
+
@queue = []
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@not_empty = ConditionVariable.new
|
|
21
|
+
@not_full = ConditionVariable.new
|
|
22
|
+
@closed = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Enqueue a message. Returns self (chainable).
|
|
26
|
+
def push(message) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
return self if @closed
|
|
29
|
+
|
|
30
|
+
if @queue.size >= @capacity
|
|
31
|
+
case @overflow
|
|
32
|
+
when :block
|
|
33
|
+
@not_full.wait(@mutex) while @queue.size >= @capacity && !@closed
|
|
34
|
+
return self if @closed
|
|
35
|
+
when :drop_oldest
|
|
36
|
+
@queue.shift
|
|
37
|
+
when :drop_newest
|
|
38
|
+
return self
|
|
39
|
+
when :error
|
|
40
|
+
raise MailboxFullError, "Mailbox full (capacity=#{@capacity})"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@queue << message
|
|
45
|
+
@not_empty.signal
|
|
46
|
+
end
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Dequeue the next message. Blocks until a message arrives or the mailbox
|
|
51
|
+
# is closed. Returns nil if closed and empty, or if +timeout+ expires.
|
|
52
|
+
def pop(timeout: nil) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
if timeout
|
|
55
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
56
|
+
while @queue.empty? && !@closed
|
|
57
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
58
|
+
return nil if remaining <= 0
|
|
59
|
+
|
|
60
|
+
@not_empty.wait(@mutex, remaining)
|
|
61
|
+
end
|
|
62
|
+
else
|
|
63
|
+
@not_empty.wait(@mutex) while @queue.empty? && !@closed
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
return nil if @queue.empty?
|
|
67
|
+
|
|
68
|
+
msg = @queue.shift
|
|
69
|
+
@not_full.signal
|
|
70
|
+
msg
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Close the mailbox. Blocked callers in +pop+ wake up and return nil.
|
|
75
|
+
def close
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
@closed = true
|
|
78
|
+
@not_empty.broadcast
|
|
79
|
+
@not_full.broadcast
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def closed?
|
|
84
|
+
@mutex.synchronize { @closed }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def size
|
|
88
|
+
@mutex.synchronize { @queue.size }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def empty?
|
|
92
|
+
@mutex.synchronize { @queue.empty? }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
class Agent
|
|
5
|
+
# Immutable message passed between agents through a Mailbox.
|
|
6
|
+
#
|
|
7
|
+
# type — Symbol identifying the message kind (e.g. :increment, :get)
|
|
8
|
+
# payload — frozen Hash of message data (default: {})
|
|
9
|
+
# reply_to — one-shot Mailbox for sync request-reply (nil for async)
|
|
10
|
+
class Message
|
|
11
|
+
attr_reader :type, :payload, :reply_to
|
|
12
|
+
|
|
13
|
+
def initialize(type:, payload: {}, reply_to: nil)
|
|
14
|
+
@type = type.to_sym
|
|
15
|
+
@payload = (payload || {}).freeze
|
|
16
|
+
@reply_to = reply_to
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
class Agent
|
|
5
|
+
# External handle to a running agent.
|
|
6
|
+
#
|
|
7
|
+
# Callers interact with agents exclusively through Ref — they never touch
|
|
8
|
+
# the Mailbox, StateHolder, or Thread directly. This allows the Supervisor
|
|
9
|
+
# to swap out internals on restart without invalidating existing Ref objects.
|
|
10
|
+
class Ref
|
|
11
|
+
def initialize(thread:, mailbox:, state_holder:)
|
|
12
|
+
@thread = thread
|
|
13
|
+
@mailbox = mailbox
|
|
14
|
+
@state_holder = state_holder
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Asynchronous fire-and-forget. Returns self.
|
|
19
|
+
def send(type, payload = {})
|
|
20
|
+
mailbox.push(Message.new(type: type.to_sym, payload: payload))
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Synchronous request-reply. Blocks until the handler responds or timeout
|
|
25
|
+
# elapses. Raises Igniter::Agent::TimeoutError on timeout.
|
|
26
|
+
def call(type, payload = {}, timeout: 5)
|
|
27
|
+
reply_box = Mailbox.new(capacity: 1, overflow: :drop_newest)
|
|
28
|
+
mailbox.push(Message.new(type: type.to_sym, payload: payload, reply_to: reply_box))
|
|
29
|
+
reply = reply_box.pop(timeout: timeout)
|
|
30
|
+
raise TimeoutError, "Agent did not reply within #{timeout}s" unless reply
|
|
31
|
+
|
|
32
|
+
reply.payload[:value]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Request graceful shutdown. Closes the mailbox so the runner exits after
|
|
36
|
+
# processing any in-flight message. Blocks until the thread finishes.
|
|
37
|
+
def stop(timeout: 5)
|
|
38
|
+
mailbox.close
|
|
39
|
+
thread&.join(timeout)
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Forcefully terminate the agent thread.
|
|
44
|
+
def kill
|
|
45
|
+
thread&.kill
|
|
46
|
+
mailbox.close
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def alive?
|
|
51
|
+
thread&.alive? || false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Read the current state snapshot without blocking the agent.
|
|
55
|
+
def state
|
|
56
|
+
state_holder.get
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Supervisor-internal: swap out internals when the agent restarts.
|
|
60
|
+
# Callers keep the same Ref object; the new thread/mailbox/state_holder
|
|
61
|
+
# are transparently injected.
|
|
62
|
+
def rebind(thread:, mailbox:, state_holder:)
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
@thread = thread
|
|
65
|
+
@mailbox = mailbox
|
|
66
|
+
@state_holder = state_holder
|
|
67
|
+
end
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def thread
|
|
74
|
+
@mutex.synchronize { @thread }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def mailbox
|
|
78
|
+
@mutex.synchronize { @mailbox }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def state_holder
|
|
82
|
+
@mutex.synchronize { @state_holder }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|