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,447 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Igniter
|
|
7
|
+
module Store
|
|
8
|
+
module Protocol
|
|
9
|
+
class Interpreter
|
|
10
|
+
HANDLERS = {
|
|
11
|
+
store: Handlers::StoreHandler,
|
|
12
|
+
history: Handlers::HistoryHandler,
|
|
13
|
+
access_path: Handlers::AccessPathHandler,
|
|
14
|
+
relation: Handlers::RelationHandler,
|
|
15
|
+
projection: Handlers::ProjectionHandler,
|
|
16
|
+
derivation: Handlers::DerivationHandler,
|
|
17
|
+
command: Handlers::CommandHandler,
|
|
18
|
+
effect: Handlers::EffectHandler,
|
|
19
|
+
subscription: Handlers::SubscriptionHandler,
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Default thresholds for storage-level alerts in observability_snapshot.
|
|
23
|
+
# Override per-interpreter via alert_thresholds: at construction time.
|
|
24
|
+
DEFAULT_STORAGE_ALERT_THRESHOLDS = {
|
|
25
|
+
quarantine_receipt_count: 10,
|
|
26
|
+
storage_byte_size: 1_073_741_824 # 1 GiB
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def initialize(store, alert_thresholds: {})
|
|
30
|
+
@store = store
|
|
31
|
+
@registry = {} # content fingerprint → Receipt (dedup)
|
|
32
|
+
@alert_thresholds = DEFAULT_STORAGE_ALERT_THRESHOLDS.merge(alert_thresholds)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Generic descriptor registration — dispatches by kind:.
|
|
36
|
+
# Returns a Receipt with status :accepted, :rejected, or :deduplicated.
|
|
37
|
+
def register(descriptor)
|
|
38
|
+
descriptor = descriptor.transform_keys(&:to_sym)
|
|
39
|
+
kind = descriptor[:kind]&.to_sym
|
|
40
|
+
|
|
41
|
+
return Receipt.rejection("Missing required field: kind") unless kind
|
|
42
|
+
|
|
43
|
+
handler_class = HANDLERS[kind]
|
|
44
|
+
return Receipt.rejection("Unknown descriptor kind: #{kind.inspect}", kind: kind) unless handler_class
|
|
45
|
+
|
|
46
|
+
fp = fingerprint(descriptor)
|
|
47
|
+
return Receipt.deduplicated(kind: kind, name: descriptor[:name]&.to_sym) if @registry.key?(fp)
|
|
48
|
+
|
|
49
|
+
receipt = handler_class.new(@store).call(descriptor)
|
|
50
|
+
@registry[fp] = receipt if receipt.accepted?
|
|
51
|
+
receipt
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Named registration helpers — vocabulary aliases for register.
|
|
55
|
+
def register_store(descriptor) = register(descriptor)
|
|
56
|
+
def register_history(descriptor) = register(descriptor)
|
|
57
|
+
def register_access_path(descriptor) = register(descriptor)
|
|
58
|
+
def register_relation(descriptor) = register(descriptor)
|
|
59
|
+
def register_projection(descriptor) = register(descriptor)
|
|
60
|
+
def register_derivation(descriptor) = register(descriptor)
|
|
61
|
+
def register_command(descriptor) = register(descriptor)
|
|
62
|
+
def register_effect(descriptor) = register(descriptor)
|
|
63
|
+
def register_subscription(descriptor) = register(descriptor)
|
|
64
|
+
|
|
65
|
+
# Write a fact. Returns a write Receipt carrying fact_id and value_hash.
|
|
66
|
+
def write(store:, key:, value:, causation: nil, valid_time: nil, term: nil,
|
|
67
|
+
producer: nil, derivation: nil)
|
|
68
|
+
fact = @store.write(
|
|
69
|
+
store: store.to_sym,
|
|
70
|
+
key: key,
|
|
71
|
+
value: value,
|
|
72
|
+
causation: causation,
|
|
73
|
+
valid_time: valid_time,
|
|
74
|
+
term: term,
|
|
75
|
+
producer: producer,
|
|
76
|
+
derivation: derivation
|
|
77
|
+
)
|
|
78
|
+
Receipt.write_accepted(store: store.to_sym, key: key, fact: fact)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Append an event to a history. Returns an append Receipt carrying the
|
|
82
|
+
# generated fact key, fact_id, and value_hash.
|
|
83
|
+
def append(history:, event:, key: nil, partition_key: nil, schema_version: 1,
|
|
84
|
+
valid_time: nil, term: nil, producer: nil, derivation: nil)
|
|
85
|
+
fact = @store.append(
|
|
86
|
+
history: history.to_sym,
|
|
87
|
+
event: event,
|
|
88
|
+
schema_version: schema_version,
|
|
89
|
+
valid_time: valid_time,
|
|
90
|
+
term: term,
|
|
91
|
+
partition_key: partition_key&.to_sym,
|
|
92
|
+
producer: producer,
|
|
93
|
+
derivation: derivation
|
|
94
|
+
)
|
|
95
|
+
Receipt.append_accepted(history: history.to_sym, fact: fact, requested_key: key)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Accept a full fact packet hash (kind: :fact) and write it to the store.
|
|
99
|
+
# Designed for wire replay, server ingestion, and protocol-native clients.
|
|
100
|
+
# Note: at: is recorded in the packet but cannot override the engine timestamp —
|
|
101
|
+
# the engine assigns monotonic timestamps on write.
|
|
102
|
+
def write_fact(packet)
|
|
103
|
+
packet = packet.transform_keys(&:to_sym)
|
|
104
|
+
kind = packet[:kind]&.to_sym
|
|
105
|
+
return Receipt.rejection("write_fact: expected kind: :fact, got #{kind.inspect}", kind: :fact) unless kind == :fact
|
|
106
|
+
|
|
107
|
+
store = packet[:store]
|
|
108
|
+
key = packet[:key]
|
|
109
|
+
value = packet[:value]
|
|
110
|
+
return Receipt.rejection("write_fact: missing store:", kind: :fact) unless store
|
|
111
|
+
return Receipt.rejection("write_fact: missing key:", kind: :fact) unless key
|
|
112
|
+
return Receipt.rejection("write_fact: missing value:", kind: :fact) unless value
|
|
113
|
+
|
|
114
|
+
fact = @store.write(
|
|
115
|
+
store: store.to_sym,
|
|
116
|
+
key: key.to_s,
|
|
117
|
+
value: value,
|
|
118
|
+
causation: packet[:causation],
|
|
119
|
+
valid_time: packet[:valid_time],
|
|
120
|
+
term: packet[:term],
|
|
121
|
+
producer: packet[:producer],
|
|
122
|
+
derivation: packet[:derivation]
|
|
123
|
+
)
|
|
124
|
+
Receipt.write_accepted(store: store.to_sym, key: key, fact: fact)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Read the current value for a key (or nil).
|
|
128
|
+
def read(store:, key:, as_of: nil)
|
|
129
|
+
@store.read(store: store.to_sym, key: key, as_of: as_of)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Query facts matching all where: conditions.
|
|
133
|
+
# Performs a latest-per-key scan; access paths provide introspection metadata
|
|
134
|
+
# but index-accelerated query planning is a future engine concern.
|
|
135
|
+
def query(store:, where: {}, order: nil, limit: nil, as_of: nil)
|
|
136
|
+
store_sym = store.to_sym
|
|
137
|
+
facts = @store.history(store: store_sym, as_of: as_of)
|
|
138
|
+
|
|
139
|
+
# Reduce to latest fact per key.
|
|
140
|
+
latest = {}
|
|
141
|
+
facts.each do |f|
|
|
142
|
+
existing = latest[f.key]
|
|
143
|
+
latest[f.key] = f if existing.nil? || f.transaction_time > existing.transaction_time
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
rows = latest.values
|
|
147
|
+
|
|
148
|
+
where.each do |field, val|
|
|
149
|
+
sym = field.to_sym
|
|
150
|
+
rows = rows.select { |fact| fact.value[sym] == val }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
rows = rows.sort_by { |fact| fact.value[order.to_sym] } if order
|
|
154
|
+
rows = rows.first(limit) if limit
|
|
155
|
+
rows.map { |fact| { key: fact.key, value: fact.value } }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Resolve a named relation (delegates to IgniterStore#resolve).
|
|
159
|
+
def resolve(relation_name, from:, as_of: nil)
|
|
160
|
+
@store.resolve(relation_name, from: from, as_of: as_of)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Read-only provenance: compact causation chain for one store/key.
|
|
164
|
+
def causation_chain(store:, key:)
|
|
165
|
+
@store.causation_chain(store: store.to_sym, key: key)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Read-only provenance: causal proof and downstream derivation metadata.
|
|
169
|
+
def lineage(store:, key:)
|
|
170
|
+
@store.lineage(store: store.to_sym, key: key)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Read-only provenance: compact fact reference, without fact value.
|
|
174
|
+
def fact_ref(fact_id)
|
|
175
|
+
@store.fact_ref(fact_id)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Resolve a named relation with source keys preserved for client-side
|
|
179
|
+
# typed record reconstruction. The value-only #resolve API remains
|
|
180
|
+
# stable for existing protocol callers.
|
|
181
|
+
def resolve_items(relation_name, from:, as_of: nil)
|
|
182
|
+
rule = @store.schema_graph.relation_for(name: relation_name)
|
|
183
|
+
raise ArgumentError, "No relation registered: #{relation_name.inspect}" unless rule
|
|
184
|
+
|
|
185
|
+
index_entry = @store.read(store: :"__rel_#{relation_name}", key: from.to_s, as_of: as_of)
|
|
186
|
+
return [] unless index_entry
|
|
187
|
+
|
|
188
|
+
index_entry[:keys].filter_map do |key|
|
|
189
|
+
value = @store.read(store: rule.source, key: key, as_of: as_of)
|
|
190
|
+
{ key: key, value: value } if value
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# OP2: unified protocol metadata snapshot.
|
|
195
|
+
# Combines raw descriptor registry (store/history/subscription),
|
|
196
|
+
# engine routing metadata (access paths), and all derived graph artifacts
|
|
197
|
+
# (relations, projections, derivations, scatters, retention) into one
|
|
198
|
+
# canonical introspection response.
|
|
199
|
+
# Used by Companion, StoreServer, visual tools, and compliance test kits.
|
|
200
|
+
def metadata_snapshot
|
|
201
|
+
g = @store.schema_graph
|
|
202
|
+
ds = g.descriptor_snapshot
|
|
203
|
+
snap = {
|
|
204
|
+
schema_version: 1,
|
|
205
|
+
stores: ds[:stores],
|
|
206
|
+
histories: ds[:histories],
|
|
207
|
+
access_paths: g.metadata_snapshot,
|
|
208
|
+
relations: g.relation_snapshot,
|
|
209
|
+
projections: g.projection_snapshot,
|
|
210
|
+
commands: g.command_snapshot,
|
|
211
|
+
effects: g.effect_snapshot,
|
|
212
|
+
derivations: g.derivation_snapshot,
|
|
213
|
+
scatters: g.scatter_snapshot,
|
|
214
|
+
subscriptions: ds[:subscriptions],
|
|
215
|
+
retention: g.retention_snapshot
|
|
216
|
+
}
|
|
217
|
+
stats = @store.storage_stats
|
|
218
|
+
snap[:storage] = stats if stats
|
|
219
|
+
snap
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Physical storage stats from the backend (SegmentedFileBackend).
|
|
223
|
+
# Returns nil when the backend does not support it.
|
|
224
|
+
def storage_stats(store: nil)
|
|
225
|
+
@store.storage_stats(store: store)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Detailed per-segment manifest from the backend.
|
|
229
|
+
# Returns nil when the backend does not support it.
|
|
230
|
+
def segment_manifest(store: nil)
|
|
231
|
+
@store.segment_manifest(store: store)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Raw descriptor-only snapshot (store/history/subscription).
|
|
235
|
+
# Use metadata_snapshot for the full picture; this is a lower-level accessor.
|
|
236
|
+
def descriptor_snapshot
|
|
237
|
+
@store.schema_graph.descriptor_snapshot
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# OP4: generates a SyncProfile for a cold hub or incremental update.
|
|
241
|
+
#
|
|
242
|
+
# Full sync (cursor: nil): all facts + full descriptor snapshot
|
|
243
|
+
# Incremental (cursor: given): facts since cursor[:value] timestamp + snapshot
|
|
244
|
+
# stores: Array<Symbol> optional store filter (nil = all stores)
|
|
245
|
+
#
|
|
246
|
+
# The returned SyncProfile#next_cursor should be persisted by the hub and
|
|
247
|
+
# sent back as cursor: on the next call to receive only new facts.
|
|
248
|
+
def sync_hub_profile(as_of: nil, cursor: nil, stores: nil)
|
|
249
|
+
from = cursor&.dig(:value)
|
|
250
|
+
|
|
251
|
+
raw_facts = @store.fact_log_all(since: from, as_of: as_of)
|
|
252
|
+
|
|
253
|
+
if stores
|
|
254
|
+
allowed = Array(stores).map(&:to_sym).to_set
|
|
255
|
+
raw_facts = raw_facts.select { |f| allowed.include?(f.store) }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
fact_packets = raw_facts.map { |f| serialize_fact(f) }
|
|
259
|
+
|
|
260
|
+
SyncProfile.new(
|
|
261
|
+
schema_version: 1,
|
|
262
|
+
kind: :sync_hub_profile,
|
|
263
|
+
generated_at: Process.clock_gettime(Process::CLOCK_REALTIME),
|
|
264
|
+
cursor: cursor,
|
|
265
|
+
descriptors: metadata_snapshot,
|
|
266
|
+
facts: fact_packets,
|
|
267
|
+
retention: @store.schema_graph.retention_snapshot,
|
|
268
|
+
compaction_receipts: compaction_receipt_summaries,
|
|
269
|
+
compaction_activity: compaction_activity,
|
|
270
|
+
subscription_checkpoints: {}
|
|
271
|
+
)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Normalized compaction lifecycle activity.
|
|
275
|
+
#
|
|
276
|
+
# Returns a response envelope with schema_version, generated_at, filters,
|
|
277
|
+
# activity (normalized entries), and count.
|
|
278
|
+
#
|
|
279
|
+
# Filtering:
|
|
280
|
+
# store: delegate to IgniterStore#compaction_activity(store:)
|
|
281
|
+
# kind: filter entries by :kind
|
|
282
|
+
# since: keep entries where occurred_at >= since
|
|
283
|
+
# limit: cap result count after all other filters
|
|
284
|
+
def compaction_activity(store: nil, kind: nil, since: nil, limit: nil)
|
|
285
|
+
store_sym = store&.to_sym
|
|
286
|
+
entries = @store.compaction_activity(store: store_sym)
|
|
287
|
+
|
|
288
|
+
entries = entries.select { |e| e[:kind].to_s == kind.to_s } if kind
|
|
289
|
+
entries = entries.select { |e| e[:occurred_at] >= since.to_f } if since
|
|
290
|
+
entries = entries.first(limit.to_i) if limit
|
|
291
|
+
|
|
292
|
+
{
|
|
293
|
+
schema_version: 1,
|
|
294
|
+
generated_at: Time.now.iso8601(3),
|
|
295
|
+
filters: {
|
|
296
|
+
store: store&.to_s,
|
|
297
|
+
kind: kind&.to_s,
|
|
298
|
+
since: since&.to_f,
|
|
299
|
+
limit: limit&.to_i
|
|
300
|
+
},
|
|
301
|
+
activity: entries,
|
|
302
|
+
count: entries.size
|
|
303
|
+
}
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# OP4: return all (or range-filtered) facts as serialized fact packets.
|
|
307
|
+
# Suitable for WAL replay to a cold hub or test double.
|
|
308
|
+
#
|
|
309
|
+
# Filter forms:
|
|
310
|
+
# { store: :name }
|
|
311
|
+
# { store: :name, key: "event-key" }
|
|
312
|
+
# { store: :name, partition_key: :tracker_id, partition_value: "sleep" }
|
|
313
|
+
def replay(from: nil, to: nil, filter: nil)
|
|
314
|
+
if filter
|
|
315
|
+
filter = filter.transform_keys(&:to_sym)
|
|
316
|
+
store_sym = filter[:store]&.to_sym
|
|
317
|
+
|
|
318
|
+
if store_sym && filter.key?(:key)
|
|
319
|
+
return @store.history(
|
|
320
|
+
store: store_sym,
|
|
321
|
+
key: filter[:key],
|
|
322
|
+
since: from,
|
|
323
|
+
as_of: to
|
|
324
|
+
).map { |f| serialize_fact(f) }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
if store_sym && filter[:partition_key] && filter.key?(:partition_value)
|
|
328
|
+
return @store.history_partition(
|
|
329
|
+
store: store_sym,
|
|
330
|
+
partition_key: filter[:partition_key].to_sym,
|
|
331
|
+
partition_value: filter[:partition_value],
|
|
332
|
+
since: from,
|
|
333
|
+
as_of: to
|
|
334
|
+
).map { |f| serialize_fact(f) }
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
raw_facts = @store.fact_log_all(since: from, as_of: to)
|
|
339
|
+
raw_facts = raw_facts.select { |f| f.store == filter[:store]&.to_sym } if filter && filter[:store]
|
|
340
|
+
raw_facts.map { |f| serialize_fact(f) }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# OP3: returns the WireEnvelope router for this interpreter.
|
|
344
|
+
# Accepts process-boundary envelope hashes and returns response envelopes.
|
|
345
|
+
def wire
|
|
346
|
+
@wire ||= WireEnvelope.new(self)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# OP3: convenience shorthand — dispatch one wire envelope hash.
|
|
350
|
+
def dispatch(envelope)
|
|
351
|
+
wire.dispatch(envelope)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# ── Observability ─────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
# Returns the canonical storage-level observability snapshot.
|
|
357
|
+
#
|
|
358
|
+
# Canonical shape (same top-level keys at every layer; server-only fields
|
|
359
|
+
# are nil at the protocol level):
|
|
360
|
+
# schema_version, generated_at, status, uptime_ms (nil),
|
|
361
|
+
# metrics (nil), alerts, storage, server (nil)
|
|
362
|
+
#
|
|
363
|
+
# storage-level alerts (quarantine_receipt_count, storage_byte_size) are
|
|
364
|
+
# checked against +alert_thresholds+ configured at construction time.
|
|
365
|
+
def observability_snapshot
|
|
366
|
+
storage = @store.storage_stats rescue nil
|
|
367
|
+
alerts = check_storage_alerts(storage)
|
|
368
|
+
{
|
|
369
|
+
schema_version: 1,
|
|
370
|
+
generated_at: Time.now.iso8601(3),
|
|
371
|
+
status: :ready,
|
|
372
|
+
uptime_ms: nil,
|
|
373
|
+
metrics: nil,
|
|
374
|
+
alerts: alerts,
|
|
375
|
+
storage: storage,
|
|
376
|
+
server: nil
|
|
377
|
+
}
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
private
|
|
381
|
+
|
|
382
|
+
def check_storage_alerts(storage)
|
|
383
|
+
return [] unless storage
|
|
384
|
+
alerts = []
|
|
385
|
+
stores = storage["stores"] || {}
|
|
386
|
+
|
|
387
|
+
qc = stores.values.sum { |s| s["quarantine_receipt_count"].to_i }
|
|
388
|
+
t = @alert_thresholds[:quarantine_receipt_count]
|
|
389
|
+
if t && qc > t
|
|
390
|
+
alerts << {
|
|
391
|
+
type: :quarantine_receipt_count,
|
|
392
|
+
threshold: t,
|
|
393
|
+
current_value: qc,
|
|
394
|
+
message: "quarantine_receipt_count exceeded threshold: #{qc} > #{t}"
|
|
395
|
+
}
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
bs = stores.values.sum { |s| s["byte_size"].to_i }
|
|
399
|
+
t = @alert_thresholds[:storage_byte_size]
|
|
400
|
+
if t && bs > t
|
|
401
|
+
alerts << {
|
|
402
|
+
type: :storage_byte_size,
|
|
403
|
+
threshold: t,
|
|
404
|
+
current_value: bs,
|
|
405
|
+
message: "storage_byte_size exceeded threshold: #{bs} > #{t}"
|
|
406
|
+
}
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
alerts
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def fingerprint(descriptor)
|
|
413
|
+
Digest::SHA256.hexdigest(descriptor.to_a.sort_by { |k, _| k.to_s }.inspect)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def serialize_fact(fact)
|
|
417
|
+
{
|
|
418
|
+
schema_version: 1,
|
|
419
|
+
kind: :fact,
|
|
420
|
+
id: fact.id,
|
|
421
|
+
store: fact.store,
|
|
422
|
+
key: fact.key,
|
|
423
|
+
value: fact.value,
|
|
424
|
+
value_hash: fact.value_hash,
|
|
425
|
+
causation: fact.causation,
|
|
426
|
+
transaction_time: fact.transaction_time,
|
|
427
|
+
valid_time: fact.valid_time,
|
|
428
|
+
producer: fact.producer,
|
|
429
|
+
derivation: fact.derivation
|
|
430
|
+
}
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def compaction_receipt_summaries
|
|
434
|
+
@store.compaction_receipts.map do |f|
|
|
435
|
+
{
|
|
436
|
+
id: f.id,
|
|
437
|
+
compacted_store: f.value[:compacted_store],
|
|
438
|
+
strategy: f.value[:strategy],
|
|
439
|
+
compacted_count: f.value[:compacted_count],
|
|
440
|
+
compacted_at: f.value[:compacted_at]
|
|
441
|
+
}
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Store
|
|
5
|
+
module Protocol
|
|
6
|
+
Receipt = Struct.new(
|
|
7
|
+
:schema_version,
|
|
8
|
+
:kind,
|
|
9
|
+
:status, # :accepted | :rejected | :deduplicated
|
|
10
|
+
:name, # descriptor name (descriptor receipts)
|
|
11
|
+
:store, # store name (write receipts)
|
|
12
|
+
:key, # record key (write receipts)
|
|
13
|
+
:fact_id, # fact UUID (write receipts)
|
|
14
|
+
:value_hash, # SHA256 of value (write receipts)
|
|
15
|
+
:warnings,
|
|
16
|
+
:errors,
|
|
17
|
+
:derived,
|
|
18
|
+
keyword_init: true
|
|
19
|
+
) do
|
|
20
|
+
def accepted? = status == :accepted
|
|
21
|
+
def rejected? = status == :rejected
|
|
22
|
+
def deduplicated? = status == :deduplicated
|
|
23
|
+
|
|
24
|
+
def self.accepted(kind:, name:, warnings: [], derived: [])
|
|
25
|
+
new(schema_version: 1, kind: kind, status: :accepted,
|
|
26
|
+
name: name, warnings: warnings, errors: [], derived: derived)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.rejection(message, kind: nil, name: nil)
|
|
30
|
+
new(schema_version: 1, kind: kind, status: :rejected,
|
|
31
|
+
name: name, warnings: [], errors: [message], derived: [])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.deduplicated(kind:, name:)
|
|
35
|
+
new(schema_version: 1, kind: kind, status: :deduplicated,
|
|
36
|
+
name: name, warnings: ["descriptor already registered"], errors: [], derived: [])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.write_accepted(store:, key:, fact:)
|
|
40
|
+
new(
|
|
41
|
+
schema_version: 1,
|
|
42
|
+
kind: :receipt,
|
|
43
|
+
status: :accepted,
|
|
44
|
+
store: store,
|
|
45
|
+
key: key,
|
|
46
|
+
fact_id: fact.id,
|
|
47
|
+
value_hash: fact.value_hash,
|
|
48
|
+
warnings: [],
|
|
49
|
+
errors: [],
|
|
50
|
+
derived: []
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.append_accepted(history:, fact:, requested_key: nil)
|
|
55
|
+
warnings = []
|
|
56
|
+
if requested_key && requested_key.to_s != fact.key.to_s
|
|
57
|
+
warnings << "append key is metadata only in protocol v0; generated fact key returned"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
new(
|
|
61
|
+
schema_version: 1,
|
|
62
|
+
kind: :append_receipt,
|
|
63
|
+
status: :accepted,
|
|
64
|
+
store: history,
|
|
65
|
+
key: fact.key,
|
|
66
|
+
fact_id: fact.id,
|
|
67
|
+
value_hash: fact.value_hash,
|
|
68
|
+
warnings: warnings,
|
|
69
|
+
errors: [],
|
|
70
|
+
derived: []
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_h
|
|
75
|
+
{
|
|
76
|
+
schema_version: schema_version,
|
|
77
|
+
kind: kind,
|
|
78
|
+
status: status,
|
|
79
|
+
name: name,
|
|
80
|
+
store: store,
|
|
81
|
+
key: key,
|
|
82
|
+
fact_id: fact_id,
|
|
83
|
+
value_hash: value_hash,
|
|
84
|
+
warnings: warnings,
|
|
85
|
+
errors: errors,
|
|
86
|
+
derived: derived
|
|
87
|
+
}.compact
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def to_json(*args)
|
|
91
|
+
to_h.to_json(*args)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Store
|
|
5
|
+
module Protocol
|
|
6
|
+
# OP4 — Sync Hub Profile value object.
|
|
7
|
+
#
|
|
8
|
+
# A SyncProfile is a point-in-time package that carries everything a durable
|
|
9
|
+
# hub needs to synchronize with the live store:
|
|
10
|
+
#
|
|
11
|
+
# descriptors — full metadata_snapshot (OP2)
|
|
12
|
+
# facts — serialized fact packets (full or incremental)
|
|
13
|
+
# retention — retention policy snapshot
|
|
14
|
+
# compaction_receipts — compaction history
|
|
15
|
+
# cursor — watermark for next incremental sync
|
|
16
|
+
# subscription_checkpoints — last-delivered position per subscription (OP4+)
|
|
17
|
+
#
|
|
18
|
+
# Cursor:
|
|
19
|
+
# nil — this is a fresh full snapshot (hub has never synced)
|
|
20
|
+
# { kind: :timestamp, value: Float } — resume from this timestamp
|
|
21
|
+
#
|
|
22
|
+
# A hub stores the cursor locally. On the next sync request it sends
|
|
23
|
+
# cursor: back and receives only facts written since that timestamp.
|
|
24
|
+
SyncProfile = Struct.new(
|
|
25
|
+
:schema_version,
|
|
26
|
+
:kind,
|
|
27
|
+
:generated_at, # Float (CLOCK_REALTIME)
|
|
28
|
+
:cursor, # { kind: :timestamp, value: Float } | nil
|
|
29
|
+
:descriptors, # Hash — from Protocol::Interpreter#metadata_snapshot
|
|
30
|
+
:facts, # Array<Hash> — serialized fact packets
|
|
31
|
+
:retention, # Hash — from SchemaGraph#retention_snapshot
|
|
32
|
+
:compaction_receipts, # Array<Hash> — compaction summaries
|
|
33
|
+
:compaction_activity, # Hash — normalized activity envelope from Interpreter#compaction_activity
|
|
34
|
+
:subscription_checkpoints, # Hash — subscription name → last position
|
|
35
|
+
keyword_init: true
|
|
36
|
+
) do
|
|
37
|
+
def full? = cursor.nil?
|
|
38
|
+
def incremental? = !full?
|
|
39
|
+
def fact_count = facts.size
|
|
40
|
+
|
|
41
|
+
def to_json(*opts) = to_h.to_json(*opts)
|
|
42
|
+
|
|
43
|
+
# Build the cursor that a hub should send on its next sync request.
|
|
44
|
+
def next_cursor
|
|
45
|
+
return nil if facts.empty?
|
|
46
|
+
latest_ts = facts.max_by { |f| f[:transaction_time] || f[:timestamp] }
|
|
47
|
+
.then { |f| f[:transaction_time] || f[:timestamp] }
|
|
48
|
+
{ kind: :timestamp, value: latest_ts }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|