mirrors-sdk 0.1.0
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/Gemfile +7 -0
- data/README.md +273 -0
- data/Rakefile +11 -0
- data/lib/mirrors_sdk/agent_context.rb +51 -0
- data/lib/mirrors_sdk/capture.rb +236 -0
- data/lib/mirrors_sdk/circuit.rb +51 -0
- data/lib/mirrors_sdk/client.rb +261 -0
- data/lib/mirrors_sdk/configuration.rb +74 -0
- data/lib/mirrors_sdk/context.rb +281 -0
- data/lib/mirrors_sdk/delivery.rb +74 -0
- data/lib/mirrors_sdk/executor.rb +226 -0
- data/lib/mirrors_sdk/models.rb +104 -0
- data/lib/mirrors_sdk/snapshot.rb +129 -0
- data/lib/mirrors_sdk/transport.rb +223 -0
- data/lib/mirrors_sdk/version.rb +5 -0
- data/lib/mirrors_sdk/wire.rb +238 -0
- data/lib/mirrors_sdk/worker.rb +114 -0
- data/lib/mirrors_sdk.rb +13 -0
- data/mirrors-sdk.gemspec +23 -0
- data/skills/install-mirrors-ruby-sdk/SKILL.md +152 -0
- data/test/capture_test.rb +303 -0
- data/test/configuration_test.rb +64 -0
- data/test/context_test.rb +295 -0
- data/test/executor_test.rb +93 -0
- data/test/fixtures/canonical-events.json +38 -0
- data/test/integration_test.rb +154 -0
- data/test/lifecycle_test.rb +128 -0
- data/test/snapshot_test.rb +88 -0
- data/test/support/runtime_server.rb +101 -0
- data/test/test_helper.rb +30 -0
- data/test/transport_test.rb +182 -0
- data/test/wire_test.rb +128 -0
- metadata +72 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "monitor"
|
|
5
|
+
|
|
6
|
+
module MirrorsSDK
|
|
7
|
+
# The application owns its model/tool loop; this client only captures and retrieves.
|
|
8
|
+
class Mirrors
|
|
9
|
+
def initialize(**options)
|
|
10
|
+
@pid, @lock = Process.pid, Monitor.new
|
|
11
|
+
@closed, @failed, @sequence = false, false, 0
|
|
12
|
+
@deliveries = Deliveries.new
|
|
13
|
+
@traces, @recent = {}, {}
|
|
14
|
+
begin
|
|
15
|
+
@config = Config.new(**options)
|
|
16
|
+
rescue StandardError
|
|
17
|
+
@config = nil
|
|
18
|
+
end
|
|
19
|
+
@enabled = !!(@config && @config.enabled)
|
|
20
|
+
begin
|
|
21
|
+
@worker = Worker.new(@config) if @config
|
|
22
|
+
@context = ContextHost.new(@config, @worker&.transport, @worker&.context_executor)
|
|
23
|
+
@worker&.start(@context)
|
|
24
|
+
rescue StandardError
|
|
25
|
+
@failed = true
|
|
26
|
+
@worker&.dispose
|
|
27
|
+
@context ||= ContextHost.new(nil, nil, nil)
|
|
28
|
+
@context.stop("worker_failed")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def capture_trace(trace, final: false, terminal: nil)
|
|
33
|
+
started = monotonic
|
|
34
|
+
return forked_ticket if forked?
|
|
35
|
+
|
|
36
|
+
reason = @lock.synchronize { unavailable }
|
|
37
|
+
return @deliveries.drop(reason) if reason
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
snapshot = Wire.snapshot(trace, final: final, terminal: terminal)
|
|
41
|
+
encoded = Wire.encode(snapshot)
|
|
42
|
+
value = { "trace" => snapshot, "final" => final, "captureOnly" => true }
|
|
43
|
+
value["terminal"] = String.new(terminal) unless terminal.nil?
|
|
44
|
+
digest = Digest::SHA256.digest(Wire.encode(value))
|
|
45
|
+
metadata = source_metadata(snapshot)
|
|
46
|
+
rescue StandardError
|
|
47
|
+
identifier = trace_identifier(trace)
|
|
48
|
+
release_trace(identifier) if identifier
|
|
49
|
+
return @deliveries.drop("invalid_evidence")
|
|
50
|
+
end
|
|
51
|
+
@lock.synchronize do
|
|
52
|
+
reason = unavailable
|
|
53
|
+
return @deliveries.drop(reason) if reason
|
|
54
|
+
|
|
55
|
+
trace_id = snapshot["id"]
|
|
56
|
+
if @recent.key?(trace_id)
|
|
57
|
+
recent = @recent[trace_id]
|
|
58
|
+
return recent && recent[0] == digest ? recent[1] : @deliveries.drop("terminal_conflict")
|
|
59
|
+
end
|
|
60
|
+
previous = @traces[trace_id]
|
|
61
|
+
if previous && previous[2] != metadata
|
|
62
|
+
release_trace(trace_id) if final
|
|
63
|
+
return @deliveries.drop("invalid_evidence")
|
|
64
|
+
end
|
|
65
|
+
final ? @context.release(trace_id) : @context.observe(trace_id, encoded)
|
|
66
|
+
return previous[1] if previous && previous[0] == digest && previous[1].receipt.status == "pending"
|
|
67
|
+
|
|
68
|
+
if @deliveries.full?(final) || (!final && !previous && @traces.length >= 64)
|
|
69
|
+
release_trace(trace_id) if final
|
|
70
|
+
return @deliveries.drop("capacity")
|
|
71
|
+
end
|
|
72
|
+
ticket = @deliveries.create(final, trace_id)
|
|
73
|
+
if final
|
|
74
|
+
@traces.delete(trace_id)
|
|
75
|
+
remember(trace_id, [digest, ticket])
|
|
76
|
+
else
|
|
77
|
+
@traces[trace_id] = [digest, ticket, metadata]
|
|
78
|
+
end
|
|
79
|
+
budget = @config.timeout_ms || (final ? 6000 : 1000)
|
|
80
|
+
@sequence += 1
|
|
81
|
+
begin
|
|
82
|
+
job = @worker.capture(value, deadline: started + budget / 1000.0, sequence: @sequence)
|
|
83
|
+
if job
|
|
84
|
+
@deliveries.attach(ticket, job)
|
|
85
|
+
job.on_complete { |done| capture_done(ticket, done) }
|
|
86
|
+
else
|
|
87
|
+
@deliveries.settle(ticket, DeliveryReceipt.new("dropped", "capacity"))
|
|
88
|
+
@worker.release(trace_id) if final
|
|
89
|
+
end
|
|
90
|
+
rescue StandardError
|
|
91
|
+
@deliveries.settle(ticket, DeliveryReceipt.new("unconfirmed", "worker_failed"))
|
|
92
|
+
@worker.release(trace_id) if final
|
|
93
|
+
end
|
|
94
|
+
ticket
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def get_context(trace, timeout_ms: 1000, cancel: nil)
|
|
99
|
+
started = monotonic
|
|
100
|
+
return ContextResult.new(reason: "forked_process") if forked?
|
|
101
|
+
|
|
102
|
+
@lock.synchronize do
|
|
103
|
+
reason = unavailable
|
|
104
|
+
reason ||= "invalid_options" unless cancel.nil? || cancel.instance_of?(Cancellation)
|
|
105
|
+
reason ||= "canceled" if cancel.instance_of?(Cancellation) && cancel.canceled?
|
|
106
|
+
reason ||= "execution_closed" if known_closed?(trace)
|
|
107
|
+
return @context.empty(reason, started) if reason
|
|
108
|
+
end
|
|
109
|
+
wait = @context.start(trace, timeout_ms: timeout_ms, started: started)
|
|
110
|
+
# Finalization can race with snapshot preparation outside the client lock.
|
|
111
|
+
@lock.synchronize { @context.release(trace_identifier(trace)) if known_closed?(trace) }
|
|
112
|
+
@context.await(wait, cancel: cancel)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def release_trace(trace_id)
|
|
116
|
+
return if forked?
|
|
117
|
+
begin
|
|
118
|
+
return unless Object.instance_method(:class).bind(trace_id).call == String
|
|
119
|
+
|
|
120
|
+
trace_id = String.new(trace_id)
|
|
121
|
+
Wire.id(trace_id)
|
|
122
|
+
rescue StandardError
|
|
123
|
+
return
|
|
124
|
+
end
|
|
125
|
+
@lock.synchronize do
|
|
126
|
+
@context.release(trace_id)
|
|
127
|
+
@traces.delete(trace_id)
|
|
128
|
+
remember(trace_id, nil) unless @recent.key?(trace_id)
|
|
129
|
+
@deliveries.cancel("canceled", trace_id)
|
|
130
|
+
@worker&.release(trace_id)
|
|
131
|
+
end
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def set_enabled(enabled)
|
|
136
|
+
return if forked? || !(enabled.equal?(true) || enabled.equal?(false))
|
|
137
|
+
|
|
138
|
+
@lock.synchronize do
|
|
139
|
+
return if @closed || @enabled == enabled
|
|
140
|
+
|
|
141
|
+
@enabled = enabled
|
|
142
|
+
@context.set_enabled(enabled)
|
|
143
|
+
unless enabled
|
|
144
|
+
@traces.keys.each { |trace_id| release_trace(trace_id) }
|
|
145
|
+
@deliveries.cancel("disabled")
|
|
146
|
+
end
|
|
147
|
+
@worker&.set_enabled(enabled)
|
|
148
|
+
end
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def flush(timeout_ms: 2000)
|
|
153
|
+
return FlushResult.new(unconfirmed: 1) if forked?
|
|
154
|
+
|
|
155
|
+
@deliveries.flush(timeout_ms)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def close(timeout_ms: 2000)
|
|
159
|
+
return FlushResult.new(unconfirmed: 1) if forked?
|
|
160
|
+
|
|
161
|
+
@lock.synchronize do
|
|
162
|
+
@closed = true
|
|
163
|
+
@context.stop
|
|
164
|
+
end
|
|
165
|
+
result = flush(timeout_ms: timeout_ms)
|
|
166
|
+
dispose
|
|
167
|
+
result
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def dispose
|
|
171
|
+
return if forked?
|
|
172
|
+
|
|
173
|
+
@lock.synchronize do
|
|
174
|
+
@closed = true
|
|
175
|
+
@context.stop
|
|
176
|
+
@traces.clear
|
|
177
|
+
@recent.clear
|
|
178
|
+
@deliveries.cancel("closed")
|
|
179
|
+
@worker&.dispose
|
|
180
|
+
end
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def diagnostics
|
|
185
|
+
return { reason: "forked_process" } if forked?
|
|
186
|
+
|
|
187
|
+
@lock.synchronize do
|
|
188
|
+
@deliveries.diagnostics.merge(active_executions: @traces.length, recent_executions: @recent.length)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def start_turn
|
|
193
|
+
TurnTimer.new
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.start_turn
|
|
197
|
+
TurnTimer.new
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def monotonic
|
|
203
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def forked?
|
|
207
|
+
Process.pid != @pid
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def unavailable
|
|
211
|
+
return "invalid_options" unless @config
|
|
212
|
+
return "worker_failed" if @failed
|
|
213
|
+
return "closed" if @closed
|
|
214
|
+
return "disabled" unless @enabled
|
|
215
|
+
return "worker_failed" unless @worker
|
|
216
|
+
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def known_closed?(trace)
|
|
221
|
+
identifier = trace_identifier(trace)
|
|
222
|
+
identifier && @recent.key?(identifier)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def trace_identifier(trace)
|
|
226
|
+
return unless Object.instance_method(:class).bind(trace).call == Hash
|
|
227
|
+
|
|
228
|
+
identifier = Hash.instance_method(:[]).bind(trace).call("id")
|
|
229
|
+
return unless Object.instance_method(:class).bind(identifier).call == String
|
|
230
|
+
|
|
231
|
+
Wire.id(String.new(identifier))
|
|
232
|
+
rescue StandardError
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def remember(trace_id, value)
|
|
237
|
+
@recent.delete(trace_id)
|
|
238
|
+
@recent[trace_id] = value
|
|
239
|
+
@recent.shift if @recent.length > 128
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def source_metadata(trace)
|
|
243
|
+
%w[model entryPoint completeness conversationId conversationRevision].each_with_object({}) do |key, result|
|
|
244
|
+
result[key] = trace[key] if trace.key?(key)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def capture_done(ticket, job)
|
|
249
|
+
receipt = job.value
|
|
250
|
+
@deliveries.settle(ticket, receipt)
|
|
251
|
+
rescue StandardError
|
|
252
|
+
@deliveries.settle(ticket, DeliveryReceipt.new("unconfirmed", "transport"))
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def forked_ticket
|
|
256
|
+
ticket = DeliveryTicket.new("forked")
|
|
257
|
+
ticket.__send__(:settle, DeliveryReceipt.new("dropped", "forked_process"))
|
|
258
|
+
ticket
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module MirrorsSDK
|
|
6
|
+
# Immutable validated settings; credentials never appear in diagnostic inspection.
|
|
7
|
+
class Config
|
|
8
|
+
CLASS = Object.instance_method(:class)
|
|
9
|
+
attr_reader :api_url, :api_key, :agent_id, :timeout_ms, :enabled,
|
|
10
|
+
:failure_threshold, :cooldown_ms, :heartbeat_interval_ms, :mode_lease_ms
|
|
11
|
+
|
|
12
|
+
def initialize(api_url:, api_key:, agent_id:, timeout_ms: nil, enabled: true,
|
|
13
|
+
failure_threshold: 3, cooldown_ms: 30_000,
|
|
14
|
+
heartbeat_interval_ms: 1_000, mode_lease_ms: 3_000)
|
|
15
|
+
api_url = plain_string(api_url, "API URL")
|
|
16
|
+
api_key = plain_string(api_key, "API key")
|
|
17
|
+
agent_id = plain_string(agent_id, "agent ID")
|
|
18
|
+
unless api_url.bytesize.between?(1, 4096) &&
|
|
19
|
+
!api_url.match?(/[\x00-\x20\x7f\\?#]/)
|
|
20
|
+
raise ArgumentError, "Invalid API URL"
|
|
21
|
+
end
|
|
22
|
+
uri = URI.parse(api_url)
|
|
23
|
+
unless uri.hostname && !uri.hostname.empty? && !uri.userinfo &&
|
|
24
|
+
uri.port && uri.port.between?(1, 65_535) &&
|
|
25
|
+
(uri.scheme == "https" || (uri.scheme == "http" &&
|
|
26
|
+
%w[localhost 127.0.0.1 ::1].include?(uri.hostname)))
|
|
27
|
+
raise ArgumentError, "HTTPS is required except for loopback HTTP"
|
|
28
|
+
end
|
|
29
|
+
unless api_key.bytesize.between?(1, 4096) &&
|
|
30
|
+
!api_key.match?(/[^\x21-\x7e]/)
|
|
31
|
+
raise ArgumentError, "Invalid API key"
|
|
32
|
+
end
|
|
33
|
+
unless agent_id.match?(/\A[A-Za-z0-9_.-]{1,128}\z/)
|
|
34
|
+
raise ArgumentError, "Invalid agent ID"
|
|
35
|
+
end
|
|
36
|
+
raise ArgumentError, "enabled must be a boolean" unless true.equal?(enabled) || false.equal?(enabled)
|
|
37
|
+
|
|
38
|
+
integer(timeout_ms, 1, 30_000, "timeout_ms") unless nil.equal?(timeout_ms)
|
|
39
|
+
integer(failure_threshold, 1, 100, "failure_threshold")
|
|
40
|
+
integer(cooldown_ms, 1, 300_000, "cooldown_ms")
|
|
41
|
+
integer(heartbeat_interval_ms, 20, 1_000, "heartbeat_interval_ms")
|
|
42
|
+
integer(mode_lease_ms, 100, 3_000, "mode_lease_ms")
|
|
43
|
+
if heartbeat_interval_ms >= mode_lease_ms
|
|
44
|
+
raise ArgumentError, "Heartbeat interval must be shorter than the mode lease"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@api_url, @api_key, @agent_id = [api_url, api_key, agent_id].map(&:freeze)
|
|
48
|
+
@timeout_ms, @enabled = timeout_ms, enabled
|
|
49
|
+
@failure_threshold, @cooldown_ms = failure_threshold, cooldown_ms
|
|
50
|
+
@heartbeat_interval_ms, @mode_lease_ms = heartbeat_interval_ms, mode_lease_ms
|
|
51
|
+
freeze
|
|
52
|
+
rescue URI::InvalidURIError
|
|
53
|
+
raise ArgumentError, "Invalid API URL"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def inspect
|
|
57
|
+
"#<MirrorsSDK::Config agent_id=#{@agent_id.inspect} enabled=#{@enabled}>"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def plain_string(value, name)
|
|
63
|
+
raise ArgumentError, "Invalid #{name}" unless CLASS.bind(value).call == String
|
|
64
|
+
|
|
65
|
+
String.new(value)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def integer(value, minimum, maximum, name)
|
|
69
|
+
unless CLASS.bind(value).call == Integer && value.between?(minimum, maximum)
|
|
70
|
+
raise ArgumentError, "Invalid #{name}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module MirrorsSDK
|
|
8
|
+
# Keeps decisions bound to the evidence, mode lease and caller's original budget.
|
|
9
|
+
class ContextHost
|
|
10
|
+
OBJECT_CLASS = Object.instance_method(:class)
|
|
11
|
+
HASH_GET = Hash.instance_method(:[])
|
|
12
|
+
Wait = Struct.new(:identifier, :trace_id, :snapshot, :started, :deadline,
|
|
13
|
+
:version, :shadow, :result, :rejection, :job, :condition,
|
|
14
|
+
keyword_init: true)
|
|
15
|
+
|
|
16
|
+
def initialize(config, transport = nil, executor = nil)
|
|
17
|
+
@config, @transport, @executor = config, transport, executor
|
|
18
|
+
@lock = Monitor.new
|
|
19
|
+
@pending = {}
|
|
20
|
+
@mode = nil
|
|
21
|
+
@expires = 0.0
|
|
22
|
+
@enabled = !!(config && config.enabled)
|
|
23
|
+
@closed = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get(trace, timeout_ms: 1000, cancel: nil, started: now)
|
|
27
|
+
return empty("canceled", started) if canceled?(cancel)
|
|
28
|
+
|
|
29
|
+
self.await(start(trace, timeout_ms: timeout_ms, started: started), cancel: cancel)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Two phases let the client fence finalization that races with snapshot copying.
|
|
33
|
+
def start(trace, timeout_ms: 1000, started: now)
|
|
34
|
+
wait = Wait.new(started: started, deadline: started, condition: @lock.new_cond)
|
|
35
|
+
unless timeout_ms.instance_of?(Integer) && timeout_ms.between?(1, 1000)
|
|
36
|
+
wait.result = empty("invalid_options", started)
|
|
37
|
+
return wait
|
|
38
|
+
end
|
|
39
|
+
wait.deadline += timeout_ms / 1000.0
|
|
40
|
+
@lock.synchronize do
|
|
41
|
+
reason = unavailable(wait.deadline)
|
|
42
|
+
if reason && reason != "shadow"
|
|
43
|
+
wait.result = empty(reason, started)
|
|
44
|
+
return wait
|
|
45
|
+
end
|
|
46
|
+
trace_id = HASH_GET.bind(trace).call("id") if OBJECT_CLASS.bind(trace).call == Hash
|
|
47
|
+
unless OBJECT_CLASS.bind(trace_id).call == String
|
|
48
|
+
wait.result = empty("invalid_evidence", started)
|
|
49
|
+
return wait
|
|
50
|
+
end
|
|
51
|
+
release(trace_id, "superseded")
|
|
52
|
+
if @pending.length >= 16
|
|
53
|
+
wait.result = empty("capacity", started)
|
|
54
|
+
return wait
|
|
55
|
+
end
|
|
56
|
+
wait.identifier = SecureRandom.uuid
|
|
57
|
+
wait.trace_id = String.new(trace_id)
|
|
58
|
+
wait.version = @mode["version"]
|
|
59
|
+
wait.shadow = reason == "shadow"
|
|
60
|
+
@pending[wait.identifier] = wait
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Reserve capacity before copying; never hold the shared lock during encoding.
|
|
64
|
+
begin
|
|
65
|
+
snapshot = Wire.snapshot(trace, deadline: wait.deadline)
|
|
66
|
+
encoded = Wire.encode(snapshot, deadline: wait.deadline)
|
|
67
|
+
request = { "protocol" => 1, "agentId" => @config.agent_id,
|
|
68
|
+
"requestId" => wait.identifier, "trace" => snapshot }
|
|
69
|
+
request["shadowOnly"] = true if wait.shadow
|
|
70
|
+
body = Wire.encode(request, limit: 1_048_576, deadline: wait.deadline)
|
|
71
|
+
rescue StandardError => error
|
|
72
|
+
@lock.synchronize { finish(wait, error.is_a?(Timeout::Error) ? "timeout" : "invalid_evidence") }
|
|
73
|
+
return wait
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@lock.synchronize do
|
|
77
|
+
return wait unless @pending[wait.identifier].equal?(wait)
|
|
78
|
+
if now >= wait.deadline
|
|
79
|
+
finish(wait, "timeout")
|
|
80
|
+
return wait
|
|
81
|
+
end
|
|
82
|
+
wait.snapshot = encoded
|
|
83
|
+
wait.result = empty("shadow", started) if wait.shadow
|
|
84
|
+
begin
|
|
85
|
+
wait.job = @executor.submit do |token|
|
|
86
|
+
@transport.request("retrieve-context", body, deadline: wait.deadline, cancel: token) do |value|
|
|
87
|
+
Wire.context_response(value, request)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
if wait.job
|
|
91
|
+
wait.job.on_complete { |job| receive(wait, job) }
|
|
92
|
+
else
|
|
93
|
+
finish(wait, "capacity")
|
|
94
|
+
end
|
|
95
|
+
rescue StandardError
|
|
96
|
+
finish(wait, "worker_failed")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
wait
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def await(wait, cancel: nil)
|
|
103
|
+
completed = false
|
|
104
|
+
@lock.synchronize do
|
|
105
|
+
loop do
|
|
106
|
+
reason = canceled?(cancel) ? "canceled" : mode_reason
|
|
107
|
+
reason ||= "timeout" if now >= wait.deadline
|
|
108
|
+
finish(wait, reason) if !wait.result && reason
|
|
109
|
+
break if wait.result
|
|
110
|
+
|
|
111
|
+
wait.condition.wait([0.005, [wait.deadline - now, 0].max].min)
|
|
112
|
+
end
|
|
113
|
+
result = finalize(wait, cancel)
|
|
114
|
+
completed = true
|
|
115
|
+
result
|
|
116
|
+
end
|
|
117
|
+
ensure
|
|
118
|
+
@lock.synchronize { finish(wait, "canceled") } unless completed
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def empty(reason, started)
|
|
122
|
+
@lock.synchronize do
|
|
123
|
+
fresh_mode = @mode && now < @expires && wall < Wire.utc(@mode["expiresAt"])
|
|
124
|
+
mode = fresh_mode ? @mode["mode"] : "shadow"
|
|
125
|
+
ContextResult.new(mode: reason == "disabled" ? "off" : mode,
|
|
126
|
+
delivery: reason == "canceled" ? "canceled" : "unconfirmed",
|
|
127
|
+
reason: reason, elapsed_ms: [0, (now - started) * 1000].max)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def observe_mode(value, started)
|
|
132
|
+
@lock.synchronize do
|
|
133
|
+
return if @closed || !@enabled || !@config
|
|
134
|
+
return unless (started.instance_of?(Integer) || started.instance_of?(Float)) &&
|
|
135
|
+
started.finite? && started <= now
|
|
136
|
+
return unless Wire.mode_response(value, @config.agent_id)
|
|
137
|
+
|
|
138
|
+
changed = @mode && [@mode["version"], @mode["mode"]] != [value["version"], value["mode"]]
|
|
139
|
+
@mode = value.dup
|
|
140
|
+
@expires = [now + [0, Wire.utc(value["expiresAt"]) - wall].max,
|
|
141
|
+
started + [Wire.lifetime(value), @config.mode_lease_ms / 1000.0].min].min
|
|
142
|
+
invalidate("mode_changed") if changed
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def observe(trace_id, snapshot)
|
|
147
|
+
@lock.synchronize do
|
|
148
|
+
@pending.values.each do |wait|
|
|
149
|
+
finish(wait, "superseded") if wait.trace_id == trace_id && wait.snapshot != snapshot
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def release(trace_id, reason = "execution_closed")
|
|
155
|
+
@lock.synchronize do
|
|
156
|
+
@pending.values.each { |wait| finish(wait, reason) if wait.trace_id == trace_id }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def set_enabled(enabled)
|
|
161
|
+
@lock.synchronize do
|
|
162
|
+
@enabled = enabled
|
|
163
|
+
@mode = nil
|
|
164
|
+
@expires = 0
|
|
165
|
+
invalidate("disabled") unless enabled
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def stop(reason = "closed")
|
|
170
|
+
@lock.synchronize do
|
|
171
|
+
@closed = true
|
|
172
|
+
invalidate(reason)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def diagnostics
|
|
177
|
+
@lock.synchronize { { pending_context: @pending.length, mode: @mode && @mode["mode"] } }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def unavailable(deadline)
|
|
183
|
+
return "invalid_options" unless @config
|
|
184
|
+
return "closed" if @closed
|
|
185
|
+
return "disabled" unless @enabled
|
|
186
|
+
return "timeout" if now >= deadline
|
|
187
|
+
return "worker_failed" unless @transport && @executor
|
|
188
|
+
|
|
189
|
+
mode_reason
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def mode_reason
|
|
193
|
+
return "mode_unknown" unless @mode
|
|
194
|
+
return "mode_expired" if now >= @expires || wall >= Wire.utc(@mode["expiresAt"])
|
|
195
|
+
|
|
196
|
+
@mode["mode"] unless @mode["mode"] == "active"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def receive(wait, job)
|
|
200
|
+
@lock.synchronize do
|
|
201
|
+
return unless @pending[wait.identifier].equal?(wait)
|
|
202
|
+
reason = mode_reason
|
|
203
|
+
if wait.shadow || reason
|
|
204
|
+
finish(wait, reason || "shadow")
|
|
205
|
+
return
|
|
206
|
+
end
|
|
207
|
+
begin
|
|
208
|
+
value = job.value
|
|
209
|
+
freshness = ContextFreshness.new(
|
|
210
|
+
request_id: value["requestId"], trace_id: value["traceId"],
|
|
211
|
+
decision_id: value["decisionId"], mode_version: value["modeVersion"],
|
|
212
|
+
issued_at: value["issuedAt"], expires_at: value["expiresAt"]
|
|
213
|
+
)
|
|
214
|
+
reason = freshness_reason(wait, freshness)
|
|
215
|
+
if reason
|
|
216
|
+
finish(wait, reason)
|
|
217
|
+
else
|
|
218
|
+
wait.result = ContextResult.new(context: value["context"] || "", mode: value["mode"],
|
|
219
|
+
delivery: "accepted", reason: value["reason"],
|
|
220
|
+
elapsed_ms: (now - wait.started) * 1000, freshness: freshness)
|
|
221
|
+
wait.condition.broadcast
|
|
222
|
+
end
|
|
223
|
+
rescue StandardError => error
|
|
224
|
+
finish(wait, error.respond_to?(:reason) ? error.reason : "transport")
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def freshness_reason(wait, freshness)
|
|
230
|
+
return "timeout" if now >= wait.deadline
|
|
231
|
+
return "mode_changed" if @mode["version"] != wait.version || freshness.mode_version != wait.version
|
|
232
|
+
|
|
233
|
+
dates = { "issuedAt" => freshness.issued_at, "expiresAt" => freshness.expires_at }
|
|
234
|
+
if Wire.utc(freshness.expires_at) <= wall || now >= wait.started + Wire.lifetime(dates)
|
|
235
|
+
"expired"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def finalize(wait, cancel)
|
|
240
|
+
result = wait.result
|
|
241
|
+
reason = "canceled" if canceled?(cancel)
|
|
242
|
+
if result.delivery == "accepted"
|
|
243
|
+
reason ||= wait.rejection
|
|
244
|
+
reason ||= "superseded" unless @pending[wait.identifier].equal?(wait)
|
|
245
|
+
reason ||= mode_reason || freshness_reason(wait, result.freshness)
|
|
246
|
+
@pending.delete(wait.identifier)
|
|
247
|
+
end
|
|
248
|
+
if reason
|
|
249
|
+
finish(wait, reason)
|
|
250
|
+
return empty(reason, wait.started)
|
|
251
|
+
end
|
|
252
|
+
ContextResult.new(context: result.context, mode: result.mode, delivery: result.delivery,
|
|
253
|
+
reason: result.reason, elapsed_ms: [0, (now - wait.started) * 1000].max,
|
|
254
|
+
freshness: result.freshness)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def finish(wait, reason)
|
|
258
|
+
@pending.delete(wait.identifier)
|
|
259
|
+
wait.rejection ||= reason
|
|
260
|
+
wait.result ||= empty(reason, wait.started)
|
|
261
|
+
wait.condition.broadcast
|
|
262
|
+
wait.job.cancel(reason) if wait.job
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def invalidate(reason)
|
|
266
|
+
@pending.values.each { |wait| finish(wait, reason) }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def canceled?(cancel)
|
|
270
|
+
OBJECT_CLASS.bind(cancel).call == Cancellation && cancel.canceled?
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def now
|
|
274
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def wall
|
|
278
|
+
Time.now.to_f
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module MirrorsSDK
|
|
6
|
+
# Bounded bookkeeping. Flush observes only tickets pending when it starts.
|
|
7
|
+
class Deliveries
|
|
8
|
+
def initialize
|
|
9
|
+
@lock = Monitor.new
|
|
10
|
+
@pending = {}
|
|
11
|
+
@counts = { submitted: 0, accepted: 0, dropped: 0, unconfirmed: 0 }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create(final = false, trace_id = "")
|
|
15
|
+
@lock.synchronize do
|
|
16
|
+
ticket = DeliveryTicket.new
|
|
17
|
+
@pending[ticket.id] = [ticket, final, trace_id, nil]
|
|
18
|
+
@counts[:submitted] += 1
|
|
19
|
+
ticket
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def drop(reason)
|
|
24
|
+
ticket = create
|
|
25
|
+
settle(ticket, DeliveryReceipt.new("dropped", reason))
|
|
26
|
+
ticket
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def full?(final)
|
|
30
|
+
@lock.synchronize { @pending.values.count { |item| item[1] == final } >= 16 }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def attach(ticket, job)
|
|
34
|
+
@lock.synchronize do
|
|
35
|
+
item = @pending[ticket.id]
|
|
36
|
+
item[3] = job if item
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def settle(ticket, receipt)
|
|
41
|
+
@lock.synchronize do
|
|
42
|
+
return unless @pending.delete(ticket.id)
|
|
43
|
+
|
|
44
|
+
@counts[receipt.status.to_sym] += 1
|
|
45
|
+
ticket.__send__(:settle, receipt)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def cancel(reason, trace_id = nil)
|
|
50
|
+
jobs = @lock.synchronize do
|
|
51
|
+
selected = @pending.values.select { |item| trace_id.nil? || (item[2] == trace_id && !item[1]) }
|
|
52
|
+
selected.each do |ticket, _final, _identifier, job|
|
|
53
|
+
settle(ticket, DeliveryReceipt.new(job ? "unconfirmed" : "dropped", reason))
|
|
54
|
+
end
|
|
55
|
+
selected.map(&:last).compact
|
|
56
|
+
end
|
|
57
|
+
jobs.each { |job| job.cancel(reason) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def flush(timeout_ms)
|
|
61
|
+
tickets = @lock.synchronize { @pending.values.map(&:first) }
|
|
62
|
+
valid = (timeout_ms.instance_of?(Integer) || timeout_ms.instance_of?(Float)) && timeout_ms.between?(0, 30_000)
|
|
63
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (valid ? timeout_ms / 1000.0 : 0)
|
|
64
|
+
tickets.each { |ticket| ticket.wait([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
|
|
65
|
+
counts = { accepted: 0, dropped: 0, unconfirmed: 0, pending: 0 }
|
|
66
|
+
tickets.each { |ticket| counts[ticket.receipt.status.to_sym] += 1 }
|
|
67
|
+
FlushResult.new(**counts)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def diagnostics
|
|
71
|
+
@lock.synchronize { @counts.merge(pending: @pending.length) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|