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.
@@ -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