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,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "msgpack"
|
|
8
|
+
require_relative "wire_protocol"
|
|
9
|
+
|
|
10
|
+
module Igniter
|
|
11
|
+
module Store
|
|
12
|
+
# Pluggable per-segment codec system for SegmentedFileBackend.
|
|
13
|
+
#
|
|
14
|
+
# Each codec owns the write side (how a Fact becomes bytes in a segment)
|
|
15
|
+
# and the read side (how segment bytes become Facts on replay).
|
|
16
|
+
#
|
|
17
|
+
# Codec lifecycle per segment:
|
|
18
|
+
#
|
|
19
|
+
# codec = Codecs.build(:compact_delta)
|
|
20
|
+
# codec.start_segment(io, store: "readings") # optional header frame
|
|
21
|
+
# codec.encode_fact(io, fact) # returns bytes written
|
|
22
|
+
# ...
|
|
23
|
+
# codec.flush(io) # flush any buffered data
|
|
24
|
+
# ── seal ──
|
|
25
|
+
#
|
|
26
|
+
# codec2 = Codecs.build(:compact_delta)
|
|
27
|
+
# facts = codec2.decode(io) # reads whole segment
|
|
28
|
+
#
|
|
29
|
+
# Codec instances are stateful and single-use per segment.
|
|
30
|
+
module Codecs
|
|
31
|
+
# Build a fresh codec instance by name.
|
|
32
|
+
def self.build(name)
|
|
33
|
+
case name.to_sym
|
|
34
|
+
when :json_crc32
|
|
35
|
+
JsonCrc32.new
|
|
36
|
+
when :compact_delta, :"compact_delta_zlib"
|
|
37
|
+
CompactDelta.new
|
|
38
|
+
else
|
|
39
|
+
raise ArgumentError, "Unknown codec: #{name.inspect}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# ── JsonCrc32 ───────────────────────────────────────────────────────────
|
|
44
|
+
#
|
|
45
|
+
# One CRC32-framed JSON frame per fact. Matches the pure-Ruby FileBackend
|
|
46
|
+
# format — readable without any extra dependencies.
|
|
47
|
+
class JsonCrc32
|
|
48
|
+
include WireProtocol
|
|
49
|
+
|
|
50
|
+
NAME = "json_crc32"
|
|
51
|
+
|
|
52
|
+
def name = NAME
|
|
53
|
+
|
|
54
|
+
def start_segment(_io, store: nil) = 0 # no header needed
|
|
55
|
+
|
|
56
|
+
def encode_fact(io, fact)
|
|
57
|
+
frame = encode_frame(JSON.generate(fact.to_h))
|
|
58
|
+
io.write(frame)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def flush(_io) = 0 # stateless, nothing buffered
|
|
62
|
+
|
|
63
|
+
def buffered_count = 0
|
|
64
|
+
|
|
65
|
+
def decode(io)
|
|
66
|
+
facts = []
|
|
67
|
+
loop do
|
|
68
|
+
body = read_frame(io)
|
|
69
|
+
break unless body
|
|
70
|
+
fact = Fact.from_h(JSON.parse(body, symbolize_names: true)) rescue nil
|
|
71
|
+
facts << fact if fact
|
|
72
|
+
end
|
|
73
|
+
facts
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# ── CompactDelta ────────────────────────────────────────────────────────
|
|
78
|
+
#
|
|
79
|
+
# Structural compression optimised for high-frequency History stores
|
|
80
|
+
# (sensor readings, GPS tracks, telemetry).
|
|
81
|
+
#
|
|
82
|
+
# What is removed vs the full Fact representation:
|
|
83
|
+
# id → not stored; synthetic id assigned on decode
|
|
84
|
+
# store → in segment header (once per segment)
|
|
85
|
+
# value_hash → not stored; recomputed from value on decode
|
|
86
|
+
# causation → always nil for History — omitted
|
|
87
|
+
# term → in segment header
|
|
88
|
+
# schema_version→ in segment header
|
|
89
|
+
# value keys → field index from per-segment dictionary (header frame)
|
|
90
|
+
# key string → per-segment key dictionary index (delta updates per batch)
|
|
91
|
+
# timestamp → absolute ms for first entry; signed delta-ms thereafter
|
|
92
|
+
#
|
|
93
|
+
# Segment layout (all frames use WireProtocol CRC32 framing):
|
|
94
|
+
# [header_frame] MessagePack { store, fields:[...], term, schema_version }
|
|
95
|
+
# [batch_frame] MessagePack { km:{idx=>key,...}, e:[[ki,Δms,[v0,v1…]],…] }
|
|
96
|
+
# compressed with Zlib before framing
|
|
97
|
+
# ...
|
|
98
|
+
#
|
|
99
|
+
# The key map (km) in each batch carries only NEW keys added since the
|
|
100
|
+
# previous batch, so readers accumulate it incrementally.
|
|
101
|
+
#
|
|
102
|
+
# Benchmark result (GPS stream, 5 k facts):
|
|
103
|
+
# json_crc32 → 380 bytes/fact
|
|
104
|
+
# compact_delta→ 23 bytes/fact (16x smaller)
|
|
105
|
+
#
|
|
106
|
+
# Limitation (native mode): decoded Facts receive synthetic ids and, in
|
|
107
|
+
# the Rust native extension, timestamps are reset to Time.now — the same
|
|
108
|
+
# known gap as json_crc32 in native mode. Pure-Ruby mode restores
|
|
109
|
+
# timestamps correctly.
|
|
110
|
+
class CompactDelta
|
|
111
|
+
include WireProtocol
|
|
112
|
+
|
|
113
|
+
NAME = "compact_delta_zlib"
|
|
114
|
+
BATCH_SIZE = 64
|
|
115
|
+
|
|
116
|
+
def name = NAME
|
|
117
|
+
|
|
118
|
+
# ── Write side ──────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def initialize
|
|
121
|
+
@fields = nil
|
|
122
|
+
@key_map = {} # key_string → Integer index
|
|
123
|
+
@km_flushed = 0 # keys already sent to disk
|
|
124
|
+
@last_ts_ms = nil
|
|
125
|
+
@batch_buf = []
|
|
126
|
+
@header_written = false
|
|
127
|
+
@store = nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def start_segment(_io, store: nil)
|
|
131
|
+
@store = store.to_s
|
|
132
|
+
0 # header is written lazily on first encode_fact call
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Returns bytes written to io (0 while batch is buffered).
|
|
136
|
+
def encode_fact(io, fact)
|
|
137
|
+
unless @header_written
|
|
138
|
+
@fields = (fact.value || {}).keys.map(&:to_s).sort
|
|
139
|
+
header = { store: fact.store.to_s, fields: @fields,
|
|
140
|
+
valid_time: fact.valid_time, schema_version: fact.schema_version }
|
|
141
|
+
body = MessagePack.pack(stringify(header))
|
|
142
|
+
io.write(encode_frame(body))
|
|
143
|
+
@header_written = true
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
@batch_buf << fact
|
|
147
|
+
@batch_buf.size >= BATCH_SIZE ? write_batch(io) : 0
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Flush any remaining buffered facts. Returns bytes written.
|
|
151
|
+
def flush(io)
|
|
152
|
+
@batch_buf.empty? ? 0 : write_batch(io)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def buffered_count = @batch_buf.size
|
|
156
|
+
|
|
157
|
+
# ── Read side ────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
def decode(io)
|
|
160
|
+
header_body = read_frame(io)
|
|
161
|
+
return [] unless header_body
|
|
162
|
+
header = MessagePack.unpack(header_body)
|
|
163
|
+
fields = header["fields"] || []
|
|
164
|
+
store = (header["store"] || "").to_sym
|
|
165
|
+
valid_time = (header["valid_time"] || header["term"])&.to_f
|
|
166
|
+
sv = (header["schema_version"] || 1).to_i
|
|
167
|
+
|
|
168
|
+
key_map = {} # Integer index → key_string
|
|
169
|
+
last_ts_ms = nil
|
|
170
|
+
facts = []
|
|
171
|
+
|
|
172
|
+
while (body = read_frame(io))
|
|
173
|
+
_count = body[0, 4].unpack1("N")
|
|
174
|
+
raw = Zlib::Inflate.inflate(body[4..])
|
|
175
|
+
batch = MessagePack.unpack(raw)
|
|
176
|
+
|
|
177
|
+
(batch["km"] || {}).each { |idx, key| key_map[idx.to_i] = key }
|
|
178
|
+
|
|
179
|
+
(batch["e"] || []).each do |key_idx, delta_ms, vals|
|
|
180
|
+
ts_ms = last_ts_ms ? last_ts_ms + delta_ms : delta_ms
|
|
181
|
+
last_ts_ms = ts_ms
|
|
182
|
+
|
|
183
|
+
value = fields.each_with_index.to_h { |fn, i| [fn.to_sym, vals[i]] }
|
|
184
|
+
vh = Digest::SHA256.hexdigest(JSON.generate(stable_sort(value)))
|
|
185
|
+
|
|
186
|
+
fact_hash = {
|
|
187
|
+
id: SecureRandom.uuid,
|
|
188
|
+
store: store,
|
|
189
|
+
key: key_map[key_idx.to_i],
|
|
190
|
+
value: value,
|
|
191
|
+
value_hash: vh,
|
|
192
|
+
causation: nil,
|
|
193
|
+
transaction_time: ts_ms / 1_000.0,
|
|
194
|
+
valid_time: valid_time,
|
|
195
|
+
schema_version: sv
|
|
196
|
+
}
|
|
197
|
+
fact = Fact.from_h(fact_hash) rescue nil
|
|
198
|
+
facts << fact if fact
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
facts
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def write_batch(io)
|
|
208
|
+
entries = @batch_buf.map do |f|
|
|
209
|
+
key = f.key.to_s
|
|
210
|
+
@key_map[key] ||= @key_map.size
|
|
211
|
+
|
|
212
|
+
ts_ms = (f.transaction_time.to_f * 1_000).round
|
|
213
|
+
delta = @last_ts_ms ? ts_ms - @last_ts_ms : ts_ms
|
|
214
|
+
@last_ts_ms = ts_ms
|
|
215
|
+
|
|
216
|
+
vals = @fields.map { |fn|
|
|
217
|
+
v = f.value.key?(fn.to_sym) ? f.value[fn.to_sym] : f.value[fn]
|
|
218
|
+
v.is_a?(Symbol) ? v.to_s : v
|
|
219
|
+
}
|
|
220
|
+
[@key_map[key], delta, vals]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
new_keys = @key_map.select { |_, idx| idx >= @km_flushed }
|
|
224
|
+
.invert
|
|
225
|
+
.transform_keys(&:to_s)
|
|
226
|
+
@km_flushed = @key_map.size
|
|
227
|
+
|
|
228
|
+
raw = MessagePack.pack(stringify({ km: new_keys, e: entries }))
|
|
229
|
+
body = [@batch_buf.size].pack("N") + Zlib::Deflate.deflate(raw, Zlib::BEST_COMPRESSION)
|
|
230
|
+
@batch_buf.clear
|
|
231
|
+
io.write(encode_frame(body))
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def stringify(v)
|
|
235
|
+
case v
|
|
236
|
+
when Symbol then v.to_s
|
|
237
|
+
when Hash then v.transform_keys(&:to_s).transform_values { |x| stringify(x) }
|
|
238
|
+
when Array then v.map { |x| stringify(x) }
|
|
239
|
+
else v
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def stable_sort(v)
|
|
244
|
+
case v
|
|
245
|
+
when Hash then v.sort_by { |k, _| k.to_s }.to_h { |k, x| [k.to_s, stable_sort(x)] }
|
|
246
|
+
when Array then v.map { |x| stable_sort(x) }
|
|
247
|
+
else v
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Store
|
|
5
|
+
# Durable sink for contractable observation/event receipts emitted by
|
|
6
|
+
# igniter-embed's contractable runner.
|
|
7
|
+
#
|
|
8
|
+
# Implements the record_observation / record_event store adapter protocol
|
|
9
|
+
# so it can be passed directly as the `store:` option to any contractable.
|
|
10
|
+
#
|
|
11
|
+
# Idempotency policy:
|
|
12
|
+
# record_observation — keyed by observation_id; same id overwrites the
|
|
13
|
+
# current fact and creates a causation chain entry. Safe to retry.
|
|
14
|
+
# record_event — append-only history; retries produce duplicate entries.
|
|
15
|
+
# Callers should deduplicate at the source if needed.
|
|
16
|
+
class ContractableReceiptSink
|
|
17
|
+
REQUIRED_OBSERVATION_FIELDS = %i[observation_id receipt_kind].freeze
|
|
18
|
+
REQUIRED_EVENT_FIELDS = %i[event_id receipt_kind observation_id].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :store, :client, :observations_store, :events_store, :producer
|
|
21
|
+
|
|
22
|
+
def initialize(
|
|
23
|
+
store: nil,
|
|
24
|
+
client: nil,
|
|
25
|
+
observations_store: :contractable_observations,
|
|
26
|
+
events_store: :contractable_events,
|
|
27
|
+
producer: { type: :embed, name: :contractable_receipt_sink }
|
|
28
|
+
)
|
|
29
|
+
raise ArgumentError, "ContractableReceiptSink requires store: or client:" unless store || client
|
|
30
|
+
|
|
31
|
+
@store = store
|
|
32
|
+
@client = client
|
|
33
|
+
@observations_store = observations_store.to_sym
|
|
34
|
+
@events_store = events_store.to_sym
|
|
35
|
+
@producer = producer
|
|
36
|
+
register_descriptors
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def record_observation(receipt)
|
|
40
|
+
validate_receipt!(receipt, REQUIRED_OBSERVATION_FIELDS, :contractable_observation)
|
|
41
|
+
target.write(
|
|
42
|
+
store: observations_store,
|
|
43
|
+
key: receipt[:observation_id].to_s,
|
|
44
|
+
value: receipt,
|
|
45
|
+
producer: producer
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def record_event(receipt)
|
|
50
|
+
validate_receipt!(receipt, REQUIRED_EVENT_FIELDS, :contractable_event)
|
|
51
|
+
target.append(
|
|
52
|
+
history: events_store,
|
|
53
|
+
event: receipt,
|
|
54
|
+
partition_key: :observation_id,
|
|
55
|
+
producer: producer
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def observation(observation_id)
|
|
60
|
+
normalize_read_result(target.read(store: observations_store, key: observation_id.to_s))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def events_for(observation_id)
|
|
64
|
+
history_partition_values(
|
|
65
|
+
store: events_store,
|
|
66
|
+
partition_key: :observation_id,
|
|
67
|
+
partition_value: observation_id.to_s
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def observations(status: nil, limit: nil)
|
|
72
|
+
all_facts = history_facts(store: observations_store)
|
|
73
|
+
by_key = {}
|
|
74
|
+
all_facts.each { |f| by_key[fact_key(f)] = f }
|
|
75
|
+
results = by_key.values.sort_by { |f| fact_transaction_time(f) }.map { |f| fact_value(f) }
|
|
76
|
+
results = results.select { |r| r[:status] == status } if status
|
|
77
|
+
limit ? results.take(limit) : results
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def error_events(limit: nil)
|
|
81
|
+
results = history_facts(store: events_store).map { |f| fact_value(f) }.select { |r| r[:severity] == :error }
|
|
82
|
+
limit ? results.take(limit) : results
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def target
|
|
88
|
+
client || store
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_receipt!(receipt, required_fields, expected_kind)
|
|
92
|
+
missing = required_fields.select { |f| receipt[f].nil? }
|
|
93
|
+
raise ArgumentError, "contractable receipt missing required fields: #{missing.join(", ")}" if missing.any?
|
|
94
|
+
|
|
95
|
+
actual_kind = receipt[:receipt_kind]
|
|
96
|
+
return if actual_kind == expected_kind
|
|
97
|
+
|
|
98
|
+
raise ArgumentError, "expected receipt_kind #{expected_kind.inspect}, got #{actual_kind.inspect}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def register_descriptors
|
|
102
|
+
target.register_descriptor(
|
|
103
|
+
kind: :store,
|
|
104
|
+
name: observations_store,
|
|
105
|
+
key: :observation_id,
|
|
106
|
+
fields: %i[observation_id name role stage status sampled async started_at finished_at duration_ms redaction],
|
|
107
|
+
producer: { system: :igniter_embed }
|
|
108
|
+
)
|
|
109
|
+
target.register_descriptor(
|
|
110
|
+
kind: :history,
|
|
111
|
+
name: events_store,
|
|
112
|
+
key: :event_id,
|
|
113
|
+
partition_key: :observation_id,
|
|
114
|
+
fields: %i[event_id observation_id event severity summary occurred_at]
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def normalize_read_result(result)
|
|
119
|
+
return result.value if result.respond_to?(:value) && result.respond_to?(:found?)
|
|
120
|
+
|
|
121
|
+
if result.is_a?(Hash) && result.key?(:value)
|
|
122
|
+
result[:value]
|
|
123
|
+
else
|
|
124
|
+
result
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def history_partition_values(store:, partition_key:, partition_value:)
|
|
129
|
+
if target.respond_to?(:history_partition)
|
|
130
|
+
return target.history_partition(
|
|
131
|
+
store: store,
|
|
132
|
+
partition_key: partition_key,
|
|
133
|
+
partition_value: partition_value
|
|
134
|
+
).map(&:value)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
history_facts(store: store)
|
|
138
|
+
.map { |f| fact_value(f) }
|
|
139
|
+
.select { |value| value[partition_key] == partition_value }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def history_facts(store:)
|
|
143
|
+
return target.history(store: store) if target.respond_to?(:history)
|
|
144
|
+
|
|
145
|
+
replay_result = target.replay(store: store)
|
|
146
|
+
return replay_result.facts if replay_result.respond_to?(:facts)
|
|
147
|
+
|
|
148
|
+
if replay_result.is_a?(Hash) && replay_result.key?(:facts)
|
|
149
|
+
replay_result[:facts]
|
|
150
|
+
else
|
|
151
|
+
Array(replay_result)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def fact_key(fact)
|
|
156
|
+
fact.is_a?(Hash) ? fact[:key] : fact.key
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def fact_value(fact)
|
|
160
|
+
fact.is_a?(Hash) ? fact[:value] : fact.value
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def fact_transaction_time(fact)
|
|
164
|
+
if fact.is_a?(Hash)
|
|
165
|
+
fact[:transaction_time] || fact[:timestamp] || 0
|
|
166
|
+
else
|
|
167
|
+
fact.transaction_time
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Igniter
|
|
8
|
+
module Store
|
|
9
|
+
unless defined?(NATIVE) && NATIVE
|
|
10
|
+
# Pure-Ruby Fact Struct — skipped when the Rust native extension is loaded.
|
|
11
|
+
# The native extension provides its own Fact class with :build and reader methods.
|
|
12
|
+
Fact = Struct.new(
|
|
13
|
+
:id,
|
|
14
|
+
:store,
|
|
15
|
+
:key,
|
|
16
|
+
:value,
|
|
17
|
+
:value_hash,
|
|
18
|
+
:causation,
|
|
19
|
+
:transaction_time,
|
|
20
|
+
:valid_time,
|
|
21
|
+
:schema_version,
|
|
22
|
+
:producer,
|
|
23
|
+
:derivation,
|
|
24
|
+
keyword_init: true
|
|
25
|
+
) do
|
|
26
|
+
# Canonical build entry point.
|
|
27
|
+
# valid_time: domain time (writer-supplied, nullable Float).
|
|
28
|
+
# term: backward-compat alias for valid_time — accepted but deprecated.
|
|
29
|
+
def self.build(store:, key:, value:, causation: nil, valid_time: nil, term: nil,
|
|
30
|
+
schema_version: 1, producer: nil, derivation: nil)
|
|
31
|
+
vt = valid_time.nil? ? (term ? term.to_f : nil) : valid_time.to_f
|
|
32
|
+
serialized = JSON.generate(stable_sort(value))
|
|
33
|
+
new(
|
|
34
|
+
id: SecureRandom.uuid,
|
|
35
|
+
store: store,
|
|
36
|
+
key: key,
|
|
37
|
+
value: deep_freeze(value),
|
|
38
|
+
value_hash: Digest::SHA256.hexdigest(serialized),
|
|
39
|
+
causation: causation,
|
|
40
|
+
transaction_time: Process.clock_gettime(Process::CLOCK_REALTIME),
|
|
41
|
+
valid_time: vt,
|
|
42
|
+
schema_version: schema_version,
|
|
43
|
+
producer: producer ? deep_freeze(producer) : nil,
|
|
44
|
+
derivation: derivation ? deep_freeze(derivation) : nil
|
|
45
|
+
).freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private_class_method def self.stable_sort(value)
|
|
49
|
+
case value
|
|
50
|
+
when Hash
|
|
51
|
+
value.sort_by { |key, _entry| key.to_s }.to_h do |key, entry|
|
|
52
|
+
[key.to_s, stable_sort(entry)]
|
|
53
|
+
end
|
|
54
|
+
when Array
|
|
55
|
+
value.map { |entry| stable_sort(entry) }
|
|
56
|
+
else
|
|
57
|
+
value
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private_class_method def self.deep_freeze(value)
|
|
62
|
+
case value
|
|
63
|
+
when Hash
|
|
64
|
+
value.transform_values { |entry| deep_freeze(entry) }.freeze
|
|
65
|
+
when Array
|
|
66
|
+
value.map { |entry| deep_freeze(entry) }.freeze
|
|
67
|
+
else
|
|
68
|
+
value.frozen? ? value : value.dup.freeze
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Backward-compat: callers that read fact.timestamp still work.
|
|
73
|
+
alias_method :timestamp, :transaction_time
|
|
74
|
+
# Backward-compat: callers that read fact.term still work.
|
|
75
|
+
alias_method :term, :valid_time
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reopen Fact (Ruby Struct or native class) and add from_h + normalizations.
|
|
80
|
+
class Fact
|
|
81
|
+
if defined?(Igniter::Store::NATIVE) && Igniter::Store::NATIVE
|
|
82
|
+
# Native extension stores `store` as a Rust String; normalize to Symbol
|
|
83
|
+
# to match the Ruby Struct fallback behaviour.
|
|
84
|
+
alias_method :_native_store_str, :store
|
|
85
|
+
def store = _native_store_str.to_sym
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Reconstruct a Fact from a wire-deserialized hash.
|
|
89
|
+
# Accepts both old field names (timestamp, term) and new names
|
|
90
|
+
# (transaction_time, valid_time) for smooth transition.
|
|
91
|
+
#
|
|
92
|
+
# In native mode: Fact.new is unavailable (no Ruby allocator), so we call
|
|
93
|
+
# Fact.build which recomputes id and transaction_time. This is a known
|
|
94
|
+
# Phase 2 gap for time-travel fidelity over the network.
|
|
95
|
+
def self.from_h(h)
|
|
96
|
+
h = h.transform_keys(&:to_sym)
|
|
97
|
+
h[:store] = h.fetch(:store).to_sym
|
|
98
|
+
# Accept both old (timestamp) and new (transaction_time) key names.
|
|
99
|
+
h[:transaction_time] = (h[:transaction_time] || h[:timestamp])&.to_f || 0.0
|
|
100
|
+
h[:valid_time] = (h[:valid_time] || h[:term])&.to_f
|
|
101
|
+
|
|
102
|
+
if defined?(Igniter::Store::NATIVE) && Igniter::Store::NATIVE
|
|
103
|
+
build(
|
|
104
|
+
store: h[:store],
|
|
105
|
+
key: h[:key],
|
|
106
|
+
value: h[:value],
|
|
107
|
+
causation: h[:causation],
|
|
108
|
+
valid_time: h[:valid_time],
|
|
109
|
+
schema_version: h.fetch(:schema_version, 1),
|
|
110
|
+
producer: h[:producer],
|
|
111
|
+
derivation: h[:derivation]
|
|
112
|
+
)
|
|
113
|
+
else
|
|
114
|
+
new(**h.slice(:id, :store, :key, :value, :value_hash, :causation,
|
|
115
|
+
:transaction_time, :valid_time, :schema_version,
|
|
116
|
+
:producer, :derivation)).freeze
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Store
|
|
7
|
+
unless defined?(NATIVE) && NATIVE
|
|
8
|
+
# Pure-Ruby FactLog — skipped when the Rust native extension is loaded.
|
|
9
|
+
class FactLog
|
|
10
|
+
include MonitorMixin
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
super()
|
|
14
|
+
@log = []
|
|
15
|
+
@by_id = {}
|
|
16
|
+
@by_key = Hash.new { |hash, key| hash[key] = [] }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def append(fact)
|
|
20
|
+
synchronize do
|
|
21
|
+
@log << fact
|
|
22
|
+
@by_id[fact.id] = fact
|
|
23
|
+
@by_key[[fact.store, fact.key]] << fact
|
|
24
|
+
end
|
|
25
|
+
fact
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def replay(fact)
|
|
29
|
+
synchronize do
|
|
30
|
+
@log << fact
|
|
31
|
+
@by_id[fact.id] = fact
|
|
32
|
+
@by_key[[fact.store, fact.key]] << fact
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def latest_for(store:, key:, as_of: nil)
|
|
37
|
+
facts = synchronize { @by_key[[store, key]].dup }
|
|
38
|
+
facts = facts.select { |fact| fact.transaction_time <= as_of } if as_of
|
|
39
|
+
facts.last
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def facts_for(store:, key: nil, since: nil, as_of: nil)
|
|
43
|
+
synchronize do
|
|
44
|
+
facts = key ? @by_key[[store, key]].dup : @log.select { |fact| fact.store == store }
|
|
45
|
+
facts = facts.select { |fact| fact.transaction_time >= since } if since
|
|
46
|
+
facts = facts.select { |fact| fact.transaction_time <= as_of } if as_of
|
|
47
|
+
facts
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def query_scope(store:, filters:, as_of: nil)
|
|
52
|
+
synchronize do
|
|
53
|
+
seen = {}
|
|
54
|
+
@by_key.each do |(s, k), facts|
|
|
55
|
+
next unless s == store
|
|
56
|
+
candidates = as_of ? facts.select { |f| f.transaction_time <= as_of } : facts
|
|
57
|
+
latest = candidates.last
|
|
58
|
+
next unless latest
|
|
59
|
+
seen[k] = latest if matches_filters?(latest.value, filters)
|
|
60
|
+
end
|
|
61
|
+
seen.values
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def all_facts
|
|
66
|
+
synchronize { @log.dup }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def size
|
|
70
|
+
synchronize { @log.size }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def matches_filters?(value, filters)
|
|
76
|
+
return false unless value.is_a?(Hash)
|
|
77
|
+
filters.all? { |k, v| value[k] == v }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if defined?(NATIVE) && NATIVE
|
|
83
|
+
# Patch the Rust-native FactLog to expose all_facts.
|
|
84
|
+
# The native append is intercepted to track which stores have been written;
|
|
85
|
+
# all_facts then collects via facts_for(store:) per known store.
|
|
86
|
+
class FactLog
|
|
87
|
+
alias_method :_native_append_unwrapped, :append
|
|
88
|
+
|
|
89
|
+
def append(fact)
|
|
90
|
+
@_seen_stores ||= []
|
|
91
|
+
s = fact.store
|
|
92
|
+
@_seen_stores << s unless @_seen_stores.include?(s)
|
|
93
|
+
_native_append_unwrapped(fact)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def all_facts
|
|
97
|
+
@_seen_stores ||= []
|
|
98
|
+
@_seen_stores.flat_map { |s| facts_for(store: s) }.sort_by(&:transaction_time)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|