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,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