upkeep-rails 0.2.5-aarch64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +244 -0
  4. data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
  5. data/docs/how-it-works.md +329 -0
  6. data/docs/plans/sqlglot-active-record-migration.md +211 -0
  7. data/docs/plans/turbo-frame-subscription-composition.md +90 -0
  8. data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
  9. data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
  10. data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
  11. data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
  12. data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
  13. data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
  14. data/lib/generators/upkeep/install/install_generator.rb +192 -0
  15. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  16. data/lib/generators/upkeep/install/templates/subscription.js +288 -0
  17. data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
  18. data/lib/upkeep/active_record_query.rb +248 -0
  19. data/lib/upkeep/capture/request.rb +150 -0
  20. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  21. data/lib/upkeep/dag.rb +454 -0
  22. data/lib/upkeep/delivery/action_cable_adapter.rb +48 -0
  23. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  24. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  25. data/lib/upkeep/delivery/transport.rb +194 -0
  26. data/lib/upkeep/delivery/turbo_streams.rb +339 -0
  27. data/lib/upkeep/delivery.rb +7 -0
  28. data/lib/upkeep/dependencies.rb +600 -0
  29. data/lib/upkeep/herb/developer_report.rb +135 -0
  30. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  31. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  32. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  33. data/lib/upkeep/herb/template_manifest.rb +548 -0
  34. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  35. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  36. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  37. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  38. data/lib/upkeep/invalidation/planner.rb +411 -0
  39. data/lib/upkeep/invalidation.rb +7 -0
  40. data/lib/upkeep/rails/action_view_capture.rb +1007 -0
  41. data/lib/upkeep/rails/activation_token.rb +55 -0
  42. data/lib/upkeep/rails/cable/channel.rb +165 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +65 -0
  46. data/lib/upkeep/rails/cluster_guard.rb +57 -0
  47. data/lib/upkeep/rails/configuration.rb +252 -0
  48. data/lib/upkeep/rails/controller_runtime.rb +187 -0
  49. data/lib/upkeep/rails/install.rb +28 -0
  50. data/lib/upkeep/rails/job_runtime.rb +43 -0
  51. data/lib/upkeep/rails/railtie.rb +44 -0
  52. data/lib/upkeep/rails/replay.rb +244 -0
  53. data/lib/upkeep/rails/testing.rb +259 -0
  54. data/lib/upkeep/rails.rb +466 -0
  55. data/lib/upkeep/replay.rb +462 -0
  56. data/lib/upkeep/runtime.rb +1276 -0
  57. data/lib/upkeep/shared_streams.rb +86 -0
  58. data/lib/upkeep/sql_dependency_analysis.rb +553 -0
  59. data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
  60. data/lib/upkeep/sqlglot/native.rb +121 -0
  61. data/lib/upkeep/sqlglot/native_library.rb +23 -0
  62. data/lib/upkeep/sqlglot.rb +367 -0
  63. data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
  64. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
  65. data/lib/upkeep/subscriptions/active_registry.rb +80 -0
  66. data/lib/upkeep/subscriptions/base_store.rb +110 -0
  67. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  68. data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
  69. data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
  70. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
  71. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  72. data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
  73. data/lib/upkeep/subscriptions/shape.rb +117 -0
  74. data/lib/upkeep/subscriptions/store.rb +349 -0
  75. data/lib/upkeep/subscriptions.rb +7 -0
  76. data/lib/upkeep/targeting.rb +146 -0
  77. data/lib/upkeep/version.rb +5 -0
  78. data/lib/upkeep-rails.rb +3 -0
  79. data/lib/upkeep.rb +15 -0
  80. data/upkeep-rails.gemspec +66 -0
  81. metadata +327 -0
data/lib/upkeep/dag.rb ADDED
@@ -0,0 +1,454 @@
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 replace_subgraph(scope_id, replacement_graph, replacement_scope_id: scope_id)
150
+ raise KeyError, scope_id unless node?(scope_id)
151
+ raise KeyError, replacement_scope_id unless replacement_graph.node?(replacement_scope_id)
152
+
153
+ removed_ids = contained_node_ids(scope_id).to_h { |id| [id, true] }
154
+ replacement_ids = replacement_graph.contained_node_ids(replacement_scope_id).to_h { |id| [id, true] }
155
+ composed = self.class.new
156
+
157
+ nodes.each_value do |node|
158
+ next if node.kind == :dependency || removed_ids[node.id]
159
+
160
+ composed.add_node(node.id, kind: node.kind, payload: node.payload)
161
+ end
162
+ replacement_graph.nodes.each_value do |node|
163
+ next if node.kind == :dependency || !replacement_ids[node.id]
164
+
165
+ composed.add_node(node.id, kind: node.kind, payload: node.payload)
166
+ end
167
+
168
+ edges.each do |edge|
169
+ next unless edge.reason == :contains
170
+ next unless composed.node?(edge.from) && composed.node?(edge.to)
171
+ next if removed_ids[edge.from] || edge.to != scope_id && removed_ids[edge.to]
172
+
173
+ composed.add_edge(edge.from, edge.to, reason: :contains)
174
+ end
175
+ replacement_graph.edges.each do |edge|
176
+ next unless edge.reason == :contains
177
+ next unless replacement_ids[edge.from] && replacement_ids[edge.to]
178
+
179
+ composed.add_edge(edge.from, edge.to, reason: :contains)
180
+ end
181
+
182
+ composed.nodes.keys.each do |owner_id|
183
+ source = replacement_ids[owner_id] ? replacement_graph : self
184
+ source.dependencies_for(owner_id).each { |dependency| composed.add_dependency(owner_id, dependency) }
185
+ end
186
+
187
+ composed
188
+ end
189
+
190
+ def attach_subgraph(scope_id, replacement_graph, parent_id:, replacement_scope_id: scope_id)
191
+ raise KeyError, scope_id if node?(scope_id)
192
+ raise KeyError, replacement_scope_id unless replacement_graph.node?(replacement_scope_id)
193
+ raise KeyError, parent_id unless node?(parent_id)
194
+
195
+ replacement_ids = replacement_graph.contained_node_ids(replacement_scope_id).to_h { |id| [id, true] }
196
+ composed = self.class.new
197
+
198
+ nodes.each_value do |node|
199
+ next if node.kind == :dependency
200
+
201
+ composed.add_node(node.id, kind: node.kind, payload: node.payload)
202
+ end
203
+ edges.each do |edge|
204
+ next unless edge.reason == :contains
205
+
206
+ composed.add_edge(edge.from, edge.to, reason: :contains)
207
+ end
208
+ composed.nodes.keys.each do |owner_id|
209
+ dependencies_for(owner_id).each { |dependency| composed.add_dependency(owner_id, dependency) }
210
+ end
211
+
212
+ replacement_graph.nodes.each_value do |node|
213
+ next if node.kind == :dependency || !replacement_ids[node.id]
214
+
215
+ composed.add_node(node.id, kind: node.kind, payload: node.payload)
216
+ end
217
+ replacement_graph.edges.each do |edge|
218
+ next unless edge.reason == :contains
219
+ next unless replacement_ids[edge.from] && replacement_ids[edge.to]
220
+
221
+ composed.add_edge(edge.from, edge.to, reason: :contains)
222
+ end
223
+ composed.add_edge(parent_id, replacement_scope_id, reason: :contains)
224
+ replacement_ids.each_key do |owner_id|
225
+ replacement_graph.dependencies_for(owner_id).each do |dependency|
226
+ composed.add_dependency(owner_id, dependency)
227
+ end
228
+ end
229
+
230
+ composed
231
+ end
232
+
233
+ def frame_nodes
234
+ nodes.values.select { |node| node.kind == :frame }
235
+ end
236
+
237
+ def dependency_nodes
238
+ nodes.values.select { |node| node.kind == :dependency }
239
+ end
240
+
241
+ def summary
242
+ {
243
+ nodes: nodes.size,
244
+ edges: edges.size,
245
+ frames: frame_nodes.size,
246
+ manifest_attached_frames: frame_nodes.count { |node| node.payload[:manifest_path] },
247
+ dependencies: dependency_nodes.size,
248
+ containment_edges: edges.count { |edge| edge.reason == :contains },
249
+ dependency_edges: edges.count { |edge| edge.reason == :depends_on },
250
+ replay_recipes: frame_nodes.count { |node| node.payload[:recipe] },
251
+ replay_recipe_kinds: frame_nodes.filter_map { |node| node.payload[:recipe]&.kind }.map(&:to_s).uniq.sort,
252
+ dependency_sources: dependency_nodes.map { |node| node.payload.source.to_s }.uniq.sort
253
+ }
254
+ end
255
+
256
+ def report
257
+ {
258
+ summary: summary,
259
+ frames: frame_reports,
260
+ dependencies: dependency_reports,
261
+ edges: edges.map(&:to_h)
262
+ }
263
+ end
264
+
265
+ def to_h(dependencies: :all)
266
+ serialized_nodes = serializable_nodes(dependencies: dependencies)
267
+ node_ids = serialized_nodes.to_h { |node| [node.id, true] }
268
+
269
+ {
270
+ nodes: serialized_nodes.map { |node| serialize_node(node) },
271
+ edges: edges.select { |edge| node_ids[edge.from] && node_ids[edge.to] }.map(&:to_h)
272
+ }
273
+ end
274
+
275
+ def self.from_h(snapshot)
276
+ snapshot = symbolize_keys(snapshot)
277
+ graph = new
278
+ graph.nodes.clear
279
+ graph.edges.clear
280
+ graph.send(:reset_indexes!)
281
+
282
+ snapshot.fetch(:nodes).each do |node_snapshot|
283
+ node_snapshot = symbolize_keys(node_snapshot)
284
+ kind = node_snapshot.fetch(:kind).to_sym
285
+ graph.nodes[node_snapshot.fetch(:id)] = Node.new(
286
+ node_snapshot.fetch(:id),
287
+ kind,
288
+ deserialize_payload(kind, node_snapshot.fetch(:payload))
289
+ )
290
+ end
291
+
292
+ snapshot.fetch(:edges).each do |edge_snapshot|
293
+ edge_snapshot = symbolize_keys(edge_snapshot)
294
+ graph.add_edge(
295
+ edge_snapshot.fetch(:from),
296
+ edge_snapshot.fetch(:to),
297
+ reason: edge_snapshot.fetch(:reason).to_sym
298
+ )
299
+ end
300
+
301
+ graph.send(:rebuild_dependency_index)
302
+ graph
303
+ end
304
+
305
+ def frame_reports
306
+ frame_nodes.map do |node|
307
+ {
308
+ id: node.id,
309
+ kind: node.payload.fetch(:kind),
310
+ template: node.payload[:template],
311
+ site_id: node.payload[:site_id],
312
+ manifest_path: node.payload[:manifest_path],
313
+ manifest_fingerprint: node.payload[:manifest_fingerprint],
314
+ locals: node.payload[:locals],
315
+ contains: outgoing_edges(node.id, reason: :contains).map(&:to),
316
+ dependencies: dependencies_for(node.id).map(&:to_h),
317
+ replay_recipe: recipe_report(node.payload[:recipe])
318
+ }.compact
319
+ end
320
+ end
321
+
322
+ def recipe_report(recipe)
323
+ return unless recipe
324
+
325
+ snapshot = recipe.to_h
326
+ replay = snapshot[:replay] || snapshot["replay"] || {}
327
+ replay_json = JSON.generate(replay)
328
+ {
329
+ kind: recipe.kind.to_s,
330
+ target_kind: recipe.target_kind,
331
+ target_id: recipe.target_id,
332
+ runtime: recipe.runtime,
333
+ template: recipe.template,
334
+ replay: {
335
+ type: recipe.replay.respond_to?(:type) ? recipe.replay.type : nil,
336
+ keys: replay.keys.map(&:to_s).sort,
337
+ bytes: replay_json.bytesize,
338
+ digest: Digest::SHA256.hexdigest(replay_json)
339
+ }.compact
340
+ }.compact
341
+ end
342
+
343
+ def dependency_reports
344
+ dependency_nodes.map do |node|
345
+ {
346
+ id: node.id,
347
+ dependency: node.payload.to_h,
348
+ owners: dependency_owner_ids(node.id)
349
+ }
350
+ end
351
+ end
352
+
353
+ private
354
+
355
+ def serializable_nodes(dependencies:)
356
+ case dependencies
357
+ when :all
358
+ nodes.values
359
+ when :identity
360
+ nodes.values.select { |node| node.kind != :dependency || node.payload.identity? }
361
+ else
362
+ raise ArgumentError, "unsupported graph dependency serialization mode: #{dependencies.inspect}"
363
+ end
364
+ end
365
+
366
+ def reset_indexes!
367
+ @edge_keys = {}
368
+ @outgoing_edges_by_from = Hash.new { |hash, key| hash[key] = [] }
369
+ @incoming_edges_by_to = Hash.new { |hash, key| hash[key] = [] }
370
+ @dependency_owner_ids_by_node = Hash.new { |hash, key| hash[key] = [] }
371
+ @dependencies_by_node = Hash.new { |hash, key| hash[key] = [] }
372
+ @dependency_cache_keys_by_node = Hash.new { |hash, key| hash[key] = {} }
373
+ end
374
+
375
+ def rebuild_dependency_index
376
+ @dependencies_by_node = Hash.new { |hash, key| hash[key] = [] }
377
+ @dependency_cache_keys_by_node = Hash.new { |hash, key| hash[key] = {} }
378
+
379
+ edges.each do |edge|
380
+ next unless edge.reason == :depends_on
381
+
382
+ dependency = nodes.fetch(edge.to).payload
383
+ dependency_cache_keys = @dependency_cache_keys_by_node[edge.from]
384
+ next if dependency_cache_keys.key?(dependency.cache_key)
385
+
386
+ dependency_cache_keys[dependency.cache_key] = true
387
+ @dependencies_by_node[edge.from] << dependency
388
+ end
389
+ end
390
+
391
+ def edge_key(from, to, reason)
392
+ [from, to, reason]
393
+ end
394
+
395
+ class << self
396
+ def deserialize_payload(kind, payload)
397
+ payload = symbolize_keys(payload)
398
+
399
+ case kind
400
+ when :dependency
401
+ Dependencies.from_h(payload)
402
+ when :frame
403
+ deserialize_frame_payload(payload)
404
+ else
405
+ payload
406
+ end
407
+ end
408
+
409
+ def deserialize_frame_payload(payload)
410
+ payload.each_with_object({}) do |(key, value), frame_payload|
411
+ frame_payload[key] = key == :recipe && value ? Replay::Recipe.from_h(value) : value
412
+ end
413
+ end
414
+
415
+ def symbolize_keys(value)
416
+ case value
417
+ when Hash
418
+ value.each_with_object({}) do |(key, nested_value), result|
419
+ normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
420
+ result[normalized_key] = symbolize_keys(nested_value)
421
+ end
422
+ when Array
423
+ value.map { |nested_value| symbolize_keys(nested_value) }
424
+ else
425
+ value
426
+ end
427
+ end
428
+ end
429
+
430
+ def serialize_node(node)
431
+ {
432
+ id: node.id,
433
+ kind: node.kind,
434
+ payload: serialize_payload(node)
435
+ }
436
+ end
437
+
438
+ def serialize_payload(node)
439
+ case node.kind
440
+ when :dependency
441
+ node.payload.to_h
442
+ when :frame
443
+ node.payload.each_with_object({}) do |(key, value), frame_payload|
444
+ frame_payload[key] = key == :recipe && value ? value.to_h : value
445
+ end
446
+ else
447
+ node.payload
448
+ end
449
+ end
450
+ end
451
+ end
452
+ end
453
+
454
+ require_relative "dag/subscription_shape"
@@ -0,0 +1,48 @@
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
+ SUBSCRIPTION_STREAM_PREFIX = "upkeep:subscription"
11
+
12
+ def self.stream_name_for(subscriber_id)
13
+ "#{STREAM_PREFIX}:#{Digest::SHA256.hexdigest(subscriber_id.to_s)[0, 32]}"
14
+ end
15
+
16
+ def self.subscription_stream_name_for(subscription_id)
17
+ "#{SUBSCRIPTION_STREAM_PREFIX}:#{Digest::SHA256.hexdigest(subscription_id.to_s)[0, 32]}"
18
+ end
19
+
20
+ def initialize(server: default_server)
21
+ @server = server
22
+ end
23
+
24
+ def deliver(envelope)
25
+ stream_name = envelope.stream_name || self.class.stream_name_for(envelope.subscriber_id)
26
+ payload = {
27
+ subscriber_id: envelope.subscriber_id,
28
+ stream_name: stream_name,
29
+ envelope_digest: Transport.envelope_digest(envelope),
30
+ bytesize: envelope.body.bytesize
31
+ }
32
+
33
+ ActiveSupport::Notifications.instrument("deliver.upkeep", payload) do
34
+ server.broadcast(stream_name, envelope.body)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :server
41
+
42
+ def default_server
43
+ require "action_cable"
44
+ ::ActionCable.server
45
+ end
46
+ end
47
+ end
48
+ 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