coil 1.4.0 → 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f7049380265432b66236482dba5ed20a432673b604021ad2ab0e1859f939bbf
4
- data.tar.gz: 7160db6c58dff54ad63fd03898e7262b3ab6c8811d93fadec005c5dcfdfdbebf
3
+ metadata.gz: fb9891f8607d9006f5b72a07e88725a18e57dd6ea8a22c7c610eb9e490557983
4
+ data.tar.gz: eac76e2c7a1a1c3f3697613a637fa939b11ecb588e65dd828cb4ddd2f7b1137f
5
5
  SHA512:
6
- metadata.gz: 4bc6ed0c1ab07e8afd6e9ae51baf9f61f162806b4f59dc727c155fd79082607e9181158096085db6ed8fdebb9adbeaf204ba92c8ddd9b6a245b4c0d4e85c9613
7
- data.tar.gz: 880a4cf79ecf5b761ee4dac86c984e36387d15479c5e0d51f58c370bd0301dd6f448aeeebfa6744d42ed3faecd67ce8983d938988d4dd5be209d713925f91fa9
6
+ metadata.gz: 0c23e1a776d61ce557f1ace11aac2dc29fc71ee87a4dfd8037bb50266bdbb1254d87d5e1a3870f1622f9aa7837719e0608a89f0162da0f6c55e64a7adaa21341
7
+ data.tar.gz: 961ef82b7218976a207cf06af86ca2e7339fc31d255a6d1aaf8d80bf762436afe88e43af3dc0f27e69950fe0d5cb4abdfd5350bc17596aa05000d85b5878cc83
data/README.md CHANGED
@@ -36,10 +36,11 @@ gem "schema_version_cache"
36
36
 
37
37
  Install engine and migrations:
38
38
  ```console
39
- $ bundle
40
- $ bundle exec rails coil:install:migrations
41
- $ bundle exec rails db:migrate
39
+ $ bundle install
40
+ $ bundle exec rails coil:install:migrations db:migrate
42
41
  ```
42
+ (_NOTE_: Also run the above commands when upgrading, as newer versions may
43
+ introduce additional migrations.)
43
44
 
44
45
  Register periodic jobs:
45
46
  ```ruby
@@ -49,9 +50,15 @@ Sidekiq.configure_server do |config|
49
50
  config.periodic do |mgr|
50
51
  mgr.register("*/10 * * * *", "Coil::Inbox::MessagesPeriodicJob")
51
52
  mgr.register("5-59/10 * * * *", "Coil::Outbox::MessagesPeriodicJob")
53
+
54
+ mgr.register("7-59/20 * * * *", "Coil::Inbox::MessagesCleanupJob")
55
+ mgr.register("12-59/20 * * * *", "Coil::Outbox::MessagesCleanupJob")
52
56
  end
53
57
  end
54
58
  ```
59
+ (_NOTE_: The cleanup jobs delete already-processed messages once their retention
60
+ period has passed. Retention periods can be [configured](#configuration) using
61
+ `Coil.inbox_retention_period` and `Coil.outbox_retention_period`.)
55
62
 
56
63
  Filter retryable errors out of alerting, e.g. airbrake:
57
64
  ```ruby
@@ -242,6 +249,8 @@ initializer at `config/initializers/coil.rb` with the following content, then
242
249
  uncomment and adjust the settings you wish to change:
243
250
  ```ruby
244
251
  # Coil.sidekiq_queue = "default"
252
+ # Coil.inbox_retention_period = 12.weeks
253
+ # Coil.outbox_retention_period = 12.weeks
245
254
  ```
246
255
 
247
256
  ## Development
@@ -0,0 +1,18 @@
1
+ # typed: strict
2
+
3
+ # This job deletes processed inbox messages whose retention period has passed.
4
+ module Coil
5
+ module Inbox
6
+ class MessagesCleanupJob < TransactionalMessagesCleanupJob
7
+ private
8
+
9
+ def message_parent_class
10
+ ::Coil::Inbox::Message
11
+ end
12
+
13
+ def retention_period
14
+ ::Coil.inbox_retention_period
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # typed: strict
2
+
3
+ # This job deletes processed outbox messages whose retention period has passed.
4
+ module Coil
5
+ module Outbox
6
+ class MessagesCleanupJob < TransactionalMessagesCleanupJob
7
+ private
8
+
9
+ def message_parent_class
10
+ ::Coil::Outbox::Message
11
+ end
12
+
13
+ def retention_period
14
+ ::Coil.outbox_retention_period
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,106 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # A cleanup job deletes processed messages whose retention period has passed.
5
+ module Coil
6
+ class TransactionalMessagesCleanupJob < ApplicationJob
7
+ DuplicateJobError = Class.new(StandardError)
8
+
9
+ # Sidekiq is not designed for long-running jobs, so we place an upper bound
10
+ # on job duration. When a job exceeds this bound, we'll enqueue a subsequent
11
+ # job to pick up where we left off.
12
+ MAX_DURATION = 5.minutes
13
+
14
+ sidekiq_options retry: 4, dead: false
15
+
16
+ def perform(batch_size = 1000)
17
+ result = delete_messages(batch_size)
18
+ total_deletions = result.deletions.values.sum
19
+ deletions_json = result.deletions.to_json
20
+
21
+ case result
22
+ when Finished
23
+ Rails.logger.info(<<~INFO.squish)
24
+ #{self.class} finished after deleting #{total_deletions} messages
25
+ (#{deletions_json}).
26
+ INFO
27
+ when ExceededDeadline
28
+ Rails.logger.info(<<~INFO.squish)
29
+ #{self.class} exceeded deadline after deleting #{total_deletions}
30
+ messages (#{deletions_json}). Enqueuing subsequent job.
31
+ INFO
32
+ self.class.perform_async(batch_size)
33
+ end
34
+ rescue DuplicateJobError
35
+ # A duplicate job is in the midst of its message-deletion loop. We'll call
36
+ # this job done and allow the other one to continue.
37
+ end
38
+
39
+ private
40
+
41
+ def delete_messages(batch_size)
42
+ ApplicationRecord.uncached do
43
+ locking do
44
+ _delete_messages(batch_size)
45
+ end
46
+ end
47
+ end
48
+
49
+ Finished = Struct.new(:deletions)
50
+ ExceededDeadline = Struct.new(:deletions)
51
+
52
+ def _delete_messages(batch_size)
53
+ now = Time.current
54
+ created_before = now - retention_period
55
+ deadline = now + MAX_DURATION
56
+ deletions = Hash.new(0)
57
+
58
+ # Identify distinct message types, their associated job types, and the
59
+ # messages that can safely be deleted. Delete in batches until finished
60
+ # or the deadline is exceeded.
61
+ message_parent_class.select(:type).distinct.pluck(:type).each do |type|
62
+ message_class = message_class_for(type)
63
+ messages =
64
+ if message_class.present?
65
+ message_class.processed(processor_name: message_class.new.job_class.name)
66
+ else
67
+ message_parent_class.where(type:)
68
+ end
69
+
70
+ messages
71
+ .where(created_at: nil...created_before)
72
+ .in_batches(of: batch_size) do |batch|
73
+ return ExceededDeadline.new(deletions) if Time.current > deadline
74
+ deletions[type] += batch.distinct(false).delete_all
75
+ end
76
+ end
77
+
78
+ Finished.new(deletions)
79
+ end
80
+
81
+ QUEUE_TYPE = "CLEANUP_QUEUE"
82
+
83
+ def locking(&blk)
84
+ QueueLocking.locking(
85
+ queue_type: QUEUE_TYPE,
86
+ message_type: message_parent_class.to_s,
87
+ message_keys: [self.class.to_s],
88
+ wait: false,
89
+ &blk
90
+ )
91
+ rescue QueueLocking::LockWaitTimeout
92
+ raise DuplicateJobError
93
+ end
94
+
95
+ def message_class_for(type)
96
+ message_parent_class.sti_class_for(type)
97
+ rescue ActiveRecord::SubclassNotFound
98
+ end
99
+
100
+ def message_parent_class
101
+ end
102
+
103
+ def retention_period
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,32 @@
1
+ # typed: false
2
+
3
+ class AddForeignKeyToInboxCompletionsOnDeleteCascade < ActiveRecord::Migration[6.0]
4
+ def change
5
+ from_table = :coil_inbox_completions
6
+ to_table = :coil_inbox_messages
7
+ column = :last_completed_message_id
8
+ old_key = find_foreign_key(from_table, to_table:, column:, on_delete: nil)
9
+
10
+ # NOTE: To minimize the impact on read/write availability while adding this
11
+ # foreign key, we specify `validate: false`, skipping the table scan that
12
+ # would normally be performed to validate that all existing rows satisfy the
13
+ # new constraint. We can then validate it in a separate transaction (see the
14
+ # next migration) without blocking reads/writes to the tables involved.
15
+ # https://www.postgresql.org/docs/current/sql-altertable.html#SQL-ALTERTABLE-DESC-ADD-TABLE-CONSTRAINT
16
+ add_foreign_key(
17
+ from_table,
18
+ to_table,
19
+ column:,
20
+ name: "#{old_key.name}_on_delete_cascade",
21
+ on_delete: :cascade,
22
+ validate: false
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def find_foreign_key(from_table, **options)
29
+ foreign_keys(from_table).detect { |fk| fk.defined_for?(**options) } ||
30
+ raise("No foreign key found from table '#{from_table}' for #{options}")
31
+ end
32
+ end
@@ -0,0 +1,19 @@
1
+ # typed: false
2
+
3
+ class ValidateForeignKeyInboxCompletions < ActiveRecord::Migration[6.0]
4
+ def change
5
+ from_table = :coil_inbox_completions
6
+ to_table = :coil_inbox_messages
7
+ column = :last_completed_message_id
8
+ key = find_foreign_key(from_table, to_table:, column:, on_delete: :cascade)
9
+
10
+ validate_foreign_key from_table, to_table, name: key.name
11
+ end
12
+
13
+ private
14
+
15
+ def find_foreign_key(from_table, **options)
16
+ foreign_keys(from_table).detect { |fk| fk.defined_for?(**options) } ||
17
+ raise("No foreign key found from table '#{from_table}' for #{options}")
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ # typed: false
2
+
3
+ class RemoveOldForeignKeyFromInboxCompletions < ActiveRecord::Migration[6.0]
4
+ def change
5
+ from_table = :coil_inbox_completions
6
+ to_table = :coil_inbox_messages
7
+ column = :last_completed_message_id
8
+ new_key = find_foreign_key(from_table, to_table:, column:, on_delete: :cascade)
9
+
10
+ remove_foreign_key(
11
+ from_table,
12
+ to_table,
13
+ column:,
14
+ name: new_key.name.delete_suffix("_on_delete_cascade")
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ def find_foreign_key(from_table, **options)
21
+ foreign_keys(from_table).detect { |fk| fk.defined_for?(**options) } ||
22
+ raise("No foreign key found from table '#{from_table}' for #{options}")
23
+ end
24
+ end
@@ -0,0 +1,32 @@
1
+ # typed: false
2
+
3
+ class AddForeignKeyToOutboxCompletionsOnDeleteCascade < ActiveRecord::Migration[6.0]
4
+ def change
5
+ from_table = :coil_outbox_completions
6
+ to_table = :coil_outbox_messages
7
+ column = :last_completed_message_id
8
+ old_key = find_foreign_key(from_table, to_table:, column:, on_delete: nil)
9
+
10
+ # NOTE: To minimize the impact on read/write availability while adding this
11
+ # foreign key, we specify `validate: false`, skipping the table scan that
12
+ # would normally be performed to validate that all existing rows satisfy the
13
+ # new constraint. We can then validate it in a separate transaction (see the
14
+ # next migration) without blocking reads/writes to the tables involved.
15
+ # https://www.postgresql.org/docs/current/sql-altertable.html#SQL-ALTERTABLE-DESC-ADD-TABLE-CONSTRAINT
16
+ add_foreign_key(
17
+ from_table,
18
+ to_table,
19
+ column:,
20
+ name: "#{old_key.name}_on_delete_cascade",
21
+ on_delete: :cascade,
22
+ validate: false
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def find_foreign_key(from_table, **options)
29
+ foreign_keys(from_table).detect { |fk| fk.defined_for?(**options) } ||
30
+ raise("No foreign key found from table '#{from_table}' for #{options}")
31
+ end
32
+ end
@@ -0,0 +1,19 @@
1
+ # typed: false
2
+
3
+ class ValidateForeignKeyOutboxCompletions < ActiveRecord::Migration[6.0]
4
+ def change
5
+ from_table = :coil_outbox_completions
6
+ to_table = :coil_outbox_messages
7
+ column = :last_completed_message_id
8
+ key = find_foreign_key(from_table, to_table:, column:, on_delete: :cascade)
9
+
10
+ validate_foreign_key from_table, to_table, name: key.name
11
+ end
12
+
13
+ private
14
+
15
+ def find_foreign_key(from_table, **options)
16
+ foreign_keys(from_table).detect { |fk| fk.defined_for?(**options) } ||
17
+ raise("No foreign key found from table '#{from_table}' for #{options}")
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ # typed: false
2
+
3
+ class RemoveOldForeignKeyFromOutboxCompletions < ActiveRecord::Migration[6.0]
4
+ def change
5
+ from_table = :coil_outbox_completions
6
+ to_table = :coil_outbox_messages
7
+ column = :last_completed_message_id
8
+ new_key = find_foreign_key(from_table, to_table:, column:, on_delete: :cascade)
9
+
10
+ remove_foreign_key(
11
+ from_table,
12
+ to_table,
13
+ column:,
14
+ name: new_key.name.delete_suffix("_on_delete_cascade")
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ def find_foreign_key(from_table, **options)
21
+ foreign_keys(from_table).detect { |fk| fk.defined_for?(**options) } ||
22
+ raise("No foreign key found from table '#{from_table}' for #{options}")
23
+ end
24
+ end
data/lib/coil/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Coil
5
- VERSION = "1.4.0"
5
+ VERSION = "1.5.0"
6
6
  end
data/lib/coil.rb CHANGED
@@ -7,4 +7,6 @@ require "coil/queue_locking"
7
7
 
8
8
  module Coil
9
9
  mattr_accessor :sidekiq_queue, default: "default"
10
+ mattr_accessor :inbox_retention_period, default: 12.weeks
11
+ mattr_accessor :outbox_retention_period, default: 12.weeks
10
12
  end
data/rbi/coil.rbi CHANGED
@@ -79,6 +79,16 @@ Coil::Inbox::Message::COMPLETION = Coil::Inbox::Completion
79
79
  Coil::Inbox::Message::PERSISTENCE_QUEUE = T.let(T.unsafe(nil), String)
80
80
  Coil::Inbox::Message::PROCESS_QUEUE = T.let(T.unsafe(nil), String)
81
81
 
82
+ class Coil::Inbox::MessagesCleanupJob < ::Coil::TransactionalMessagesCleanupJob
83
+ private
84
+
85
+ sig { override.returns(T.class_of(::Coil::Inbox::Message)) }
86
+ def message_parent_class; end
87
+
88
+ sig { override.returns(ActiveSupport::Duration) }
89
+ def retention_period; end
90
+ end
91
+
82
92
  class Coil::Inbox::MessagesPeriodicJob < ::Coil::TransactionalMessagesPeriodicJob
83
93
  private
84
94
 
@@ -140,6 +150,16 @@ Coil::Outbox::Message::COMPLETION = Coil::Outbox::Completion
140
150
  Coil::Outbox::Message::PERSISTENCE_QUEUE = T.let(T.unsafe(nil), String)
141
151
  Coil::Outbox::Message::PROCESS_QUEUE = T.let(T.unsafe(nil), String)
142
152
 
153
+ class Coil::Outbox::MessagesCleanupJob < ::Coil::TransactionalMessagesCleanupJob
154
+ private
155
+
156
+ sig { override.returns(T.class_of(::Coil::Outbox::Message)) }
157
+ def message_parent_class; end
158
+
159
+ sig { override.returns(ActiveSupport::Duration) }
160
+ def retention_period; end
161
+ end
162
+
143
163
  class Coil::Outbox::MessagesPeriodicJob < ::Coil::TransactionalMessagesPeriodicJob
144
164
  private
145
165
 
@@ -311,6 +331,45 @@ class Coil::TransactionalMessagesJob::DuplicateJobError < ::StandardError; end
311
331
  Coil::TransactionalMessagesJob::MAX_DURATION = T.let(T.unsafe(nil), ActiveSupport::Duration)
312
332
  class Coil::TransactionalMessagesJob::RetryableError < ::StandardError; end
313
333
 
334
+ class Coil::TransactionalMessagesCleanupJob < ::Coil::ApplicationJob
335
+ abstract!
336
+
337
+ class DuplicateJobError < ::StandardError; end
338
+
339
+ MAX_DURATION = T.let(T.unsafe(nil), ActiveSupport::Duration)
340
+
341
+ sig { params(batch_size: Integer).void }
342
+ def perform(batch_size = 1000); end
343
+
344
+ private
345
+
346
+ Result = T.type_alias { T.any(Finished, ExceededDeadline) }
347
+
348
+ sig { params(batch_size: Integer).returns(Result) }
349
+ def delete_messages(batch_size); end
350
+
351
+ sig { params(batch_size: Integer).returns(Result) }
352
+ def _delete_messages(batch_size); end
353
+
354
+ QUEUE_TYPE = T.let(T.unsafe(nil), String)
355
+
356
+ sig {
357
+ type_parameters(:P)
358
+ .params(blk: T.proc.returns(T.type_parameter(:P)))
359
+ .returns(T.type_parameter(:P))
360
+ }
361
+ def locking(&blk); end
362
+
363
+ sig { params(type: String).returns(T.nilable(::Coil::AnyMessageClass)) }
364
+ def message_class_for(type); end
365
+
366
+ sig { abstract.returns(::Coil::AnyMessageClass) }
367
+ def message_parent_class; end
368
+
369
+ sig { abstract.returns(ActiveSupport::Duration) }
370
+ def retention_period; end
371
+ end
372
+
314
373
  class Coil::TransactionalMessagesPeriodicJob < ::Coil::ApplicationJob
315
374
  abstract!
316
375
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coil
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Brennan
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2024-12-11 00:00:00.000000000 Z
12
+ date: 2025-01-29 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -253,8 +253,11 @@ files:
253
253
  - README.md
254
254
  - Rakefile
255
255
  - app/jobs/coil/application_job.rb
256
+ - app/jobs/coil/inbox/messages_cleanup_job.rb
256
257
  - app/jobs/coil/inbox/messages_periodic_job.rb
258
+ - app/jobs/coil/outbox/messages_cleanup_job.rb
257
259
  - app/jobs/coil/outbox/messages_periodic_job.rb
260
+ - app/jobs/coil/transactional_messages_cleanup_job.rb
258
261
  - app/jobs/coil/transactional_messages_job.rb
259
262
  - app/jobs/coil/transactional_messages_periodic_job.rb
260
263
  - app/models/coil/application_record.rb
@@ -268,6 +271,12 @@ files:
268
271
  - app/models/concerns/coil/transactional_message.rb
269
272
  - config/routes.rb
270
273
  - db/migrate/20240604163650_create_coil_tables.rb
274
+ - db/migrate/20250101182458_add_foreign_key_to_inbox_completions_on_delete_cascade.rb
275
+ - db/migrate/20250102031753_validate_foreign_key_inbox_completions.rb
276
+ - db/migrate/20250102040148_remove_old_foreign_key_from_inbox_completions.rb
277
+ - db/migrate/20250102040649_add_foreign_key_to_outbox_completions_on_delete_cascade.rb
278
+ - db/migrate/20250102040950_validate_foreign_key_outbox_completions.rb
279
+ - db/migrate/20250102041225_remove_old_foreign_key_from_outbox_completions.rb
271
280
  - lib/coil.rb
272
281
  - lib/coil/engine.rb
273
282
  - lib/coil/queue_locking.rb