wal 0.0.16 → 0.0.19

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: fad10e5a8aea823dba89b5f50ba13e60b771d442e9dbe682b0b03c8c0969b1c1
4
- data.tar.gz: b1a2fff4a202dad05abe78d79fed809d18b6396fd97a8d6aef3438e7a30ab3c8
3
+ metadata.gz: 8547683e990bc915e382feecac5220a3b6b31a3d7e59c0891511f30c2eec5d19
4
+ data.tar.gz: e1bbac497d67a1cf76c8e56a002f45bf12849fe4fdfb4415ea3d8ee573e44e81
5
5
  SHA512:
6
- metadata.gz: cb9cd1dcdb4afa3611597d6aac89470892d63d0fabe1db2cb1ba6c6e8013cf610cf27e20185a40a8aa0365ee5e5639b44789718aa46cded921dfa4c2c92c7938
7
- data.tar.gz: 1655d1ee9a327383ddde0483a1e8a432d9b529601b7d3f700a427a0ac1560c01f786230e8fd93d62fc503d688cd4d02a92c69d7e53d05801c62106b2dfcb00da
6
+ metadata.gz: f119216774509c4056ed84ccd29d43c9d743d67c3365289b2b18be0b92dd2117f543bf282fb6071abdf27d3774a2e1326238ab73e37c4c7e55bd5267c6583b96
7
+ data.tar.gz: d938d438d7d7b79457dba62a4460fba9e9523f953a618eacf4706ac1cf01293aafdbbf71ec35cf8a0a5156ea4b38a4e138c0cc7a373c38f6208f6136d926bd17
data/README.md CHANGED
@@ -4,7 +4,7 @@ Wal is a framework that lets you hook into Postgres WAL events directly from you
4
4
 
5
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
- Also, unlike ActiveRecord callbacks, these events are guaranteed by Postgre to be 100% consistent, ensuring you never miss one.
7
+ Also, unlike ActiveRecord callbacks, these events are guaranteed by Postgres to be 100% consistent, ensuring you never miss one.
8
8
 
9
9
  # Getting started
10
10
 
@@ -45,42 +45,52 @@ class DenormalizePostWatcher < Wal::RecordWatcher
45
45
 
46
46
  # When a `Post` title or body is changed, we update its `DenormalizedPost` record
47
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
- )
48
+ DenormalizedPost
49
+ .where(post_id: event.primary_key)
50
+ .update_all(
51
+ title: event.new["title"],
52
+ body: event.new["body"],
53
+ )
52
54
  end
53
55
 
54
56
  # When a `Post` category changes, we also update its `DenormalizedPost` record
55
57
  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
- )
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
+ )
60
64
  end
61
65
 
62
66
  # When a `Category` changes, we update all the `DenormalizedPosts` referencing it
63
67
  on_update Category, changed: [:name] do |event|
64
- DenormalizedPost.where(category_id: event.primary_key).update_all(
65
- category_name: event.new["name"],
66
- )
68
+ DenormalizedPost
69
+ .where(category_id: event.primary_key)
70
+ .update_all(
71
+ category_name: event.new["name"],
72
+ )
67
73
  end
68
74
 
69
75
  # 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
- )
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
+ )
75
83
  end
76
84
  end
77
85
  ```
78
86
 
79
87
  You might wonder: *Why not just use ActiveRecord callbacks for this?*
80
88
 
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.
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.
82
92
 
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.
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.
84
94
 
85
95
  ## Configuring the Watcher
86
96
 
data/exe/wal CHANGED
@@ -28,18 +28,59 @@ 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
34
75
  replication_slot = cli["--slot"]
35
76
  replication_slot = replication_slot.presence || "wal_watcher_#{SecureRandom.alphanumeric(4)}" if use_temporary_slot
36
77
  publications = cli["--publication"] || []
37
- replicator = cli["--replicator"].presence&.constantize || Wal::Replicator
78
+ replicator_class = 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_class.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"]
@@ -49,17 +90,31 @@ elsif cli["start"]
49
90
  watcher = config["watcher"].constantize.new
50
91
  temporary = config["temporary"] || false
51
92
  publications = config["publications"] || []
52
- replicator = config["replicator"].presence&.constantize || Wal::Replicator
93
+ replicator_class = 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_class.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
 
@@ -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.16"
4
+ VERSION = "0.0.19"
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.16"
10
+ VERSION = "0.0.19"
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.16
4
+ version: 0.0.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Navarro
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-09-17 00:00:00.000000000 Z
10
+ date: 2025-09-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: pg