upkeep-rails 0.1.6

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 (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -0,0 +1,407 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/notifications"
5
+ require "logger"
6
+ require_relative "json_snapshot"
7
+ require_relative "persistent_reverse_index"
8
+ require_relative "store"
9
+
10
+ module Upkeep
11
+ module Subscriptions
12
+ class ActiveRecordSubscriptionPersistence
13
+ PERSIST_NOTIFICATION = "persist_subscription_store.upkeep"
14
+ INDEX_ENTRIES_SNAPSHOT_KEY = "__upkeep_index_entries"
15
+
16
+ def initialize(subscription_record:, index_record:, shape_index_record:, index_builder:)
17
+ @subscription_record = subscription_record
18
+ @index_record = index_record
19
+ @shape_index_record = shape_index_record
20
+ @index_builder = index_builder
21
+ @count_mutex = Mutex.new
22
+ @count_cache = nil
23
+ end
24
+
25
+ def persist_jobs(jobs)
26
+ if ActiveSupport::Notifications.notifier.listening?(PERSIST_NOTIFICATION)
27
+ payload = {
28
+ store: "active_record",
29
+ jobs: jobs.size,
30
+ subscriptions: jobs.count { |job| persist_subscription?(job) },
31
+ index_jobs: jobs.count { |job| persist_index?(job) },
32
+ dependency_entries: jobs.sum { |job| persist_index?(job) ? job.entries.size : 0 },
33
+ pending_index_entries: jobs.sum { |job| persist_subscription?(job) ? job.entries.size : 0 },
34
+ operations: operation_counts(jobs)
35
+ }
36
+ ActiveSupport::Notifications.instrument(PERSIST_NOTIFICATION, payload) do
37
+ result = persist_jobs_without_instrumentation(jobs)
38
+ payload[:subscription_rows] = result.fetch(:subscription_rows)
39
+ payload[:index_rows] = result.fetch(:index_rows)
40
+ payload[:direct_index_rows] = result.fetch(:direct_index_rows)
41
+ payload[:shape_index_rows] = result.fetch(:shape_index_rows)
42
+ end
43
+ else
44
+ persist_jobs_without_instrumentation(jobs)
45
+ end
46
+ end
47
+
48
+ def touch(id, metadata:, now:)
49
+ subscription_record.where(id: id).find_each do |record|
50
+ record.update_columns(
51
+ metadata: record.metadata.to_h.merge(metadata),
52
+ updated_at: now
53
+ )
54
+ end
55
+ end
56
+
57
+ def prune_stale!(older_than:)
58
+ stale_ids = subscription_record.where(subscription_record.arel_table[:updated_at].lt(older_than)).pluck(:id)
59
+ return [] if stale_ids.empty?
60
+
61
+ delete(stale_ids)
62
+ stale_ids
63
+ end
64
+
65
+ def delete(ids)
66
+ ids = Array(ids)
67
+ return if ids.empty?
68
+
69
+ silence_active_record_logging do
70
+ ActiveRecord::Base.connection_pool.with_connection do
71
+ ActiveRecord::Base.transaction do
72
+ shape_keys = shape_keys_for_subscriptions(ids)
73
+ index_record.where(subscription_id: ids).delete_all
74
+ deleted = subscription_record.where(id: ids).delete_all
75
+ delete_orphaned_shape_index_rows(shape_keys)
76
+ decrement_count_cache(deleted)
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ def fetch(id)
83
+ record = subscription_record.find(id)
84
+ subscription_with_metadata(record)
85
+ end
86
+
87
+ def fetch_with_index_entries(id)
88
+ record = subscription_record.find(id)
89
+ [subscription_with_metadata(record), index_entries_from_snapshot(record.recorder_snapshot)]
90
+ end
91
+
92
+ def subscriptions
93
+ subscription_record.order(:created_at, :id).map { |record| subscription_with_metadata(record) }
94
+ end
95
+
96
+ def reset
97
+ index_record.delete_all
98
+ shape_index_record.delete_all
99
+ subscription_record.delete_all
100
+ write_count_cache(0)
101
+ end
102
+
103
+ def count
104
+ @count_mutex.synchronize do
105
+ @count_cache ||= subscription_record.count
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ attr_reader :subscription_record, :index_record, :shape_index_record, :index_builder
112
+
113
+ def persist_jobs_without_instrumentation(jobs)
114
+ subscription_jobs = jobs.select { |job| persist_subscription?(job) }
115
+ index_jobs = jobs.select { |job| persist_index?(job) }
116
+
117
+ result = silence_active_record_logging do
118
+ ActiveRecord::Base.connection_pool.with_connection do
119
+ ActiveRecord::Base.transaction do
120
+ subscription_rows = persist_subscription_records(subscription_jobs)
121
+ index_result = index_subscriptions(index_jobs)
122
+ {
123
+ subscription_rows: subscription_rows,
124
+ index_rows: index_result.fetch(:index_rows),
125
+ direct_index_rows: index_result.fetch(:direct_index_rows),
126
+ shape_index_rows: index_result.fetch(:shape_index_rows)
127
+ }
128
+ end
129
+ end
130
+ end
131
+ increment_count_cache(result.fetch(:subscription_rows))
132
+ result
133
+ end
134
+
135
+ def persist_subscription_records(jobs)
136
+ now = Time.now
137
+ rows = jobs.map do |job|
138
+ subscription = job.subscription
139
+ {
140
+ id: subscription.id,
141
+ subscriber_id: subscription.subscriber_id,
142
+ metadata: metadata_without_shape_key(subscription.metadata),
143
+ subscription_shape_key: shape_key_for_metadata(subscription.metadata),
144
+ recorder_snapshot: dump(persistent_snapshot_for(job)),
145
+ created_at: now,
146
+ updated_at: now
147
+ }
148
+ end
149
+
150
+ subscription_record.insert_all!(rows) if rows.any?
151
+ rows.size
152
+ end
153
+
154
+ def index_subscriptions(jobs)
155
+ return index_result if jobs.empty?
156
+
157
+ now = Time.now
158
+ subscription_ids = jobs.map { |job| job.subscription.id }.uniq
159
+ shape_jobs, direct_jobs = jobs.partition { |job| shape_indexable?(job) }
160
+
161
+ index_record.where(subscription_id: subscription_ids).delete_all
162
+
163
+ direct_rows = index_direct_subscriptions(direct_jobs, now: now)
164
+ shape_rows = index_shape_subscriptions(shape_jobs, now: now)
165
+ index_result(direct_index_rows: direct_rows, shape_index_rows: shape_rows)
166
+ end
167
+
168
+ def index_direct_subscriptions(jobs, now:)
169
+ return 0 if jobs.empty?
170
+
171
+ grouped_rows = {}
172
+
173
+ jobs.each do |job|
174
+ job.entries.each do |entry|
175
+ index_builder.lookup_keys_for_dependency(entry.dependency).each do |lookup_key|
176
+ lookup_attributes = typed_lookup_attributes(entry.dependency, lookup_key)
177
+ key = [job.subscription.id, lookup_attributes]
178
+ row = grouped_rows[key] ||= {
179
+ subscription_id: job.subscription.id,
180
+ lookup_key_digest: PersistentReverseIndex.digest(lookup_key),
181
+ owner_ids: [],
182
+ created_at: now,
183
+ updated_at: now
184
+ }.merge(lookup_attributes)
185
+ row.fetch(:owner_ids) << entry.owner_id
186
+ end
187
+ end
188
+ end
189
+
190
+ rows = grouped_rows.values.map do |row|
191
+ row.merge(owner_ids_snapshot: dump(row.delete(:owner_ids).uniq))
192
+ end
193
+
194
+ index_record.insert_all!(rows) if rows.any?
195
+ rows.size
196
+ end
197
+
198
+ def index_shape_subscriptions(jobs, now:)
199
+ return 0 if jobs.empty?
200
+
201
+ grouped_rows = {}
202
+ shape_keys = jobs.map { |job| shape_index_key(job) }.compact.uniq
203
+
204
+ jobs.each do |job|
205
+ shape_key = shape_index_key(job)
206
+ job.entries.each do |entry|
207
+ index_builder.lookup_keys_for_dependency(entry.dependency).each do |lookup_key|
208
+ lookup_attributes = typed_lookup_attributes(entry.dependency, lookup_key)
209
+ key = [shape_key, lookup_attributes]
210
+ row = grouped_rows[key] ||= {
211
+ subscription_shape_key: shape_key,
212
+ lookup_key_digest: PersistentReverseIndex.digest(lookup_key),
213
+ owner_ids: [],
214
+ created_at: now,
215
+ updated_at: now
216
+ }.merge(lookup_attributes)
217
+ row.fetch(:owner_ids) << entry.owner_id
218
+ end
219
+ end
220
+ end
221
+
222
+ rows = grouped_rows.values.map do |row|
223
+ row.merge(owner_ids_snapshot: dump(row.delete(:owner_ids).uniq))
224
+ end
225
+
226
+ shape_index_record.where(subscription_shape_key: shape_keys).delete_all
227
+ shape_index_record.insert_all!(rows) if rows.any?
228
+ rows.size
229
+ end
230
+
231
+ def typed_lookup_attributes(dependency, lookup_key)
232
+ lookup_type = lookup_key.fetch(0)
233
+ source = dependency.source.to_s
234
+ dependency_key = dependency.key
235
+
236
+ case lookup_type
237
+ when :active_record_attribute
238
+ _type, table, record_id, attribute = lookup_key
239
+ {
240
+ dependency_source: source,
241
+ lookup_table: table.to_s,
242
+ lookup_record_id_snapshot: dump(record_id),
243
+ lookup_attribute: attribute.to_s,
244
+ dependency_table: dependency_key.fetch(:table).to_s,
245
+ dependency_predicate_digest: nil,
246
+ dependency_metadata_snapshot: nil
247
+ }
248
+ when :active_record_attribute_any_id
249
+ _type, table, attribute = lookup_key
250
+ {
251
+ dependency_source: source,
252
+ lookup_table: table.to_s,
253
+ lookup_record_id_snapshot: nil,
254
+ lookup_attribute: attribute.to_s,
255
+ dependency_table: dependency_key.fetch(:table).to_s,
256
+ dependency_predicate_digest: nil,
257
+ dependency_metadata_snapshot: nil
258
+ }
259
+ when :active_record_collection_column
260
+ _type, table, attribute = lookup_key
261
+ {
262
+ dependency_source: source,
263
+ lookup_table: table.to_s,
264
+ lookup_record_id_snapshot: nil,
265
+ lookup_attribute: attribute.to_s,
266
+ dependency_table: dependency_key.fetch(:table).to_s,
267
+ dependency_predicate_digest: dependency_key.fetch(:predicate_digest).to_s,
268
+ dependency_metadata_snapshot: dump(dependency.metadata)
269
+ }
270
+ else
271
+ raise ArgumentError, "unsupported persistent lookup key: #{lookup_key.inspect}"
272
+ end
273
+ end
274
+
275
+ def subscription_with_metadata(record)
276
+ subscription = Subscription.from_h(load(record.recorder_snapshot))
277
+ metadata = subscription.metadata.merge(record.metadata.to_h)
278
+ metadata = metadata.merge(subscription_shape_key: record.subscription_shape_key) if record.subscription_shape_key
279
+
280
+ Subscription.new(
281
+ subscription.id,
282
+ subscription.subscriber_id,
283
+ subscription.recorder,
284
+ subscription.graph,
285
+ metadata
286
+ )
287
+ end
288
+
289
+ def persistent_snapshot_for(job)
290
+ subscription = job.subscription
291
+ subscription.to_persistent_h.merge(
292
+ INDEX_ENTRIES_SNAPSHOT_KEY => index_entries_snapshot(job.entries)
293
+ )
294
+ end
295
+
296
+ def index_entries_snapshot(entries)
297
+ entries.map do |entry|
298
+ {
299
+ subscription_id: entry.subscription_id,
300
+ owner_id: entry.owner_id,
301
+ dependency: entry.dependency.to_h
302
+ }
303
+ end
304
+ end
305
+
306
+ def index_entries_from_snapshot(recorder_snapshot)
307
+ snapshot = load(recorder_snapshot)
308
+ Array(snapshot.fetch(INDEX_ENTRIES_SNAPSHOT_KEY)).map do |entry_snapshot|
309
+ entry_snapshot = Dependencies.symbolize_keys(entry_snapshot)
310
+ dependency = Dependencies.from_h(entry_snapshot.fetch(:dependency))
311
+ ReverseIndex::Entry.new(
312
+ entry_snapshot.fetch(:subscription_id),
313
+ entry_snapshot.fetch(:owner_id),
314
+ dependency.cache_key,
315
+ dependency,
316
+ nil,
317
+ nil
318
+ )
319
+ end
320
+ end
321
+
322
+ def dump(value)
323
+ JsonSnapshot.dump(value)
324
+ end
325
+
326
+ def load(snapshot)
327
+ JsonSnapshot.load(snapshot)
328
+ end
329
+
330
+ def increment_count_cache(value)
331
+ @count_mutex.synchronize { @count_cache += value if @count_cache }
332
+ end
333
+
334
+ def decrement_count_cache(value)
335
+ @count_mutex.synchronize { @count_cache -= value if @count_cache }
336
+ end
337
+
338
+ def write_count_cache(value)
339
+ @count_mutex.synchronize { @count_cache = value }
340
+ end
341
+
342
+ def shape_keys_for_subscriptions(ids)
343
+ subscription_record.where(id: ids).pluck(:subscription_shape_key).compact.uniq
344
+ end
345
+
346
+ def delete_orphaned_shape_index_rows(shape_keys)
347
+ shape_keys.each do |shape_key|
348
+ next if subscription_record.where(subscription_shape_key: shape_key).exists?
349
+
350
+ shape_index_record.where(subscription_shape_key: shape_key).delete_all
351
+ end
352
+ end
353
+
354
+ def shape_index_key(job)
355
+ shape_key_for_metadata(job.subscription.metadata)
356
+ end
357
+
358
+ def shape_indexable?(job)
359
+ shape_index_key(job) && job.entries.any? && job.entries.all?(&:cohort?)
360
+ end
361
+
362
+ def index_result(direct_index_rows: 0, shape_index_rows: 0)
363
+ {
364
+ index_rows: direct_index_rows + shape_index_rows,
365
+ direct_index_rows: direct_index_rows,
366
+ shape_index_rows: shape_index_rows
367
+ }
368
+ end
369
+
370
+ def shape_key_for_metadata(metadata)
371
+ metadata_value(metadata, :subscription_shape_key)
372
+ end
373
+
374
+ def metadata_without_shape_key(metadata)
375
+ metadata.to_h.dup.tap do |attributes|
376
+ attributes.delete(:subscription_shape_key)
377
+ attributes.delete("subscription_shape_key")
378
+ end
379
+ end
380
+
381
+ def metadata_value(metadata, key)
382
+ metadata.to_h[key] || metadata.to_h[key.to_s]
383
+ end
384
+
385
+ def persist_subscription?(job)
386
+ job.operation == :persist_subscription
387
+ end
388
+
389
+ def persist_index?(job)
390
+ job.operation == :persist_index
391
+ end
392
+
393
+ def operation_counts(jobs)
394
+ jobs.each_with_object(Hash.new(0)) { |job, counts| counts[job.operation.to_s] += 1 }.to_h
395
+ end
396
+
397
+ def silence_active_record_logging
398
+ logger = ActiveRecord::Base.logger
399
+ if logger.respond_to?(:silence)
400
+ logger.silence(::Logger::WARN) { yield }
401
+ else
402
+ yield
403
+ end
404
+ end
405
+ end
406
+ end
407
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "reverse_index"
4
+
5
+ module Upkeep
6
+ module Subscriptions
7
+ class ActiveRegistry
8
+ def initialize(reverse_index: ReverseIndex.new)
9
+ @mutex = Mutex.new
10
+ @subscriptions = {}
11
+ @reverse_index = reverse_index
12
+ end
13
+
14
+ def register(subscription, entries: nil)
15
+ @mutex.synchronize do
16
+ @subscriptions[subscription.id] = subscription
17
+ if entries
18
+ @reverse_index.index_entries(entries, subscription: subscription)
19
+ else
20
+ @reverse_index.index(subscription)
21
+ end
22
+ end
23
+ end
24
+
25
+ def fetch(id)
26
+ @mutex.synchronize { @subscriptions[id] }
27
+ end
28
+
29
+ def subscriptions
30
+ @mutex.synchronize { @subscriptions.values }
31
+ end
32
+
33
+ def unregister(ids)
34
+ ids = Array(ids)
35
+ @mutex.synchronize do
36
+ ids.each do |id|
37
+ next unless @subscriptions.delete(id)
38
+
39
+ @reverse_index.delete_subscription(id)
40
+ end
41
+ end
42
+ end
43
+
44
+ def touch(id, metadata:)
45
+ @mutex.synchronize do
46
+ subscription = @subscriptions[id]
47
+ return false unless subscription
48
+
49
+ @subscriptions[id] = subscription.with(metadata: subscription.metadata.merge(metadata))
50
+ true
51
+ end
52
+ end
53
+
54
+ def entries_for(changes)
55
+ @mutex.synchronize { @reverse_index.entries_for(changes) }
56
+ end
57
+
58
+ def reset
59
+ @mutex.synchronize do
60
+ @subscriptions = {}
61
+ @reverse_index = ReverseIndex.new
62
+ end
63
+ end
64
+
65
+ def covers?(persistent_count)
66
+ count >= persistent_count
67
+ end
68
+
69
+ def count
70
+ @mutex.synchronize { @subscriptions.size }
71
+ end
72
+
73
+ def summary
74
+ @mutex.synchronize do
75
+ @reverse_index.summary.merge(subscriptions: @subscriptions.size)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def rebuild_reverse_index!
82
+ @reverse_index = ReverseIndex.new
83
+ @subscriptions.each_value { |subscription| @reverse_index.index(subscription) }
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Subscriptions
5
+ class AsyncDurableWriter
6
+ DEFAULT_BATCH_SIZE = 100
7
+ DEFAULT_FLUSH_INTERVAL = 1.0
8
+ Job = Data.define(:subscription, :entries, :operation)
9
+
10
+ def initialize(batch_size: DEFAULT_BATCH_SIZE, flush_interval: DEFAULT_FLUSH_INTERVAL, &persist_batch)
11
+ @batch_size = batch_size
12
+ @flush_interval = flush_interval
13
+ @persist_batch = persist_batch
14
+ @mutex = Mutex.new
15
+ @available = ConditionVariable.new
16
+ @drained = ConditionVariable.new
17
+ @queue = []
18
+ @pending = 0
19
+ @inflight_ids = Hash.new(0)
20
+ @closed = false
21
+ @flush_now = false
22
+ @errors = []
23
+ @worker = Thread.new { work_loop }
24
+ @worker.name = "upkeep-durable-writer" if @worker.respond_to?(:name=)
25
+ end
26
+
27
+ def enqueue(subscription, entries:, operation:)
28
+ @mutex.synchronize do
29
+ raise IOError, "Upkeep durable writer is closed" if @closed
30
+
31
+ @queue << Job.new(subscription, entries, operation)
32
+ @pending += 1
33
+ @available.signal
34
+ end
35
+ end
36
+
37
+ def cancel(ids)
38
+ ids = Array(ids)
39
+ return [] if ids.empty?
40
+
41
+ requested_ids = ids.to_h { |id| [id, true] }
42
+
43
+ @mutex.synchronize do
44
+ removed = 0
45
+ @queue.delete_if do |job|
46
+ id = job.subscription.id
47
+ requested_ids.key?(id).tap do |matched|
48
+ removed += 1 if matched
49
+ end
50
+ end
51
+ @pending -= removed
52
+ @drained.broadcast if @pending.zero?
53
+ @drained.wait(@mutex) while ids.any? { |id| @inflight_ids.fetch(id, 0).positive? }
54
+ ids
55
+ end
56
+ end
57
+
58
+ def drain(raise_errors: true)
59
+ errors = @mutex.synchronize do
60
+ @flush_now = true
61
+ @available.broadcast
62
+ @drained.wait(@mutex) while @pending.positive?
63
+ drained_errors = @errors
64
+ @errors = [] if raise_errors
65
+ drained_errors
66
+ end
67
+
68
+ raise errors.first if raise_errors && errors.any?
69
+
70
+ errors
71
+ end
72
+
73
+ def shutdown
74
+ drain(raise_errors: false)
75
+ @mutex.synchronize do
76
+ @closed = true
77
+ @available.broadcast
78
+ end
79
+ @worker.join
80
+ end
81
+
82
+ private
83
+
84
+ def work_loop
85
+ loop do
86
+ batch = next_batch
87
+ break unless batch
88
+
89
+ begin
90
+ @persist_batch.call(batch)
91
+ rescue StandardError => error
92
+ @mutex.synchronize { @errors << error }
93
+ ensure
94
+ @mutex.synchronize do
95
+ batch.each do |job|
96
+ id = job.subscription.id
97
+ @inflight_ids[id] -= 1
98
+ @inflight_ids.delete(id) unless @inflight_ids[id].positive?
99
+ end
100
+ @pending -= batch.size
101
+ @drained.broadcast if @pending.zero?
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ def next_batch
108
+ @mutex.synchronize do
109
+ @available.wait(@mutex) while @queue.empty? && !@closed
110
+ return nil if @queue.empty? && @closed
111
+
112
+ wait_for_batch_fill
113
+ @flush_now = false
114
+ @queue.shift(@batch_size).tap do |batch|
115
+ batch.each { |job| @inflight_ids[job.subscription.id] += 1 }
116
+ end
117
+ end
118
+ end
119
+
120
+ def wait_for_batch_fill
121
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @flush_interval
122
+ while @queue.size < @batch_size && !@closed && !@flush_now
123
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
124
+ break unless remaining.positive?
125
+
126
+ @available.wait(@mutex, remaining)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end