igniter-ledger 0.5.2
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 +7 -0
- data/README.md +481 -0
- data/examples/intelligent_ledger/availability_boundary_ledger.rb +1190 -0
- data/examples/intelligent_ledger/availability_deriver.rb +150 -0
- data/examples/intelligent_ledger/availability_ledger.rb +197 -0
- data/examples/intelligent_ledger/ledger_boundary.rb +180 -0
- data/examples/store_poc.rb +45 -0
- data/exe/igniter-ledger-server +111 -0
- data/exe/igniter-store-server +6 -0
- data/ext/igniter_store_native/Cargo.toml +28 -0
- data/ext/igniter_store_native/extconf.rb +6 -0
- data/ext/igniter_store_native/src/fact.rs +303 -0
- data/ext/igniter_store_native/src/fact_log.rs +180 -0
- data/ext/igniter_store_native/src/file_backend.rs +91 -0
- data/ext/igniter_store_native/src/lib.rs +55 -0
- data/lib/igniter/ledger.rb +7 -0
- data/lib/igniter/store/access_path.rb +84 -0
- data/lib/igniter/store/change_event.rb +65 -0
- data/lib/igniter/store/changefeed_buffer.rb +585 -0
- data/lib/igniter/store/codecs.rb +253 -0
- data/lib/igniter/store/contractable_receipt_sink.rb +172 -0
- data/lib/igniter/store/fact.rb +121 -0
- data/lib/igniter/store/fact_log.rb +103 -0
- data/lib/igniter/store/file_backend.rb +269 -0
- data/lib/igniter/store/http_adapter.rb +413 -0
- data/lib/igniter/store/igniter_store.rb +838 -0
- data/lib/igniter/store/mcp_adapter.rb +403 -0
- data/lib/igniter/store/native.rb +80 -0
- data/lib/igniter/store/network_backend.rb +159 -0
- data/lib/igniter/store/protocol/handlers/access_path_handler.rb +38 -0
- data/lib/igniter/store/protocol/handlers/command_handler.rb +59 -0
- data/lib/igniter/store/protocol/handlers/derivation_handler.rb +27 -0
- data/lib/igniter/store/protocol/handlers/effect_handler.rb +65 -0
- data/lib/igniter/store/protocol/handlers/history_handler.rb +24 -0
- data/lib/igniter/store/protocol/handlers/projection_handler.rb +41 -0
- data/lib/igniter/store/protocol/handlers/relation_handler.rb +43 -0
- data/lib/igniter/store/protocol/handlers/store_handler.rb +24 -0
- data/lib/igniter/store/protocol/handlers/subscription_handler.rb +24 -0
- data/lib/igniter/store/protocol/interpreter.rb +447 -0
- data/lib/igniter/store/protocol/receipt.rb +96 -0
- data/lib/igniter/store/protocol/sync_profile.rb +53 -0
- data/lib/igniter/store/protocol/wire_envelope.rb +214 -0
- data/lib/igniter/store/protocol.rb +27 -0
- data/lib/igniter/store/read_cache.rb +163 -0
- data/lib/igniter/store/schema_graph.rb +248 -0
- data/lib/igniter/store/segmented_file_backend.rb +699 -0
- data/lib/igniter/store/server_config.rb +55 -0
- data/lib/igniter/store/server_logger.rb +64 -0
- data/lib/igniter/store/server_metrics.rb +222 -0
- data/lib/igniter/store/store_server.rb +597 -0
- data/lib/igniter/store/subscription_registry.rb +73 -0
- data/lib/igniter/store/tbackend_adapter_descriptor.rb +307 -0
- data/lib/igniter/store/tcp_adapter.rb +127 -0
- data/lib/igniter/store/wire_protocol.rb +42 -0
- data/lib/igniter/store.rb +64 -0
- data/lib/igniter-ledger.rb +4 -0
- data/lib/igniter-store.rb +5 -0
- metadata +212 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module Igniter
|
|
7
|
+
module Store
|
|
8
|
+
module IntelligentLedger
|
|
9
|
+
# Pure computation: derives an AvailabilitySnapshot from base facts.
|
|
10
|
+
# No store dependency — all IO lives in AvailabilityLedger.
|
|
11
|
+
#
|
|
12
|
+
# Template format (value Hash):
|
|
13
|
+
# { "weekly_schedule" => { "1" => [["09:00","17:00"]], "3" => [...], ... } }
|
|
14
|
+
# Keys are wday strings ("0"=Sun … "6"=Sat); values are arrays of [start_time, end_time].
|
|
15
|
+
#
|
|
16
|
+
# Override/reservation value:
|
|
17
|
+
# { "start" => <unix float>, "end" => <unix float>, "type" => "block"|"reserve"|"cancel",
|
|
18
|
+
# "order_id" => "..." (optional) }
|
|
19
|
+
class AvailabilityDeriver
|
|
20
|
+
DERIVATION_NAME = "availability_snapshot"
|
|
21
|
+
DERIVATION_VERSION = "1.0"
|
|
22
|
+
|
|
23
|
+
# Returns a value Hash suitable for storing as an AvailabilitySnapshotFact.
|
|
24
|
+
#
|
|
25
|
+
# base_facts:
|
|
26
|
+
# :template — Fact or nil (weekly schedule)
|
|
27
|
+
# :overrides — Array<Fact> (explicit time blocks)
|
|
28
|
+
# :active_reservations — Array<Fact> (reserved order slots)
|
|
29
|
+
#
|
|
30
|
+
# horizon_start — Date (inclusive, start of window)
|
|
31
|
+
# horizon_days — Integer (number of days to expand)
|
|
32
|
+
# source_fact_ids — Array<String> (all fact IDs that contributed)
|
|
33
|
+
# source_fact_refs — Array<Hash> (structured refs: id/store/role; optional)
|
|
34
|
+
def derive(base_facts:, horizon_start:, horizon_days:, source_fact_ids:, source_fact_refs: nil)
|
|
35
|
+
template_value = base_facts[:template]&.value || {}
|
|
36
|
+
override_facts = base_facts[:overrides] || []
|
|
37
|
+
reservation_facts = base_facts[:active_reservations] || []
|
|
38
|
+
|
|
39
|
+
# 1. Expand template into intervals over the horizon window
|
|
40
|
+
available = expand_template(template_value, horizon_start, horizon_days)
|
|
41
|
+
|
|
42
|
+
# 2. Collect blocking intervals (overrides + reservations)
|
|
43
|
+
blocked = collect_blocked(override_facts, reservation_facts)
|
|
44
|
+
|
|
45
|
+
# 3. Subtract blocked from available
|
|
46
|
+
blocked.each { |b| available = subtract_interval(available, b) }
|
|
47
|
+
|
|
48
|
+
# 4. Compute total available seconds
|
|
49
|
+
available_seconds = available.sum { |s, e| e - s }
|
|
50
|
+
|
|
51
|
+
refs = (source_fact_refs || []).uniq { |r| r["id"] || r[:id] }
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
"available_slots" => available.map { |s, e| { "start" => s, "end" => e } },
|
|
55
|
+
"blocked_intervals" => blocked.map { |s, e| { "start" => s, "end" => e } },
|
|
56
|
+
"available_seconds" => available_seconds.round,
|
|
57
|
+
"derived_from_fact_ids" => source_fact_ids.uniq,
|
|
58
|
+
"derived_from_fact_refs" => refs,
|
|
59
|
+
"derivation" => {
|
|
60
|
+
"name" => DERIVATION_NAME,
|
|
61
|
+
"version" => DERIVATION_VERSION
|
|
62
|
+
},
|
|
63
|
+
"computed_at" => Time.now.iso8601(3),
|
|
64
|
+
"horizon_start" => horizon_start.iso8601,
|
|
65
|
+
"horizon_days" => horizon_days
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Expands the weekly_schedule template into concrete [start_ts, end_ts] pairs
|
|
72
|
+
# over [horizon_start, horizon_start + horizon_days).
|
|
73
|
+
# Fact values have symbol keys (native extension normalises all keys to symbols).
|
|
74
|
+
def expand_template(template_value, horizon_start, horizon_days)
|
|
75
|
+
schedule = template_value[:weekly_schedule] || {}
|
|
76
|
+
intervals = []
|
|
77
|
+
|
|
78
|
+
horizon_days.times do |offset|
|
|
79
|
+
day = horizon_start + offset
|
|
80
|
+
wday_sym = day.wday.to_s.to_sym
|
|
81
|
+
wday_slots = schedule[wday_sym] || []
|
|
82
|
+
wday_slots.each do |slot|
|
|
83
|
+
start_ts = day_time_to_unix(day, slot[0].to_s)
|
|
84
|
+
end_ts = day_time_to_unix(day, slot[1].to_s)
|
|
85
|
+
intervals << [start_ts, end_ts] if end_ts > start_ts
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
intervals.sort_by(&:first)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Gathers blocking intervals from override and reservation facts.
|
|
93
|
+
# Ignores facts whose type is "cancel" (cancellations restore availability).
|
|
94
|
+
def collect_blocked(override_facts, reservation_facts)
|
|
95
|
+
blocked = []
|
|
96
|
+
|
|
97
|
+
override_facts.each do |f|
|
|
98
|
+
v = f.value
|
|
99
|
+
next if v[:type].to_s == "cancel"
|
|
100
|
+
s = v[:start].to_f
|
|
101
|
+
e = v[:end].to_f
|
|
102
|
+
blocked << [s, e] if e > s
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
reservation_facts.each do |f|
|
|
106
|
+
v = f.value
|
|
107
|
+
next if v[:type].to_s == "cancel"
|
|
108
|
+
s = v[:start].to_f
|
|
109
|
+
e = v[:end].to_f
|
|
110
|
+
blocked << [s, e] if e > s
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
blocked.sort_by(&:first)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Subtracts one [block_start, block_end] interval from a list of [start, end] pairs.
|
|
117
|
+
# Handles: no-overlap, full-containment, left-trim, right-trim, split.
|
|
118
|
+
def subtract_interval(intervals, block)
|
|
119
|
+
bs, be = block
|
|
120
|
+
result = []
|
|
121
|
+
intervals.each do |s, e|
|
|
122
|
+
if be <= s || bs >= e
|
|
123
|
+
# no overlap — keep as-is
|
|
124
|
+
result << [s, e]
|
|
125
|
+
elsif bs <= s && be >= e
|
|
126
|
+
# block fully covers slot — drop
|
|
127
|
+
elsif bs > s && be < e
|
|
128
|
+
# block splits slot — keep left and right fragments
|
|
129
|
+
result << [s, bs]
|
|
130
|
+
result << [be, e]
|
|
131
|
+
elsif bs <= s
|
|
132
|
+
# block trims left side
|
|
133
|
+
result << [be, e] if be < e
|
|
134
|
+
else
|
|
135
|
+
# block trims right side
|
|
136
|
+
result << [s, bs] if bs > s
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
result
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Converts a Date + "HH:MM" string to a Unix timestamp (Float).
|
|
143
|
+
def day_time_to_unix(date, time_str)
|
|
144
|
+
h, m = time_str.split(":").map(&:to_i)
|
|
145
|
+
Time.utc(date.year, date.month, date.day, h, m, 0).to_f
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "availability_deriver"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Store
|
|
7
|
+
module IntelligentLedger
|
|
8
|
+
# Store-backed orchestrator for the AvailabilitySnapshot derivation.
|
|
9
|
+
#
|
|
10
|
+
# Reads base facts from an IgniterStore, invokes AvailabilityDeriver,
|
|
11
|
+
# then persists the snapshot fact and a derivation receipt.
|
|
12
|
+
#
|
|
13
|
+
# Store layout:
|
|
14
|
+
# :availability_templates — key: technician_id
|
|
15
|
+
# :availability_overrides — key: "technician_id/override_id"
|
|
16
|
+
# :order_events — key: order_id (value has "type": "reserved"/"cancelled")
|
|
17
|
+
# :availability_snapshots — key: "technician_id/horizon_bucket"
|
|
18
|
+
# :derivation_receipts — key: snapshot_fact_id
|
|
19
|
+
class AvailabilityLedger
|
|
20
|
+
PRODUCER = { "system" => "availability_ledger", "version" => AvailabilityDeriver::DERIVATION_VERSION }.freeze
|
|
21
|
+
|
|
22
|
+
def initialize(store:)
|
|
23
|
+
@store = store
|
|
24
|
+
@deriver = AvailabilityDeriver.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Writes a template fact for a technician.
|
|
28
|
+
# weekly_schedule: { "1" => [["09:00","17:00"]], ... }
|
|
29
|
+
def write_template(technician_id:, weekly_schedule:)
|
|
30
|
+
@store.write(
|
|
31
|
+
store: :availability_templates,
|
|
32
|
+
key: technician_id.to_s,
|
|
33
|
+
value: { "weekly_schedule" => weekly_schedule }
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Writes an override fact (block a specific interval).
|
|
38
|
+
# type: "block" (default) or "cancel"
|
|
39
|
+
def write_override(technician_id:, override_id:, start_time:, end_time:, type: "block")
|
|
40
|
+
@store.write(
|
|
41
|
+
store: :availability_overrides,
|
|
42
|
+
key: "#{technician_id}/#{override_id}",
|
|
43
|
+
value: {
|
|
44
|
+
"technician_id" => technician_id.to_s,
|
|
45
|
+
"start" => start_time.to_f,
|
|
46
|
+
"end" => end_time.to_f,
|
|
47
|
+
"type" => type
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Writes an order event fact.
|
|
53
|
+
# type: "reserved" or "cancelled"
|
|
54
|
+
def write_order_event(order_id:, technician_id:, start_time:, end_time:, type: "reserved")
|
|
55
|
+
@store.write(
|
|
56
|
+
store: :order_events,
|
|
57
|
+
key: order_id.to_s,
|
|
58
|
+
value: {
|
|
59
|
+
"order_id" => order_id.to_s,
|
|
60
|
+
"technician_id" => technician_id.to_s,
|
|
61
|
+
"start" => start_time.to_f,
|
|
62
|
+
"end" => end_time.to_f,
|
|
63
|
+
"type" => type
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Derives and persists an availability snapshot for a technician over a horizon window.
|
|
69
|
+
#
|
|
70
|
+
# horizon_start — Date (inclusive)
|
|
71
|
+
# horizon_days — Integer (window length)
|
|
72
|
+
#
|
|
73
|
+
# Returns { snapshot_fact:, receipt_fact: }.
|
|
74
|
+
def compute_snapshot(technician_id:, horizon_start:, horizon_days:)
|
|
75
|
+
tid = technician_id.to_s
|
|
76
|
+
|
|
77
|
+
# --- gather base facts ---
|
|
78
|
+
template_fact = @store.history(store: :availability_templates, key: tid).last
|
|
79
|
+
|
|
80
|
+
override_facts = @store.history(store: :availability_overrides).select do |f|
|
|
81
|
+
f.key.start_with?("#{tid}/")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
order_facts = @store.history(store: :order_events).select do |f|
|
|
85
|
+
f.value[:technician_id].to_s == tid
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Active reservations = latest event per order_id where type != "cancelled"
|
|
89
|
+
active_reservations = active_order_facts(order_facts)
|
|
90
|
+
|
|
91
|
+
# Collect source fact IDs and structured refs (id + store + role).
|
|
92
|
+
source_ids = []
|
|
93
|
+
source_refs = []
|
|
94
|
+
|
|
95
|
+
if template_fact
|
|
96
|
+
source_ids << template_fact.id
|
|
97
|
+
source_refs << { "id" => template_fact.id, "store" => "availability_templates",
|
|
98
|
+
"role" => "template", "key" => template_fact.key }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
override_facts.each do |f|
|
|
102
|
+
source_ids << f.id
|
|
103
|
+
source_refs << { "id" => f.id, "store" => "availability_overrides",
|
|
104
|
+
"role" => "override", "key" => f.key }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
order_facts.each do |f|
|
|
108
|
+
source_ids << f.id
|
|
109
|
+
source_refs << { "id" => f.id, "store" => "order_events",
|
|
110
|
+
"role" => "order_event", "key" => f.key }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
base_facts = {
|
|
114
|
+
template: template_fact,
|
|
115
|
+
overrides: override_facts,
|
|
116
|
+
active_reservations: active_reservations
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# --- derive ---
|
|
120
|
+
snapshot_value = @deriver.derive(
|
|
121
|
+
base_facts: base_facts,
|
|
122
|
+
horizon_start: horizon_start,
|
|
123
|
+
horizon_days: horizon_days,
|
|
124
|
+
source_fact_ids: source_ids,
|
|
125
|
+
source_fact_refs: source_refs
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# --- persist snapshot ---
|
|
129
|
+
bucket = "#{horizon_start.iso8601}/#{horizon_days}d"
|
|
130
|
+
snapshot_fact = @store.write(
|
|
131
|
+
store: :availability_snapshots,
|
|
132
|
+
key: "#{tid}/#{bucket}",
|
|
133
|
+
value: snapshot_value,
|
|
134
|
+
producer: PRODUCER,
|
|
135
|
+
derivation: snapshot_derivation_metadata(snapshot_value)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# --- persist receipt ---
|
|
139
|
+
receipt_value = {
|
|
140
|
+
"snapshot_fact_id" => snapshot_fact.id,
|
|
141
|
+
"technician_id" => tid,
|
|
142
|
+
"horizon_start" => horizon_start.iso8601,
|
|
143
|
+
"horizon_days" => horizon_days,
|
|
144
|
+
"derivation_name" => AvailabilityDeriver::DERIVATION_NAME,
|
|
145
|
+
"derivation_version" => AvailabilityDeriver::DERIVATION_VERSION,
|
|
146
|
+
"source_fact_ids" => source_ids.uniq,
|
|
147
|
+
"source_fact_refs" => source_refs.uniq { |r| r["id"] },
|
|
148
|
+
"derived_at" => Time.now.iso8601(3)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
receipt_fact = @store.write(
|
|
152
|
+
store: :derivation_receipts,
|
|
153
|
+
key: snapshot_fact.id,
|
|
154
|
+
value: receipt_value,
|
|
155
|
+
producer: PRODUCER
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
{ snapshot_fact: snapshot_fact, receipt_fact: receipt_fact }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Reads the latest snapshot Fact for a technician/horizon combination.
|
|
162
|
+
# Returns nil if none exists.
|
|
163
|
+
def read_snapshot(technician_id:, horizon_start:, horizon_days:)
|
|
164
|
+
bucket = "#{horizon_start.iso8601}/#{horizon_days}d"
|
|
165
|
+
@store.history(store: :availability_snapshots, key: "#{technician_id}/#{bucket}").last
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Reads the derivation receipt Fact for a given snapshot_fact_id.
|
|
169
|
+
def read_receipt(snapshot_fact_id)
|
|
170
|
+
@store.history(store: :derivation_receipts, key: snapshot_fact_id).last
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
# From a list of order event facts, return only the facts that represent
|
|
176
|
+
# the latest state per order_id and are NOT cancelled.
|
|
177
|
+
def active_order_facts(order_facts)
|
|
178
|
+
by_order = order_facts.group_by { |f| f.key }
|
|
179
|
+
by_order.filter_map do |_order_id, facts|
|
|
180
|
+
latest = facts.max_by(&:transaction_time)
|
|
181
|
+
latest if latest&.value&.fetch(:type, nil).to_s != "cancelled"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def snapshot_derivation_metadata(snapshot_value)
|
|
186
|
+
raw = snapshot_value.fetch("derivation")
|
|
187
|
+
{
|
|
188
|
+
name: raw.fetch("name"),
|
|
189
|
+
version: raw.fetch("version"),
|
|
190
|
+
source_fact_ids: snapshot_value.fetch("derived_from_fact_ids"),
|
|
191
|
+
source_fact_refs: snapshot_value.fetch("derived_from_fact_refs", [])
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Igniter
|
|
7
|
+
module Store
|
|
8
|
+
module IntelligentLedger
|
|
9
|
+
# A closed semantic boundary over facts for a technician availability day.
|
|
10
|
+
#
|
|
11
|
+
# Lifecycle: open -> closed -> settled -> compacted
|
|
12
|
+
#
|
|
13
|
+
# Once closed, result_hash and output_fact_id are immutable. Settlement
|
|
14
|
+
# materialises useful long-lived memory (summary, metrics, receipt) before
|
|
15
|
+
# compaction. Compaction requires settlement first, then marks internal
|
|
16
|
+
# detail as purged while preserving output and result_hash.
|
|
17
|
+
class LedgerBoundary
|
|
18
|
+
POLICY_NAME = "technician_day"
|
|
19
|
+
RULE_VERSION = "1.0"
|
|
20
|
+
|
|
21
|
+
attr_reader :boundary_key, :subject, :status, :result_hash,
|
|
22
|
+
:source_fact_ids, :source_fact_refs,
|
|
23
|
+
:output_fact_id, :output_value,
|
|
24
|
+
:receipt_fact_id, :detail_status, :closed_at,
|
|
25
|
+
:compacted_at, :compaction_receipt_id,
|
|
26
|
+
:settlement_status, :settlement_receipt_id
|
|
27
|
+
|
|
28
|
+
def initialize(subject:, rule_version: RULE_VERSION)
|
|
29
|
+
@subject = subject.freeze
|
|
30
|
+
@rule_version = rule_version
|
|
31
|
+
@boundary_key = build_boundary_key
|
|
32
|
+
@status = :open
|
|
33
|
+
@detail_status = :full
|
|
34
|
+
@source_fact_ids = [].freeze
|
|
35
|
+
@source_fact_refs = [].freeze
|
|
36
|
+
@output_fact_id = nil
|
|
37
|
+
@output_value = nil
|
|
38
|
+
@receipt_fact_id = nil
|
|
39
|
+
@result_hash = nil
|
|
40
|
+
@closed_at = nil
|
|
41
|
+
@compacted_at = nil
|
|
42
|
+
@compaction_receipt_id = nil
|
|
43
|
+
@settlement_status = :unsettled
|
|
44
|
+
@settlement_receipt_id = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def id = @boundary_key
|
|
48
|
+
|
|
49
|
+
def open? = @status == :open
|
|
50
|
+
def closed? = @status == :closed || @status == :compacted
|
|
51
|
+
def compacted? = @status == :compacted
|
|
52
|
+
def settled? = @settlement_status == :settled
|
|
53
|
+
|
|
54
|
+
# Transitions open -> closed.
|
|
55
|
+
# output_fact, receipt_fact, result_hash are immutable after this point.
|
|
56
|
+
# source_fact_refs — structured refs (id/store/role); optional, defaults to [].
|
|
57
|
+
# Refs are normalized to string keys for consistency across store round-trips.
|
|
58
|
+
def close!(output_fact:, receipt_fact:, source_fact_ids:, source_fact_refs: [])
|
|
59
|
+
raise "boundary already closed" unless @status == :open
|
|
60
|
+
|
|
61
|
+
@output_fact_id = output_fact.id
|
|
62
|
+
@output_value = output_fact.value
|
|
63
|
+
@receipt_fact_id = receipt_fact.id
|
|
64
|
+
@source_fact_ids = source_fact_ids.uniq.freeze
|
|
65
|
+
@source_fact_refs = source_fact_refs
|
|
66
|
+
.map { |r| r.transform_keys(&:to_s) }
|
|
67
|
+
.uniq { |r| r["id"] }
|
|
68
|
+
.freeze
|
|
69
|
+
@result_hash = compute_result_hash(@output_value, @source_fact_ids)
|
|
70
|
+
@status = :closed
|
|
71
|
+
@closed_at = Time.now
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Transitions settlement_status: :unsettled -> :settled.
|
|
75
|
+
# Boundary must be closed first. settlement_receipt_id is immutable after this.
|
|
76
|
+
def settle!(settlement_receipt_id:)
|
|
77
|
+
raise "boundary must be closed before settlement" unless @status == :closed
|
|
78
|
+
raise "boundary already settled" if @settlement_status == :settled
|
|
79
|
+
|
|
80
|
+
@settlement_receipt_id = settlement_receipt_id
|
|
81
|
+
@settlement_status = :settled
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Transitions closed -> compacted.
|
|
85
|
+
# Requires settlement first. Internal detail is marked purged;
|
|
86
|
+
# output, result_hash, and settlement outputs remain intact.
|
|
87
|
+
def compact!(compaction_receipt_id:)
|
|
88
|
+
raise "boundary must be closed before compaction" unless @status == :closed
|
|
89
|
+
raise "boundary must be settled before compaction" unless @settlement_status == :settled
|
|
90
|
+
|
|
91
|
+
@compaction_receipt_id = compaction_receipt_id
|
|
92
|
+
@detail_status = :purged
|
|
93
|
+
@status = :compacted
|
|
94
|
+
@compacted_at = Time.now
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns a class-level deterministic key without instantiating a full boundary.
|
|
98
|
+
def self.key_for(company_id:, technician_id:, date:, rule_version: RULE_VERSION)
|
|
99
|
+
"#{POLICY_NAME}/company=#{company_id}/technician=#{technician_id}/date=#{date}/version=#{rule_version}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Rebuilds a LedgerBoundary from persisted store data after a process restart.
|
|
103
|
+
#
|
|
104
|
+
# boundary_record — value hash from :ledger_boundaries (symbol keys from store)
|
|
105
|
+
# output_value — value hash recovered from :availability_snapshots, or nil
|
|
106
|
+
# settlement_receipt_id — fact ID from :ledger_settlement_receipts, nil if unsettled
|
|
107
|
+
# compaction_receipt_id — fact ID from :ledger_cleanup_receipts, nil if not compacted
|
|
108
|
+
# compacted_at — parsed Time from cleanup receipt, nil if not compacted
|
|
109
|
+
#
|
|
110
|
+
# Status is authoritative from receipt evidence: if compaction_receipt_id is present
|
|
111
|
+
# the status is :compacted regardless of what the boundary record says, because the
|
|
112
|
+
# boundary record in :ledger_boundaries is written at close time and never updated.
|
|
113
|
+
def self.from_persisted(boundary_record:, output_value: nil,
|
|
114
|
+
settlement_receipt_id: nil,
|
|
115
|
+
compaction_receipt_id: nil,
|
|
116
|
+
compacted_at: nil)
|
|
117
|
+
obj = allocate
|
|
118
|
+
obj.__send__(:restore_from_record!,
|
|
119
|
+
boundary_record: boundary_record,
|
|
120
|
+
output_value: output_value,
|
|
121
|
+
settlement_receipt_id: settlement_receipt_id,
|
|
122
|
+
compaction_receipt_id: compaction_receipt_id,
|
|
123
|
+
compacted_at: compacted_at)
|
|
124
|
+
obj
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def restore_from_record!(boundary_record:, output_value:,
|
|
130
|
+
settlement_receipt_id:, compaction_receipt_id:, compacted_at:)
|
|
131
|
+
@boundary_key = boundary_record[:boundary_key]
|
|
132
|
+
@rule_version = boundary_record[:rule_version] || RULE_VERSION
|
|
133
|
+
@subject = boundary_record[:subject].freeze
|
|
134
|
+
|
|
135
|
+
# Status: cleanup receipt evidence overrides the stored boundary status
|
|
136
|
+
# (boundary record is written at close time and never updated on compact)
|
|
137
|
+
@status = compaction_receipt_id ? :compacted : :closed
|
|
138
|
+
@detail_status = compaction_receipt_id ? :purged : (boundary_record[:detail_status]&.to_sym || :full)
|
|
139
|
+
|
|
140
|
+
@output_fact_id = boundary_record[:output_fact_id]
|
|
141
|
+
@output_value = output_value
|
|
142
|
+
@receipt_fact_id = boundary_record[:receipt_fact_id]
|
|
143
|
+
@result_hash = boundary_record[:result_hash]
|
|
144
|
+
@source_fact_ids = Array(boundary_record[:source_fact_ids]).freeze
|
|
145
|
+
@source_fact_refs = Array(boundary_record[:source_fact_refs] || [])
|
|
146
|
+
.map { |r| r.transform_keys(&:to_s) }
|
|
147
|
+
.freeze
|
|
148
|
+
|
|
149
|
+
@closed_at = parse_time_safe(boundary_record[:closed_at])
|
|
150
|
+
@compacted_at = compacted_at
|
|
151
|
+
@compaction_receipt_id = compaction_receipt_id
|
|
152
|
+
|
|
153
|
+
@settlement_status = settlement_receipt_id ? :settled : :unsettled
|
|
154
|
+
@settlement_receipt_id = settlement_receipt_id
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def parse_time_safe(val)
|
|
158
|
+
val ? Time.parse(val.to_s) : nil
|
|
159
|
+
rescue ArgumentError, TypeError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def build_boundary_key
|
|
164
|
+
s = @subject
|
|
165
|
+
self.class.key_for(
|
|
166
|
+
company_id: s[:company_id],
|
|
167
|
+
technician_id: s[:technician_id],
|
|
168
|
+
date: s[:date],
|
|
169
|
+
rule_version: @rule_version
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def compute_result_hash(output_value, source_fact_ids)
|
|
174
|
+
content = output_value.to_s + source_fact_ids.sort.join(",") + @rule_version
|
|
175
|
+
Digest::SHA256.hexdigest(content)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "igniter-ledger"
|
|
5
|
+
|
|
6
|
+
store = Igniter::Store.memory
|
|
7
|
+
|
|
8
|
+
invalidations = []
|
|
9
|
+
store.register_path(
|
|
10
|
+
Igniter::Store.access_path(
|
|
11
|
+
store: :reminders,
|
|
12
|
+
lookup: :primary_key,
|
|
13
|
+
scope: nil,
|
|
14
|
+
filters: nil,
|
|
15
|
+
cache_ttl: 60,
|
|
16
|
+
consumers: [->(store_name, key) { invalidations << [store_name, key] }]
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
t_before = Process.clock_gettime(Process::CLOCK_REALTIME)
|
|
21
|
+
first = store.write(store: :reminders, key: "r1", value: { title: "Buy milk", status: :open })
|
|
22
|
+
sleep 0.01
|
|
23
|
+
t_mid = Process.clock_gettime(Process::CLOCK_REALTIME)
|
|
24
|
+
second = store.write(store: :reminders, key: "r1", value: { title: "Buy milk", status: :closed })
|
|
25
|
+
|
|
26
|
+
store.append(history: :reminder_logs, event: { reminder_id: "r1", action: :created, at: t_before })
|
|
27
|
+
store.append(history: :reminder_logs, event: { reminder_id: "r1", action: :closed, at: Time.now.to_f })
|
|
28
|
+
|
|
29
|
+
wal_path = File.join(Dir.tmpdir, "igniter_ledger_package_poc_#{Process.pid}.jsonl")
|
|
30
|
+
begin
|
|
31
|
+
file_store = Igniter::Store.open(wal_path)
|
|
32
|
+
file_store.write(store: :tasks, key: "t1", value: { title: "Package POC", done: false })
|
|
33
|
+
file_store.write(store: :tasks, key: "t1", value: { title: "Package POC", done: true })
|
|
34
|
+
replayed = Igniter::Store.open(wal_path)
|
|
35
|
+
|
|
36
|
+
puts "access_paths=#{store.schema_graph.paths_for(:reminders).length}"
|
|
37
|
+
puts "chain_intact=#{second.causation == first.id}"
|
|
38
|
+
puts "current_status=#{store.read(store: :reminders, key: "r1").fetch(:status)}"
|
|
39
|
+
puts "status_at_mid=#{store.time_travel(store: :reminders, key: "r1", at: t_mid).fetch(:status)}"
|
|
40
|
+
puts "invalidations=#{invalidations.inspect}"
|
|
41
|
+
puts "history_count=#{store.history(store: :reminder_logs).length}"
|
|
42
|
+
puts "wal_replay_done=#{replayed.read(store: :tasks, key: "t1").fetch(:done)}"
|
|
43
|
+
ensure
|
|
44
|
+
File.delete(wal_path) if File.exist?(wal_path)
|
|
45
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
#
|
|
5
|
+
# igniter-ledger-server — standalone LedgerServer process
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# igniter-ledger-server [options]
|
|
9
|
+
#
|
|
10
|
+
# Examples:
|
|
11
|
+
# igniter-ledger-server
|
|
12
|
+
# igniter-ledger-server --port 7400 --backend memory
|
|
13
|
+
# igniter-ledger-server --backend file --path /var/lib/igniter/store.wal --pid-file /run/igniter-ledger.pid
|
|
14
|
+
# igniter-ledger-server --host 0.0.0.0 --port 7400 --log-level debug
|
|
15
|
+
|
|
16
|
+
require "optparse"
|
|
17
|
+
require_relative "../lib/igniter-ledger"
|
|
18
|
+
|
|
19
|
+
opts = {}
|
|
20
|
+
cf_opts = {}
|
|
21
|
+
cf_thresholds = {}
|
|
22
|
+
|
|
23
|
+
parser = OptionParser.new do |o|
|
|
24
|
+
o.banner = "Usage: igniter-ledger-server [options]"
|
|
25
|
+
o.separator ""
|
|
26
|
+
o.separator "Network:"
|
|
27
|
+
o.on("--host HOST", "Bind host (default: 127.0.0.1)") { |v| opts[:host] = v }
|
|
28
|
+
o.on("--port PORT", Integer, "Bind port (default: 7400)") { |v| opts[:port] = v }
|
|
29
|
+
o.on("--transport TYPE", "tcp or unix (default: tcp)") { |v| opts[:transport] = v.to_sym }
|
|
30
|
+
|
|
31
|
+
o.separator ""
|
|
32
|
+
o.separator "Storage:"
|
|
33
|
+
o.on("--backend BACKEND", "memory or file (default: memory)") { |v| opts[:backend] = v.to_sym }
|
|
34
|
+
o.on("--path PATH", "WAL file path (required for --backend file)") { |v| opts[:path] = v }
|
|
35
|
+
|
|
36
|
+
o.separator ""
|
|
37
|
+
o.separator "Operations:"
|
|
38
|
+
o.on("--log-level LEVEL", "debug|info|warn|error (default: info)") { |v| opts[:log_level] = v.to_sym }
|
|
39
|
+
o.on("--pid-file PATH", "Write PID to file on startup") { |v| opts[:pid_file] = v }
|
|
40
|
+
o.on("--drain-timeout SECS", Integer, "Graceful shutdown drain timeout (default: 5)") { |v| opts[:drain_timeout] = v }
|
|
41
|
+
|
|
42
|
+
o.separator ""
|
|
43
|
+
o.separator "Protocol API (envelope dispatch):"
|
|
44
|
+
o.on("--http-port PORT", Integer, "HTTP adapter port (default: disabled)") { |v| opts[:http_port] = v }
|
|
45
|
+
o.on("--tcp-port PORT", Integer, "TCP adapter port (default: disabled)") { |v| opts[:tcp_port] = v }
|
|
46
|
+
|
|
47
|
+
o.separator ""
|
|
48
|
+
o.separator "Changefeed:"
|
|
49
|
+
o.on("--changefeed-max-size N", Integer, "Event ring size (default: 1000)") { |v| cf_opts[:max_size] = v }
|
|
50
|
+
o.on("--changefeed-subscriber-queue-size N", Integer, "Per-subscriber queue size (default: 100)") { |v| cf_opts[:subscriber_queue_size] = v }
|
|
51
|
+
o.on("--changefeed-overflow POLICY", "drop_oldest|drop_newest (default: drop_oldest)") { |v| cf_opts[:overflow] = v.to_sym }
|
|
52
|
+
o.on("--changefeed-close-policy POLICY", "drain|discard (default: drain)") { |v| cf_opts[:close_policy] = v.to_sym }
|
|
53
|
+
o.on("--changefeed-diagnostic-ring-size N", Integer, "Diagnostic ring size (default: 100)") { |v| cf_opts[:diagnostic_ring_size] = v }
|
|
54
|
+
o.on("--changefeed-alert-total-queued N", Integer, "Alert: total_queued >= N") { |v| cf_thresholds[:total_queued] = v }
|
|
55
|
+
o.on("--changefeed-alert-overflow-dropped-total N", Integer, "Alert: overflow_dropped_total >= N") { |v| cf_thresholds[:overflow_dropped_total] = v }
|
|
56
|
+
o.on("--changefeed-alert-failed-total N", Integer, "Alert: failed_total >= N") { |v| cf_thresholds[:failed_total] = v }
|
|
57
|
+
o.on("--changefeed-alert-queue-pressure-ratio FLOAT", Float, "Alert: queue_pressure_ratio >= FLOAT") { |v| cf_thresholds[:queue_pressure_ratio] = v }
|
|
58
|
+
|
|
59
|
+
o.separator ""
|
|
60
|
+
o.on("-h", "--help", "Show this help and exit") do
|
|
61
|
+
puts o
|
|
62
|
+
exit 0
|
|
63
|
+
end
|
|
64
|
+
o.on("-v", "--version", "Show version and exit") do
|
|
65
|
+
version = begin
|
|
66
|
+
require "igniter/version"
|
|
67
|
+
Igniter::VERSION
|
|
68
|
+
rescue LoadError
|
|
69
|
+
begin
|
|
70
|
+
require_relative "../../../lib/igniter/version"
|
|
71
|
+
Igniter::VERSION
|
|
72
|
+
rescue LoadError
|
|
73
|
+
Gem.loaded_specs["igniter-ledger"]&.version&.to_s || "unknown"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
puts "igniter-ledger #{version}"
|
|
77
|
+
exit 0
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
parser.parse!
|
|
83
|
+
rescue OptionParser::InvalidOption, OptionParser::InvalidArgument => e
|
|
84
|
+
warn "Error: #{e.message}"
|
|
85
|
+
warn parser
|
|
86
|
+
exit 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Validate --backend file requires --path
|
|
90
|
+
if opts[:backend] == :file && opts[:path].nil?
|
|
91
|
+
warn "Error: --backend file requires --path PATH"
|
|
92
|
+
exit 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
http_port = opts.delete(:http_port)
|
|
96
|
+
tcp_port = opts.delete(:tcp_port)
|
|
97
|
+
|
|
98
|
+
cf_opts[:alert_thresholds] = cf_thresholds unless cf_thresholds.empty?
|
|
99
|
+
opts[:changefeed] = cf_opts unless cf_opts.empty?
|
|
100
|
+
|
|
101
|
+
config = Igniter::Ledger::ServerConfig.new(**opts)
|
|
102
|
+
|
|
103
|
+
$stdout.sync = true
|
|
104
|
+
$stderr.sync = true
|
|
105
|
+
|
|
106
|
+
server = Igniter::Ledger::LedgerServer.new(config: config)
|
|
107
|
+
if http_port || tcp_port
|
|
108
|
+
server.start_with_adapters(http_port: http_port, tcp_port: tcp_port)
|
|
109
|
+
else
|
|
110
|
+
server.start_foreground
|
|
111
|
+
end
|