wal 0.0.0 → 0.0.1

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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +1 -0
  3. data/README.md +49 -0
  4. data/Rakefile +9 -1
  5. data/exe/wal +64 -0
  6. data/lib/wal/active_record_context_extension.rb +15 -0
  7. data/lib/wal/noop_watcher.rb +12 -0
  8. data/lib/wal/record_watcher.rb +389 -0
  9. data/lib/wal/replicator.rb +205 -0
  10. data/lib/wal/streaming_watcher.rb +74 -0
  11. data/lib/wal/version.rb +2 -1
  12. data/lib/wal/watcher.rb +95 -0
  13. data/lib/wal.rb +128 -1
  14. data/rbi/wal.rbi +295 -0
  15. data/sig/wal.rbs +184 -2
  16. data/sorbet/config +7 -0
  17. data/sorbet/rbi/annotations/.gitattributes +1 -0
  18. data/sorbet/rbi/annotations/activemodel.rbi +89 -0
  19. data/sorbet/rbi/annotations/activerecord.rbi +98 -0
  20. data/sorbet/rbi/annotations/activesupport.rbi +463 -0
  21. data/sorbet/rbi/annotations/minitest.rbi +119 -0
  22. data/sorbet/rbi/annotations/rainbow.rbi +269 -0
  23. data/sorbet/rbi/gems/.gitattributes +1 -0
  24. data/sorbet/rbi/gems/actioncable@8.0.2.rbi +9 -0
  25. data/sorbet/rbi/gems/actionmailbox@8.0.2.rbi +9 -0
  26. data/sorbet/rbi/gems/actionmailer@8.0.2.rbi +9 -0
  27. data/sorbet/rbi/gems/actionpack@8.0.2.rbi +21122 -0
  28. data/sorbet/rbi/gems/actiontext@8.0.2.rbi +9 -0
  29. data/sorbet/rbi/gems/actionview@8.0.2.rbi +16423 -0
  30. data/sorbet/rbi/gems/activejob@8.0.2.rbi +9 -0
  31. data/sorbet/rbi/gems/activemodel@8.0.2.rbi +6866 -0
  32. data/sorbet/rbi/gems/activerecord@8.0.2.rbi +43227 -0
  33. data/sorbet/rbi/gems/activestorage@8.0.2.rbi +9 -0
  34. data/sorbet/rbi/gems/activesupport@8.0.2.rbi +21110 -0
  35. data/sorbet/rbi/gems/ast@2.4.3.rbi +585 -0
  36. data/sorbet/rbi/gems/base64@0.3.0.rbi +545 -0
  37. data/sorbet/rbi/gems/benchmark@0.4.1.rbi +619 -0
  38. data/sorbet/rbi/gems/bigdecimal@3.2.2.rbi +78 -0
  39. data/sorbet/rbi/gems/builder@3.3.0.rbi +9 -0
  40. data/sorbet/rbi/gems/commander@5.0.0.rbi +9 -0
  41. data/sorbet/rbi/gems/concurrent-ruby@1.3.5.rbi +11657 -0
  42. data/sorbet/rbi/gems/connection_pool@2.5.3.rbi +9 -0
  43. data/sorbet/rbi/gems/crass@1.0.6.rbi +623 -0
  44. data/sorbet/rbi/gems/date@3.4.1.rbi +75 -0
  45. data/sorbet/rbi/gems/diff-lcs@1.6.2.rbi +1134 -0
  46. data/sorbet/rbi/gems/docker-api@2.4.0.rbi +1719 -0
  47. data/sorbet/rbi/gems/docopt@0.6.1.rbi +9 -0
  48. data/sorbet/rbi/gems/drb@2.2.3.rbi +1661 -0
  49. data/sorbet/rbi/gems/erubi@1.13.1.rbi +155 -0
  50. data/sorbet/rbi/gems/excon@1.2.7.rbi +1514 -0
  51. data/sorbet/rbi/gems/globalid@1.2.1.rbi +9 -0
  52. data/sorbet/rbi/gems/highline@3.0.1.rbi +9 -0
  53. data/sorbet/rbi/gems/i18n@1.14.7.rbi +2359 -0
  54. data/sorbet/rbi/gems/io-console@0.8.0.rbi +9 -0
  55. data/sorbet/rbi/gems/logger@1.7.0.rbi +963 -0
  56. data/sorbet/rbi/gems/loofah@2.24.1.rbi +1105 -0
  57. data/sorbet/rbi/gems/mail@2.8.1.rbi +9 -0
  58. data/sorbet/rbi/gems/marcel@1.0.4.rbi +9 -0
  59. data/sorbet/rbi/gems/mini_mime@1.1.5.rbi +9 -0
  60. data/sorbet/rbi/gems/minitest@5.25.5.rbi +1704 -0
  61. data/sorbet/rbi/gems/multi_json@1.15.0.rbi +268 -0
  62. data/sorbet/rbi/gems/net-imap@0.5.9.rbi +9 -0
  63. data/sorbet/rbi/gems/net-pop@0.1.2.rbi +9 -0
  64. data/sorbet/rbi/gems/net-protocol@0.2.2.rbi +292 -0
  65. data/sorbet/rbi/gems/net-smtp@0.5.1.rbi +9 -0
  66. data/sorbet/rbi/gems/netrc@0.11.0.rbi +159 -0
  67. data/sorbet/rbi/gems/nio4r@2.7.4.rbi +9 -0
  68. data/sorbet/rbi/gems/nokogiri@1.18.8.rbi +8206 -0
  69. data/sorbet/rbi/gems/ostruct@0.6.2.rbi +354 -0
  70. data/sorbet/rbi/gems/parallel@1.27.0.rbi +291 -0
  71. data/sorbet/rbi/gems/parlour@9.1.1.rbi +3071 -0
  72. data/sorbet/rbi/gems/parser@3.3.8.0.rbi +7338 -0
  73. data/sorbet/rbi/gems/pg-replication-protocol@0.0.7.rbi +633 -0
  74. data/sorbet/rbi/gems/pg@1.5.9.rbi +2806 -0
  75. data/sorbet/rbi/gems/pp@0.6.2.rbi +368 -0
  76. data/sorbet/rbi/gems/prettyprint@0.2.0.rbi +477 -0
  77. data/sorbet/rbi/gems/prism@1.4.0.rbi +41732 -0
  78. data/sorbet/rbi/gems/psych@5.2.3.rbi +2435 -0
  79. data/sorbet/rbi/gems/racc@1.8.1.rbi +160 -0
  80. data/sorbet/rbi/gems/rack-session@2.1.1.rbi +727 -0
  81. data/sorbet/rbi/gems/rack-test@2.2.0.rbi +734 -0
  82. data/sorbet/rbi/gems/rack@3.1.16.rbi +4940 -0
  83. data/sorbet/rbi/gems/rackup@2.2.1.rbi +230 -0
  84. data/sorbet/rbi/gems/rails-dom-testing@2.3.0.rbi +858 -0
  85. data/sorbet/rbi/gems/rails-html-sanitizer@1.6.2.rbi +785 -0
  86. data/sorbet/rbi/gems/rails@8.0.2.rbi +9 -0
  87. data/sorbet/rbi/gems/railties@8.0.2.rbi +3865 -0
  88. data/sorbet/rbi/gems/rainbow@3.1.1.rbi +403 -0
  89. data/sorbet/rbi/gems/rake@13.2.1.rbi +3120 -0
  90. data/sorbet/rbi/gems/rbi@0.3.6.rbi +6893 -0
  91. data/sorbet/rbi/gems/rbs@3.9.4.rbi +6978 -0
  92. data/sorbet/rbi/gems/rdoc@6.12.0.rbi +12760 -0
  93. data/sorbet/rbi/gems/reline@0.6.0.rbi +2451 -0
  94. data/sorbet/rbi/gems/rexml@3.4.1.rbi +5240 -0
  95. data/sorbet/rbi/gems/rspec-core@3.13.4.rbi +11348 -0
  96. data/sorbet/rbi/gems/rspec-expectations@3.13.5.rbi +8189 -0
  97. data/sorbet/rbi/gems/rspec-mocks@3.13.5.rbi +5350 -0
  98. data/sorbet/rbi/gems/rspec-sorbet@1.9.2.rbi +164 -0
  99. data/sorbet/rbi/gems/rspec-support@3.13.4.rbi +1630 -0
  100. data/sorbet/rbi/gems/rspec@3.13.1.rbi +83 -0
  101. data/sorbet/rbi/gems/securerandom@0.4.1.rbi +75 -0
  102. data/sorbet/rbi/gems/spoom@1.6.3.rbi +6985 -0
  103. data/sorbet/rbi/gems/stringio@3.1.5.rbi +9 -0
  104. data/sorbet/rbi/gems/tapioca@0.16.11.rbi +3628 -0
  105. data/sorbet/rbi/gems/testcontainers-core@0.2.0.rbi +1005 -0
  106. data/sorbet/rbi/gems/testcontainers-postgres@0.2.0.rbi +145 -0
  107. data/sorbet/rbi/gems/thor@1.3.2.rbi +4378 -0
  108. data/sorbet/rbi/gems/timeout@0.4.3.rbi +157 -0
  109. data/sorbet/rbi/gems/tzinfo@2.0.6.rbi +5918 -0
  110. data/sorbet/rbi/gems/uri@1.0.3.rbi +2349 -0
  111. data/sorbet/rbi/gems/useragent@0.16.11.rbi +9 -0
  112. data/sorbet/rbi/gems/websocket-driver@0.8.0.rbi +9 -0
  113. data/sorbet/rbi/gems/websocket-extensions@0.1.5.rbi +9 -0
  114. data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +435 -0
  115. data/sorbet/rbi/gems/yard@0.9.37.rbi +18379 -0
  116. data/sorbet/rbi/gems/zeitwerk@2.7.3.rbi +9 -0
  117. data/sorbet/tapioca/config.yml +5 -0
  118. data/sorbet/tapioca/require.rb +12 -0
  119. metadata +231 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8bf05ae2e837a2307b76eb41a17343f41c3f40340b935d681b955fad9ee48db2
4
- data.tar.gz: 5111d2c221b9f4151da3e5fe73d66cff1c87ead67df9e537f00544fbba118e70
3
+ metadata.gz: 4bb4ac6fb7d48a2eb780f26f4f454369c83d16428833f57dfcba55b22b125376
4
+ data.tar.gz: 96c9ef5e37b344478db683c66209ca84c68ded6016878180dce5cc92d43274c9
5
5
  SHA512:
6
- metadata.gz: bdb71b6b2dc3e1773dffe0dec0c1d4d61aabab6faf76cade6277b8b65354e69795ed8a6a32ca8968e1b348641f6b53c5ca38faea621249adfc6d7cf8760870b8
7
- data.tar.gz: 983cd7e80bec90f766341bae9b8fdd1c1d3f641d56ad687eec541f8d52a27e2de023a17d447c2fe75c171bc6d8d12399c619d49a83a9cb508821ebf48af8ffec
6
+ metadata.gz: a0ada4fd59799ef8beb0d4c57d9e87a2df1073b407023931b511a6a3458db29a471af0745884b5ff7e338da4dd3bf3c8af95d319eab7538d744a8f399c8ad5f1
7
+ data.tar.gz: 858fa64c003c1c5b75630c30ab7a05d3376dd2a347b182aeb90f9af2d87f975833f202a9dccebffbed8d78d3db6b24ee3d524b0a2115fb52a0c39b0dff016e8a
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
- task default: %i[]
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