wal 0.0.0 → 0.0.2
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 +4 -4
- data/.rspec +1 -0
- data/README.md +49 -0
- data/Rakefile +9 -1
- data/exe/wal +64 -0
- data/lib/wal/active_record_context_extension.rb +15 -0
- data/lib/wal/noop_watcher.rb +12 -0
- data/lib/wal/record_watcher.rb +389 -0
- data/lib/wal/replicator.rb +205 -0
- data/lib/wal/streaming_watcher.rb +74 -0
- data/lib/wal/version.rb +2 -1
- data/lib/wal/watcher.rb +95 -0
- data/lib/wal.rb +128 -1
- data/rbi/wal.rbi +295 -0
- data/sig/wal.rbs +184 -2
- data/sorbet/config +7 -0
- data/sorbet/rbi/annotations/.gitattributes +1 -0
- data/sorbet/rbi/annotations/activemodel.rbi +89 -0
- data/sorbet/rbi/annotations/activerecord.rbi +98 -0
- data/sorbet/rbi/annotations/activesupport.rbi +463 -0
- data/sorbet/rbi/annotations/minitest.rbi +119 -0
- data/sorbet/rbi/annotations/rainbow.rbi +269 -0
- data/sorbet/rbi/gems/.gitattributes +1 -0
- data/sorbet/rbi/gems/actioncable@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/actionmailbox@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/actionmailer@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/actionpack@8.0.2.rbi +21122 -0
- data/sorbet/rbi/gems/actiontext@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/actionview@8.0.2.rbi +16423 -0
- data/sorbet/rbi/gems/activejob@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/activemodel@8.0.2.rbi +6866 -0
- data/sorbet/rbi/gems/activerecord@8.0.2.rbi +43227 -0
- data/sorbet/rbi/gems/activestorage@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/activesupport@8.0.2.rbi +21110 -0
- data/sorbet/rbi/gems/ast@2.4.3.rbi +585 -0
- data/sorbet/rbi/gems/base64@0.3.0.rbi +545 -0
- data/sorbet/rbi/gems/benchmark@0.4.1.rbi +619 -0
- data/sorbet/rbi/gems/bigdecimal@3.2.2.rbi +78 -0
- data/sorbet/rbi/gems/builder@3.3.0.rbi +9 -0
- data/sorbet/rbi/gems/commander@5.0.0.rbi +9 -0
- data/sorbet/rbi/gems/concurrent-ruby@1.3.5.rbi +11657 -0
- data/sorbet/rbi/gems/connection_pool@2.5.3.rbi +9 -0
- data/sorbet/rbi/gems/crass@1.0.6.rbi +623 -0
- data/sorbet/rbi/gems/date@3.4.1.rbi +75 -0
- data/sorbet/rbi/gems/diff-lcs@1.6.2.rbi +1134 -0
- data/sorbet/rbi/gems/docker-api@2.4.0.rbi +1719 -0
- data/sorbet/rbi/gems/docopt@0.6.1.rbi +9 -0
- data/sorbet/rbi/gems/drb@2.2.3.rbi +1661 -0
- data/sorbet/rbi/gems/erubi@1.13.1.rbi +155 -0
- data/sorbet/rbi/gems/excon@1.2.7.rbi +1514 -0
- data/sorbet/rbi/gems/globalid@1.2.1.rbi +9 -0
- data/sorbet/rbi/gems/highline@3.0.1.rbi +9 -0
- data/sorbet/rbi/gems/i18n@1.14.7.rbi +2359 -0
- data/sorbet/rbi/gems/io-console@0.8.0.rbi +9 -0
- data/sorbet/rbi/gems/logger@1.7.0.rbi +963 -0
- data/sorbet/rbi/gems/loofah@2.24.1.rbi +1105 -0
- data/sorbet/rbi/gems/mail@2.8.1.rbi +9 -0
- data/sorbet/rbi/gems/marcel@1.0.4.rbi +9 -0
- data/sorbet/rbi/gems/mini_mime@1.1.5.rbi +9 -0
- data/sorbet/rbi/gems/minitest@5.25.5.rbi +1704 -0
- data/sorbet/rbi/gems/multi_json@1.15.0.rbi +268 -0
- data/sorbet/rbi/gems/net-imap@0.5.9.rbi +9 -0
- data/sorbet/rbi/gems/net-pop@0.1.2.rbi +9 -0
- data/sorbet/rbi/gems/net-protocol@0.2.2.rbi +292 -0
- data/sorbet/rbi/gems/net-smtp@0.5.1.rbi +9 -0
- data/sorbet/rbi/gems/netrc@0.11.0.rbi +159 -0
- data/sorbet/rbi/gems/nio4r@2.7.4.rbi +9 -0
- data/sorbet/rbi/gems/nokogiri@1.18.8.rbi +8206 -0
- data/sorbet/rbi/gems/ostruct@0.6.2.rbi +354 -0
- data/sorbet/rbi/gems/parallel@1.27.0.rbi +291 -0
- data/sorbet/rbi/gems/parlour@9.1.1.rbi +3071 -0
- data/sorbet/rbi/gems/parser@3.3.8.0.rbi +7338 -0
- data/sorbet/rbi/gems/pg-replication-protocol@0.0.7.rbi +633 -0
- data/sorbet/rbi/gems/pg@1.5.9.rbi +2806 -0
- data/sorbet/rbi/gems/pp@0.6.2.rbi +368 -0
- data/sorbet/rbi/gems/prettyprint@0.2.0.rbi +477 -0
- data/sorbet/rbi/gems/prism@1.4.0.rbi +41732 -0
- data/sorbet/rbi/gems/psych@5.2.3.rbi +2435 -0
- data/sorbet/rbi/gems/racc@1.8.1.rbi +160 -0
- data/sorbet/rbi/gems/rack-session@2.1.1.rbi +727 -0
- data/sorbet/rbi/gems/rack-test@2.2.0.rbi +734 -0
- data/sorbet/rbi/gems/rack@3.1.16.rbi +4940 -0
- data/sorbet/rbi/gems/rackup@2.2.1.rbi +230 -0
- data/sorbet/rbi/gems/rails-dom-testing@2.3.0.rbi +858 -0
- data/sorbet/rbi/gems/rails-html-sanitizer@1.6.2.rbi +785 -0
- data/sorbet/rbi/gems/rails@8.0.2.rbi +9 -0
- data/sorbet/rbi/gems/railties@8.0.2.rbi +3865 -0
- data/sorbet/rbi/gems/rainbow@3.1.1.rbi +403 -0
- data/sorbet/rbi/gems/rake@13.2.1.rbi +3120 -0
- data/sorbet/rbi/gems/rbi@0.3.6.rbi +6893 -0
- data/sorbet/rbi/gems/rbs@3.9.4.rbi +6978 -0
- data/sorbet/rbi/gems/rdoc@6.12.0.rbi +12760 -0
- data/sorbet/rbi/gems/reline@0.6.0.rbi +2451 -0
- data/sorbet/rbi/gems/rexml@3.4.1.rbi +5240 -0
- data/sorbet/rbi/gems/rspec-core@3.13.4.rbi +11348 -0
- data/sorbet/rbi/gems/rspec-expectations@3.13.5.rbi +8189 -0
- data/sorbet/rbi/gems/rspec-mocks@3.13.5.rbi +5350 -0
- data/sorbet/rbi/gems/rspec-sorbet@1.9.2.rbi +164 -0
- data/sorbet/rbi/gems/rspec-support@3.13.4.rbi +1630 -0
- data/sorbet/rbi/gems/rspec@3.13.1.rbi +83 -0
- data/sorbet/rbi/gems/securerandom@0.4.1.rbi +75 -0
- data/sorbet/rbi/gems/spoom@1.6.3.rbi +6985 -0
- data/sorbet/rbi/gems/stringio@3.1.5.rbi +9 -0
- data/sorbet/rbi/gems/tapioca@0.16.11.rbi +3628 -0
- data/sorbet/rbi/gems/testcontainers-core@0.2.0.rbi +1005 -0
- data/sorbet/rbi/gems/testcontainers-postgres@0.2.0.rbi +145 -0
- data/sorbet/rbi/gems/thor@1.3.2.rbi +4378 -0
- data/sorbet/rbi/gems/timeout@0.4.3.rbi +157 -0
- data/sorbet/rbi/gems/tzinfo@2.0.6.rbi +5918 -0
- data/sorbet/rbi/gems/uri@1.0.3.rbi +2349 -0
- data/sorbet/rbi/gems/useragent@0.16.11.rbi +9 -0
- data/sorbet/rbi/gems/websocket-driver@0.8.0.rbi +9 -0
- data/sorbet/rbi/gems/websocket-extensions@0.1.5.rbi +9 -0
- data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +435 -0
- data/sorbet/rbi/gems/yard@0.9.37.rbi +18379 -0
- data/sorbet/rbi/gems/zeitwerk@2.7.3.rbi +9 -0
- data/sorbet/tapioca/config.yml +5 -0
- data/sorbet/tapioca/require.rb +12 -0
- metadata +231 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cc5df8a8a67077b8c9a4da905a102e72bf4b1e6e41ad2569a4662038d81d123a
|
4
|
+
data.tar.gz: dc5dd65b5b87f43b0b1b4bc7d7d66b2b97fa92fd04963bbbdb32f2e781f4bcaf
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0e1b415b21cef101a2493ed922b95e8e70e972575bb6bfc190200aee567a51a4582a26b29f07eca316621ae48ec4b63e995e718cec3a963ffd42fb6774c40ee1
|
7
|
+
data.tar.gz: 940d945bafe3f36dd303a427bbe25524f50a98ac88a76f2ef02e842cfe30b190ba87c047a15256c5b7f80408ee211bda08beb7b6bb37bb93325540fd5ad6e4e7
|
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--require spec_helper
|
data/README.md
CHANGED
@@ -0,0 +1,49 @@
|
|
1
|
+
# Wal
|
2
|
+
|
3
|
+
Easily hook into Postgres WAL event log from your Rails app.
|
4
|
+
|
5
|
+
Proper documentation TBD
|
6
|
+
|
7
|
+
## Examples
|
8
|
+
|
9
|
+
### Watch for model changes using the RecordWatcher DSL
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
class ProductAvailabilityWatcher < Wal::RecordWatcher
|
13
|
+
on_save Product, changed: %w[price] do |event|
|
14
|
+
recalculate_inventory_price(event.primary_key, event.new["price"])
|
15
|
+
end
|
16
|
+
|
17
|
+
on_destroy Product do |event|
|
18
|
+
clear_product_inventory(event.primary_key)
|
19
|
+
end
|
20
|
+
|
21
|
+
on_save Sales, changed: %w[status] do |event|
|
22
|
+
recalculate_inventory_quantity(event.primary_key)
|
23
|
+
end
|
24
|
+
|
25
|
+
def recalculate_inventory_price(product_id, new_price)
|
26
|
+
# ...
|
27
|
+
end
|
28
|
+
|
29
|
+
def clear_product_inventory(product_id)
|
30
|
+
# ...
|
31
|
+
end
|
32
|
+
|
33
|
+
def recalculate_inventory_quantity(sales_id)
|
34
|
+
# ...
|
35
|
+
end
|
36
|
+
end
|
37
|
+
```
|
38
|
+
|
39
|
+
### Basic watcher implementation
|
40
|
+
|
41
|
+
```ruby
|
42
|
+
class LogWatcher
|
43
|
+
include Wal::Watcher
|
44
|
+
|
45
|
+
def on_event(event)
|
46
|
+
puts "Wal event received #{event}"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
```
|
data/Rakefile
CHANGED
@@ -1,4 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "bundler/gem_tasks"
|
4
|
-
|
4
|
+
|
5
|
+
task(:test) { sh "bundle exec rspec" }
|
6
|
+
|
7
|
+
task default: %i[build]
|
8
|
+
|
9
|
+
task("sig/wal.rbi") { sh "bundle exec parlour" }
|
10
|
+
task("rbi/wal.rbs") { sh "rbs prototype rbi rbi/wal.rbi > sig/wal.rbs" }
|
11
|
+
|
12
|
+
Rake::Task["build"].enhance(["sig/wal.rbi", "rbi/wal.rbs"])
|
data/exe/wal
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "docopt"
|
4
|
+
|
5
|
+
begin
|
6
|
+
cli = Docopt.docopt(<<~DOCOPT)
|
7
|
+
Usage:
|
8
|
+
wal watch --watcher <watcher-class> (--slot <replication-slot> | --tmp-slot) [--publication=<publication>...] [--with-timings]
|
9
|
+
wal start <config-file>
|
10
|
+
|
11
|
+
Options:
|
12
|
+
-h --help Show this screen.
|
13
|
+
--watcher=<watcher-class> The watcher class to be used to listen for WAL changes.
|
14
|
+
--slot=<replication-slot> The replication slot that will be used.
|
15
|
+
--tmp-slot Use a temporary replication slot.
|
16
|
+
[--publication=<publication>...] Force using the informed Postgres publications.
|
17
|
+
[--with-timings] Add timing logs to the output.
|
18
|
+
DOCOPT
|
19
|
+
rescue Docopt::Exit => err
|
20
|
+
puts err.message
|
21
|
+
exit
|
22
|
+
end
|
23
|
+
|
24
|
+
require "./config/environment"
|
25
|
+
|
26
|
+
if cli["watch"]
|
27
|
+
watcher = cli["--watcher"].constantize.new
|
28
|
+
watcher = Wal::MonitoringWatcher.new(watcher) if cli["--with-timings"]
|
29
|
+
|
30
|
+
use_temporary_slot = cli["--tmp-slot"] || false
|
31
|
+
replication_slot = cli["--slot"]
|
32
|
+
replication_slot = replication_slot.presence || "wal_watcher_#{SecureRandom.alphanumeric(4)}" if use_temporary_slot
|
33
|
+
|
34
|
+
Wal::Replicator
|
35
|
+
.new(replication_slot:, use_temporary_slot:)
|
36
|
+
.replicate_forever(watcher, publications: cli["--publication"])
|
37
|
+
|
38
|
+
elsif cli["start"]
|
39
|
+
workers = YAML.load_file(cli["<config-file>"])["slots"].map do |slot, config|
|
40
|
+
watcher = config["watcher"].constantize.new
|
41
|
+
watcher = Wal::MonitoringWatcher.new(watcher) if config["log_execution_time"]
|
42
|
+
temporary = config["temporary"] || false
|
43
|
+
publications = config["publications"] || []
|
44
|
+
|
45
|
+
Thread.new(slot, watcher, temporary, publications) do |replication_slot, watcher, use_temporary_slot, publications|
|
46
|
+
replication_slot = "#{replication_slot}_#{SecureRandom.alphanumeric(4)}" if use_temporary_slot
|
47
|
+
puts "Watcher started for #{replication_slot} slot (#{publications.join(", ")})"
|
48
|
+
|
49
|
+
Wal::Replicator
|
50
|
+
.new(replication_slot:, use_temporary_slot:)
|
51
|
+
.replicate_forever(watcher, publications:)
|
52
|
+
|
53
|
+
puts "Watcher finished for #{replication_slot}"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
Signal.trap("INT") do
|
58
|
+
puts "Stopping WAL workers..."
|
59
|
+
workers.each(&:kill)
|
60
|
+
puts "WAL workers stopped"
|
61
|
+
end
|
62
|
+
|
63
|
+
workers.each(&:join)
|
64
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require "active_record/connection_adapters/postgresql_adapter"
|
2
|
+
|
3
|
+
module Wal
|
4
|
+
module ActiveRecordContextExtension
|
5
|
+
def set_wal_watcher_context(context, prefix: "")
|
6
|
+
execute "SELECT pg_logical_emit_message(true, #{quote(prefix)}, #{quote(context.to_json)})" if transaction_open?
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
if defined? ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
12
|
+
class ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
13
|
+
include Wal::ActiveRecordContextExtension
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# typed: strict
|
2
|
+
|
3
|
+
module Wal
|
4
|
+
# A watcher that does nothing. Just for performance testing in general. Useful in testing aswell.
|
5
|
+
class NoopWatcher
|
6
|
+
extend T::Sig
|
7
|
+
include Wal::Watcher
|
8
|
+
|
9
|
+
sig { override.params(event: Event).void }
|
10
|
+
def on_event(event); end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,389 @@
|
|
1
|
+
# typed: true
|
2
|
+
|
3
|
+
module Wal
|
4
|
+
# Watcher that process records at the end of a transaction, keeping only its final state.
|
5
|
+
#
|
6
|
+
# Example:
|
7
|
+
#
|
8
|
+
# ```ruby
|
9
|
+
# class InventoryAvailabilityWatcher < Wal::RecordWatcher
|
10
|
+
# on_save Item, changed: %w[weight_unid_id] do |event|
|
11
|
+
# recalculate_inventory_availability(event.primary_key)
|
12
|
+
# end
|
13
|
+
#
|
14
|
+
# on_save SalesOrder, changed: %w[status] do |event|
|
15
|
+
# next unless event.attributes_changes(:status).one? "filled"
|
16
|
+
#
|
17
|
+
# OrderItem
|
18
|
+
# .where(sales_order_id: event.primary_key)
|
19
|
+
# .pluck(:item_id)
|
20
|
+
# .each { |item_id| recalculate_inventory_availability(item_id) }
|
21
|
+
# end
|
22
|
+
#
|
23
|
+
# on_save OrderItem, changed: %w[item_id weight_unit weight_unid_id] do |event|
|
24
|
+
# if (old_item_id, new_item_id = event.attributes_changes("item_id"))
|
25
|
+
# recalculate_inventory_availability(old_item_id)
|
26
|
+
# recalculate_inventory_availability(new_item_id)
|
27
|
+
# else
|
28
|
+
# recalculate_inventory_availability(event.attribute(:item_id))
|
29
|
+
# end
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
# on_destroy OrderItem do |event|
|
33
|
+
# recalculate_inventory_availability(event.attribute(:item_id))
|
34
|
+
# end
|
35
|
+
#
|
36
|
+
# def recalculate_inventory_availability(item_id)
|
37
|
+
# ...
|
38
|
+
# end
|
39
|
+
# end
|
40
|
+
# ```
|
41
|
+
class RecordWatcher
|
42
|
+
extend T::Sig
|
43
|
+
extend T::Helpers
|
44
|
+
include Wal::Watcher
|
45
|
+
abstract!
|
46
|
+
|
47
|
+
RecordEvent = T.type_alias { T.any(InsertEvent, UpdateEvent, DeleteEvent) }
|
48
|
+
|
49
|
+
def self.inherited(subclass)
|
50
|
+
super
|
51
|
+
@@change_callbacks = Hash.new { |hash, key| hash[key] = [] }
|
52
|
+
@@delete_callbacks = Hash.new { |hash, key| hash[key] = [] }
|
53
|
+
end
|
54
|
+
|
55
|
+
sig do
|
56
|
+
params(
|
57
|
+
table: T.any(String, T.class_of(::ActiveRecord::Base)),
|
58
|
+
block: T.proc.bind(T.attached_class).params(event: InsertEvent).void,
|
59
|
+
).void
|
60
|
+
end
|
61
|
+
def self.on_insert(table, &block)
|
62
|
+
table = table.is_a?(String) ? table : table.table_name
|
63
|
+
@@change_callbacks[table].push(only: [:create], block: block)
|
64
|
+
end
|
65
|
+
|
66
|
+
sig do
|
67
|
+
params(
|
68
|
+
table: T.any(String, T.class_of(::ActiveRecord::Base)),
|
69
|
+
changed: T.nilable(T::Array[T.any(String, Symbol)]),
|
70
|
+
block: T.proc.bind(T.attached_class).params(event: UpdateEvent).void,
|
71
|
+
).void
|
72
|
+
end
|
73
|
+
def self.on_update(table, changed: nil, &block)
|
74
|
+
table = table.is_a?(String) ? table : table.table_name
|
75
|
+
@@change_callbacks[table].push(only: [:update], changed: changed&.map(&:to_s), block: block)
|
76
|
+
end
|
77
|
+
|
78
|
+
sig do
|
79
|
+
params(
|
80
|
+
table: T.any(String, T.class_of(::ActiveRecord::Base)),
|
81
|
+
changed: T.nilable(T::Array[T.any(String, Symbol)]),
|
82
|
+
block: T.proc.bind(T.attached_class).params(event: T.any(InsertEvent, UpdateEvent)).void,
|
83
|
+
).void
|
84
|
+
end
|
85
|
+
def self.on_save(table, changed: nil, &block)
|
86
|
+
table = table.is_a?(String) ? table : table.table_name
|
87
|
+
@@change_callbacks[table].push(only: [:create, :update], changed: changed&.map(&:to_s), block: block)
|
88
|
+
end
|
89
|
+
|
90
|
+
sig do
|
91
|
+
params(
|
92
|
+
table: T.any(String, T.class_of(::ActiveRecord::Base)),
|
93
|
+
block: T.proc.bind(T.attached_class).params(event: DeleteEvent).void,
|
94
|
+
).void
|
95
|
+
end
|
96
|
+
def self.on_destroy(table, &block)
|
97
|
+
table = table.is_a?(String) ? table : table.table_name
|
98
|
+
@@delete_callbacks[table].push(block: block)
|
99
|
+
end
|
100
|
+
|
101
|
+
sig { params(event: RecordEvent).void }
|
102
|
+
def on_record_changed(event)
|
103
|
+
case event
|
104
|
+
when InsertEvent
|
105
|
+
@@change_callbacks[event.table]
|
106
|
+
.filter { |callback| callback[:only].include? :create }
|
107
|
+
.each { |callback| instance_exec(event, &callback[:block]) }
|
108
|
+
|
109
|
+
when UpdateEvent
|
110
|
+
@@change_callbacks[event.table]
|
111
|
+
.filter { |callback| callback[:only].include? :update }
|
112
|
+
.each do |callback|
|
113
|
+
if (attributes = callback[:changed])
|
114
|
+
instance_exec(event, &callback[:block]) unless (event.diff.keys & attributes).empty?
|
115
|
+
else
|
116
|
+
instance_exec(event, &callback[:block])
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
when DeleteEvent
|
121
|
+
@@delete_callbacks[event.table].each do |callback|
|
122
|
+
instance_exec(event, &callback[:block])
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
sig { params(table: String).returns(T::Boolean) }
|
128
|
+
def should_watch_table?(table)
|
129
|
+
(@@change_callbacks.keys | @@delete_callbacks.keys).include? table
|
130
|
+
end
|
131
|
+
|
132
|
+
# `RecordWatcher` supports two processing strategies:
|
133
|
+
#
|
134
|
+
# `:memory`: Stores and aggregates records from a single transaction in memory. This has better performance but uses
|
135
|
+
# more memory, as at least one event for each record must be stored in memory until the end of a transaction
|
136
|
+
#
|
137
|
+
# `:temporary_table`: Offloads the record aggregation to a temporary table on the database. This is useful when you
|
138
|
+
# are processing very large transactions that can't fit in memory. The tradeoff is obviously a worse performance.
|
139
|
+
#
|
140
|
+
# These strategies can be defined per transaction, and by default it will uses the memory one, and only fallback
|
141
|
+
# to the temporary table if the transaction size is roughly 2 gigabytes or more.
|
142
|
+
sig { params(event: BeginTransactionEvent).returns(Symbol) }
|
143
|
+
def aggregation_strategy(event)
|
144
|
+
if event.estimated_size > 1024.pow(3) * 2
|
145
|
+
:temporary_table
|
146
|
+
else
|
147
|
+
:memory
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
sig { override.params(event: Event).void }
|
152
|
+
def on_event(event)
|
153
|
+
if event.is_a? BeginTransactionEvent
|
154
|
+
@current_record_watcher = case (strategy = aggregation_strategy(event))
|
155
|
+
when :memory
|
156
|
+
MemoryRecordWatcher.new(self)
|
157
|
+
when :temporary_table
|
158
|
+
TemporaryTableRecordWatcher.new(self)
|
159
|
+
else
|
160
|
+
raise "Invalid aggregation strategy: #{strategy}"
|
161
|
+
end
|
162
|
+
end
|
163
|
+
@current_record_watcher.on_event(event)
|
164
|
+
end
|
165
|
+
|
166
|
+
class MemoryRecordWatcher
|
167
|
+
extend T::Sig
|
168
|
+
extend T::Helpers
|
169
|
+
include Wal::Watcher
|
170
|
+
include Wal::Watcher::SeparatedEvents
|
171
|
+
|
172
|
+
# Records indexed by table and primary key
|
173
|
+
RecordsStorage = T.type_alias { T::Hash[[String, Integer], T.nilable(RecordEvent)] }
|
174
|
+
|
175
|
+
def initialize(watcher)
|
176
|
+
@watcher = watcher
|
177
|
+
end
|
178
|
+
|
179
|
+
sig { params(event: BeginTransactionEvent).void }
|
180
|
+
def on_begin(event)
|
181
|
+
@records = T.let({}, T.nilable(RecordsStorage))
|
182
|
+
end
|
183
|
+
|
184
|
+
def on_commit(_event)
|
185
|
+
@records
|
186
|
+
&.values
|
187
|
+
&.lazy
|
188
|
+
&.each { |event| @watcher.on_record_changed(event) if event }
|
189
|
+
end
|
190
|
+
|
191
|
+
sig { params(event: InsertEvent).void }
|
192
|
+
def on_insert(event)
|
193
|
+
if (id = event.primary_key)
|
194
|
+
@records ||= {}
|
195
|
+
@records[[event.table, id]] = event
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
sig { params(event: UpdateEvent).void }
|
200
|
+
def on_update(event)
|
201
|
+
if (id = event.primary_key)
|
202
|
+
@records ||= {}
|
203
|
+
@records[[event.table, id]] = case (existing_event = @records[[event.table, id]])
|
204
|
+
when InsertEvent
|
205
|
+
# A record inserted on this transaction is being updated, which means it should still reflect as a insert
|
206
|
+
# event, we just change the information to reflect the most current data that was just updated.
|
207
|
+
existing_event.with(new: event.new)
|
208
|
+
|
209
|
+
when UpdateEvent
|
210
|
+
# We are updating again a event that was already updated on this transaction.
|
211
|
+
# Same as the insert, we keep the old data from the previous update and the new data from the new one.
|
212
|
+
existing_event.with(new: event.new)
|
213
|
+
|
214
|
+
else
|
215
|
+
event
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
sig { params(event: DeleteEvent).void }
|
221
|
+
def on_delete(event)
|
222
|
+
if (id = event.primary_key)
|
223
|
+
@records ||= {}
|
224
|
+
@records[[event.table, id]] = case (existing_event = @records[[event.table, id]])
|
225
|
+
when InsertEvent
|
226
|
+
# We are removing a record that was inserted on this transaction, we should not even report this change, as
|
227
|
+
# this record never existed outside this transaction anyways.
|
228
|
+
nil
|
229
|
+
|
230
|
+
when UpdateEvent
|
231
|
+
# Deleting a record that was previously updated by this transaction. Just store the previous data while
|
232
|
+
# keeping the record as deleted.
|
233
|
+
event.with(old: existing_event.old)
|
234
|
+
|
235
|
+
else
|
236
|
+
event
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
class TemporaryTableRecordWatcher
|
243
|
+
extend T::Sig
|
244
|
+
extend T::Helpers
|
245
|
+
include Wal::Watcher
|
246
|
+
include Wal::Watcher::SeparatedEvents
|
247
|
+
|
248
|
+
# ActiveRecord base class used to persist the temporary table. Defaults to `ActiveRecord::Base`, but can be
|
249
|
+
# changed if you want, for example, to use a different database for Wal processing.
|
250
|
+
# Note that the class specified here must be a `abstract_class`.
|
251
|
+
mattr_accessor :base_active_record_class
|
252
|
+
|
253
|
+
def initialize(watcher, batch_size: 5_000)
|
254
|
+
@watcher = watcher
|
255
|
+
@batch_size = 5_000
|
256
|
+
end
|
257
|
+
|
258
|
+
sig { params(event: BeginTransactionEvent).void }
|
259
|
+
def on_begin(event)
|
260
|
+
@table = begin
|
261
|
+
table_name = "temp_record_watcher_#{SecureRandom.alphanumeric(10).downcase}"
|
262
|
+
|
263
|
+
base_class.connection.create_table(table_name, temporary: true) do |t|
|
264
|
+
t.bigint :transaction_id, null: false
|
265
|
+
t.bigint :lsn, null: false
|
266
|
+
t.column :action, :string, null: false
|
267
|
+
t.string :table_name, null: false
|
268
|
+
t.bigint :primary_key
|
269
|
+
t.jsonb :old, null: false, default: {}
|
270
|
+
t.jsonb :new, null: false, default: {}
|
271
|
+
t.jsonb :context, null: false, default: {}
|
272
|
+
end
|
273
|
+
|
274
|
+
unique_index = %i[table_name primary_key]
|
275
|
+
|
276
|
+
base_class.connection.add_index table_name, unique_index, unique: true
|
277
|
+
|
278
|
+
Class.new(base_class) do
|
279
|
+
# Using cast here since Sorbet bugs when we don't pass a explicit class to `Class.new`
|
280
|
+
T.cast(self, T.class_of(::ActiveRecord::Base)).table_name = table_name
|
281
|
+
|
282
|
+
# All this sh$#1t was necessary because AR schema cache doesn't work with temporary tables...
|
283
|
+
insert_all_class = Class.new(::ActiveRecord::InsertAll) do
|
284
|
+
unique_index_definition = ::ActiveRecord::ConnectionAdapters::IndexDefinition.new(
|
285
|
+
table_name, "unique_index", true, unique_index
|
286
|
+
)
|
287
|
+
define_method(:find_unique_index_for) { |_| unique_index_definition }
|
288
|
+
end
|
289
|
+
|
290
|
+
define_singleton_method(:upsert) do |attributes, update_only: nil|
|
291
|
+
insert_all_class
|
292
|
+
.new(
|
293
|
+
T.cast(self, T.class_of(::ActiveRecord::Base)).none,
|
294
|
+
T.cast(self, T.class_of(::ActiveRecord::Base)).connection,
|
295
|
+
[attributes],
|
296
|
+
on_duplicate: :update,
|
297
|
+
unique_by: unique_index,
|
298
|
+
update_only:,
|
299
|
+
returning: nil,
|
300
|
+
record_timestamps: nil,
|
301
|
+
)
|
302
|
+
.execute
|
303
|
+
end
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
def on_commit(_event)
|
309
|
+
@table
|
310
|
+
.in_batches(of: @batch_size)
|
311
|
+
.each_record
|
312
|
+
.lazy
|
313
|
+
.filter_map { |persisted_event| deserialize(persisted_event) }
|
314
|
+
.each { |event| @watcher.on_record_changed(event) }
|
315
|
+
|
316
|
+
base_class.connection.drop_table @table.table_name
|
317
|
+
end
|
318
|
+
|
319
|
+
sig { params(event: InsertEvent).void }
|
320
|
+
def on_insert(event)
|
321
|
+
@table.upsert(serialize(event))
|
322
|
+
end
|
323
|
+
|
324
|
+
sig { params(event: UpdateEvent).void }
|
325
|
+
def on_update(event)
|
326
|
+
@table.upsert(serialize(event), update_only: %w[new])
|
327
|
+
end
|
328
|
+
|
329
|
+
sig { params(event: DeleteEvent).void }
|
330
|
+
def on_delete(event)
|
331
|
+
case @table.where(table_name: event.table, primary_key: event.primary_key).pluck(:action, :old).first
|
332
|
+
in ["insert", _]
|
333
|
+
@table.where(table_name: event.table, primary_key: event.primary_key).delete_all
|
334
|
+
in ["update", old]
|
335
|
+
@table.upsert(serialize(event).merge(old:))
|
336
|
+
in ["delete", _]
|
337
|
+
# We don't need to store another delete
|
338
|
+
else
|
339
|
+
@table.upsert(serialize(event))
|
340
|
+
end
|
341
|
+
end
|
342
|
+
|
343
|
+
private
|
344
|
+
|
345
|
+
sig { returns(T.class_of(::ActiveRecord::Base)) }
|
346
|
+
def base_class
|
347
|
+
self.class.base_active_record_class || ::ActiveRecord::Base
|
348
|
+
end
|
349
|
+
|
350
|
+
def serialize(event)
|
351
|
+
serialized = {
|
352
|
+
transaction_id: event.transaction_id,
|
353
|
+
lsn: event.lsn,
|
354
|
+
table_name: event.table,
|
355
|
+
primary_key: event.primary_key,
|
356
|
+
context: event.context,
|
357
|
+
}
|
358
|
+
case event
|
359
|
+
when InsertEvent
|
360
|
+
serialized.merge(action: "insert", new: event.new)
|
361
|
+
when UpdateEvent
|
362
|
+
serialized.merge(action: "update", old: event.old, new: event.new)
|
363
|
+
when DeleteEvent
|
364
|
+
serialized.merge(action: "delete", old: event.old)
|
365
|
+
else
|
366
|
+
serialized
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
def deserialize(persisted_event)
|
371
|
+
deserialized = {
|
372
|
+
transaction_id: persisted_event.transaction_id,
|
373
|
+
lsn: persisted_event.lsn,
|
374
|
+
table: persisted_event.table_name,
|
375
|
+
primary_key: persisted_event.primary_key,
|
376
|
+
context: persisted_event.context,
|
377
|
+
}
|
378
|
+
case persisted_event.action
|
379
|
+
when "insert"
|
380
|
+
InsertEvent.new(**deserialized, new: persisted_event.new)
|
381
|
+
when "update"
|
382
|
+
UpdateEvent.new(**deserialized, old: persisted_event.old, new: persisted_event.new)
|
383
|
+
when "delete"
|
384
|
+
DeleteEvent.new(**deserialized, old: persisted_event.old)
|
385
|
+
end
|
386
|
+
end
|
387
|
+
end
|
388
|
+
end
|
389
|
+
end
|