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,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require "set"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require_relative "wire_protocol"
|
|
8
|
+
|
|
9
|
+
module Igniter
|
|
10
|
+
module Store
|
|
11
|
+
unless defined?(NATIVE) && NATIVE
|
|
12
|
+
# Pure-Ruby FileBackend — skipped when the Rust native extension is loaded.
|
|
13
|
+
#
|
|
14
|
+
# WAL format (v2): length-prefixed frames with CRC32 integrity check.
|
|
15
|
+
#
|
|
16
|
+
# Each frame:
|
|
17
|
+
# [4 bytes BE uint32: body_len][body_len bytes: JSON][4 bytes BE uint32: CRC32(body)]
|
|
18
|
+
#
|
|
19
|
+
# Snapshot format (path + ".snap"):
|
|
20
|
+
# [header frame: JSON { type: "snapshot_header", fact_count: N, written_at: T }]
|
|
21
|
+
# [fact frame 1] ... [fact frame N]
|
|
22
|
+
#
|
|
23
|
+
# On open, if a snapshot file exists: snapshot facts are loaded first, WAL
|
|
24
|
+
# facts whose IDs are already in the snapshot are skipped. Combined result
|
|
25
|
+
# is sorted by timestamp. Startup cost is O(snapshot_size + delta_wal_size)
|
|
26
|
+
# instead of O(total_wal_size).
|
|
27
|
+
class FileBackend
|
|
28
|
+
include WireProtocol
|
|
29
|
+
|
|
30
|
+
SNAPSHOT_SUFFIX = ".snap"
|
|
31
|
+
|
|
32
|
+
def initialize(path)
|
|
33
|
+
@path = path.to_s
|
|
34
|
+
@file = File.open(@path, "ab")
|
|
35
|
+
@file.sync = true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def write_fact(fact)
|
|
39
|
+
body = JSON.generate(fact.to_h)
|
|
40
|
+
@file.write(encode_frame(body))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Combines snapshot (if present) + WAL delta into a chronologically
|
|
44
|
+
# ordered list of facts. Facts in the snapshot are deduplicated against
|
|
45
|
+
# the WAL by ID so a checkpoint never causes double-replay.
|
|
46
|
+
def replay
|
|
47
|
+
snapshot_facts, seen_ids = load_snapshot
|
|
48
|
+
wal_facts = read_wal_frames.reject { |f| seen_ids.include?(f.id) }
|
|
49
|
+
(snapshot_facts + wal_facts).sort_by(&:transaction_time)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Atomically writes all +facts+ to a snapshot file (<wal_path>.snap).
|
|
53
|
+
# Uses a tmp file + rename so a partial write never corrupts an existing
|
|
54
|
+
# snapshot. The WAL file is untouched; the snapshot is a parallel read
|
|
55
|
+
# artefact only.
|
|
56
|
+
def write_snapshot(facts)
|
|
57
|
+
tmp = "#{snapshot_path}.tmp"
|
|
58
|
+
File.open(tmp, "wb") do |f|
|
|
59
|
+
header = JSON.generate({
|
|
60
|
+
type: "snapshot_header",
|
|
61
|
+
fact_count: facts.size,
|
|
62
|
+
written_at: Process.clock_gettime(Process::CLOCK_REALTIME)
|
|
63
|
+
})
|
|
64
|
+
f.write(encode_frame(header))
|
|
65
|
+
facts.each { |fact| f.write(encode_frame(JSON.generate(fact.to_h))) }
|
|
66
|
+
end
|
|
67
|
+
FileUtils.mv(tmp, snapshot_path)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Pruning-safe barrier: atomically replace the snapshot with +facts+ AND
|
|
71
|
+
# truncate the WAL so that dropped facts cannot resurface on reopen.
|
|
72
|
+
#
|
|
73
|
+
# Normal checkpoint (#write_snapshot) is non-destructive — it leaves the
|
|
74
|
+
# WAL intact, which means any fact not present in the snapshot will still
|
|
75
|
+
# be loaded from the WAL on next open. For physical purge that is wrong:
|
|
76
|
+
# the dropped fact ids would not be in the new snapshot, so the WAL would
|
|
77
|
+
# replay them back into existence.
|
|
78
|
+
#
|
|
79
|
+
# This method:
|
|
80
|
+
# 1. Writes the new snapshot atomically (tmp → rename).
|
|
81
|
+
# 2. Closes the current WAL file handle.
|
|
82
|
+
# 3. Truncates the WAL to 0 bytes (new open in write mode).
|
|
83
|
+
# 4. Reopens for future appends.
|
|
84
|
+
#
|
|
85
|
+
# After a successful call, close/reopen will load only the snapshot facts.
|
|
86
|
+
def replace_with_snapshot!(facts)
|
|
87
|
+
write_snapshot(facts)
|
|
88
|
+
@file.close
|
|
89
|
+
File.open(@path, "wb") {} # truncate WAL
|
|
90
|
+
@file = File.open(@path, "ab")
|
|
91
|
+
@file.sync = true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def snapshot_path
|
|
95
|
+
@path + SNAPSHOT_SUFFIX
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def close
|
|
99
|
+
@file.close
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Parses a raw JSON body into a frozen Fact. Returns nil on parse error.
|
|
105
|
+
def decode_fact(body)
|
|
106
|
+
payload = JSON.parse(body, symbolize_names: true)
|
|
107
|
+
Fact.from_h(payload)
|
|
108
|
+
rescue JSON::ParserError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# --- WAL reading ---
|
|
113
|
+
|
|
114
|
+
def read_wal_frames
|
|
115
|
+
return [] unless File.exist?(@path)
|
|
116
|
+
facts = []
|
|
117
|
+
File.open(@path, "rb") do |f|
|
|
118
|
+
loop do
|
|
119
|
+
body = read_frame(f)
|
|
120
|
+
break unless body
|
|
121
|
+
fact = decode_fact(body)
|
|
122
|
+
facts << fact if fact
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
facts
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- Snapshot reading ---
|
|
129
|
+
|
|
130
|
+
# Returns [Array<Fact>, Set<id>] from the snapshot file, or [[], Set[]]
|
|
131
|
+
# if no snapshot exists or the snapshot is corrupt.
|
|
132
|
+
def load_snapshot
|
|
133
|
+
return [[], Set.new] unless File.exist?(snapshot_path)
|
|
134
|
+
|
|
135
|
+
facts = []
|
|
136
|
+
File.open(snapshot_path, "rb") do |f|
|
|
137
|
+
header_body = read_frame(f)
|
|
138
|
+
return [[], Set.new] unless header_body
|
|
139
|
+
|
|
140
|
+
header = JSON.parse(header_body, symbolize_names: true)
|
|
141
|
+
return [[], Set.new] unless header[:type] == "snapshot_header"
|
|
142
|
+
|
|
143
|
+
loop do
|
|
144
|
+
body = read_frame(f)
|
|
145
|
+
break unless body
|
|
146
|
+
fact = decode_fact(body)
|
|
147
|
+
facts << fact if fact
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
[facts, Set.new(facts.map(&:id))]
|
|
152
|
+
rescue StandardError
|
|
153
|
+
[[], Set.new]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if defined?(NATIVE) && NATIVE
|
|
159
|
+
# Patch native FileBackend to add snapshot support.
|
|
160
|
+
# The native class exposes write_fact, replay (WAL-only), and close.
|
|
161
|
+
# We add: write_snapshot, snapshot_path, SNAPSHOT_SUFFIX, and a
|
|
162
|
+
# snapshot-aware replay that merges snapshot facts with the WAL delta.
|
|
163
|
+
#
|
|
164
|
+
# Deduplication uses the original fact id embedded in the snapshot JSON
|
|
165
|
+
# (not the id on the reconstructed Fact object, which is regenerated by
|
|
166
|
+
# Fact.build in native mode).
|
|
167
|
+
|
|
168
|
+
module NativeFileBackendSnapshotSupport
|
|
169
|
+
include WireProtocol
|
|
170
|
+
|
|
171
|
+
SNAPSHOT_SUFFIX = ".snap"
|
|
172
|
+
|
|
173
|
+
def snapshot_path
|
|
174
|
+
@_ruby_path + SNAPSHOT_SUFFIX
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Pruning-safe barrier for native-backed stores.
|
|
178
|
+
# Writes the snapshot atomically, then truncates the WAL file so that
|
|
179
|
+
# dropped facts cannot be replayed on reopen.
|
|
180
|
+
# The native write handle is still open; after truncation, the next
|
|
181
|
+
# native write will restart from offset 0 (append mode semantics).
|
|
182
|
+
def replace_with_snapshot!(facts)
|
|
183
|
+
write_snapshot(facts)
|
|
184
|
+
File.open(@_ruby_path, "wb") {} # truncate WAL
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def write_snapshot(facts)
|
|
188
|
+
tmp = "#{snapshot_path}.tmp"
|
|
189
|
+
File.open(tmp, "wb") do |f|
|
|
190
|
+
header = JSON.generate({
|
|
191
|
+
type: "snapshot_header",
|
|
192
|
+
fact_count: facts.size,
|
|
193
|
+
written_at: Process.clock_gettime(Process::CLOCK_REALTIME)
|
|
194
|
+
})
|
|
195
|
+
f.write(encode_frame(header))
|
|
196
|
+
facts.each { |fact| f.write(encode_frame(JSON.generate(fact.to_h))) }
|
|
197
|
+
end
|
|
198
|
+
FileUtils.mv(tmp, snapshot_path)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Merges snapshot facts (if any) with WAL facts not already in the snapshot.
|
|
202
|
+
# Deduplication is by original id read directly from the JSON frame, because
|
|
203
|
+
# Fact.from_h in native mode regenerates ids via Fact.build.
|
|
204
|
+
def replay
|
|
205
|
+
snapshot_facts, seen_ids = load_native_snapshot
|
|
206
|
+
wal_facts = _native_replay_wal
|
|
207
|
+
delta = wal_facts.reject { |f| seen_ids.include?(f.id) }
|
|
208
|
+
snapshot_facts + delta
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
def load_native_snapshot
|
|
214
|
+
return [[], Set.new] unless File.exist?(snapshot_path)
|
|
215
|
+
|
|
216
|
+
facts = []
|
|
217
|
+
seen_ids = Set.new
|
|
218
|
+
File.open(snapshot_path, "rb") do |f|
|
|
219
|
+
header_body = read_frame(f)
|
|
220
|
+
return [[], Set.new] unless header_body
|
|
221
|
+
header = JSON.parse(header_body, symbolize_names: true)
|
|
222
|
+
return [[], Set.new] unless header[:type] == "snapshot_header"
|
|
223
|
+
|
|
224
|
+
loop do
|
|
225
|
+
body = read_frame(f)
|
|
226
|
+
break unless body
|
|
227
|
+
h = JSON.parse(body, symbolize_names: true)
|
|
228
|
+
seen_ids.add(h[:id]) if h[:id]
|
|
229
|
+
fact = Fact.from_h(h)
|
|
230
|
+
facts << fact if fact
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
[facts, seen_ids]
|
|
234
|
+
rescue StandardError
|
|
235
|
+
[[], Set.new]
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
class FileBackend
|
|
240
|
+
include NativeFileBackendSnapshotSupport
|
|
241
|
+
|
|
242
|
+
SNAPSHOT_SUFFIX = NativeFileBackendSnapshotSupport::SNAPSHOT_SUFFIX
|
|
243
|
+
|
|
244
|
+
# The native extension defines `replay` (WAL-only) as a class-level method,
|
|
245
|
+
# which shadows the module's snapshot-aware `replay`. Save the WAL-only
|
|
246
|
+
# version under an alias, then override `replay` in the class body so the
|
|
247
|
+
# snapshot-aware path is used on store open.
|
|
248
|
+
alias_method :_native_replay_wal, :replay
|
|
249
|
+
|
|
250
|
+
def replay
|
|
251
|
+
snapshot_facts, seen_ids = load_native_snapshot
|
|
252
|
+
wal_facts = _native_replay_wal
|
|
253
|
+
delta = wal_facts.reject { |f| seen_ids.include?(f.id) }
|
|
254
|
+
snapshot_facts + delta
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
class << self
|
|
258
|
+
alias_method :_native_new, :new
|
|
259
|
+
|
|
260
|
+
def new(path)
|
|
261
|
+
obj = _native_new(path)
|
|
262
|
+
obj.instance_variable_set(:@_ruby_path, path.to_s)
|
|
263
|
+
obj
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Igniter
|
|
7
|
+
module Store
|
|
8
|
+
# HTTP transport adapter for the Igniter Store Open Protocol.
|
|
9
|
+
#
|
|
10
|
+
# Exposes Protocol::Interpreter over HTTP via a Rack-compatible app.
|
|
11
|
+
# The canonical endpoint is POST /v1/dispatch which accepts and returns
|
|
12
|
+
# a WireEnvelope JSON object.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# adapter = HTTPAdapter.new(interpreter: interpreter, port: 7300)
|
|
16
|
+
# adapter.rack_app # → Rack-compatible, mountable in any server
|
|
17
|
+
# adapter.start # → foreground via Puma (dev dep)
|
|
18
|
+
# adapter.start_async / adapter.stop
|
|
19
|
+
class HTTPAdapter
|
|
20
|
+
module ResponseHelper
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def json_response(status, data)
|
|
24
|
+
body = JSON.generate(data)
|
|
25
|
+
[status, { "Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s }, [body]]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def method_not_allowed
|
|
29
|
+
json_response(405, { error: "Method not allowed" })
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class DispatchHandler
|
|
34
|
+
include ResponseHelper
|
|
35
|
+
|
|
36
|
+
def initialize(interpreter)
|
|
37
|
+
@interpreter = interpreter
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call(env)
|
|
41
|
+
return method_not_allowed unless env["REQUEST_METHOD"] == "POST"
|
|
42
|
+
|
|
43
|
+
body = env["rack.input"].read
|
|
44
|
+
begin
|
|
45
|
+
envelope = JSON.parse(body, symbolize_names: true)
|
|
46
|
+
rescue JSON::ParserError => e
|
|
47
|
+
return json_response(400, { error: "Invalid JSON: #{e.message}" })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
json_response(200, @interpreter.wire.dispatch(envelope))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class HealthHandler
|
|
55
|
+
include ResponseHelper
|
|
56
|
+
|
|
57
|
+
def initialize(health_provider: nil)
|
|
58
|
+
@health_provider = health_provider
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def call(env)
|
|
62
|
+
return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
|
|
63
|
+
|
|
64
|
+
health = @health_provider ? @health_provider.call : { protocol: :igniter_store, schema_version: 1, status: :ready }
|
|
65
|
+
json_response(200, health)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class MetadataHandler
|
|
70
|
+
include ResponseHelper
|
|
71
|
+
|
|
72
|
+
def initialize(interpreter)
|
|
73
|
+
@interpreter = interpreter
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def call(env)
|
|
77
|
+
return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
|
|
78
|
+
|
|
79
|
+
json_response(200, @interpreter.metadata_snapshot)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns the canonical observability snapshot at GET /v1/status.
|
|
84
|
+
# When +status_provider+ is given (e.g. StoreServer#observability_snapshot),
|
|
85
|
+
# it is called to produce the full server+storage shape.
|
|
86
|
+
# Otherwise falls back to the interpreter's storage-level snapshot.
|
|
87
|
+
class StatusHandler
|
|
88
|
+
include ResponseHelper
|
|
89
|
+
|
|
90
|
+
def initialize(interpreter:, status_provider: nil)
|
|
91
|
+
@interpreter = interpreter
|
|
92
|
+
@status_provider = status_provider
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def call(env)
|
|
96
|
+
return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
|
|
97
|
+
|
|
98
|
+
data = @status_provider ? @status_provider.call : @interpreter.observability_snapshot
|
|
99
|
+
json_response(200, data)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Readiness probe: 200 when ready to serve traffic, 503 otherwise (draining,
|
|
104
|
+
# stopped, or initialising). +ready_provider+ must return truthy/falsy.
|
|
105
|
+
class ReadyHandler
|
|
106
|
+
include ResponseHelper
|
|
107
|
+
|
|
108
|
+
def initialize(ready_provider: nil)
|
|
109
|
+
@ready_provider = ready_provider
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def call(env)
|
|
113
|
+
return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
|
|
114
|
+
|
|
115
|
+
ready = @ready_provider ? @ready_provider.call : true
|
|
116
|
+
if ready
|
|
117
|
+
json_response(200, { status: "ready" })
|
|
118
|
+
else
|
|
119
|
+
json_response(503, { status: "unavailable" })
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Returns the metrics sub-hash from the observability snapshot.
|
|
125
|
+
class MetricsHandler
|
|
126
|
+
include ResponseHelper
|
|
127
|
+
|
|
128
|
+
def initialize(metrics_provider: nil)
|
|
129
|
+
@metrics_provider = metrics_provider
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def call(env)
|
|
133
|
+
return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
|
|
134
|
+
|
|
135
|
+
data = @metrics_provider ? @metrics_provider.call : {}
|
|
136
|
+
json_response(200, data)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Streaming body for SSE responses.
|
|
141
|
+
#
|
|
142
|
+
# Emits retained catch-up events from +replay_events+, then blocks on a
|
|
143
|
+
# live subscription to the ChangefeedBuffer until #close is called.
|
|
144
|
+
#
|
|
145
|
+
# SSE frame format per event:
|
|
146
|
+
# id: <sequence>
|
|
147
|
+
# event: fact_committed
|
|
148
|
+
# data: <ChangeEvent#to_h JSON>
|
|
149
|
+
# (blank line)
|
|
150
|
+
#
|
|
151
|
+
# #close is safe to call from any thread — it pushes a sentinel to unblock
|
|
152
|
+
# the live delivery loop so the subscription handle is released cleanly.
|
|
153
|
+
class SseBody
|
|
154
|
+
SSE_SENTINEL = :__sse_close
|
|
155
|
+
|
|
156
|
+
def initialize(buf, replay_events, sub_stores)
|
|
157
|
+
@buf = buf
|
|
158
|
+
@replay_events = replay_events
|
|
159
|
+
@sub_stores = sub_stores
|
|
160
|
+
@queue = nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def each
|
|
164
|
+
@replay_events.each { |e| yield sse_frame(e) }
|
|
165
|
+
|
|
166
|
+
@queue = Queue.new
|
|
167
|
+
handle = @buf.subscribe(stores: @sub_stores) { |e| @queue << e }
|
|
168
|
+
|
|
169
|
+
begin
|
|
170
|
+
loop do
|
|
171
|
+
event = @queue.pop
|
|
172
|
+
break if event.equal?(SSE_SENTINEL)
|
|
173
|
+
yield sse_frame(event)
|
|
174
|
+
end
|
|
175
|
+
rescue IOError, Errno::EPIPE
|
|
176
|
+
nil
|
|
177
|
+
ensure
|
|
178
|
+
handle&.close
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def close
|
|
183
|
+
@queue&.push(SSE_SENTINEL)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def sse_frame(event)
|
|
189
|
+
"id: #{event.cursor[:sequence]}\nevent: fact_committed\ndata: #{JSON.generate(event.to_h)}\n\n"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# GET /v1/events — Server-Sent Events transport over ChangefeedBuffer.
|
|
194
|
+
#
|
|
195
|
+
# Protocol:
|
|
196
|
+
# 1. Replay retained events (catch-up).
|
|
197
|
+
# 2. Subscribe for live events.
|
|
198
|
+
#
|
|
199
|
+
# Cursor input (both optional):
|
|
200
|
+
# Last-Event-ID: N → replay after sequence N (browser auto-reconnect)
|
|
201
|
+
# ?cursor=N → same, for simple clients / tests
|
|
202
|
+
#
|
|
203
|
+
# Store filtering (optional):
|
|
204
|
+
# ?store=tasks → single store
|
|
205
|
+
# ?stores=tasks,reminders → multiple stores
|
|
206
|
+
# (none) → all stores (wildcard)
|
|
207
|
+
#
|
|
208
|
+
# Error: when the requested cursor is too old (gap due to ring overflow),
|
|
209
|
+
# returns 409 JSON instead of starting the stream.
|
|
210
|
+
class SseEventsHandler
|
|
211
|
+
include ResponseHelper
|
|
212
|
+
|
|
213
|
+
def initialize(changefeed_provider:)
|
|
214
|
+
@changefeed_provider = changefeed_provider
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def call(env)
|
|
218
|
+
return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
|
|
219
|
+
|
|
220
|
+
buf = @changefeed_provider&.call
|
|
221
|
+
return json_response(503, { error: "SSE events endpoint not configured" }) unless buf
|
|
222
|
+
|
|
223
|
+
cursor = parse_sse_cursor(env)
|
|
224
|
+
stores = parse_sse_stores(env)
|
|
225
|
+
|
|
226
|
+
replay_result = buf.replay(
|
|
227
|
+
cursor: cursor,
|
|
228
|
+
stores: stores.empty? ? nil : stores
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if replay_result[:status] == :cursor_too_old
|
|
232
|
+
return json_response(409, {
|
|
233
|
+
status: "cursor_too_old",
|
|
234
|
+
oldest_cursor: replay_result[:oldest_cursor],
|
|
235
|
+
newest_cursor: replay_result[:newest_cursor],
|
|
236
|
+
dropped_total: replay_result[:dropped_total]
|
|
237
|
+
})
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
body = SseBody.new(buf, replay_result[:events], stores)
|
|
241
|
+
[200, sse_headers, body]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
def sse_headers
|
|
247
|
+
{
|
|
248
|
+
"Content-Type" => "text/event-stream; charset=utf-8",
|
|
249
|
+
"Cache-Control" => "no-cache",
|
|
250
|
+
"X-Accel-Buffering" => "no"
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def parse_sse_cursor(env)
|
|
255
|
+
last_id = env["HTTP_LAST_EVENT_ID"]
|
|
256
|
+
if last_id && !last_id.empty?
|
|
257
|
+
seq = Integer(last_id, 10) rescue nil
|
|
258
|
+
return { sequence: seq } if seq
|
|
259
|
+
end
|
|
260
|
+
query = Rack::Utils.parse_query(env["QUERY_STRING"] || "")
|
|
261
|
+
cursor_s = query["cursor"]
|
|
262
|
+
if cursor_s && !cursor_s.empty?
|
|
263
|
+
seq = Integer(cursor_s, 10) rescue nil
|
|
264
|
+
return { sequence: seq } if seq
|
|
265
|
+
end
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def parse_sse_stores(env)
|
|
270
|
+
query = Rack::Utils.parse_query(env["QUERY_STRING"] || "")
|
|
271
|
+
stores_s = query["stores"] || query["store"] || ""
|
|
272
|
+
stores_s.split(",").map(&:strip).reject(&:empty?)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# GET /v1/compaction/activity — normalized compaction lifecycle activity.
|
|
277
|
+
#
|
|
278
|
+
# Query params (all optional):
|
|
279
|
+
# ?store=orders
|
|
280
|
+
# ?kind=exact_prune
|
|
281
|
+
# ?since=1714000000
|
|
282
|
+
# ?limit=50
|
|
283
|
+
#
|
|
284
|
+
# Returns same JSON shape as Protocol::Interpreter#compaction_activity.
|
|
285
|
+
# Non-GET → 405. Invalid numeric since/limit → 400.
|
|
286
|
+
class CompactionActivityHandler
|
|
287
|
+
include ResponseHelper
|
|
288
|
+
|
|
289
|
+
def initialize(interpreter)
|
|
290
|
+
@interpreter = interpreter
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def call(env)
|
|
294
|
+
return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
|
|
295
|
+
|
|
296
|
+
query = Rack::Utils.parse_query(env["QUERY_STRING"] || "")
|
|
297
|
+
|
|
298
|
+
store = query["store"]
|
|
299
|
+
kind = query["kind"]
|
|
300
|
+
|
|
301
|
+
since_raw = query["since"]
|
|
302
|
+
if since_raw
|
|
303
|
+
since = Float(since_raw) rescue nil
|
|
304
|
+
return json_response(400, { error: "Invalid numeric value for 'since': #{since_raw.inspect}" }) if since.nil?
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
limit_raw = query["limit"]
|
|
308
|
+
if limit_raw
|
|
309
|
+
limit = Integer(limit_raw, 10) rescue nil
|
|
310
|
+
return json_response(400, { error: "Invalid integer value for 'limit': #{limit_raw.inspect}" }) if limit.nil?
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
result = @interpreter.compaction_activity(
|
|
314
|
+
store: store,
|
|
315
|
+
kind: kind,
|
|
316
|
+
since: since,
|
|
317
|
+
limit: limit
|
|
318
|
+
)
|
|
319
|
+
json_response(200, result)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Returns recent structured events from the server event ring buffer.
|
|
324
|
+
class EventsRecentHandler
|
|
325
|
+
include ResponseHelper
|
|
326
|
+
|
|
327
|
+
def initialize(events_provider: nil)
|
|
328
|
+
@events_provider = events_provider
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def call(env)
|
|
332
|
+
return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
|
|
333
|
+
|
|
334
|
+
events = @events_provider ? @events_provider.call : []
|
|
335
|
+
json_response(200, { events: events, count: events.size })
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# ── Adapter ──────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
def initialize(interpreter:, port: 7300, host: "0.0.0.0",
|
|
342
|
+
health_provider: nil, status_provider: nil,
|
|
343
|
+
ready_provider: nil, metrics_provider: nil, events_provider: nil,
|
|
344
|
+
changefeed_provider: nil)
|
|
345
|
+
@interpreter = interpreter
|
|
346
|
+
@port = port
|
|
347
|
+
@host = host
|
|
348
|
+
@health_provider = health_provider
|
|
349
|
+
@status_provider = status_provider
|
|
350
|
+
@ready_provider = ready_provider
|
|
351
|
+
@metrics_provider = metrics_provider
|
|
352
|
+
@events_provider = events_provider
|
|
353
|
+
@changefeed_provider = changefeed_provider
|
|
354
|
+
@puma = nil
|
|
355
|
+
@thread = nil
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Returns a Rack-compatible app mountable in any Rack server.
|
|
359
|
+
def rack_app
|
|
360
|
+
interp = @interpreter
|
|
361
|
+
hp = @health_provider
|
|
362
|
+
sp = @status_provider
|
|
363
|
+
rp = @ready_provider
|
|
364
|
+
mp = @metrics_provider
|
|
365
|
+
ep = @events_provider
|
|
366
|
+
cp = @changefeed_provider
|
|
367
|
+
not_found = ->(env) {
|
|
368
|
+
body = JSON.generate({ error: "Not found: #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}" })
|
|
369
|
+
[404, { "Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s }, [body]]
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
Rack::Builder.new do
|
|
373
|
+
map "/v1/dispatch" do run DispatchHandler.new(interp) end
|
|
374
|
+
map "/v1/health" do run HealthHandler.new(health_provider: hp) end
|
|
375
|
+
map "/v1/status" do run StatusHandler.new(interpreter: interp, status_provider: sp) end
|
|
376
|
+
map "/v1/ready" do run ReadyHandler.new(ready_provider: rp) end
|
|
377
|
+
map "/v1/metrics" do run MetricsHandler.new(metrics_provider: mp) end
|
|
378
|
+
# /v1/events/recent must precede /v1/events to avoid prefix shadowing.
|
|
379
|
+
map "/v1/events/recent" do run EventsRecentHandler.new(events_provider: ep) end
|
|
380
|
+
map "/v1/events" do run SseEventsHandler.new(changefeed_provider: cp) end
|
|
381
|
+
map "/v1/metadata" do run MetadataHandler.new(interp) end
|
|
382
|
+
map "/v1/compaction/activity" do run CompactionActivityHandler.new(interp) end
|
|
383
|
+
run not_found
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Starts the server in the current thread (blocks). Requires puma.
|
|
388
|
+
def start
|
|
389
|
+
require "puma"
|
|
390
|
+
@puma = Puma::Server.new(rack_app)
|
|
391
|
+
@puma.add_tcp_listener(@host, @port)
|
|
392
|
+
@puma.run.join
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Starts in a background thread. Returns self.
|
|
396
|
+
def start_async
|
|
397
|
+
@thread = Thread.new { start }
|
|
398
|
+
sleep 0.05
|
|
399
|
+
self
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def stop
|
|
403
|
+
@puma&.stop(true) rescue nil
|
|
404
|
+
@thread&.join(2) rescue nil
|
|
405
|
+
self
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def bind_address
|
|
409
|
+
"#{@host}:#{@port}"
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|