wal 0.0.15 → 0.0.18

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: 14c30e66cc9c1425cb894a9cfb558941017659cf4f0ad5a2183782c25df4a5b4
4
- data.tar.gz: 7704871035cecbb90ee47cbefff50ad2fe58b4515f7d2c0d439e40adf31bf3f4
3
+ metadata.gz: d279bf26f64494f4f52cff1122ade2a9435cd3942be615a92a49f7a5dd64b5d6
4
+ data.tar.gz: 6b899eacde8085da1ab80bd9cb63741f9b0a7357c1fc86f7821d52d07fbbe48f
5
5
  SHA512:
6
- metadata.gz: e47256ec844eace9aabb20a1364fc0d0ab724e81c335f7b0bbb826871a328d8a0dca806d3e2ec891717339acc2286b2f71c2f424ccfed60c906772ded95f10ef
7
- data.tar.gz: 0c584c14ec81977b7a26d66117a42180c73450da3a53ec9fa7dc5fe6ddd11e941833de45024032b3a8c7d31ddab6f7261911eaff7e2d83328031d7b8f4ddf9b2
6
+ metadata.gz: 8ad343f0b1a3468cf4707e68fcda76ba9859dbba1243c2dfa90c33eef1d0ea50e2791d57ca824163c4f1c7f231ddfeea0c3919400ecba3836c5314947caab6ad
7
+ data.tar.gz: d5975b490f804064db2e1fba3dd02821d2877927d9333cbc8aa780a347295dd6816eb35a9468163c0d5f301a66d494504b4c195f79f30deb52f56cf48ef2b75a
data/README.md CHANGED
@@ -1,49 +1,136 @@
1
1
  # Wal
2
2
 
3
- Easily hook into Postgres WAL event log from your Rails app.
3
+ Wal is a framework that lets you hook into Postgres WAL events directly from your Rails application.
4
4
 
5
- Proper documentation TBD
5
+ Unlike using database triggers, Wal allows you to keep your logic in your application code while still reacting to persistence events coming from the database.
6
6
 
7
- ## Examples
7
+ Also, unlike ActiveRecord callbacks, these events are guaranteed by Postgres to be 100% consistent, ensuring you never miss one.
8
8
 
9
- ### Watch for model changes using the RecordWatcher DSL
9
+ # Getting started
10
+
11
+ ## Installation
12
+
13
+ Add `wal` to your application's Gemfile:
10
14
 
11
15
  ```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
+ gem "wal"
17
+ ```
18
+
19
+ And then:
20
+
21
+ ```bash
22
+ $ bundle install
23
+ ```
24
+
25
+ ## Getting started
16
26
 
17
- on_delete Product do |event|
18
- clear_product_inventory(event.primary_key)
27
+ The core building block in Wal is a `Watcher`. The easiest way to create one is by extending `Wal::RecordWatcher`, which handles most of the boilerplate for you.
28
+
29
+ For example, let's create a watcher that denormalizes `Post` and `Category` models into a `DenormalizedPost`.
30
+
31
+ Create a new file at `app/watchers/denormalize_post_watcher.rb`:
32
+
33
+ ```ruby
34
+ class DenormalizePostWatcher < Wal::RecordWatcher
35
+ # When a new `Post` is created, we create a new `DenormalizedPost` record
36
+ on_insert Post do |event|
37
+ DenormalizedPost.create!(
38
+ post_id: event.primary_key,
39
+ title: event.new["title"],
40
+ body: event.new["body"],
41
+ category_id: event.new["category_id"],
42
+ category_name: Category.find_by(id: event.new["category_id"])&.name,
43
+ )
19
44
  end
20
45
 
21
- on_save Sales, changed: %w[status] do |event|
22
- recalculate_inventory_quantity(event.primary_key)
46
+ # When a `Post` title or body is changed, we update its `DenormalizedPost` record
47
+ on_update Post, changed: [:title, :body] do |event|
48
+ DenormalizedPost
49
+ .where(post_id: event.primary_key)
50
+ .update_all(
51
+ title: event.new["title"],
52
+ body: event.new["body"],
53
+ )
23
54
  end
24
55
 
25
- def recalculate_inventory_price(product_id, new_price)
26
- # ...
56
+ # When a `Post` category changes, we also update its `DenormalizedPost` record
57
+ on_update Post, changed: [:category_id] do |event|
58
+ DenormalizedPost
59
+ .where(post_id: event.primary_key)
60
+ .update_all(
61
+ category_id: event.new["category_id"],
62
+ category_name: Category.find_by(id: event.new["category_id"])&.name,
63
+ )
27
64
  end
28
65
 
29
- def clear_product_inventory(product_id)
30
- # ...
66
+ # When a `Category` changes, we update all the `DenormalizedPosts` referencing it
67
+ on_update Category, changed: [:name] do |event|
68
+ DenormalizedPost
69
+ .where(category_id: event.primary_key)
70
+ .update_all(
71
+ category_name: event.new["name"],
72
+ )
31
73
  end
32
74
 
33
- def recalculate_inventory_quantity(sales_id)
34
- # ...
75
+ # Finally when a `Category` is deleted, we clear all the `DenormalizedPosts` referencing it
76
+ on_delete Category do |event|
77
+ DenormalizedPost
78
+ .where(category_id: event.primary_key)
79
+ .update_all(
80
+ category_id: nil,
81
+ category_name: nil,
82
+ )
35
83
  end
36
84
  end
37
85
  ```
38
86
 
39
- ### Basic watcher implementation
87
+ You might wonder: *Why not just use ActiveRecord callbacks for this?*
40
88
 
41
- ```ruby
42
- class LogWatcher
43
- include Wal::Watcher
89
+ And while it is hard to justify that for our simple example, ActiveRecord callbacks are not guaranteed to always run. Depending on the methods you use to perform the changes, they can be skipped.
90
+
91
+ Wal ensures every single change is captured. *Even when updates happen directly in the database and bypass Rails entirely*. That's the main reason to use it: when you need 100% consistency.
92
+
93
+ Usually one could resort into database triggers when full consistency is required, but running and maintaining application level code on the database tends to be painful. Wal let's you do the same but at the application level.
44
94
 
45
- def on_event(event)
46
- puts "Wal event received #{event}"
95
+ ## Configuring the Watcher
96
+
97
+ Wal relies on [Postgres logical replication](https://www.postgresql.org/docs/current/logical-replication.html) to stream changes to your watchers.
98
+
99
+ First, create a [Postgres publication](https://www.postgresql.org/docs/current/logical-replication-publication.html) for the tables your watcher uses. Wal provides a generator for this:
100
+
101
+ ```
102
+ $ rails generate wal:migration DenormalizePostWatcher
103
+ ```
104
+
105
+ This will generate a new migration with all the tables that your watcher uses:
106
+ ```ruby
107
+ class SetDenormalizePostWatcherPublication < ActiveRecord::Migration
108
+ def change
109
+ define_publication :denormalize_post_publication do |p|
110
+ p.table :posts
111
+ p.table :categories
112
+ end
47
113
  end
48
114
  end
49
115
  ```
116
+
117
+ Next, create a `config/wal.yml` configuration file to link the `Watcher` to its publication:
118
+
119
+ ```yaml
120
+ slots:
121
+ denormalize_posts:
122
+ watcher: DenormalizePostWatcher
123
+ publications:
124
+ - denormalize_post_publication
125
+ ```
126
+ This associates your watcher with the `denormalize_post_publication` and with the `denormalize_posts` [Postgres replication slot](https://www.postgresql.org/docs/9.4/warm-standby.html#STREAMING-REPLICATION-SLOTS).
127
+
128
+ ## Running the Watcher
129
+
130
+ With everything configured, start the Wal process:
131
+
132
+ ```bash
133
+ bundle exec wal start config/wal.yaml
134
+ ```
135
+
136
+ Wal will now process your replication slot and run the `DenormalizePostWatcher` whenever a change occur.
data/exe/wal CHANGED
@@ -28,6 +28,47 @@ require "./config/environment"
28
28
 
29
29
  db_config = ActiveRecord::Base.configurations.configs_for(name: "primary").configuration_hash
30
30
 
31
+ class Wal::LoggingReplicator
32
+ def initialize(slot, replicator)
33
+ @slot = slot
34
+ @replicator = replicator
35
+ end
36
+
37
+ def replicate_forever(watcher, publications:)
38
+ replication = @replicator.replicate(watcher, publications:)
39
+ count = 0
40
+ start = Time.now
41
+ loop do
42
+ case (event = replication.next)
43
+ when Wal::BeginTransactionEvent
44
+ start = Time.now
45
+ count = 0
46
+ if event.estimated_size > 0
47
+ Wal.logger&.info("[#{@slot}] Begin transaction=#{event.transaction_id} size=#{event.estimated_size}")
48
+ end
49
+ when Wal::CommitTransactionEvent
50
+ if count > 0
51
+ elapsed = ((Time.now - start) * 1000.0).round(1)
52
+ Wal.logger&.info("[#{@slot}] Commit transaction=#{event.transaction_id} elapsed=#{elapsed} events=#{count}")
53
+ end
54
+ when Wal::InsertEvent
55
+ Wal.logger&.debug("[#{@slot}] Insert transaction=#{event.transaction_id} table=#{event.table} primary_key=#{event.primary_key}")
56
+ count += 1
57
+ when Wal::UpdateEvent
58
+ Wal.logger&.debug("[#{@slot}] Update transaction=#{event.transaction_id} table=#{event.table} primary_key=#{event.primary_key}")
59
+ count += 1
60
+ when Wal::DeleteEvent
61
+ Wal.logger&.debug("[#{@slot}] Delete transaction=#{event.transaction_id} table=#{event.table} primary_key=#{event.primary_key}")
62
+ count += 1
63
+ else
64
+ count += 1
65
+ end
66
+ end
67
+ rescue StopIteration
68
+ nil
69
+ end
70
+ end
71
+
31
72
  if cli["watch"]
32
73
  watcher = cli["--watcher"].constantize.new
33
74
  use_temporary_slot = cli["--tmp-slot"] || false
@@ -37,9 +78,9 @@ if cli["watch"]
37
78
  replicator = cli["--replicator"].presence&.constantize || Wal::Replicator
38
79
 
39
80
  puts "Watcher started for #{replication_slot} slot (#{publications.join(", ")})"
40
- replicator
41
- .new(replication_slot:, use_temporary_slot:, db_config:)
42
- .replicate_forever(watcher, publications:)
81
+ replicator = replicator.new(replication_slot:, use_temporary_slot:, db_config:)
82
+ replicator = Wal::LoggingReplicator.new(replication_slot, replicator)
83
+ replicator.replicate_forever(watcher, publications:)
43
84
  puts "Watcher finished for #{replication_slot}"
44
85
 
45
86
  elsif cli["start"]
@@ -50,16 +91,30 @@ elsif cli["start"]
50
91
  temporary = config["temporary"] || false
51
92
  publications = config["publications"] || []
52
93
  replicator = config["replicator"].presence&.constantize || Wal::Replicator
94
+ retries = config["retries"]&.to_i || 5
53
95
 
54
96
  Thread.new(slot, watcher, temporary, publications) do |replication_slot, watcher, use_temporary_slot, publications|
55
97
  replication_slot = "#{replication_slot}_#{SecureRandom.alphanumeric(4)}" if use_temporary_slot
56
98
  puts "Watcher started for #{replication_slot} slot (#{publications.join(", ")})"
57
99
 
58
- replicator
59
- .new(replication_slot:, use_temporary_slot:, db_config:)
60
- .replicate_forever(watcher, publications:)
100
+ replicator = replicator.new(replication_slot:, use_temporary_slot:, db_config:)
101
+ replicator = Wal::LoggingReplicator.new(replication_slot, replicator)
102
+
103
+ begin
104
+ replicator.replicate_forever(watcher, publications:)
105
+ rescue StandardError => err
106
+ if retries > 0
107
+ Wal.logger&.error("[#{replication_slot}] Error #{err}")
108
+ retries -= 1
109
+ sleep 2 ** retries
110
+ retry
111
+ end
112
+ raise
113
+ end
61
114
 
62
115
  puts "Watcher finished for #{replication_slot}"
116
+
117
+ Process.kill("TERM", Process.pid)
63
118
  end
64
119
  end
65
120
 
@@ -32,7 +32,7 @@ module Wal
32
32
  end
33
33
 
34
34
  def publication_name
35
- "#{watcher.underscore}_publication"
35
+ "#{class_name.gsub("Watcher", "").underscore}_publication"
36
36
  end
37
37
 
38
38
  def class_name
@@ -88,6 +88,7 @@ module Wal
88
88
 
89
89
  in XLogData(lsn:, data: PG::Replication::PGOutput::Message(prefix: "wal_ping"))
90
90
  watch_conn.standby_status_update(write_lsn: [watch_conn.last_confirmed_lsn, lsn].compact.max)
91
+ next
91
92
 
92
93
  in XLogData(data: PG::Replication::PGOutput::Message(prefix:, content:)) if watcher.valid_context_prefix? prefix
93
94
  begin
data/lib/wal/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wal
4
- VERSION = "0.0.15"
4
+ VERSION = "0.0.18"
5
5
  end
data/lib/wal.rb CHANGED
@@ -12,6 +12,14 @@ require_relative "wal/railtie"
12
12
  require_relative "wal/version"
13
13
 
14
14
  module Wal
15
+ class << self
16
+ attr_accessor :logger
17
+ end
18
+
19
+ def self.configure(&block)
20
+ yield self
21
+ end
22
+
15
23
  class BeginTransactionEvent < Data.define(:transaction_id, :lsn, :final_lsn, :timestamp)
16
24
  def estimated_size
17
25
  final_lsn - lsn
data/rbi/wal.rbi CHANGED
@@ -7,7 +7,15 @@ module Wal
7
7
  UpdateEvent,
8
8
  DeleteEvent,
9
9
  ) }
10
- VERSION = "0.0.15"
10
+ VERSION = "0.0.18"
11
+
12
+ class << self
13
+ sig { returns(T.class_of(Logger)) }
14
+ attr_accessor :logger
15
+ end
16
+
17
+ sig { params(block: T.proc.params(config: T.class_of(Wal)).void).void }
18
+ def self.configure(&block); end
11
19
 
12
20
  class BeginTransactionEvent < T::Struct
13
21
  prop :transaction_id, Integer, immutable: true
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.15
4
+ version: 0.0.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Navarro
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-09-16 00:00:00.000000000 Z
10
+ date: 2025-09-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: pg
@@ -104,7 +104,6 @@ files:
104
104
  - lib/wal/version.rb
105
105
  - lib/wal/watcher.rb
106
106
  - rbi/wal.rbi
107
- - sig/wal.rbs
108
107
  licenses:
109
108
  - MIT
110
109
  metadata:
data/sig/wal.rbs DELETED
@@ -1,186 +0,0 @@
1
- # typed: strong
2
- module Wal
3
- end
4
-
5
- Wal::Event: untyped
6
-
7
- Wal::VERSION: untyped
8
-
9
- class Wal::BeginTransactionEvent < T::Struct
10
- def estimated_size: () -> Integer
11
- end
12
-
13
- class Wal::CommitTransactionEvent < T::Struct
14
- end
15
-
16
- module Wal::ChangeEvent
17
- def diff: () -> ::Hash[String, [ untyped, untyped ]]
18
-
19
- def changed_attribute?: (Symbol | String attribute) -> bool
20
-
21
- def attribute: (Symbol | String attribute) -> untyped
22
-
23
- def attribute_changes: (Symbol | String attribute) -> [ untyped, untyped ]?
24
-
25
- def attribute_was: (Symbol | String attribute) -> untyped
26
- end
27
-
28
- class Wal::InsertEvent < T::Struct
29
- include ::Wal::ChangeEvent
30
-
31
- def diff: () -> ::Hash[String, [ untyped, untyped ]]
32
- end
33
-
34
- class Wal::UpdateEvent < T::Struct
35
- include ::Wal::ChangeEvent
36
-
37
- def diff: () -> ::Hash[String, [ untyped, untyped ]]
38
- end
39
-
40
- class Wal::DeleteEvent < T::Struct
41
- include ::Wal::ChangeEvent
42
-
43
- def diff: () -> ::Hash[String, [ untyped, untyped ]]
44
- end
45
-
46
- module Wal::ActiveRecordContextExtension
47
- def set_wal_watcher_context: (untyped context, ?prefix: untyped prefix) -> untyped
48
- end
49
-
50
- class Wal::NoopWatcher
51
- include Wal::Watcher
52
-
53
- def on_event: (Event event) -> void
54
- end
55
-
56
- class Wal::RecordWatcher
57
- include Wal::Watcher
58
-
59
- extend T::Helpers
60
-
61
- def self.inherited: (untyped subclass) -> untyped
62
-
63
- def self.on_insert: (String | singleton(::ActiveRecord::Base) table) { (InsertEvent event) -> void } -> void
64
-
65
- def self.on_update: (String | singleton(::ActiveRecord::Base) table, ?changed: ::Array[String | Symbol]? changed) { (UpdateEvent event) -> void } -> void
66
-
67
- def self.on_save: (String | singleton(::ActiveRecord::Base) table, ?changed: ::Array[String | Symbol]? changed) { (InsertEvent | UpdateEvent event) -> void } -> void
68
-
69
- def self.on_destroy: (String | singleton(::ActiveRecord::Base) table) { (DeleteEvent event) -> void } -> void
70
-
71
- def on_record_changed: (RecordEvent event) -> void
72
-
73
- def should_watch_table?: (String table) -> bool
74
-
75
- def aggregation_strategy: (BeginTransactionEvent event) -> Symbol
76
-
77
- def on_event: (Event event) -> void
78
- end
79
-
80
- Wal::Wal::RecordWatcher::RecordEvent: untyped
81
-
82
- class Wal::Wal::RecordWatcher::MemoryRecordWatcher
83
- include Wal::Watcher
84
-
85
- include Wal::Watcher::SeparatedEvents
86
-
87
- extend T::Helpers
88
-
89
- def initialize: (untyped watcher) -> void
90
-
91
- def on_begin: (BeginTransactionEvent event) -> void
92
-
93
- def on_commit: (untyped _event) -> untyped
94
-
95
- def on_insert: (InsertEvent event) -> void
96
-
97
- def on_update: (UpdateEvent event) -> void
98
-
99
- def on_delete: (DeleteEvent event) -> void
100
- end
101
-
102
- Wal::Wal::RecordWatcher::Wal::Wal::RecordWatcher::MemoryRecordWatcher::RecordsStorage: untyped
103
-
104
- class Wal::Wal::RecordWatcher::TemporaryTableRecordWatcher
105
- include Wal::Watcher
106
-
107
- include Wal::Watcher::SeparatedEvents
108
-
109
- extend T::Helpers
110
-
111
- def initialize: (untyped watcher, ?batch_size: untyped batch_size) -> void
112
-
113
- def on_begin: (BeginTransactionEvent event) -> void
114
-
115
- def on_commit: (untyped _event) -> untyped
116
-
117
- def on_insert: (InsertEvent event) -> void
118
-
119
- def on_update: (UpdateEvent event) -> void
120
-
121
- def on_delete: (DeleteEvent event) -> void
122
-
123
- def base_class: () -> singleton(::ActiveRecord::Base)
124
-
125
- def serialize: (untyped event) -> untyped
126
-
127
- def deserialize: (untyped persisted_event) -> untyped
128
- end
129
-
130
- class Wal::Replicator
131
- include PG::Replication::Protocol
132
-
133
- def initialize: (?replication_slot: String replication_slot, ?use_temporary_slot: bool use_temporary_slot, ?db_config: ::Hash[Symbol, untyped] db_config) -> void
134
-
135
- def replicate_forever: (Watcher watcher, ?publications: ::Array[String] publications) -> void
136
-
137
- def replicate: (Watcher watcher, ?publications: ::Array[String] publications) -> ::Enumerator::Lazy[Event]
138
- end
139
-
140
- class Wal::Wal::Replicator::Column < T::Struct
141
- def decode: (untyped value) -> untyped
142
- end
143
-
144
- class Wal::Wal::Replicator::Table < T::Struct
145
- def primary_key: (untyped decoded_row) -> untyped
146
-
147
- def decode_row: (untyped values) -> untyped
148
- end
149
-
150
- class Wal::StreamingWatcher
151
- include Wal::Watcher
152
-
153
- extend T::Helpers
154
-
155
- def on_transaction_events: (::Enumerator[Event] events) -> void
156
-
157
- def queue_size: (BeginTransactionEvent event) -> Integer
158
-
159
- def on_event: (Event event) -> void
160
- end
161
-
162
- module Wal::Watcher
163
- include Wal
164
-
165
- extend T::Helpers
166
-
167
- def on_event: (Event event) -> void
168
-
169
- def should_watch_table?: (String table) -> bool
170
-
171
- def valid_context_prefix?: (String prefix) -> bool
172
- end
173
-
174
- module Wal::Wal::Watcher::SeparatedEvents
175
- def on_event: (Event event) -> void
176
-
177
- def on_begin: (BeginTransactionEvent event) -> void
178
-
179
- def on_insert: (InsertEvent event) -> void
180
-
181
- def on_update: (UpdateEvent event) -> void
182
-
183
- def on_delete: (DeleteEvent event) -> void
184
-
185
- def on_commit: (CommitTransactionEvent event) -> void
186
- end