wal 0.0.14 → 0.0.16

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: a10c111df779be284d3a1e20dac362c574d0abed4483d86479ad716c39c868dd
4
- data.tar.gz: 18763673175466a85adef9b3d91eb6a188bfc2db1b8cd178678757b81a912eb1
3
+ metadata.gz: fad10e5a8aea823dba89b5f50ba13e60b771d442e9dbe682b0b03c8c0969b1c1
4
+ data.tar.gz: b1a2fff4a202dad05abe78d79fed809d18b6396fd97a8d6aef3438e7a30ab3c8
5
5
  SHA512:
6
- metadata.gz: 762f7c704d2a0dceafd9c1094e1413dbe272b7c71d676a49bcbc652d0d5266e91de357f8dd44cd5b4e26b8ec0ba2bde628c997b83cb64e1df9a218164d1ec686
7
- data.tar.gz: ed456c724a0d80a36f7d78bd477a059d89142f68f85293ff9225eb8ad4cc9a1fc3900781cf888c36742bb4406161542d9935fef9e6e3dd3919d4da51fe4f390c
6
+ metadata.gz: cb9cd1dcdb4afa3611597d6aac89470892d63d0fabe1db2cb1ba6c6e8013cf610cf27e20185a40a8aa0365ee5e5639b44789718aa46cded921dfa4c2c92c7938
7
+ data.tar.gz: 1655d1ee9a327383ddde0483a1e8a432d9b529601b7d3f700a427a0ac1560c01f786230e8fd93d62fc503d688cd4d02a92c69d7e53d05801c62106b2dfcb00da
data/README.md CHANGED
@@ -1,49 +1,126 @@
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 Postgre 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_destroy 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.where(post_id: event.primary_key).update_all(
49
+ title: event.new["title"],
50
+ body: event.new["body"],
51
+ )
23
52
  end
24
53
 
25
- def recalculate_inventory_price(product_id, new_price)
26
- # ...
54
+ # When a `Post` category changes, we also update its `DenormalizedPost` record
55
+ on_update Post, changed: [:category_id] do |event|
56
+ DenormalizedPost.where(post_id: event.primary_key).update_all(
57
+ category_id: event.new["category_id"],
58
+ category_name: Category.find_by(id: event.new["category_id"])&.name,
59
+ )
27
60
  end
28
61
 
29
- def clear_product_inventory(product_id)
30
- # ...
62
+ # When a `Category` changes, we update all the `DenormalizedPosts` referencing it
63
+ on_update Category, changed: [:name] do |event|
64
+ DenormalizedPost.where(category_id: event.primary_key).update_all(
65
+ category_name: event.new["name"],
66
+ )
31
67
  end
32
68
 
33
- def recalculate_inventory_quantity(sales_id)
34
- # ...
69
+ # Finally when a `Category` is deleted, we clear all the `DenormalizedPosts` referencing it
70
+ on_update Category, changed: [:name] do |event|
71
+ DenormalizedPost.where(category_id: event.primary_key).update_all(
72
+ category_id: nil,
73
+ category_name: nil,
74
+ )
35
75
  end
36
76
  end
37
77
  ```
38
78
 
39
- ### Basic watcher implementation
79
+ You might wonder: *Why not just use ActiveRecord callbacks for this?*
40
80
 
41
- ```ruby
42
- class LogWatcher
43
- include Wal::Watcher
81
+ While callbacks seem simpler, they are not guaranteed to always run. Depending on the methods you use to perform the changes, it can be skipped.
82
+
83
+ Wal ensures every single change is captured. Even if updates happen directly in the database and bypass Rails entirely. That's the main reason to use it: when you need 100% consistency.
84
+
85
+ ## Configuring the Watcher
44
86
 
45
- def on_event(event)
46
- puts "Wal event received #{event}"
87
+ Wal relies on [Postgres logical replication](https://www.postgresql.org/docs/current/logical-replication.html) to stream changes to your watchers.
88
+
89
+ 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:
90
+
91
+ ```
92
+ $ rails generate wal:migration DenormalizePostWatcher
93
+ ```
94
+
95
+ This will generate a new migration with all the tables that your watcher uses:
96
+ ```ruby
97
+ class SetDenormalizePostWatcherPublication < ActiveRecord::Migration
98
+ def change
99
+ define_publication :denormalize_post_publication do |p|
100
+ p.table :posts
101
+ p.table :categories
102
+ end
47
103
  end
48
104
  end
49
105
  ```
106
+
107
+ Next, create a `config/wal.yml` configuration file to link the `Watcher` to its publication:
108
+
109
+ ```yaml
110
+ slots:
111
+ denormalize_posts:
112
+ watcher: DenormalizePostWatcher
113
+ publications:
114
+ - denormalize_post_publication
115
+ ```
116
+ 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).
117
+
118
+ ## Running the Watcher
119
+
120
+ With everything configured, start the Wal process:
121
+
122
+ ```bash
123
+ bundle exec wal start config/wal.yaml
124
+ ```
125
+
126
+ Wal will now process your replication slot and run the `DenormalizePostWatcher` whenever a change occur.
@@ -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
@@ -27,7 +27,7 @@ module Wal
27
27
  # end
28
28
  # end
29
29
  #
30
- # on_destroy OrderItem do |event|
30
+ # on_delete OrderItem do |event|
31
31
  # recalculate_inventory_availability(event.attribute(:item_id))
32
32
  # end
33
33
  #
@@ -60,7 +60,7 @@ module Wal
60
60
  @@change_callbacks[table].push(only: [:create, :update], changed: changed&.map(&:to_s), block: block)
61
61
  end
62
62
 
63
- def self.on_destroy(table, &block)
63
+ def self.on_delete(table, &block)
64
64
  table = table.is_a?(String) ? table : table.table_name
65
65
  @@delete_callbacks[table].push(block: block)
66
66
  end
@@ -102,7 +102,7 @@ module Wal
102
102
  (@@change_callbacks.keys | @@delete_callbacks.keys).include? table
103
103
  end
104
104
 
105
- # `RecordWatcher` supports two processing strategies:
105
+ # `RecordWatcher` supports three processing strategies:
106
106
  #
107
107
  # `:memory`: Stores and aggregates records from a single transaction in memory. This has better performance but uses
108
108
  # more memory, as at least one event for each record must be stored in memory until the end of a transaction
@@ -110,6 +110,10 @@ module Wal
110
110
  # `:temporary_table`: Offloads the record aggregation to a temporary table on the database. This is useful when you
111
111
  # are processing very large transactions that can't fit in memory. The tradeoff is obviously a worse performance.
112
112
  #
113
+ # `:none`: Doesn't aggregate anything at all, so multiple updates on the same record on the same transaction would
114
+ # be notified. Also, if the same record is deleted on the same transaction it was created, this would end up
115
+ # triggering both `on_insert` and `on_delete` callbacks. This strategy should usually be avoided.
116
+ #
113
117
  # These strategies can be defined per transaction, and by default it will uses the memory one, and only fallback
114
118
  # to the temporary table if the transaction size is roughly 2 gigabytes or more.
115
119
  def aggregation_strategy(begin_transaction_event)
@@ -127,6 +131,8 @@ module Wal
127
131
  MemoryRecordWatcher.new(self)
128
132
  when :temporary_table
129
133
  TemporaryTableRecordWatcher.new(self)
134
+ when :none
135
+ NoAggregationWatcher.new(self)
130
136
  else
131
137
  raise "Invalid aggregation strategy: #{strategy}"
132
138
  end
@@ -134,6 +140,21 @@ module Wal
134
140
  @current_record_watcher.on_event(event)
135
141
  end
136
142
 
143
+ class NoAggregationWatcher
144
+ include Wal::Watcher
145
+
146
+ def initialize(watcher)
147
+ @watcher = watcher
148
+ end
149
+
150
+ def on_event(event)
151
+ case event
152
+ when InsertEvent, UpdateEvent, DeleteEvent
153
+ @watcher.on_record_changed(event)
154
+ end
155
+ end
156
+ end
157
+
137
158
  class MemoryRecordWatcher
138
159
  include Wal::Watcher
139
160
  include Wal::Watcher::SeparatedEvents
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.14"
4
+ VERSION = "0.0.16"
5
5
  end
data/rbi/wal.rbi CHANGED
@@ -7,7 +7,7 @@ module Wal
7
7
  UpdateEvent,
8
8
  DeleteEvent,
9
9
  ) }
10
- VERSION = "0.0.14"
10
+ VERSION = "0.0.16"
11
11
 
12
12
  class BeginTransactionEvent < T::Struct
13
13
  prop :transaction_id, Integer, immutable: true
@@ -128,7 +128,7 @@ module Wal
128
128
  def self.on_save(table, changed: nil, &block); end
129
129
 
130
130
  sig { params(table: T.any(String, T.class_of(::ActiveRecord::Base)), block: T.proc.bind(T.attached_class).params(event: Wal::DeleteEvent).void).void }
131
- def self.on_destroy(table, &block); end
131
+ def self.on_delete(table, &block); end
132
132
 
133
133
  sig { params(event: RecordEvent).void }
134
134
  def on_record_changed(event); end
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.14
4
+ version: 0.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Navarro
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-09-14 00:00:00.000000000 Z
10
+ date: 2025-09-17 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