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,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Store
|
|
5
|
+
# Configuration value object for StoreServer.
|
|
6
|
+
#
|
|
7
|
+
# All fields have safe defaults — a zero-argument ServerConfig starts an
|
|
8
|
+
# in-memory server on 127.0.0.1:7400 with info-level logging to stdout.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# config = Igniter::Store::ServerConfig.new(
|
|
12
|
+
# host: "0.0.0.0",
|
|
13
|
+
# port: 7400,
|
|
14
|
+
# backend: :file,
|
|
15
|
+
# path: "/var/lib/igniter/store.wal",
|
|
16
|
+
# log_level: :info,
|
|
17
|
+
# pid_file: "/var/run/igniter-ledger.pid",
|
|
18
|
+
# drain_timeout: 10
|
|
19
|
+
# )
|
|
20
|
+
class ServerConfig
|
|
21
|
+
DEFAULTS = {
|
|
22
|
+
host: "127.0.0.1",
|
|
23
|
+
port: 7400,
|
|
24
|
+
transport: :tcp,
|
|
25
|
+
backend: :memory,
|
|
26
|
+
path: nil,
|
|
27
|
+
log_io: $stdout,
|
|
28
|
+
log_level: :info,
|
|
29
|
+
pid_file: nil,
|
|
30
|
+
drain_timeout: 5, # seconds to wait for active connections before force-stop
|
|
31
|
+
max_connections: nil, # nil = unlimited
|
|
32
|
+
changefeed: {} # ChangefeedBuffer constructor kwargs; {} = all defaults
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
attr_reader(*DEFAULTS.keys)
|
|
36
|
+
|
|
37
|
+
def initialize(**opts)
|
|
38
|
+
unknown = opts.keys - DEFAULTS.keys
|
|
39
|
+
raise ArgumentError, "Unknown ServerConfig keys: #{unknown.inspect}" if unknown.any?
|
|
40
|
+
|
|
41
|
+
DEFAULTS.merge(opts).each { |k, v| instance_variable_set(:"@#{k}", v) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Bind address string for the server socket.
|
|
45
|
+
# TCP: "host:port". Unix: the socket path (stored in @host).
|
|
46
|
+
def bind_address
|
|
47
|
+
transport == :unix ? host : "#{host}:#{port}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_h
|
|
51
|
+
DEFAULTS.keys.to_h { |k| [k, public_send(k)] }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Igniter
|
|
7
|
+
module Store
|
|
8
|
+
# Thread-safe structured logger for StoreServer.
|
|
9
|
+
#
|
|
10
|
+
# Each line is written as:
|
|
11
|
+
# [2026-04-30T12:34:56.789] INFO message
|
|
12
|
+
#
|
|
13
|
+
# Structured events are written as:
|
|
14
|
+
# [EVENT] {"event":"server_start","ts":"2026-04-30T12:34:56.789Z",...}
|
|
15
|
+
#
|
|
16
|
+
# Pass log_io: nil to silence all output (useful in tests).
|
|
17
|
+
class ServerLogger
|
|
18
|
+
LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(io = $stdout, level = :info)
|
|
21
|
+
@io = io
|
|
22
|
+
@min = LEVELS.fetch(level, 1)
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def debug(msg) = log(:debug, msg)
|
|
27
|
+
def info(msg) = log(:info, msg)
|
|
28
|
+
def warn(msg) = log(:warn, msg)
|
|
29
|
+
def error(msg) = log(:error, msg)
|
|
30
|
+
|
|
31
|
+
# Emits a structured JSON event line:
|
|
32
|
+
# [EVENT] {"event":"connection_open","ts":"...","connection_id":"..."}
|
|
33
|
+
#
|
|
34
|
+
# +level:+ controls the minimum log level for this event (default :info).
|
|
35
|
+
# Pass level: :debug for high-frequency per-request events.
|
|
36
|
+
def event(type, level: :info, **attrs)
|
|
37
|
+
return if LEVELS.fetch(level, 1) < @min
|
|
38
|
+
return unless @io
|
|
39
|
+
|
|
40
|
+
payload = { event: type, ts: Time.now.iso8601(3) }.merge(attrs)
|
|
41
|
+
@mutex.synchronize { @io.write("[EVENT] #{JSON.generate(payload)}\n") }
|
|
42
|
+
rescue IOError, JSON::GeneratorError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def level
|
|
47
|
+
LEVELS.key(@min)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def log(level, msg)
|
|
53
|
+
return if LEVELS[level] < @min
|
|
54
|
+
return unless @io
|
|
55
|
+
|
|
56
|
+
ts = Time.now.strftime("%Y-%m-%dT%H:%M:%S.%3N")
|
|
57
|
+
line = "[#{ts}] #{level.to_s.upcase.ljust(5)} #{msg}\n"
|
|
58
|
+
@mutex.synchronize { @io.write(line) }
|
|
59
|
+
rescue IOError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Igniter
|
|
7
|
+
module Store
|
|
8
|
+
# Thread-safe metrics and telemetry collector for StoreServer.
|
|
9
|
+
#
|
|
10
|
+
# Tracks counters (requests, errors, bytes, facts), per-connection records,
|
|
11
|
+
# subscription counts, and fires in-process alerts when configurable thresholds
|
|
12
|
+
# are exceeded.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# metrics = ServerMetrics.new(thresholds: { max_connections: 200 })
|
|
16
|
+
# id = metrics.record_connection_accepted(remote_addr: "10.0.0.1")
|
|
17
|
+
# metrics.record_request(connection_id: id, op: "write_fact", bytes_in: 64, bytes_out: 16)
|
|
18
|
+
# metrics.record_connection_closed(id: id)
|
|
19
|
+
# snap = metrics.snapshot
|
|
20
|
+
class ServerMetrics
|
|
21
|
+
ConnectionRecord = Struct.new(
|
|
22
|
+
:connection_id, :accepted_at, :closed_at,
|
|
23
|
+
:remote_addr, :ops_count, :bytes_in, :bytes_out,
|
|
24
|
+
:last_op, :close_reason,
|
|
25
|
+
keyword_init: true
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
Alert = Struct.new(
|
|
29
|
+
:id, :fired_at, :type, :threshold, :current_value, :message,
|
|
30
|
+
keyword_init: true
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
DEFAULT_THRESHOLDS = {
|
|
34
|
+
max_connections: 500,
|
|
35
|
+
error_rate: 0.1,
|
|
36
|
+
replay_size: 10_000,
|
|
37
|
+
quarantine_receipt_count: 10,
|
|
38
|
+
storage_byte_size: 1_073_741_824,
|
|
39
|
+
slow_op_count: nil # nil = disabled; set to an integer to enable
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
def initialize(thresholds: {})
|
|
43
|
+
@mutex = Mutex.new
|
|
44
|
+
@thresholds = DEFAULT_THRESHOLDS.merge(thresholds)
|
|
45
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
46
|
+
@facts_written = 0
|
|
47
|
+
@facts_replayed = 0
|
|
48
|
+
@bytes_in = 0
|
|
49
|
+
@bytes_out = 0
|
|
50
|
+
@requests_total = Hash.new(0)
|
|
51
|
+
@errors_total = Hash.new(0)
|
|
52
|
+
@slow_ops_total = Hash.new(0)
|
|
53
|
+
@accepted_total = 0
|
|
54
|
+
@closed_total = 0
|
|
55
|
+
@rejected_total = 0
|
|
56
|
+
@active_conns = {}
|
|
57
|
+
@subscription_counts = Hash.new(0)
|
|
58
|
+
@alerts = []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Records a new connection. Returns the connection_id string.
|
|
62
|
+
def record_connection_accepted(remote_addr:)
|
|
63
|
+
id = SecureRandom.hex(8)
|
|
64
|
+
rec = ConnectionRecord.new(
|
|
65
|
+
connection_id: id,
|
|
66
|
+
accepted_at: Time.now,
|
|
67
|
+
closed_at: nil,
|
|
68
|
+
remote_addr: remote_addr.to_s,
|
|
69
|
+
ops_count: 0,
|
|
70
|
+
bytes_in: 0,
|
|
71
|
+
bytes_out: 0,
|
|
72
|
+
last_op: nil,
|
|
73
|
+
close_reason: nil
|
|
74
|
+
)
|
|
75
|
+
@mutex.synchronize { @active_conns[id] = rec; @accepted_total += 1 }
|
|
76
|
+
id
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def record_connection_closed(id:, reason: nil)
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
rec = @active_conns.delete(id)
|
|
82
|
+
if rec
|
|
83
|
+
rec.closed_at = Time.now
|
|
84
|
+
rec.close_reason = reason
|
|
85
|
+
end
|
|
86
|
+
@closed_total += 1
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def record_connection_rejected
|
|
91
|
+
@mutex.synchronize { @rejected_total += 1 }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Records one request dispatched on a connection.
|
|
95
|
+
def record_request(connection_id:, op:, bytes_in: 0, bytes_out: 0)
|
|
96
|
+
op_s = op.to_s
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
@requests_total[op_s] += 1
|
|
99
|
+
@bytes_in += bytes_in.to_i
|
|
100
|
+
@bytes_out += bytes_out.to_i
|
|
101
|
+
rec = @active_conns[connection_id]
|
|
102
|
+
if rec
|
|
103
|
+
rec.ops_count += 1
|
|
104
|
+
rec.bytes_in += bytes_in.to_i
|
|
105
|
+
rec.bytes_out += bytes_out.to_i
|
|
106
|
+
rec.last_op = op_s
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def record_error(op:, error_class:)
|
|
112
|
+
key = "#{error_class}/#{op}"
|
|
113
|
+
@mutex.synchronize { @errors_total[key] += 1 }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def record_slow_op(op:)
|
|
117
|
+
@mutex.synchronize { @slow_ops_total[op.to_s] += 1 }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def record_facts_written(count: 1)
|
|
121
|
+
@mutex.synchronize { @facts_written += count }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def record_facts_replayed(count:)
|
|
125
|
+
@mutex.synchronize { @facts_replayed += count }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def record_subscription_opened(store:)
|
|
129
|
+
@mutex.synchronize { @subscription_counts[store.to_s] += 1 }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def record_subscription_closed(store:)
|
|
133
|
+
@mutex.synchronize do
|
|
134
|
+
s = store.to_s
|
|
135
|
+
@subscription_counts[s] = [@subscription_counts[s] - 1, 0].max
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Returns a frozen snapshot Hash of all current metrics.
|
|
140
|
+
# +backend:+ is optional — if the backend supports storage_stats, it is included.
|
|
141
|
+
def snapshot(backend: nil)
|
|
142
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
143
|
+
@mutex.synchronize do
|
|
144
|
+
storage = backend.respond_to?(:storage_stats) ? (backend.storage_stats rescue nil) : nil
|
|
145
|
+
{
|
|
146
|
+
schema_version: 1,
|
|
147
|
+
generated_at: Time.now.iso8601(3),
|
|
148
|
+
uptime_ms: ((now - @started_at) * 1000).ceil,
|
|
149
|
+
facts_written: @facts_written,
|
|
150
|
+
facts_replayed: @facts_replayed,
|
|
151
|
+
bytes_in: @bytes_in,
|
|
152
|
+
bytes_out: @bytes_out,
|
|
153
|
+
requests_total: @requests_total.dup,
|
|
154
|
+
errors_total: @errors_total.dup,
|
|
155
|
+
slow_ops_total: @slow_ops_total.dup,
|
|
156
|
+
active_connections: @active_conns.size,
|
|
157
|
+
accepted_connections_total: @accepted_total,
|
|
158
|
+
closed_connections_total: @closed_total,
|
|
159
|
+
rejected_connections_total: @rejected_total,
|
|
160
|
+
subscription_count: @subscription_counts.values.sum,
|
|
161
|
+
subscriptions_by_store: @subscription_counts.dup,
|
|
162
|
+
storage_stats: storage,
|
|
163
|
+
alerts: @alerts.map(&:to_h)
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Evaluates alert thresholds and fires new alerts when breached.
|
|
169
|
+
# Already-fired alerts are not re-fired (no alert storms).
|
|
170
|
+
# Returns the current alerts Array.
|
|
171
|
+
def check_alerts(backend: nil)
|
|
172
|
+
@mutex.synchronize do
|
|
173
|
+
fire_alert(:max_connections, @active_conns.size)
|
|
174
|
+
total_req = @requests_total.values.sum
|
|
175
|
+
if total_req.positive?
|
|
176
|
+
total_err = @errors_total.values.sum
|
|
177
|
+
fire_alert(:error_rate, total_err.to_f / total_req)
|
|
178
|
+
end
|
|
179
|
+
if backend.respond_to?(:storage_stats)
|
|
180
|
+
begin
|
|
181
|
+
stats = backend.storage_stats
|
|
182
|
+
if stats
|
|
183
|
+
qc = stats["stores"]&.values&.sum { |s| s["quarantine_receipt_count"].to_i } || 0
|
|
184
|
+
fire_alert(:quarantine_receipt_count, qc)
|
|
185
|
+
bs = stats["stores"]&.values&.sum { |s| s["byte_size"].to_i } || 0
|
|
186
|
+
fire_alert(:storage_byte_size, bs)
|
|
187
|
+
end
|
|
188
|
+
rescue StandardError
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
total_slow = @slow_ops_total.values.sum
|
|
194
|
+
fire_alert(:slow_op_count, total_slow) if total_slow.positive?
|
|
195
|
+
end
|
|
196
|
+
@mutex.synchronize { @alerts.dup }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def alerts
|
|
200
|
+
@mutex.synchronize { @alerts.dup }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def fire_alert(type, current)
|
|
206
|
+
threshold = @thresholds[type]
|
|
207
|
+
return unless threshold
|
|
208
|
+
return unless current > threshold
|
|
209
|
+
return if @alerts.any? { |a| a.type == type }
|
|
210
|
+
|
|
211
|
+
@alerts << Alert.new(
|
|
212
|
+
id: SecureRandom.hex(6),
|
|
213
|
+
fired_at: Time.now,
|
|
214
|
+
type: type,
|
|
215
|
+
threshold: threshold,
|
|
216
|
+
current_value: current,
|
|
217
|
+
message: "#{type} exceeded threshold: #{current} > #{threshold}"
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|