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,597 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "wire_protocol"
|
|
7
|
+
require_relative "server_config"
|
|
8
|
+
require_relative "server_logger"
|
|
9
|
+
require_relative "server_metrics"
|
|
10
|
+
require_relative "subscription_registry"
|
|
11
|
+
require_relative "change_event"
|
|
12
|
+
require_relative "changefeed_buffer"
|
|
13
|
+
|
|
14
|
+
module Igniter
|
|
15
|
+
module Store
|
|
16
|
+
# Thread-safe bounded ring buffer for structured server events.
|
|
17
|
+
# Oldest events are evicted when +max_size+ is exceeded.
|
|
18
|
+
class EventRing
|
|
19
|
+
def initialize(max_size)
|
|
20
|
+
@max_size = max_size
|
|
21
|
+
@events = []
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def push(event)
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
@events.push(event)
|
|
28
|
+
@events.shift if @events.size > @max_size
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_a
|
|
33
|
+
@mutex.synchronize { @events.dup }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def size
|
|
37
|
+
@mutex.synchronize { @events.size }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Minimal TCP / Unix socket server that exposes durable fact storage over
|
|
42
|
+
# the network. Clients use NetworkBackend to connect.
|
|
43
|
+
#
|
|
44
|
+
# The server is the "durability half" of the network topology: it persists
|
|
45
|
+
# facts and serves replay requests. All in-memory indices (scope, partition,
|
|
46
|
+
# cache, coercions) are rebuilt by each client from the replayed facts.
|
|
47
|
+
#
|
|
48
|
+
# Lifecycle:
|
|
49
|
+
# server = StoreServer.new(host: "127.0.0.1", port: 7400, backend: :file, path: "store.wal")
|
|
50
|
+
# server.start_async # background thread
|
|
51
|
+
# server.wait_until_ready # blocks until accepting (no sleep needed)
|
|
52
|
+
# ...
|
|
53
|
+
# server.stop # graceful drain, then close
|
|
54
|
+
#
|
|
55
|
+
# Foreground / CLI:
|
|
56
|
+
# server.start_foreground # sets signal traps, blocks until stop
|
|
57
|
+
#
|
|
58
|
+
# Configuration object:
|
|
59
|
+
# config = ServerConfig.new(host: "0.0.0.0", port: 7400, backend: :file, ...)
|
|
60
|
+
# server = StoreServer.new(config: config)
|
|
61
|
+
class StoreServer
|
|
62
|
+
include WireProtocol
|
|
63
|
+
|
|
64
|
+
# ── Constructor ──────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
# Accepts keyword args (backward compatible) OR a +config:+ ServerConfig.
|
|
67
|
+
# Keyword args take precedence over config fields when both are given.
|
|
68
|
+
def initialize(host: nil, port: nil, transport: nil, backend: nil, path: nil,
|
|
69
|
+
logger: nil, pid_file: nil, drain_timeout: nil,
|
|
70
|
+
max_connections: nil, config: nil,
|
|
71
|
+
# Legacy positional-style: address: "host:port"
|
|
72
|
+
address: nil,
|
|
73
|
+
metrics_thresholds: {},
|
|
74
|
+
slow_op_threshold_ms: nil,
|
|
75
|
+
max_recent_events: 100,
|
|
76
|
+
changefeed: nil)
|
|
77
|
+
cfg = config || ServerConfig.new
|
|
78
|
+
|
|
79
|
+
# Keyword args override the config where explicitly provided.
|
|
80
|
+
resolved_host = host || (address ? split_address(address).first : nil) || cfg.host
|
|
81
|
+
resolved_port = port || (address ? split_address(address).last : nil) || cfg.port
|
|
82
|
+
resolved_transport = transport || cfg.transport
|
|
83
|
+
resolved_backend = backend || cfg.backend
|
|
84
|
+
resolved_path = path || cfg.path
|
|
85
|
+
resolved_pid = pid_file || cfg.pid_file
|
|
86
|
+
resolved_drain = drain_timeout || cfg.drain_timeout
|
|
87
|
+
resolved_max = max_connections || cfg.max_connections
|
|
88
|
+
|
|
89
|
+
log_io = config&.log_io || $stdout
|
|
90
|
+
log_level = config&.log_level || :info
|
|
91
|
+
|
|
92
|
+
@logger = logger || ServerLogger.new(log_io, log_level)
|
|
93
|
+
@backend_type = resolved_backend
|
|
94
|
+
@transport_type = resolved_transport
|
|
95
|
+
@backend = build_backend(resolved_backend, resolved_path)
|
|
96
|
+
@server = build_server(resolved_host, resolved_port, resolved_transport)
|
|
97
|
+
@write_mutex = Mutex.new
|
|
98
|
+
@active = 0
|
|
99
|
+
@active_mutex = Mutex.new
|
|
100
|
+
@in_memory_facts = []
|
|
101
|
+
@stopped = false
|
|
102
|
+
@started_at = nil
|
|
103
|
+
@pid_file = resolved_pid
|
|
104
|
+
@drain_timeout = resolved_drain
|
|
105
|
+
@max_connections = resolved_max
|
|
106
|
+
@ready_mutex = Mutex.new
|
|
107
|
+
@ready_cond = ConditionVariable.new
|
|
108
|
+
# The server socket is bound and listening as soon as build_server returns.
|
|
109
|
+
# Signal readiness here so wait_until_ready is race-free for callers that
|
|
110
|
+
# connect before start_async is called.
|
|
111
|
+
resolved_cf = (cfg.changefeed || {}).merge(changefeed || {})
|
|
112
|
+
@changefeed = ChangefeedBuffer.new(**resolved_cf)
|
|
113
|
+
@ready_latch = true
|
|
114
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
115
|
+
# Cache the bind address string now while the socket is guaranteed open,
|
|
116
|
+
# so start/stop threads don't race on @server.addr after close.
|
|
117
|
+
@bind_address_str = resolved_transport == :unix ?
|
|
118
|
+
resolved_host.to_s :
|
|
119
|
+
"#{@server.addr[3]}:#{@server.addr[1]}"
|
|
120
|
+
@metrics = ServerMetrics.new(thresholds: metrics_thresholds)
|
|
121
|
+
@last_error = nil
|
|
122
|
+
@draining = false
|
|
123
|
+
@slow_op_threshold_ms = slow_op_threshold_ms
|
|
124
|
+
@event_ring = EventRing.new(max_recent_events)
|
|
125
|
+
write_pid_file(resolved_pid)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ── Lifecycle ────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
# Starts the accept loop in the calling thread (blocks until #stop).
|
|
131
|
+
def start
|
|
132
|
+
@logger.info("Listening on #{@bind_address_str} " \
|
|
133
|
+
"(transport=#{@transport_type} backend=#{@backend_type})")
|
|
134
|
+
emit_event(:server_start,
|
|
135
|
+
bind_address: @bind_address_str,
|
|
136
|
+
transport: @transport_type,
|
|
137
|
+
backend: @backend_type)
|
|
138
|
+
until @stopped
|
|
139
|
+
begin
|
|
140
|
+
client = @server.accept
|
|
141
|
+
rescue IOError, Errno::EBADF
|
|
142
|
+
break
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
if @draining
|
|
146
|
+
@metrics.record_connection_rejected
|
|
147
|
+
client.close rescue nil
|
|
148
|
+
next
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
active = @active_mutex.synchronize { @active += 1; @active }
|
|
152
|
+
|
|
153
|
+
if @max_connections && active > @max_connections
|
|
154
|
+
@active_mutex.synchronize { @active -= 1 }
|
|
155
|
+
@metrics.record_connection_rejected
|
|
156
|
+
emit_event(:alert, type: :max_connections, active: active, max: @max_connections)
|
|
157
|
+
client.close rescue nil
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
@logger.debug("Connection accepted (active=#{active})")
|
|
162
|
+
Thread.new(client) { |s| handle_client(s) }
|
|
163
|
+
end
|
|
164
|
+
ensure
|
|
165
|
+
remove_pid_file
|
|
166
|
+
@logger.info("Stopped.")
|
|
167
|
+
emit_event(:server_stop, bind_address: @bind_address_str)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Starts the accept loop in a background daemon thread.
|
|
171
|
+
# Call wait_until_ready after this to avoid race conditions.
|
|
172
|
+
def start_async
|
|
173
|
+
Thread.new do
|
|
174
|
+
Thread.current.abort_on_exception = false
|
|
175
|
+
start
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Blocks until the server's accept loop is running and ready for connections.
|
|
180
|
+
# Replaces the sleep 0.05 hack in callers.
|
|
181
|
+
def wait_until_ready(timeout: 2)
|
|
182
|
+
@ready_mutex.synchronize do
|
|
183
|
+
deadline = Time.now + timeout
|
|
184
|
+
until @ready_latch
|
|
185
|
+
remaining = deadline - Time.now
|
|
186
|
+
raise "StoreServer did not become ready within #{timeout}s" if remaining <= 0
|
|
187
|
+
@ready_cond.wait(@ready_mutex, remaining)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
self
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Starts the accept loop with SIGTERM/SIGINT traps for CLI/foreground use.
|
|
194
|
+
# Blocks until a signal or #stop is called.
|
|
195
|
+
def start_foreground
|
|
196
|
+
trap("SIGTERM") { stop }
|
|
197
|
+
trap("INT") { stop }
|
|
198
|
+
start
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Gracefully stops the server.
|
|
202
|
+
# 1. Closes the server socket (no new connections accepted).
|
|
203
|
+
# 2. Waits up to +timeout+ seconds for active connections to finish.
|
|
204
|
+
# 3. Force-closes remaining connections and closes the backend.
|
|
205
|
+
def stop(timeout: nil)
|
|
206
|
+
t = timeout || @drain_timeout
|
|
207
|
+
@stopped = true
|
|
208
|
+
@logger.info("Stopping (drain_timeout=#{t}s)...")
|
|
209
|
+
@server.close rescue nil
|
|
210
|
+
remove_pid_file
|
|
211
|
+
|
|
212
|
+
deadline = Time.now + t
|
|
213
|
+
loop do
|
|
214
|
+
active = @active_mutex.synchronize { @active }
|
|
215
|
+
break if active.zero? || Time.now >= deadline
|
|
216
|
+
@logger.debug("Draining #{active} connection(s)...")
|
|
217
|
+
sleep 0.05
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
remaining = @active_mutex.synchronize { @active }
|
|
221
|
+
@logger.warn("Force-closing #{remaining} connection(s).") if remaining.positive?
|
|
222
|
+
@write_mutex.synchronize { @backend&.close rescue nil }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Transitions the server into draining state: new connections are rejected
|
|
226
|
+
# but the accept loop keeps running. Existing connections are allowed to
|
|
227
|
+
# finish (or time out). Call +stop+ afterward to tear down the socket.
|
|
228
|
+
#
|
|
229
|
+
# Returns self so callers can chain: server.drain.stop
|
|
230
|
+
def drain(timeout: nil)
|
|
231
|
+
return self if @stopped
|
|
232
|
+
|
|
233
|
+
t = timeout || @drain_timeout
|
|
234
|
+
@draining = true
|
|
235
|
+
emit_event(:server_draining, bind_address: @bind_address_str)
|
|
236
|
+
@logger.info("Draining (timeout=#{t}s)...")
|
|
237
|
+
|
|
238
|
+
deadline = Time.now + t
|
|
239
|
+
loop do
|
|
240
|
+
active = @active_mutex.synchronize { @active }
|
|
241
|
+
break if active.zero? || Time.now >= deadline
|
|
242
|
+
sleep 0.05
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
remaining = @active_mutex.synchronize { @active }
|
|
246
|
+
@logger.warn("Drain timeout: #{remaining} connection(s) still active.") if remaining.positive?
|
|
247
|
+
self
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# ── Accessors ────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
# Canonical bind address string (e.g. "127.0.0.1:7400" or "/tmp/store.sock").
|
|
253
|
+
# Cached at initialize time — safe to call even after stop closes the socket.
|
|
254
|
+
def bind_address
|
|
255
|
+
@bind_address_str
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Number of currently active client connections.
|
|
259
|
+
def active_connections
|
|
260
|
+
@active_mutex.synchronize { @active }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Number of active push subscriptions for a given store name.
|
|
264
|
+
def subscription_count(store)
|
|
265
|
+
@changefeed.subscriber_count(store)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The server's ChangefeedBuffer — used by SSE and other push transports.
|
|
269
|
+
def changefeed
|
|
270
|
+
@changefeed
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# True when the server is live and accepting traffic.
|
|
274
|
+
def ready? = !@stopped && !@draining
|
|
275
|
+
|
|
276
|
+
# True when the server has entered draining state (rejecting new connections).
|
|
277
|
+
def draining? = @draining
|
|
278
|
+
|
|
279
|
+
# Recent structured events from the bounded ring buffer.
|
|
280
|
+
# Returns an Array of event hashes (newest at end), size ≤ max_recent_events.
|
|
281
|
+
def recent_events
|
|
282
|
+
@event_ring.to_a
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Compact health snapshot Hash.
|
|
286
|
+
# status: :ready | :draining | :stopped
|
|
287
|
+
def health_snapshot
|
|
288
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
289
|
+
snap = @metrics.snapshot
|
|
290
|
+
{
|
|
291
|
+
schema_version: 1,
|
|
292
|
+
status: current_status,
|
|
293
|
+
backend: @backend_type.to_s,
|
|
294
|
+
transport: @transport_type.to_s,
|
|
295
|
+
bind_address: @bind_address_str,
|
|
296
|
+
uptime_ms: ((@started_at ? now - @started_at : 0) * 1000).ceil,
|
|
297
|
+
active_connections: active_connections,
|
|
298
|
+
subscriptions: snap[:subscription_count],
|
|
299
|
+
last_error: @last_error
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Full metrics snapshot including counters, connection telemetry, and storage stats.
|
|
304
|
+
def metrics_snapshot
|
|
305
|
+
@metrics.snapshot(backend: @backend)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Canonical observability snapshot — single source of truth for all transports.
|
|
309
|
+
#
|
|
310
|
+
# Canonical shape (same top-level keys across protocol, HTTP, MCP, and server):
|
|
311
|
+
# schema_version, generated_at, status, uptime_ms, metrics, alerts, storage, server
|
|
312
|
+
#
|
|
313
|
+
# This is the full server+storage shape. For the compact health check shape
|
|
314
|
+
# use #health_snapshot. For the pure storage-level protocol shape use
|
|
315
|
+
# Protocol::Interpreter#observability_snapshot.
|
|
316
|
+
def observability_snapshot
|
|
317
|
+
@metrics.check_alerts(backend: @backend)
|
|
318
|
+
snap = @metrics.snapshot(backend: @backend)
|
|
319
|
+
cf_snap = @changefeed.snapshot
|
|
320
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
321
|
+
# Merge server/storage alerts with changefeed delivery alerts.
|
|
322
|
+
all_alerts = Array(snap[:alerts]) + Array(cf_snap[:alerts])
|
|
323
|
+
{
|
|
324
|
+
schema_version: 1,
|
|
325
|
+
generated_at: snap[:generated_at],
|
|
326
|
+
status: current_status,
|
|
327
|
+
uptime_ms: ((@started_at ? now - @started_at : 0) * 1000).ceil,
|
|
328
|
+
metrics: {
|
|
329
|
+
requests_total: snap[:requests_total],
|
|
330
|
+
errors_total: snap[:errors_total],
|
|
331
|
+
slow_ops_total: snap[:slow_ops_total],
|
|
332
|
+
facts_written: snap[:facts_written],
|
|
333
|
+
facts_replayed: snap[:facts_replayed],
|
|
334
|
+
bytes_in: snap[:bytes_in],
|
|
335
|
+
bytes_out: snap[:bytes_out],
|
|
336
|
+
active_connections: snap[:active_connections],
|
|
337
|
+
accepted_connections_total: snap[:accepted_connections_total],
|
|
338
|
+
closed_connections_total: snap[:closed_connections_total],
|
|
339
|
+
rejected_connections_total: snap[:rejected_connections_total],
|
|
340
|
+
subscription_count: snap[:subscription_count]
|
|
341
|
+
},
|
|
342
|
+
alerts: all_alerts,
|
|
343
|
+
storage: snap[:storage_stats],
|
|
344
|
+
server: {
|
|
345
|
+
backend: @backend_type.to_s,
|
|
346
|
+
transport: @transport_type.to_s,
|
|
347
|
+
bind_address: @bind_address_str,
|
|
348
|
+
last_error: @last_error
|
|
349
|
+
},
|
|
350
|
+
changefeed: cf_snap
|
|
351
|
+
}
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Lazy Protocol::Interpreter for the envelope dispatch layer.
|
|
355
|
+
# Owns a fresh IgniterStore independent of the legacy fact log.
|
|
356
|
+
# HTTP and TCP adapters share this interpreter instance.
|
|
357
|
+
def protocol
|
|
358
|
+
@protocol ||= Protocol::Interpreter.new(IgniterStore.new)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Starts the legacy accept loop plus optional HTTP/TCP envelope adapters,
|
|
362
|
+
# all in one foreground process. Adapters are stopped on exit.
|
|
363
|
+
def start_with_adapters(http_port: nil, tcp_port: nil)
|
|
364
|
+
http = http_port ? HTTPAdapter.new(
|
|
365
|
+
interpreter: protocol,
|
|
366
|
+
port: http_port,
|
|
367
|
+
health_provider: method(:health_snapshot),
|
|
368
|
+
status_provider: method(:observability_snapshot),
|
|
369
|
+
ready_provider: method(:ready?),
|
|
370
|
+
metrics_provider: -> { observability_snapshot[:metrics] },
|
|
371
|
+
events_provider: method(:recent_events),
|
|
372
|
+
changefeed_provider: method(:changefeed)
|
|
373
|
+
) : nil
|
|
374
|
+
tcp = tcp_port ? TCPAdapter.new(interpreter: protocol, port: tcp_port) : nil
|
|
375
|
+
http&.start_async
|
|
376
|
+
tcp&.start_async
|
|
377
|
+
start_foreground
|
|
378
|
+
ensure
|
|
379
|
+
http&.stop
|
|
380
|
+
tcp&.stop
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# ── Private ──────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
private
|
|
386
|
+
|
|
387
|
+
# Emits a structured event to both the logger and the event ring buffer.
|
|
388
|
+
def emit_event(type, level: :info, **attrs)
|
|
389
|
+
@logger.event(type, level: level, **attrs)
|
|
390
|
+
@event_ring.push({ type: type, level: level, ts: Time.now.iso8601(3), **attrs })
|
|
391
|
+
rescue StandardError
|
|
392
|
+
nil # never raise from instrumentation path
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def current_status
|
|
396
|
+
if @stopped then :stopped
|
|
397
|
+
elsif @draining then :draining
|
|
398
|
+
else :ready
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def build_backend(type, path)
|
|
403
|
+
case type
|
|
404
|
+
when :memory then nil
|
|
405
|
+
when :file then FileBackend.new(path.to_s)
|
|
406
|
+
else raise ArgumentError, "StoreServer backend must be :memory or :file, got #{type.inspect}"
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def build_server(host, port, transport)
|
|
411
|
+
case transport
|
|
412
|
+
when :tcp
|
|
413
|
+
server = TCPServer.new(host.to_s, Integer(port))
|
|
414
|
+
server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
|
|
415
|
+
server
|
|
416
|
+
when :unix
|
|
417
|
+
UNIXServer.new(host.to_s)
|
|
418
|
+
else
|
|
419
|
+
raise ArgumentError, "Unknown transport: #{transport.inspect}. Use :tcp or :unix"
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def split_address(address)
|
|
424
|
+
host, port_s = address.to_s.split(":")
|
|
425
|
+
[host, port_s ? Integer(port_s) : 7400]
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def handle_client(socket)
|
|
429
|
+
close_reason = :normal
|
|
430
|
+
conn_id = nil
|
|
431
|
+
|
|
432
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, true) rescue nil
|
|
433
|
+
remote_addr = socket.peeraddr(false)[2] rescue "unknown"
|
|
434
|
+
conn_id = @metrics.record_connection_accepted(remote_addr: remote_addr)
|
|
435
|
+
emit_event(:connection_open, connection_id: conn_id, remote_addr: remote_addr)
|
|
436
|
+
|
|
437
|
+
loop do
|
|
438
|
+
body = read_frame(socket)
|
|
439
|
+
break unless body
|
|
440
|
+
|
|
441
|
+
req = JSON.parse(body, symbolize_names: true)
|
|
442
|
+
|
|
443
|
+
if req[:op] == "subscribe"
|
|
444
|
+
stores = (req[:stores] || []).map(&:to_s)
|
|
445
|
+
handle_subscription_mode(socket, stores, connection_id: conn_id)
|
|
446
|
+
break
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
450
|
+
resp = dispatch(req)
|
|
451
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).ceil
|
|
452
|
+
|
|
453
|
+
if @slow_op_threshold_ms && elapsed_ms > @slow_op_threshold_ms
|
|
454
|
+
@metrics.record_slow_op(op: req[:op].to_s)
|
|
455
|
+
emit_event(:slow_op, level: :warn, connection_id: conn_id,
|
|
456
|
+
op: req[:op].to_s, elapsed_ms: elapsed_ms,
|
|
457
|
+
threshold_ms: @slow_op_threshold_ms)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
resp = resp.merge(request_id: req[:request_id]) if req[:request_id]
|
|
461
|
+
resp_json = JSON.generate(resp)
|
|
462
|
+
resp_frame = encode_frame(resp_json)
|
|
463
|
+
@metrics.record_request(
|
|
464
|
+
connection_id: conn_id, op: req[:op].to_s,
|
|
465
|
+
bytes_in: body.bytesize, bytes_out: resp_frame.bytesize
|
|
466
|
+
)
|
|
467
|
+
if resp[:ok] == false
|
|
468
|
+
@metrics.record_error(op: req[:op].to_s, error_class: "RequestError")
|
|
469
|
+
emit_event(:request_error, level: :warn,
|
|
470
|
+
connection_id: conn_id, op: req[:op].to_s, error: resp[:error])
|
|
471
|
+
else
|
|
472
|
+
emit_event(:request, level: :debug, connection_id: conn_id, op: req[:op].to_s)
|
|
473
|
+
end
|
|
474
|
+
socket.write(resp_frame)
|
|
475
|
+
break if req[:op] == "close"
|
|
476
|
+
end
|
|
477
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::EBADF
|
|
478
|
+
close_reason = :io_error
|
|
479
|
+
rescue => e
|
|
480
|
+
close_reason = :error
|
|
481
|
+
@last_error = e.message
|
|
482
|
+
@logger.warn("handle_client: #{e.class}: #{e.message}")
|
|
483
|
+
emit_event(:backend_error, level: :error,
|
|
484
|
+
connection_id: conn_id, error_class: e.class.to_s, message: e.message)
|
|
485
|
+
@metrics.record_error(op: "connection", error_class: e.class.to_s)
|
|
486
|
+
ensure
|
|
487
|
+
socket.close rescue nil
|
|
488
|
+
@active_mutex.synchronize { @active -= 1 }
|
|
489
|
+
if conn_id
|
|
490
|
+
@metrics.record_connection_closed(id: conn_id, reason: close_reason)
|
|
491
|
+
emit_event(:connection_close, connection_id: conn_id, reason: close_reason)
|
|
492
|
+
end
|
|
493
|
+
@logger.debug("Connection closed (active=#{@active_mutex.synchronize { @active }})")
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def handle_subscription_mode(socket, stores, connection_id: nil)
|
|
497
|
+
write_mutex = Mutex.new
|
|
498
|
+
adapter = lambda do |change_event|
|
|
499
|
+
frame = encode_frame(JSON.generate({ event: "fact_written", fact: change_event.fact.to_h }))
|
|
500
|
+
write_mutex.synchronize { socket.write(frame) }
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Ack before registering: no concurrent writes possible until after this line.
|
|
504
|
+
socket.write(encode_frame(JSON.generate({ ok: true })))
|
|
505
|
+
stores.each { |s| @metrics.record_subscription_opened(store: s) }
|
|
506
|
+
emit_event(:subscription_open, connection_id: connection_id, stores: stores)
|
|
507
|
+
handle = @changefeed.subscribe(stores: stores, &adapter)
|
|
508
|
+
|
|
509
|
+
loop do
|
|
510
|
+
body = read_frame(socket)
|
|
511
|
+
break unless body
|
|
512
|
+
break if JSON.parse(body, symbolize_names: true)[:op] == "close"
|
|
513
|
+
end
|
|
514
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::EBADF
|
|
515
|
+
nil
|
|
516
|
+
ensure
|
|
517
|
+
handle&.close
|
|
518
|
+
stores.each { |s| @metrics.record_subscription_closed(store: s) }
|
|
519
|
+
emit_event(:subscription_close, connection_id: connection_id, stores: stores)
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def dispatch(req)
|
|
523
|
+
case req[:op]
|
|
524
|
+
when "write_fact"
|
|
525
|
+
fact = decode_fact(req[:fact])
|
|
526
|
+
@write_mutex.synchronize do
|
|
527
|
+
@backend&.write_fact(fact)
|
|
528
|
+
@in_memory_facts << fact
|
|
529
|
+
end
|
|
530
|
+
@changefeed.emit(fact)
|
|
531
|
+
@metrics.record_facts_written
|
|
532
|
+
{ ok: true }
|
|
533
|
+
|
|
534
|
+
when "replay"
|
|
535
|
+
facts = @write_mutex.synchronize do
|
|
536
|
+
@backend ? @backend.replay : @in_memory_facts.dup
|
|
537
|
+
end
|
|
538
|
+
@metrics.record_facts_replayed(count: facts.size)
|
|
539
|
+
{ ok: true, facts: facts.map(&:to_h) }
|
|
540
|
+
|
|
541
|
+
when "write_snapshot"
|
|
542
|
+
if @backend.respond_to?(:write_snapshot)
|
|
543
|
+
facts = (req[:facts] || []).map { |h| decode_fact(h) }
|
|
544
|
+
@write_mutex.synchronize { @backend.write_snapshot(facts) }
|
|
545
|
+
{ ok: true }
|
|
546
|
+
else
|
|
547
|
+
{ ok: true }
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
when "stats"
|
|
551
|
+
uptime_ms = @started_at ?
|
|
552
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).ceil : 0
|
|
553
|
+
facts_written = @write_mutex.synchronize { @in_memory_facts.size }
|
|
554
|
+
{
|
|
555
|
+
ok: true,
|
|
556
|
+
facts_written: facts_written,
|
|
557
|
+
connections_active: @active_mutex.synchronize { @active },
|
|
558
|
+
uptime_ms: uptime_ms
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
when "server_status"
|
|
562
|
+
{ ok: true }.merge(observability_snapshot)
|
|
563
|
+
|
|
564
|
+
when "ping"
|
|
565
|
+
{ ok: true, pong: true }
|
|
566
|
+
|
|
567
|
+
when "close"
|
|
568
|
+
{ ok: true }
|
|
569
|
+
|
|
570
|
+
else
|
|
571
|
+
{ ok: false, error_code: :unknown_op, error: "Unknown op: #{req[:op].inspect}" }
|
|
572
|
+
end
|
|
573
|
+
rescue => e
|
|
574
|
+
{ ok: false, error_code: :internal_error, error: e.message }
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def decode_fact(h)
|
|
578
|
+
Fact.from_h(h)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def write_pid_file(path)
|
|
582
|
+
return unless path
|
|
583
|
+
File.write(path, "#{Process.pid}\n")
|
|
584
|
+
@logger.info("PID #{Process.pid} written to #{path}")
|
|
585
|
+
rescue SystemCallError => e
|
|
586
|
+
@logger.warn("Could not write PID file #{path}: #{e.message}")
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def remove_pid_file
|
|
590
|
+
return unless @pid_file && File.exist?(@pid_file)
|
|
591
|
+
File.delete(@pid_file)
|
|
592
|
+
rescue SystemCallError
|
|
593
|
+
nil
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Igniter
|
|
7
|
+
module Store
|
|
8
|
+
# Routing layer of the reactive push architecture.
|
|
9
|
+
#
|
|
10
|
+
# Tracks SubscriptionRecord objects and fans out facts to their handlers.
|
|
11
|
+
# Knows nothing about sockets, frames, or wire encoding — those are the
|
|
12
|
+
# adapter's responsibility. The handler is any callable: ->(fact) { ... }
|
|
13
|
+
#
|
|
14
|
+
# TCP push adapter example (created in handle_subscription_mode):
|
|
15
|
+
# write_mutex = Mutex.new
|
|
16
|
+
# adapter = ->(fact) {
|
|
17
|
+
# frame = encode_frame(JSON.generate({ event: "fact_written", fact: fact.to_h }))
|
|
18
|
+
# write_mutex.synchronize { socket.write(frame) }
|
|
19
|
+
# }
|
|
20
|
+
# record = registry.subscribe(stores: [:tasks], &adapter)
|
|
21
|
+
# # ... later:
|
|
22
|
+
# registry.unsubscribe(record)
|
|
23
|
+
#
|
|
24
|
+
# Future adapters (WebhookAdapter, SSEAdapter, QueueAdapter) follow the same
|
|
25
|
+
# ->(fact) { ... } contract and plug in without modifying this class.
|
|
26
|
+
class SubscriptionRegistry
|
|
27
|
+
SubscriptionRecord = Struct.new(:id, :stores, :handler, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@records = []
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Register a handler callable for one or more store names.
|
|
35
|
+
# Returns the SubscriptionRecord — pass it to #unsubscribe to remove.
|
|
36
|
+
def subscribe(stores:, &handler)
|
|
37
|
+
record = SubscriptionRecord.new(
|
|
38
|
+
id: SecureRandom.hex(8),
|
|
39
|
+
stores: Array(stores).map(&:to_s),
|
|
40
|
+
handler: handler
|
|
41
|
+
)
|
|
42
|
+
@mutex.synchronize { @records << record }
|
|
43
|
+
record
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Remove a subscription. Identity-based (object equality), idempotent.
|
|
47
|
+
def unsubscribe(record)
|
|
48
|
+
return unless record
|
|
49
|
+
@mutex.synchronize { @records.reject! { |r| r.equal?(record) } }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Fan out a fact to all handlers subscribed to fact.store.
|
|
53
|
+
# Called from dispatch("write_fact") after the fact is persisted.
|
|
54
|
+
# Handlers that raise are treated as dead and removed.
|
|
55
|
+
def fan_out(fact)
|
|
56
|
+
store_s = fact.store.to_s
|
|
57
|
+
matching = @mutex.synchronize { @records.select { |r| r.stores.include?(store_s) }.dup }
|
|
58
|
+
dead = []
|
|
59
|
+
matching.each do |record|
|
|
60
|
+
record.handler.call(fact)
|
|
61
|
+
rescue StandardError
|
|
62
|
+
dead << record
|
|
63
|
+
end
|
|
64
|
+
dead.each { |r| unsubscribe(r) } unless dead.empty?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Number of active subscriptions for a given store name.
|
|
68
|
+
def subscriber_count(store)
|
|
69
|
+
@mutex.synchronize { @records.count { |r| r.stores.include?(store.to_s) } }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|