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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +481 -0
  3. data/examples/intelligent_ledger/availability_boundary_ledger.rb +1190 -0
  4. data/examples/intelligent_ledger/availability_deriver.rb +150 -0
  5. data/examples/intelligent_ledger/availability_ledger.rb +197 -0
  6. data/examples/intelligent_ledger/ledger_boundary.rb +180 -0
  7. data/examples/store_poc.rb +45 -0
  8. data/exe/igniter-ledger-server +111 -0
  9. data/exe/igniter-store-server +6 -0
  10. data/ext/igniter_store_native/Cargo.toml +28 -0
  11. data/ext/igniter_store_native/extconf.rb +6 -0
  12. data/ext/igniter_store_native/src/fact.rs +303 -0
  13. data/ext/igniter_store_native/src/fact_log.rs +180 -0
  14. data/ext/igniter_store_native/src/file_backend.rs +91 -0
  15. data/ext/igniter_store_native/src/lib.rs +55 -0
  16. data/lib/igniter/ledger.rb +7 -0
  17. data/lib/igniter/store/access_path.rb +84 -0
  18. data/lib/igniter/store/change_event.rb +65 -0
  19. data/lib/igniter/store/changefeed_buffer.rb +585 -0
  20. data/lib/igniter/store/codecs.rb +253 -0
  21. data/lib/igniter/store/contractable_receipt_sink.rb +172 -0
  22. data/lib/igniter/store/fact.rb +121 -0
  23. data/lib/igniter/store/fact_log.rb +103 -0
  24. data/lib/igniter/store/file_backend.rb +269 -0
  25. data/lib/igniter/store/http_adapter.rb +413 -0
  26. data/lib/igniter/store/igniter_store.rb +838 -0
  27. data/lib/igniter/store/mcp_adapter.rb +403 -0
  28. data/lib/igniter/store/native.rb +80 -0
  29. data/lib/igniter/store/network_backend.rb +159 -0
  30. data/lib/igniter/store/protocol/handlers/access_path_handler.rb +38 -0
  31. data/lib/igniter/store/protocol/handlers/command_handler.rb +59 -0
  32. data/lib/igniter/store/protocol/handlers/derivation_handler.rb +27 -0
  33. data/lib/igniter/store/protocol/handlers/effect_handler.rb +65 -0
  34. data/lib/igniter/store/protocol/handlers/history_handler.rb +24 -0
  35. data/lib/igniter/store/protocol/handlers/projection_handler.rb +41 -0
  36. data/lib/igniter/store/protocol/handlers/relation_handler.rb +43 -0
  37. data/lib/igniter/store/protocol/handlers/store_handler.rb +24 -0
  38. data/lib/igniter/store/protocol/handlers/subscription_handler.rb +24 -0
  39. data/lib/igniter/store/protocol/interpreter.rb +447 -0
  40. data/lib/igniter/store/protocol/receipt.rb +96 -0
  41. data/lib/igniter/store/protocol/sync_profile.rb +53 -0
  42. data/lib/igniter/store/protocol/wire_envelope.rb +214 -0
  43. data/lib/igniter/store/protocol.rb +27 -0
  44. data/lib/igniter/store/read_cache.rb +163 -0
  45. data/lib/igniter/store/schema_graph.rb +248 -0
  46. data/lib/igniter/store/segmented_file_backend.rb +699 -0
  47. data/lib/igniter/store/server_config.rb +55 -0
  48. data/lib/igniter/store/server_logger.rb +64 -0
  49. data/lib/igniter/store/server_metrics.rb +222 -0
  50. data/lib/igniter/store/store_server.rb +597 -0
  51. data/lib/igniter/store/subscription_registry.rb +73 -0
  52. data/lib/igniter/store/tbackend_adapter_descriptor.rb +307 -0
  53. data/lib/igniter/store/tcp_adapter.rb +127 -0
  54. data/lib/igniter/store/wire_protocol.rb +42 -0
  55. data/lib/igniter/store.rb +64 -0
  56. data/lib/igniter-ledger.rb +4 -0
  57. data/lib/igniter-store.rb +5 -0
  58. 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