upkeep-rails 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.

Potentially problematic release.


This version of upkeep-rails might be problematic. Click here for more details.

Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +424 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +306 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +187 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/cost-model-roadmap.md +703 -0
  9. data/docs/guides/getting-started.md +282 -0
  10. data/docs/handoff-2026-05-15.md +230 -0
  11. data/docs/production_roadmap.md +372 -0
  12. data/docs/shared-warm-scale-roadmap.md +214 -0
  13. data/docs/single-subscriber-cold-roadmap.md +192 -0
  14. data/docs/testing.md +113 -0
  15. data/lib/generators/upkeep/install/install_generator.rb +90 -0
  16. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
  17. data/lib/generators/upkeep/install/templates/subscription.js +107 -0
  18. data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
  19. data/lib/upkeep/active_record_query.rb +294 -0
  20. data/lib/upkeep/capture/request.rb +150 -0
  21. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  22. data/lib/upkeep/dag.rb +370 -0
  23. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  24. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  25. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  26. data/lib/upkeep/delivery/transport.rb +194 -0
  27. data/lib/upkeep/delivery/turbo_streams.rb +275 -0
  28. data/lib/upkeep/delivery.rb +7 -0
  29. data/lib/upkeep/dependencies.rb +466 -0
  30. data/lib/upkeep/herb/developer_report.rb +116 -0
  31. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  32. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  33. data/lib/upkeep/herb/source_instrumenter.rb +84 -0
  34. data/lib/upkeep/herb/template_manifest.rb +377 -0
  35. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  36. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  37. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  39. data/lib/upkeep/invalidation/planner.rb +341 -0
  40. data/lib/upkeep/invalidation.rb +7 -0
  41. data/lib/upkeep/rails/action_view_capture.rb +765 -0
  42. data/lib/upkeep/rails/cable/channel.rb +108 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +37 -0
  46. data/lib/upkeep/rails/configuration.rb +57 -0
  47. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  48. data/lib/upkeep/rails/install.rb +28 -0
  49. data/lib/upkeep/rails/railtie.rb +36 -0
  50. data/lib/upkeep/rails/replay.rb +176 -0
  51. data/lib/upkeep/rails/testing.rb +36 -0
  52. data/lib/upkeep/rails.rb +276 -0
  53. data/lib/upkeep/replay.rb +408 -0
  54. data/lib/upkeep/runtime.rb +1075 -0
  55. data/lib/upkeep/shared_streams.rb +72 -0
  56. data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
  57. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
  58. data/lib/upkeep/subscriptions/active_registry.rb +93 -0
  59. data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
  60. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  61. data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
  62. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
  63. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  64. data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
  65. data/lib/upkeep/subscriptions/shape.rb +116 -0
  66. data/lib/upkeep/subscriptions/store.rb +159 -0
  67. data/lib/upkeep/subscriptions.rb +7 -0
  68. data/lib/upkeep/targeting.rb +135 -0
  69. data/lib/upkeep/version.rb +5 -0
  70. data/lib/upkeep-rails.rb +3 -0
  71. data/lib/upkeep.rb +14 -0
  72. data/upkeep-rails.gemspec +53 -0
  73. metadata +296 -0
data/lib/upkeep/dag.rb ADDED
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Upkeep
7
+ module DAG
8
+ Node = Data.define(:id, :kind, :payload)
9
+ Edge = Data.define(:from, :to, :reason)
10
+
11
+ class Graph
12
+ attr_reader :nodes, :edges, :version
13
+
14
+ def initialize
15
+ @nodes = {}
16
+ @edges = []
17
+ @version = 0
18
+ reset_indexes!
19
+ end
20
+
21
+ def add_node(id, kind:, payload: {})
22
+ return nodes.fetch(id) if nodes.key?(id)
23
+
24
+ @version += 1
25
+ nodes[id] = Node.new(id, kind, payload)
26
+ end
27
+
28
+ def add_edge(from, to, reason:)
29
+ key = edge_key(from, to, reason)
30
+ return false if @edge_keys[key]
31
+
32
+ @edge_keys[key] = true
33
+ @version += 1
34
+ edge = Edge.new(from, to, reason)
35
+ edges << edge
36
+ @outgoing_edges_by_from[from] << edge
37
+ @incoming_edges_by_to[to] << edge
38
+ @dependency_owner_ids_by_node[to] << from if reason == :depends_on
39
+ true
40
+ end
41
+
42
+ def add_dependency(owner_id, dependency)
43
+ dependency_cache_key = dependency.cache_key
44
+ dependency_cache_keys = @dependency_cache_keys_by_node[owner_id]
45
+ return false if dependency_cache_keys.key?(dependency_cache_key)
46
+
47
+ add_node(owner_id, kind: :unknown) unless nodes.key?(owner_id)
48
+ add_node(dependency_cache_key, kind: :dependency, payload: dependency)
49
+ add_edge(owner_id, dependency_cache_key, reason: :depends_on)
50
+
51
+ dependency_cache_keys[dependency_cache_key] = true
52
+ @dependencies_by_node[owner_id] << dependency
53
+ true
54
+ end
55
+
56
+ def dependencies_for(node_id)
57
+ @dependencies_by_node[node_id]
58
+ end
59
+
60
+ def node(id)
61
+ nodes.fetch(id)
62
+ end
63
+
64
+ def node?(id)
65
+ nodes.key?(id)
66
+ end
67
+
68
+ def outgoing_edges(from, reason: nil)
69
+ indexed_edges = @outgoing_edges_by_from.fetch(from, [])
70
+ return indexed_edges.dup unless reason
71
+
72
+ indexed_edges.select { |edge| edge.reason == reason }
73
+ end
74
+
75
+ def incoming_edges(to, reason: nil)
76
+ indexed_edges = @incoming_edges_by_to.fetch(to, [])
77
+ return indexed_edges.dup unless reason
78
+
79
+ indexed_edges.select { |edge| edge.reason == reason }
80
+ end
81
+
82
+ def dependency_owner_ids(dependency_node_id)
83
+ @dependency_owner_ids_by_node.fetch(dependency_node_id, []).dup
84
+ end
85
+
86
+ def dependency_node_ids_matching(changes)
87
+ dependency_nodes.filter_map do |node|
88
+ node.id if changes.any? { |change| node.payload.matches_change?(change) }
89
+ end
90
+ end
91
+
92
+ def nearest_frame_nodes_from(node_id)
93
+ current = node(node_id)
94
+ return [current] if current.kind == :frame
95
+
96
+ queue = outgoing_edges(node_id, reason: :contains).map(&:to)
97
+ visited = {}
98
+ frames = []
99
+
100
+ until queue.empty?
101
+ id = queue.shift
102
+ next if visited[id]
103
+
104
+ visited[id] = true
105
+ current = node(id)
106
+ if current.kind == :frame
107
+ frames << current
108
+ else
109
+ queue.concat(outgoing_edges(id, reason: :contains).map(&:to))
110
+ end
111
+ end
112
+
113
+ frames
114
+ end
115
+
116
+ def ancestor_node_ids(node_id)
117
+ ancestors = []
118
+ current = node_id
119
+
120
+ while (edge = incoming_edges(current, reason: :contains).first)
121
+ ancestors << edge.from
122
+ current = edge.from
123
+ end
124
+
125
+ ancestors
126
+ end
127
+
128
+ def contained_by?(descendant_id, ancestor_id)
129
+ ancestor_node_ids(descendant_id).include?(ancestor_id)
130
+ end
131
+
132
+ def contained_node_ids(node_id)
133
+ ids = []
134
+ queue = [node_id]
135
+ visited = {}
136
+
137
+ until queue.empty?
138
+ id = queue.shift
139
+ next if visited[id]
140
+
141
+ visited[id] = true
142
+ ids << id
143
+ queue.concat(outgoing_edges(id, reason: :contains).map(&:to))
144
+ end
145
+
146
+ ids
147
+ end
148
+
149
+ def frame_nodes
150
+ nodes.values.select { |node| node.kind == :frame }
151
+ end
152
+
153
+ def dependency_nodes
154
+ nodes.values.select { |node| node.kind == :dependency }
155
+ end
156
+
157
+ def summary
158
+ {
159
+ nodes: nodes.size,
160
+ edges: edges.size,
161
+ frames: frame_nodes.size,
162
+ manifest_attached_frames: frame_nodes.count { |node| node.payload[:manifest_path] },
163
+ dependencies: dependency_nodes.size,
164
+ containment_edges: edges.count { |edge| edge.reason == :contains },
165
+ dependency_edges: edges.count { |edge| edge.reason == :depends_on },
166
+ replay_recipes: frame_nodes.count { |node| node.payload[:recipe] },
167
+ replay_recipe_kinds: frame_nodes.filter_map { |node| node.payload[:recipe]&.kind }.map(&:to_s).uniq.sort,
168
+ dependency_sources: dependency_nodes.map { |node| node.payload.source.to_s }.uniq.sort
169
+ }
170
+ end
171
+
172
+ def report
173
+ {
174
+ summary: summary,
175
+ frames: frame_reports,
176
+ dependencies: dependency_reports,
177
+ edges: edges.map(&:to_h)
178
+ }
179
+ end
180
+
181
+ def to_h(dependencies: :all)
182
+ serialized_nodes = serializable_nodes(dependencies: dependencies)
183
+ node_ids = serialized_nodes.to_h { |node| [node.id, true] }
184
+
185
+ {
186
+ nodes: serialized_nodes.map { |node| serialize_node(node) },
187
+ edges: edges.select { |edge| node_ids[edge.from] && node_ids[edge.to] }.map(&:to_h)
188
+ }
189
+ end
190
+
191
+ def self.from_h(snapshot)
192
+ snapshot = symbolize_keys(snapshot)
193
+ graph = new
194
+ graph.nodes.clear
195
+ graph.edges.clear
196
+ graph.send(:reset_indexes!)
197
+
198
+ snapshot.fetch(:nodes).each do |node_snapshot|
199
+ node_snapshot = symbolize_keys(node_snapshot)
200
+ kind = node_snapshot.fetch(:kind).to_sym
201
+ graph.nodes[node_snapshot.fetch(:id)] = Node.new(
202
+ node_snapshot.fetch(:id),
203
+ kind,
204
+ deserialize_payload(kind, node_snapshot.fetch(:payload))
205
+ )
206
+ end
207
+
208
+ snapshot.fetch(:edges).each do |edge_snapshot|
209
+ edge_snapshot = symbolize_keys(edge_snapshot)
210
+ graph.add_edge(
211
+ edge_snapshot.fetch(:from),
212
+ edge_snapshot.fetch(:to),
213
+ reason: edge_snapshot.fetch(:reason).to_sym
214
+ )
215
+ end
216
+
217
+ graph.send(:rebuild_dependency_index)
218
+ graph
219
+ end
220
+
221
+ def frame_reports
222
+ frame_nodes.map do |node|
223
+ {
224
+ id: node.id,
225
+ kind: node.payload.fetch(:kind),
226
+ template: node.payload[:template],
227
+ site_id: node.payload[:site_id],
228
+ manifest_path: node.payload[:manifest_path],
229
+ manifest_fingerprint: node.payload[:manifest_fingerprint],
230
+ locals: node.payload[:locals],
231
+ contains: outgoing_edges(node.id, reason: :contains).map(&:to),
232
+ dependencies: dependencies_for(node.id).map(&:to_h),
233
+ replay_recipe: recipe_report(node.payload[:recipe])
234
+ }.compact
235
+ end
236
+ end
237
+
238
+ def recipe_report(recipe)
239
+ return unless recipe
240
+
241
+ snapshot = recipe.to_h
242
+ replay = snapshot[:replay] || snapshot["replay"] || {}
243
+ replay_json = JSON.generate(replay)
244
+ {
245
+ kind: recipe.kind.to_s,
246
+ target_kind: recipe.target_kind,
247
+ target_id: recipe.target_id,
248
+ runtime: recipe.runtime,
249
+ template: recipe.template,
250
+ replay: {
251
+ type: recipe.replay.respond_to?(:type) ? recipe.replay.type : nil,
252
+ keys: replay.keys.map(&:to_s).sort,
253
+ bytes: replay_json.bytesize,
254
+ digest: Digest::SHA256.hexdigest(replay_json)
255
+ }.compact
256
+ }.compact
257
+ end
258
+
259
+ def dependency_reports
260
+ dependency_nodes.map do |node|
261
+ {
262
+ id: node.id,
263
+ dependency: node.payload.to_h,
264
+ owners: dependency_owner_ids(node.id)
265
+ }
266
+ end
267
+ end
268
+
269
+ private
270
+
271
+ def serializable_nodes(dependencies:)
272
+ case dependencies
273
+ when :all
274
+ nodes.values
275
+ when :identity
276
+ nodes.values.select { |node| node.kind != :dependency || node.payload.identity? }
277
+ else
278
+ raise ArgumentError, "unsupported graph dependency serialization mode: #{dependencies.inspect}"
279
+ end
280
+ end
281
+
282
+ def reset_indexes!
283
+ @edge_keys = {}
284
+ @outgoing_edges_by_from = Hash.new { |hash, key| hash[key] = [] }
285
+ @incoming_edges_by_to = Hash.new { |hash, key| hash[key] = [] }
286
+ @dependency_owner_ids_by_node = Hash.new { |hash, key| hash[key] = [] }
287
+ @dependencies_by_node = Hash.new { |hash, key| hash[key] = [] }
288
+ @dependency_cache_keys_by_node = Hash.new { |hash, key| hash[key] = {} }
289
+ end
290
+
291
+ def rebuild_dependency_index
292
+ @dependencies_by_node = Hash.new { |hash, key| hash[key] = [] }
293
+ @dependency_cache_keys_by_node = Hash.new { |hash, key| hash[key] = {} }
294
+
295
+ edges.each do |edge|
296
+ next unless edge.reason == :depends_on
297
+
298
+ dependency = nodes.fetch(edge.to).payload
299
+ dependency_cache_keys = @dependency_cache_keys_by_node[edge.from]
300
+ next if dependency_cache_keys.key?(dependency.cache_key)
301
+
302
+ dependency_cache_keys[dependency.cache_key] = true
303
+ @dependencies_by_node[edge.from] << dependency
304
+ end
305
+ end
306
+
307
+ def edge_key(from, to, reason)
308
+ [from, to, reason]
309
+ end
310
+
311
+ class << self
312
+ def deserialize_payload(kind, payload)
313
+ payload = symbolize_keys(payload)
314
+
315
+ case kind
316
+ when :dependency
317
+ Dependencies.from_h(payload)
318
+ when :frame
319
+ deserialize_frame_payload(payload)
320
+ else
321
+ payload
322
+ end
323
+ end
324
+
325
+ def deserialize_frame_payload(payload)
326
+ payload.each_with_object({}) do |(key, value), frame_payload|
327
+ frame_payload[key] = key == :recipe && value ? Replay::Recipe.from_h(value) : value
328
+ end
329
+ end
330
+
331
+ def symbolize_keys(value)
332
+ case value
333
+ when Hash
334
+ value.each_with_object({}) do |(key, nested_value), result|
335
+ normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
336
+ result[normalized_key] = symbolize_keys(nested_value)
337
+ end
338
+ when Array
339
+ value.map { |nested_value| symbolize_keys(nested_value) }
340
+ else
341
+ value
342
+ end
343
+ end
344
+ end
345
+
346
+ def serialize_node(node)
347
+ {
348
+ id: node.id,
349
+ kind: node.kind,
350
+ payload: serialize_payload(node)
351
+ }
352
+ end
353
+
354
+ def serialize_payload(node)
355
+ case node.kind
356
+ when :dependency
357
+ node.payload.to_h
358
+ when :frame
359
+ node.payload.each_with_object({}) do |(key, value), frame_payload|
360
+ frame_payload[key] = key == :recipe && value ? value.to_h : value
361
+ end
362
+ else
363
+ node.payload
364
+ end
365
+ end
366
+ end
367
+ end
368
+ end
369
+
370
+ require_relative "dag/subscription_shape"
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require "digest"
5
+
6
+ module Upkeep
7
+ module Delivery
8
+ class ActionCableAdapter
9
+ STREAM_PREFIX = "upkeep:subscriber"
10
+
11
+ def self.stream_name_for(subscriber_id)
12
+ "#{STREAM_PREFIX}:#{Digest::SHA256.hexdigest(subscriber_id.to_s)[0, 32]}"
13
+ end
14
+
15
+ def initialize(server: default_server)
16
+ @server = server
17
+ end
18
+
19
+ def deliver(envelope)
20
+ stream_name = envelope.stream_name || self.class.stream_name_for(envelope.subscriber_id)
21
+ payload = {
22
+ subscriber_id: envelope.subscriber_id,
23
+ stream_name: stream_name,
24
+ envelope_digest: Transport.envelope_digest(envelope),
25
+ bytesize: envelope.body.bytesize
26
+ }
27
+
28
+ ActiveSupport::Notifications.instrument("deliver.upkeep", payload) do
29
+ server.broadcast(stream_name, envelope.body)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :server
36
+
37
+ def default_server
38
+ require "action_cable"
39
+ ::ActionCable.server
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Delivery
5
+ class AsyncDispatcher
6
+ def initialize(batch_window: 0.01, &deliver)
7
+ @deliver = deliver
8
+ @batch_window = batch_window
9
+ @jobs = []
10
+ @mutex = Mutex.new
11
+ @available = ConditionVariable.new
12
+ @idle = ConditionVariable.new
13
+ @pending_jobs = 0
14
+ @last_error = nil
15
+ @stopping = false
16
+ @worker = Thread.new { work_loop }
17
+ end
18
+
19
+ def enqueue(changes)
20
+ changes = Array(changes)
21
+ return Transport::DispatchReport.new([]) if changes.empty?
22
+
23
+ @mutex.synchronize do
24
+ raise @last_error if @last_error
25
+
26
+ @pending_jobs += 1
27
+ @jobs << changes
28
+ @available.signal
29
+ end
30
+
31
+ Transport::DispatchReport.new([])
32
+ end
33
+
34
+ def drain
35
+ @mutex.synchronize do
36
+ @idle.wait(@mutex) until @pending_jobs.zero?
37
+ raise @last_error if @last_error
38
+ end
39
+ end
40
+
41
+ def shutdown
42
+ error = nil
43
+ begin
44
+ drain
45
+ rescue StandardError => shutdown_error
46
+ error = shutdown_error
47
+ ensure
48
+ @mutex.synchronize do
49
+ @stopping = true
50
+ @available.signal
51
+ end
52
+ @worker.join
53
+ end
54
+
55
+ raise error if error
56
+ end
57
+
58
+ private
59
+
60
+ attr_reader :deliver, :batch_window
61
+
62
+ def work_loop
63
+ loop do
64
+ batch = next_batch
65
+ break unless batch
66
+
67
+ begin
68
+ deliver.call(batch)
69
+ rescue StandardError => error
70
+ @mutex.synchronize { @last_error = error }
71
+ ensure
72
+ complete_jobs(batch.size)
73
+ end
74
+ end
75
+ end
76
+
77
+ def next_batch
78
+ @mutex.synchronize do
79
+ @available.wait(@mutex) while @jobs.empty? && !@stopping
80
+ return nil if @jobs.empty? && @stopping
81
+
82
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + batch_window
83
+ while !@stopping
84
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
85
+ break unless remaining.positive?
86
+
87
+ @available.wait(@mutex, remaining)
88
+ end
89
+
90
+ @jobs.shift(@jobs.length)
91
+ end
92
+ end
93
+
94
+ def complete_jobs(count)
95
+ @mutex.synchronize do
96
+ @pending_jobs -= count
97
+ @idle.broadcast if @pending_jobs.zero?
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Delivery
5
+ class BroadcastTransport
6
+ def initialize(adapter: ActionCableAdapter.new, max_queue_size: 100, retry_limit: 3)
7
+ @adapter = adapter
8
+ @max_queue_size = max_queue_size
9
+ @retry_limit = retry_limit
10
+ @adapter_overrides = {}
11
+ @queue = []
12
+ end
13
+
14
+ def connect(subscriber_id:, adapter:)
15
+ adapter_overrides[subscriber_id] = adapter
16
+ end
17
+
18
+ def disconnect(subscriber_id)
19
+ adapter_overrides.delete(subscriber_id)
20
+ retained, dropped = queue.partition { |item| item.envelope.subscriber_id != subscriber_id }
21
+ @queue = retained
22
+
23
+ Transport::Cleanup.new(subscriber_id, :disconnected, dropped.size)
24
+ end
25
+
26
+ def deliver(batch)
27
+ Transport::DispatchReport.new(batch.envelopes.map { |envelope| deliver_envelope(envelope, attempts: 0) })
28
+ end
29
+
30
+ def retry_pending(subscriber_id: nil)
31
+ selected, retained = queue.partition do |item|
32
+ subscriber_id.nil? || item.envelope.subscriber_id == subscriber_id
33
+ end
34
+ @queue = retained
35
+
36
+ Transport::DispatchReport.new(selected.map { |item| deliver_envelope(item.envelope, attempts: item.attempts) })
37
+ end
38
+
39
+ def connected?(subscriber_id)
40
+ adapter_overrides.key?(subscriber_id)
41
+ end
42
+
43
+ def summary
44
+ {
45
+ adapter_overrides: adapter_overrides.size,
46
+ queued_envelopes: queue.size,
47
+ max_queue_size: max_queue_size,
48
+ retry_limit: retry_limit
49
+ }
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :adapter, :adapter_overrides, :max_queue_size, :retry_limit, :queue
55
+
56
+ def deliver_envelope(envelope, attempts:)
57
+ next_attempt = attempts + 1
58
+ adapter_for(envelope.subscriber_id).deliver(envelope)
59
+
60
+ outcome(:delivered, envelope, attempts: next_attempt)
61
+ rescue StandardError => error
62
+ if next_attempt >= retry_limit
63
+ outcome(:dropped_retry_exhausted, envelope, attempts: next_attempt, error: error)
64
+ elsif queue.size >= max_queue_size
65
+ outcome(:backpressured, envelope, attempts: next_attempt, error: error)
66
+ else
67
+ queue << Transport::RetryItem.new(envelope, next_attempt, error)
68
+ outcome(:queued_retry, envelope, attempts: next_attempt, error: error)
69
+ end
70
+ end
71
+
72
+ def adapter_for(subscriber_id)
73
+ adapter_overrides.fetch(subscriber_id, adapter)
74
+ end
75
+
76
+ def outcome(status, envelope, attempts:, error: nil)
77
+ Transport::Outcome.new(
78
+ envelope.subscriber_id,
79
+ status,
80
+ attempts,
81
+ queue.size,
82
+ Transport.envelope_digest(envelope),
83
+ error&.class&.name,
84
+ error&.message
85
+ )
86
+ end
87
+ end
88
+ end
89
+ end