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.
@@ -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,9 @@
1
+ # typed: strict
2
+
3
+ module Coil
4
+ module Inbox
5
+ def self.table_name_prefix
6
+ module_parent.table_name_prefix + "inbox_"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # typed: strict
2
+
3
+ module Coil
4
+ module Outbox
5
+ class Completion < ApplicationRecord
6
+ include PreventDestruction
7
+
8
+ attr_readonly :processor_name, :message_type, :message_key
9
+ end
10
+ end
11
+ 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,9 @@
1
+ # typed: strict
2
+
3
+ module Coil
4
+ module Outbox
5
+ def self.table_name_prefix
6
+ module_parent.table_name_prefix + "outbox_"
7
+ end
8
+ end
9
+ 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,4 @@
1
+ # typed: false
2
+
3
+ Coil::Engine.routes.draw do
4
+ end
@@ -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
@@ -0,0 +1,9 @@
1
+ # typed: strict
2
+
3
+ module Coil
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Coil
6
+ config.generators.api_only = true
7
+ config.generators.test_framework = :rspec
8
+ end
9
+ end
@@ -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
@@ -0,0 +1,6 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Coil
5
+ VERSION = "1.3.0"
6
+ end
data/lib/coil.rb ADDED
@@ -0,0 +1,9 @@
1
+ # typed: strict
2
+
3
+ require "rails"
4
+ require "coil/version"
5
+ require "coil/engine"
6
+ require "coil/queue_locking"
7
+
8
+ module Coil
9
+ end
@@ -0,0 +1,6 @@
1
+ # typed: strict
2
+
3
+ # desc "Explaining what the task does"
4
+ # task :coil do
5
+ # # Task goes here
6
+ # end