coil 1.3.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 +7 -0
- data/LICENSE +201 -0
- data/README.md +279 -0
- data/Rakefile +8 -0
- data/app/jobs/coil/application_job.rb +7 -0
- data/app/jobs/coil/inbox/messages_periodic_job.rb +16 -0
- data/app/jobs/coil/outbox/messages_periodic_job.rb +16 -0
- data/app/jobs/coil/transactional_messages_job.rb +80 -0
- data/app/jobs/coil/transactional_messages_periodic_job.rb +33 -0
- data/app/models/coil/application_record.rb +7 -0
- data/app/models/coil/inbox/completion.rb +11 -0
- data/app/models/coil/inbox/message.rb +28 -0
- data/app/models/coil/inbox.rb +9 -0
- data/app/models/coil/outbox/completion.rb +11 -0
- data/app/models/coil/outbox/message.rb +28 -0
- data/app/models/coil/outbox.rb +9 -0
- data/app/models/concerns/coil/prevent_destruction.rb +24 -0
- data/app/models/concerns/coil/transactional_message.rb +136 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20240604163650_create_coil_tables.rb +85 -0
- data/lib/coil/engine.rb +9 -0
- data/lib/coil/queue_locking.rb +108 -0
- data/lib/coil/version.rb +6 -0
- data/lib/coil.rb +9 -0
- data/lib/tasks/coil_tasks.rake +6 -0
- data/rbi/coil.rbi +326 -0
- metadata +263 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Coil
|
|
5
|
+
module Inbox
|
|
6
|
+
class Message < ApplicationRecord
|
|
7
|
+
PERSISTENCE_QUEUE = "INBOX_PERSISTENCE_QUEUE"
|
|
8
|
+
PROCESS_QUEUE = "INBOX_PROCESS_QUEUE"
|
|
9
|
+
|
|
10
|
+
COMPLETION = ::Coil::Inbox::Completion
|
|
11
|
+
|
|
12
|
+
include TransactionalMessage
|
|
13
|
+
|
|
14
|
+
after_commit :enqueue_job, on: :create
|
|
15
|
+
|
|
16
|
+
def enqueue_job(processor_name = job_class.to_s)
|
|
17
|
+
perform_job_in(0.seconds, processor_name)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def perform_job_in(interval, processor_name = job_class.to_s)
|
|
21
|
+
job_class.perform_in(interval, key, processor_name)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def job_class
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Coil
|
|
5
|
+
module Outbox
|
|
6
|
+
class Message < ApplicationRecord
|
|
7
|
+
PERSISTENCE_QUEUE = "OUTBOX_PERSISTENCE_QUEUE"
|
|
8
|
+
PROCESS_QUEUE = "OUTBOX_PROCESS_QUEUE"
|
|
9
|
+
|
|
10
|
+
COMPLETION = ::Coil::Outbox::Completion
|
|
11
|
+
|
|
12
|
+
include TransactionalMessage
|
|
13
|
+
|
|
14
|
+
after_commit :enqueue_job, on: :create
|
|
15
|
+
|
|
16
|
+
def enqueue_job(processor_name = job_class.to_s)
|
|
17
|
+
perform_job_in(0.seconds, processor_name)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def perform_job_in(interval, processor_name = job_class.to_s)
|
|
21
|
+
job_class.perform_in(interval, key, processor_name)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def job_class
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
|
|
3
|
+
module Coil
|
|
4
|
+
module PreventDestruction
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
unless self < ActiveRecord::Base
|
|
9
|
+
raise <<~ERR.squish
|
|
10
|
+
Cannot include PreventDestruction in #{self} because #{self} does not
|
|
11
|
+
inherit from ActiveRecord::Base.
|
|
12
|
+
ERR
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
before_destroy :prevent_destruction, prepend: true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def prevent_destruction
|
|
21
|
+
throw(:abort)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
|
|
3
|
+
module Coil
|
|
4
|
+
module TransactionalMessage
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
unless self < ActiveRecord::Base
|
|
9
|
+
raise <<~ERR.squish
|
|
10
|
+
Cannot include TransactionalMessage in #{self} because #{self} does not
|
|
11
|
+
inherit from ActiveRecord::Base.
|
|
12
|
+
ERR
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
unless respond_to?(:sti_name)
|
|
16
|
+
raise <<~ERR.squish
|
|
17
|
+
Cannot include TransactionalMessage in #{self} because #{self} does not
|
|
18
|
+
use single table inheritance.
|
|
19
|
+
ERR
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
unless const_defined?(:COMPLETION)
|
|
23
|
+
raise <<~ERR.squish
|
|
24
|
+
Cannot include TransactionalMessage in #{self} because
|
|
25
|
+
#{self}::COMPLETION is not defined.
|
|
26
|
+
ERR
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
unless self::COMPLETION < ActiveRecord::Base
|
|
30
|
+
raise <<~ERR.squish
|
|
31
|
+
Cannot include TransactionalMessage in #{self} because
|
|
32
|
+
#{self}::COMPLETION does not inherit from ActiveRecord::Base.
|
|
33
|
+
ERR
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
unless const_defined?(:PERSISTENCE_QUEUE)
|
|
37
|
+
raise <<~ERR.squish
|
|
38
|
+
Cannot include TransactionalMessage in #{self} because
|
|
39
|
+
#{self}::PERSISTENCE_QUEUE is not defined.
|
|
40
|
+
ERR
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
unless const_defined?(:PROCESS_QUEUE)
|
|
44
|
+
raise <<~ERR.squish
|
|
45
|
+
Cannot include TransactionalMessage in #{self} because
|
|
46
|
+
#{self}::PROCESS_QUEUE is not defined.
|
|
47
|
+
ERR
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
validates :key, presence: true
|
|
51
|
+
|
|
52
|
+
attr_readonly :type, :key, :value
|
|
53
|
+
|
|
54
|
+
around_save :locking_persistence_queue, if: :new_record?
|
|
55
|
+
|
|
56
|
+
include PreventDestruction
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def processed?(processor_name:)
|
|
60
|
+
persisted? &&
|
|
61
|
+
completions.exists?(processor_name:, last_completed_message_id: id...)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def processed(processor_name:)
|
|
65
|
+
c = completions.find_or_initialize_by(processor_name:)
|
|
66
|
+
max_id = [c.last_completed_message_id, id].compact.max
|
|
67
|
+
c.update!(last_completed_message_id: max_id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def unprocessed_predecessors(processor_name:)
|
|
71
|
+
self.class.unprocessed(processor_name:).where(id: ...id, key:)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def locking_persistence_queue(wait: true, &blk)
|
|
75
|
+
self.class.locking_persistence_queue(keys: [key], wait:, &blk)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class_methods do
|
|
79
|
+
# Messages already processed by the given processor_name. If processor_name is
|
|
80
|
+
# an array, include messages already processed by any of the named processors.
|
|
81
|
+
def processed(processor_name:)
|
|
82
|
+
distinct.joins(
|
|
83
|
+
sanitize_sql([<<~SQL.squish, {processor_names: Array(processor_name)}])
|
|
84
|
+
INNER JOIN "#{self::COMPLETION.table_name}" "completions"
|
|
85
|
+
ON "completions"."processor_name" IN (:processor_names)
|
|
86
|
+
AND "completions"."message_type" = "#{table_name}"."type"
|
|
87
|
+
AND "completions"."message_key" = "#{table_name}"."key"
|
|
88
|
+
AND "completions"."last_completed_message_id" >= "#{table_name}"."id"
|
|
89
|
+
SQL
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Messages not yet processed by the given processor_name. If processor_name is
|
|
94
|
+
# an array, exclude messages already processed by any of the named processors.
|
|
95
|
+
def unprocessed(processor_name:)
|
|
96
|
+
joins(
|
|
97
|
+
sanitize_sql([<<~SQL.squish, {processor_names: Array(processor_name)}])
|
|
98
|
+
LEFT JOIN "#{self::COMPLETION.table_name}" "completions"
|
|
99
|
+
ON "completions"."processor_name" IN (:processor_names)
|
|
100
|
+
AND "completions"."message_type" = "#{table_name}"."type"
|
|
101
|
+
AND "completions"."message_key" = "#{table_name}"."key"
|
|
102
|
+
AND "completions"."last_completed_message_id" >= "#{table_name}"."id"
|
|
103
|
+
SQL
|
|
104
|
+
).where(completions: {id: nil})
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Find the next message, with a given key, that hasn't already been processed
|
|
108
|
+
# by the given processor_name. If processor_name is an array, exclude messages
|
|
109
|
+
# already processed by any of the named processors.
|
|
110
|
+
def next_in_line(key:, processor_name:)
|
|
111
|
+
unprocessed(processor_name:).where(key:).order(id: :asc).first
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def locking_persistence_queue(keys:, wait: true, &blk)
|
|
115
|
+
queue_type = self::PERSISTENCE_QUEUE
|
|
116
|
+
message_type = sti_name
|
|
117
|
+
QueueLocking.locking(queue_type:, message_type:, message_keys: keys, wait:, &blk)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def locking_process_queue(keys:, wait: true, &blk)
|
|
121
|
+
queue_type = self::PROCESS_QUEUE
|
|
122
|
+
message_type = sti_name
|
|
123
|
+
QueueLocking.locking(queue_type:, message_type:, message_keys: keys, wait:, &blk)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def completions
|
|
130
|
+
self.class::COMPLETION.where(
|
|
131
|
+
message_type: type,
|
|
132
|
+
message_key: key
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
|
|
3
|
+
class CreateCoilTables < ActiveRecord::Migration[6.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :coil_inbox_messages do |t|
|
|
6
|
+
t.string :type, null: false
|
|
7
|
+
t.jsonb :key, null: false
|
|
8
|
+
t.jsonb :value, null: false
|
|
9
|
+
t.jsonb :metadata, null: false, default: {}
|
|
10
|
+
t.timestamps
|
|
11
|
+
|
|
12
|
+
t.index [:type, :key]
|
|
13
|
+
t.index :created_at
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
create_table :coil_inbox_completions do |t|
|
|
17
|
+
t.string :processor_name, null: false
|
|
18
|
+
t.string :message_type, null: false
|
|
19
|
+
t.jsonb :message_key, null: false
|
|
20
|
+
t.references(
|
|
21
|
+
:last_completed_message,
|
|
22
|
+
null: false,
|
|
23
|
+
foreign_key: {to_table: :coil_inbox_messages},
|
|
24
|
+
index: true
|
|
25
|
+
)
|
|
26
|
+
t.timestamps
|
|
27
|
+
|
|
28
|
+
t.index(
|
|
29
|
+
[:processor_name, :message_type, :message_key],
|
|
30
|
+
name: "index_coil_inbox_completions_processor_message_type_key_uniq",
|
|
31
|
+
unique: true
|
|
32
|
+
)
|
|
33
|
+
# Although the above unique index means there can only be a single row for
|
|
34
|
+
# a given (processor_name, message_type, message_key) combo, providing an
|
|
35
|
+
# additional non-unique index that includes last_completed_message_id will
|
|
36
|
+
# allow some critical queries to leverage an index-only-scan (where the
|
|
37
|
+
# index itself provides all the required data, eliminating the need to
|
|
38
|
+
# also visit the table itself).
|
|
39
|
+
t.index(
|
|
40
|
+
[:processor_name, :message_type, :message_key, :last_completed_message_id],
|
|
41
|
+
name: "index_coil_inbox_completions_on_processor_message_completed"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
create_table :coil_outbox_messages do |t|
|
|
46
|
+
t.string :type, null: false
|
|
47
|
+
t.jsonb :key, null: false
|
|
48
|
+
t.jsonb :value, null: false
|
|
49
|
+
t.jsonb :metadata, null: false, default: {}
|
|
50
|
+
t.timestamps
|
|
51
|
+
|
|
52
|
+
t.index [:type, :key]
|
|
53
|
+
t.index :created_at
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
create_table :coil_outbox_completions do |t|
|
|
57
|
+
t.string :processor_name, null: false
|
|
58
|
+
t.string :message_type, null: false
|
|
59
|
+
t.jsonb :message_key, null: false
|
|
60
|
+
t.references(
|
|
61
|
+
:last_completed_message,
|
|
62
|
+
null: false,
|
|
63
|
+
foreign_key: {to_table: :coil_outbox_messages},
|
|
64
|
+
index: true
|
|
65
|
+
)
|
|
66
|
+
t.timestamps
|
|
67
|
+
|
|
68
|
+
t.index(
|
|
69
|
+
[:processor_name, :message_type, :message_key],
|
|
70
|
+
name: "index_coil_outbox_completions_processor_message_type_key_uniq",
|
|
71
|
+
unique: true
|
|
72
|
+
)
|
|
73
|
+
# Although the above unique index means there can only be a single row for
|
|
74
|
+
# a given (processor_name, message_type, message_key) combo, providing an
|
|
75
|
+
# additional non-unique index that includes last_completed_message_id will
|
|
76
|
+
# allow some critical queries to leverage an index-only-scan (where the
|
|
77
|
+
# index itself provides all the required data, eliminating the need to
|
|
78
|
+
# also visit the table itself).
|
|
79
|
+
t.index(
|
|
80
|
+
[:processor_name, :message_type, :message_key, :last_completed_message_id],
|
|
81
|
+
name: "index_coil_outbox_completions_on_processor_message_completed"
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/coil/engine.rb
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Coil
|
|
5
|
+
module QueueLocking
|
|
6
|
+
include Kernel # provides `loop` and `raise`
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
class Key
|
|
10
|
+
PREFIX = "QueueLocking"
|
|
11
|
+
|
|
12
|
+
# Construct a key suitable for obtaining a PostgreSQL advisory lock.
|
|
13
|
+
# https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS
|
|
14
|
+
def initialize(queue_type:, message_type:, message_key:)
|
|
15
|
+
@text = [PREFIX, queue_type, message_type, stringify(message_key)].join("|")
|
|
16
|
+
@int64 = Digest::SHA256.digest(@text).slice(0, 8).unpack1("q")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :text
|
|
20
|
+
attr_reader :int64
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def stringify(message_key)
|
|
25
|
+
case message_key
|
|
26
|
+
when Hash, Array
|
|
27
|
+
JSON.generate(normalize(message_key))
|
|
28
|
+
else
|
|
29
|
+
message_key.to_s
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def normalize(obj)
|
|
34
|
+
case obj
|
|
35
|
+
when Hash
|
|
36
|
+
obj.stringify_keys.sort.map { |k, v| [k, normalize(v)] }.to_h
|
|
37
|
+
when Array
|
|
38
|
+
obj.map { |x| normalize(x) }
|
|
39
|
+
else
|
|
40
|
+
obj
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class LockWaitTimeout < StandardError
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Run an action while holding advisory locks on a list of keys for messages
|
|
49
|
+
# of a given type in a given queue.
|
|
50
|
+
#
|
|
51
|
+
# By default, this waits until the locks can be obtained. To instead
|
|
52
|
+
# raise a QueueLocking::LockWaitTimeout error if locks cannot be obtained
|
|
53
|
+
# immediately, specify `wait: false`.
|
|
54
|
+
def locking(queue_type:, message_type:, message_keys:, wait: true, &blk)
|
|
55
|
+
keys = message_keys.compact.map do |message_key|
|
|
56
|
+
Key.new(queue_type:, message_type:, message_key:)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Acquire locks in a consistent order to avoid deadlocks.
|
|
60
|
+
ks = keys.uniq(&:int64).sort_by(&:int64).reverse
|
|
61
|
+
|
|
62
|
+
fn = ks.reduce(blk) do |f, key|
|
|
63
|
+
-> do
|
|
64
|
+
with_lock(key:, wait:, &f)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
ApplicationRecord.transaction(&fn)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def with_lock(key:, wait:, &blk)
|
|
74
|
+
wait ? lock(key:) : try_lock(key:)
|
|
75
|
+
blk.call
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
ACQUIRE_LOCK = "pg_advisory_xact_lock"
|
|
79
|
+
TRY_ACQUIRE_LOCK = "pg_try_advisory_xact_lock"
|
|
80
|
+
|
|
81
|
+
def lock(key:)
|
|
82
|
+
command = sql(fn: ACQUIRE_LOCK, key:)
|
|
83
|
+
connection.execute(command)
|
|
84
|
+
rescue ActiveRecord::LockWaitTimeout
|
|
85
|
+
raise LockWaitTimeout
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def try_lock(key:)
|
|
89
|
+
query = sql(fn: TRY_ACQUIRE_LOCK, key:)
|
|
90
|
+
return if connection.select_value(query)
|
|
91
|
+
raise LockWaitTimeout
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def sql(fn:, key:)
|
|
95
|
+
arg = key.int64
|
|
96
|
+
name = SecureRandom.hex # avoid cached results
|
|
97
|
+
comment = key.text.gsub(%r{(/\*)|(\*/)}, "--")
|
|
98
|
+
|
|
99
|
+
<<~SQL.squish
|
|
100
|
+
SELECT #{fn}(#{arg}) AS "#{name}" /* #{comment} */
|
|
101
|
+
SQL
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def connection
|
|
105
|
+
ApplicationRecord.connection
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/coil/version.rb
ADDED
data/lib/coil.rb
ADDED