waldit 0.0.25 → 0.0.26
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/README.md +192 -78
- data/lib/waldit/version.rb +1 -1
- data/lib/waldit/watcher.rb +223 -53
- data/lib/waldit.rb +3 -0
- data/rbi/waldit.rbi +4 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 036035f234a35b9fd2c2c352a361a8826b54d0a0c6a86d93946c0b192523ea9d
|
|
4
|
+
data.tar.gz: d655ad860384e3e1c2dc5a8a6b3ac84f320c6210f14fcb53510cb016b1bf6e0d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c4dac68e52cb6ba0a72809f09fcffdc6425e6b154fe91d1b5c5f94e4bf3ea349fccf0ea43327b69cd2b94d968b832136c8fea9bfe5dc1a04b21ba50434e8195a
|
|
7
|
+
data.tar.gz: a10904c3cd2df88460cd83be6604edc6c4bbeb8a66e1e44a47966e40eb00c4222a1de3d1720055847a516ef0513c14109bd879fbd35909244db145f02f5e828e
|
data/README.md
CHANGED
|
@@ -1,117 +1,231 @@
|
|
|
1
1
|
# Waldit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Waldit is a Postgres-based audit trail for Rails.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
It hooks into [Postgres logical replication](https://www.postgresql.org/docs/current/logical-replication.html) via the [`wal`](https://github.com/reu/wal) gem to capture every `insert`, `update`, and `delete` directly from the WAL. Unlike ActiveRecord callbacks, these events are guaranteed by Postgres to be 100% consistent -- even changes that bypass Rails entirely are captured.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Getting started
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
- **Contextual Auditing:** Add custom context to your audit records to understand who made the change and why.
|
|
11
|
-
- **Flexible Configuration:** Configure which tables and columns to watch, and how to store audit information.
|
|
12
|
-
- **High Performance:** Built on top of [`wal`](https://github.com/reu/wal), which uses PostgreSQL's logical replication for minimal overhead.
|
|
9
|
+
### Installation
|
|
13
10
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
Add this line to your application's Gemfile:
|
|
11
|
+
Add `waldit` to your application's Gemfile:
|
|
17
12
|
|
|
18
13
|
```ruby
|
|
19
14
|
gem "waldit"
|
|
20
15
|
```
|
|
21
16
|
|
|
22
|
-
|
|
17
|
+
### Database adapter
|
|
18
|
+
|
|
19
|
+
Waldit ships a custom database adapter that injects audit context into your transactions. Update your `config/database.yml`:
|
|
20
|
+
|
|
21
|
+
```yaml
|
|
22
|
+
default: &default
|
|
23
|
+
adapter: waldit
|
|
24
|
+
# ... rest of your config
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Migrations
|
|
28
|
+
|
|
29
|
+
Waldit provides migration helpers. First, create the audit table and publication:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
class SetupWaldit < ActiveRecord::Migration[7.0]
|
|
33
|
+
def change
|
|
34
|
+
create_waldit_table
|
|
35
|
+
create_waldit_publication
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then, for each table you want to audit:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class AuditUsers < ActiveRecord::Migration[7.0]
|
|
44
|
+
def change
|
|
45
|
+
add_table_to_waldit :users
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This sets `REPLICA IDENTITY FULL` on the table and adds it to the Waldit publication.
|
|
23
51
|
|
|
24
|
-
|
|
52
|
+
### Running the watcher
|
|
25
53
|
|
|
26
|
-
|
|
54
|
+
Create a `config/waldit.yml`:
|
|
27
55
|
|
|
28
|
-
|
|
56
|
+
```yaml
|
|
57
|
+
slots:
|
|
58
|
+
audit:
|
|
59
|
+
publications: [waldit_publication]
|
|
60
|
+
watcher: Waldit::Watcher
|
|
61
|
+
```
|
|
29
62
|
|
|
30
|
-
|
|
63
|
+
Then start the process:
|
|
31
64
|
|
|
32
|
-
|
|
65
|
+
```bash
|
|
66
|
+
bundle exec wal start config/waldit.yml
|
|
67
|
+
```
|
|
33
68
|
|
|
34
|
-
|
|
69
|
+
That's it. Every change to your audited tables is now being recorded.
|
|
35
70
|
|
|
36
|
-
|
|
37
|
-
default: &default
|
|
38
|
-
adapter: waldit
|
|
39
|
-
# ...
|
|
40
|
-
```
|
|
71
|
+
## Adding context
|
|
41
72
|
|
|
42
|
-
|
|
73
|
+
Wrap your operations with `Waldit.with_context` to record who made the change and why:
|
|
43
74
|
|
|
44
|
-
|
|
75
|
+
```ruby
|
|
76
|
+
Waldit.with_context(user_id: current_user.id, reason: "Profile update") do
|
|
77
|
+
user.update(name: "New Name")
|
|
78
|
+
end
|
|
79
|
+
```
|
|
45
80
|
|
|
46
|
-
|
|
47
|
-
rails generate migration create_waldit
|
|
48
|
-
```
|
|
81
|
+
Context can be nested and updated mid-transaction:
|
|
49
82
|
|
|
50
|
-
|
|
83
|
+
```ruby
|
|
84
|
+
Waldit.with_context(user_id: current_user.id) do
|
|
85
|
+
user.update(name: "New Name")
|
|
51
86
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
create_table :waldit do |t|
|
|
56
|
-
t.bigint :transaction_id, null: false
|
|
57
|
-
t.bigint :lsn, null: false
|
|
58
|
-
t.string :action, null: false
|
|
59
|
-
t.jsonb :context, default: {}
|
|
60
|
-
t.string :table_name, null: false
|
|
61
|
-
t.string :primary_key, null: false
|
|
62
|
-
t.jsonb :old, default: {}
|
|
63
|
-
t.jsonb :new, default: {}
|
|
64
|
-
t.timestamp :commited_at
|
|
87
|
+
Waldit.with_context(via: "admin_panel") do
|
|
88
|
+
account.update(plan: "premium") # context: { user_id: 1, via: "admin_panel" }
|
|
89
|
+
end
|
|
65
90
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
```
|
|
91
|
+
Waldit.add_context(batch: true)
|
|
92
|
+
other_user.update(name: "Other") # context: { user_id: 1, batch: true }
|
|
93
|
+
end
|
|
94
|
+
```
|
|
71
95
|
|
|
72
|
-
|
|
96
|
+
### Sidekiq integration
|
|
73
97
|
|
|
74
|
-
|
|
98
|
+
Waldit can propagate context into background jobs:
|
|
75
99
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
100
|
+
```ruby
|
|
101
|
+
# config/initializers/sidekiq.rb
|
|
102
|
+
Sidekiq.configure_client do |config|
|
|
103
|
+
config.client_middleware do |chain|
|
|
104
|
+
chain.add Waldit::Sidekiq::SaveContext
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
Sidekiq.configure_server do |config|
|
|
109
|
+
config.server_middleware do |chain|
|
|
110
|
+
chain.add Waldit::Sidekiq::LoadContext
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
80
114
|
|
|
81
|
-
|
|
82
|
-
config.ignored_columns = ->(table) { %w[created_at updated_at] }
|
|
83
|
-
end
|
|
84
|
-
```
|
|
115
|
+
## Querying the audit trail
|
|
85
116
|
|
|
86
|
-
|
|
117
|
+
Waldit provides scopes on the audit model:
|
|
87
118
|
|
|
88
|
-
|
|
119
|
+
```ruby
|
|
120
|
+
# All audit records for a specific record
|
|
121
|
+
Waldit.model.for(user)
|
|
89
122
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
user.update(name: "New Name")
|
|
93
|
-
end
|
|
94
|
-
```
|
|
123
|
+
# All audit records for a table
|
|
124
|
+
Waldit.model.from_model(User)
|
|
95
125
|
|
|
96
|
-
|
|
126
|
+
# All audit records with a specific context
|
|
127
|
+
Waldit.model.with_context(user_id: 1)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Each audit record exposes:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
audit = Waldit.model.for(user).last
|
|
134
|
+
|
|
135
|
+
audit.action # "insert", "update", or "delete"
|
|
136
|
+
audit.old # previous attributes (updates and deletes)
|
|
137
|
+
audit.new # new attributes (inserts and updates)
|
|
138
|
+
audit.diff # changed attributes as { "name" => ["old", "new"] }
|
|
139
|
+
audit.context # the context hash
|
|
140
|
+
audit.committed_at # when the transaction was committed
|
|
141
|
+
audit.primary_key # the record's primary key
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The `old`, `new`, and `diff` accessors are smart -- if you only store `:diff`, calling `.old` or `.new` will compute the values from the diff, and vice versa.
|
|
145
|
+
|
|
146
|
+
## Configuration
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
# config/initializers/waldit.rb
|
|
150
|
+
Waldit.configure do |config|
|
|
151
|
+
# Which tables to watch (default: all except "waldit")
|
|
152
|
+
config.watched_tables = -> table { table != "waldit" }
|
|
153
|
+
|
|
154
|
+
# Columns to exclude from audit records (default: created_at, updated_at)
|
|
155
|
+
config.ignored_columns = -> table { %w[created_at updated_at] }
|
|
156
|
+
|
|
157
|
+
# What to store per table (default: [:old, :new])
|
|
158
|
+
# Options: :old, :new, :diff (any combination)
|
|
159
|
+
config.store_changes = [:old, :new]
|
|
160
|
+
|
|
161
|
+
# WAL byte threshold for switching to streaming mode (default: 10MB)
|
|
162
|
+
# Transactions smaller than this are processed in memory for better performance
|
|
163
|
+
config.large_transaction_threshold = 10_000_000
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Storage policies
|
|
168
|
+
|
|
169
|
+
By default, Waldit stores both `old` and `new` attributes for every change. You can reduce storage by only keeping what you need:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
# Only store diffs for updates (most compact)
|
|
173
|
+
config.store_changes = :diff
|
|
174
|
+
|
|
175
|
+
# Per-table policies
|
|
176
|
+
config.store_changes = -> table {
|
|
177
|
+
case table
|
|
178
|
+
when "events" then [:new]
|
|
179
|
+
when "logs" then [:diff]
|
|
180
|
+
else [:old, :new]
|
|
181
|
+
end
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Per-table ignored columns
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
config.ignored_columns = -> table {
|
|
189
|
+
case table
|
|
190
|
+
when "users" then %w[created_at updated_at last_sign_in_at]
|
|
191
|
+
else %w[created_at updated_at]
|
|
192
|
+
end
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Custom audit model
|
|
197
|
+
|
|
198
|
+
You can provide your own model class if you need custom methods or a different table name:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
class AuditRecord < ApplicationRecord
|
|
202
|
+
include Waldit::Record
|
|
203
|
+
self.table_name = "waldit"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
Waldit.configure do |config|
|
|
207
|
+
config.model = AuditRecord
|
|
208
|
+
end
|
|
209
|
+
```
|
|
97
210
|
|
|
98
|
-
|
|
211
|
+
## How it works
|
|
99
212
|
|
|
100
|
-
|
|
101
|
-
slots:
|
|
102
|
-
audit:
|
|
103
|
-
publications: [waldit_publication]
|
|
104
|
-
watcher: Waldit::Watcher
|
|
105
|
-
```
|
|
213
|
+
Waldit uses Postgres logical replication to stream changes from the WAL (Write-Ahead Log). The flow is:
|
|
106
214
|
|
|
107
|
-
|
|
215
|
+
1. The custom database adapter sets a `waldit_context` session variable before each write operation
|
|
216
|
+
2. Postgres captures the change and the context in the WAL
|
|
217
|
+
3. `Waldit::Watcher` receives the events via a replication slot
|
|
218
|
+
4. Events are deduplicated per-transaction (multiple updates to the same record produce a single audit entry)
|
|
219
|
+
5. The final audit records are persisted to the `waldit` table
|
|
108
220
|
|
|
109
|
-
|
|
110
|
-
bundle exec wal start config/waldit.yml
|
|
111
|
-
```
|
|
221
|
+
For small transactions, events are accumulated in memory and persisted in a single batch insert. For large transactions (configurable via `large_transaction_threshold`), events are streamed and persisted individually to avoid memory pressure.
|
|
112
222
|
|
|
113
|
-
|
|
223
|
+
### Transaction-level deduplication
|
|
114
224
|
|
|
115
|
-
|
|
225
|
+
Within a single database transaction, Waldit collapses events intelligently:
|
|
116
226
|
|
|
117
|
-
|
|
227
|
+
- **Insert then update** -- recorded as a single `insert` with the final state
|
|
228
|
+
- **Multiple updates** -- recorded as a single `update` with the original `old` and final `new`
|
|
229
|
+
- **Insert then delete** -- not recorded (the record never existed outside the transaction)
|
|
230
|
+
- **Update then delete** -- recorded as a `delete` with the original `old` values
|
|
231
|
+
- **Update that reverts to original** -- not recorded (no net change)
|
data/lib/waldit/version.rb
CHANGED
data/lib/waldit/watcher.rb
CHANGED
|
@@ -6,46 +6,204 @@ module Waldit
|
|
|
6
6
|
class Watcher < Wal::StreamingWatcher
|
|
7
7
|
include Wal
|
|
8
8
|
|
|
9
|
-
def
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
def initialize(*)
|
|
10
|
+
super
|
|
11
|
+
initialize_connection
|
|
12
|
+
@retry = false
|
|
13
|
+
end
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
def on_transaction_events(events)
|
|
16
|
+
begin_event = events.next
|
|
17
|
+
if begin_event.estimated_size < Waldit.large_transaction_threshold
|
|
18
|
+
process_in_memory(events)
|
|
19
|
+
else
|
|
20
|
+
process_streaming(events)
|
|
21
|
+
end
|
|
22
|
+
rescue PG::ConnectionBad
|
|
23
|
+
raise if @retry
|
|
24
|
+
initialize_connection
|
|
25
|
+
@retry = true
|
|
26
|
+
retry
|
|
27
|
+
end
|
|
14
28
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@connection.exec_prepared("waldit_insert", audit + [new_attributes.to_json])
|
|
19
|
-
true
|
|
29
|
+
def should_watch_table?(table)
|
|
30
|
+
Waldit.watched_tables.call(table)
|
|
31
|
+
end
|
|
20
32
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
new_attributes = clean_attributes(event.table, event.new)
|
|
33
|
+
def valid_context_prefix?(prefix)
|
|
34
|
+
prefix == Waldit.context_prefix
|
|
35
|
+
end
|
|
25
36
|
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
def ignored_columns(table)
|
|
38
|
+
(@ignored_columns_cache ||= {})[table] ||= Waldit.ignored_columns.call(table)
|
|
39
|
+
end
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
def store_changes(table)
|
|
42
|
+
(@store_changes_cache ||= {})[table] ||= Waldit.store_changes.call(table)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def clean_attributes(table, attributes)
|
|
46
|
+
attributes.without(ignored_columns(table))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def record
|
|
50
|
+
Waldit.model
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
COLUMNS = %w[
|
|
56
|
+
transaction_id
|
|
57
|
+
lsn
|
|
58
|
+
table_name
|
|
59
|
+
primary_key
|
|
60
|
+
action
|
|
61
|
+
context
|
|
62
|
+
committed_at
|
|
63
|
+
old
|
|
64
|
+
new
|
|
65
|
+
diff
|
|
66
|
+
].freeze
|
|
67
|
+
|
|
68
|
+
PARAMS_PER_ROW = COLUMNS.size
|
|
69
|
+
MAX_ROWS_PER_BATCH = 65535 / PARAMS_PER_ROW
|
|
70
|
+
|
|
71
|
+
def process_in_memory(events)
|
|
72
|
+
records = {}
|
|
73
|
+
|
|
74
|
+
events.each do |event|
|
|
75
|
+
case event
|
|
76
|
+
when InsertEvent
|
|
77
|
+
next unless event.primary_key
|
|
78
|
+
key = [event.full_table_name, event.primary_key.to_json]
|
|
79
|
+
records[key] = event
|
|
80
|
+
|
|
81
|
+
when UpdateEvent
|
|
82
|
+
next unless event.primary_key
|
|
83
|
+
next if event.diff.without(ignored_columns(event.table)).empty?
|
|
84
|
+
key = [event.full_table_name, event.primary_key.to_json]
|
|
85
|
+
records[key] = case (existing_event = records[key])
|
|
86
|
+
when InsertEvent
|
|
87
|
+
# A record inserted on this transaction is being updated, which means it should still reflect as a insert
|
|
88
|
+
# event, we just change the information to reflect the most current data that was just updated.
|
|
89
|
+
existing_event.with(new: event.new)
|
|
90
|
+
when UpdateEvent
|
|
91
|
+
# We are updating again a event that was already updated on this transaction.
|
|
92
|
+
# Same as the insert, we keep the old data from the previous update and the new data from the new one.
|
|
93
|
+
existing_event.with(new: event.new)
|
|
94
|
+
else
|
|
95
|
+
event
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
when DeleteEvent
|
|
99
|
+
next unless event.primary_key
|
|
100
|
+
key = [event.full_table_name, event.primary_key.to_json]
|
|
101
|
+
records[key] = case (existing_event = records[key])
|
|
102
|
+
when InsertEvent
|
|
103
|
+
# We are removing a record that was inserted on this transaction, we should not even report this change, as
|
|
104
|
+
# this record never existed outside this transaction anyways.
|
|
105
|
+
nil
|
|
106
|
+
when UpdateEvent
|
|
107
|
+
# Deleting a record that was previously updated by this transaction. Just store the previous data while
|
|
108
|
+
# keeping the record as deleted.
|
|
109
|
+
event.with(old: existing_event.old)
|
|
110
|
+
else
|
|
111
|
+
event
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
when CommitTransactionEvent
|
|
115
|
+
rows = records.compact.values.filter_map do |evt|
|
|
116
|
+
table = evt.full_table_name
|
|
117
|
+
store = store_changes(table)
|
|
118
|
+
|
|
119
|
+
rec = {
|
|
120
|
+
committed_at: event.timestamp,
|
|
121
|
+
transaction_id: event.transaction_id,
|
|
122
|
+
lsn: evt.lsn,
|
|
123
|
+
table_name: table,
|
|
124
|
+
primary_key: evt.primary_key.to_json,
|
|
125
|
+
context: evt.context,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case evt
|
|
129
|
+
when InsertEvent
|
|
130
|
+
{ **rec, action: "insert", new: clean_attributes(table, evt.new) }
|
|
131
|
+
when UpdateEvent
|
|
132
|
+
rec = {
|
|
133
|
+
**rec,
|
|
134
|
+
action: "update",
|
|
135
|
+
old: evt.old&.then { |attrs| clean_attributes(table, attrs) } || {},
|
|
136
|
+
new: evt.new&.then { |attrs| clean_attributes(table, attrs) } || {},
|
|
137
|
+
}
|
|
138
|
+
next if rec[:old] == rec[:new]
|
|
139
|
+
rec[:old] = nil unless store.include? :old
|
|
140
|
+
rec[:new] = nil unless store.include? :new
|
|
141
|
+
rec[:diff] = clean_attributes(table, evt.diff) if store.include? :diff
|
|
142
|
+
rec
|
|
143
|
+
when DeleteEvent
|
|
144
|
+
{ **rec, action: "delete", old: clean_attributes(table, evt.old) }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
unless rows.empty?
|
|
149
|
+
if rows.size <= MAX_ROWS_PER_BATCH
|
|
150
|
+
insert_batch(rows)
|
|
151
|
+
else
|
|
152
|
+
@connection.transaction do
|
|
153
|
+
rows.each_slice(MAX_ROWS_PER_BATCH) { |batch| insert_batch(batch) }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
@retry = false
|
|
37
159
|
end
|
|
38
|
-
true
|
|
39
160
|
end
|
|
40
161
|
end
|
|
41
162
|
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
163
|
+
def insert_batch(batch)
|
|
164
|
+
rows = batch.each_with_index.map do |_, i|
|
|
165
|
+
o = i * PARAMS_PER_ROW
|
|
166
|
+
row = [
|
|
167
|
+
"$#{o + 1}",
|
|
168
|
+
"$#{o + 2}",
|
|
169
|
+
"$#{o + 3}",
|
|
170
|
+
"$#{o + 4}",
|
|
171
|
+
"$#{o + 5}::waldit_action",
|
|
172
|
+
"$#{o + 6}::jsonb",
|
|
173
|
+
"$#{o + 7}",
|
|
174
|
+
"$#{o + 8}::jsonb",
|
|
175
|
+
"$#{o + 9}::jsonb",
|
|
176
|
+
"$#{o + 10}::jsonb",
|
|
177
|
+
].join(",")
|
|
178
|
+
"(#{row})"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
params = batch.flat_map do |r|
|
|
182
|
+
[
|
|
183
|
+
r[:transaction_id],
|
|
184
|
+
r[:lsn],
|
|
185
|
+
r[:table_name],
|
|
186
|
+
r[:primary_key],
|
|
187
|
+
r[:action],
|
|
188
|
+
r[:context]&.to_json,
|
|
189
|
+
r[:committed_at],
|
|
190
|
+
r[:old]&.to_json,
|
|
191
|
+
r[:new]&.to_json,
|
|
192
|
+
r[:diff]&.to_json,
|
|
193
|
+
]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
@connection.exec_params(<<~SQL, params)
|
|
197
|
+
INSERT INTO #{record.table_name} (#{COLUMNS.join(",")})
|
|
198
|
+
VALUES #{rows.join(",")}
|
|
199
|
+
ON CONFLICT (table_name, primary_key, transaction_id)
|
|
200
|
+
DO NOTHING
|
|
201
|
+
SQL
|
|
46
202
|
end
|
|
47
203
|
|
|
48
|
-
def
|
|
204
|
+
def process_streaming(events)
|
|
205
|
+
ensure_streaming_statements_prepared
|
|
206
|
+
|
|
49
207
|
@connection.transaction do
|
|
50
208
|
tables = Set.new
|
|
51
209
|
|
|
@@ -54,7 +212,7 @@ module Waldit
|
|
|
54
212
|
when CommitTransactionEvent
|
|
55
213
|
unless tables.empty?
|
|
56
214
|
changes = [:old, :new, :diff]
|
|
57
|
-
.map { |diff| [diff, tables.filter { |table|
|
|
215
|
+
.map { |diff| [diff, tables.filter { |table| store_changes(table).include? diff }] }
|
|
58
216
|
.to_h
|
|
59
217
|
|
|
60
218
|
log_new = (changes[:new] || []).map { |table| "#{table}" }
|
|
@@ -76,7 +234,6 @@ module Waldit
|
|
|
76
234
|
])
|
|
77
235
|
end
|
|
78
236
|
|
|
79
|
-
# We sucessful retried a connection, let's reset our retry state
|
|
80
237
|
@retry = false
|
|
81
238
|
|
|
82
239
|
when InsertEvent
|
|
@@ -90,44 +247,57 @@ module Waldit
|
|
|
90
247
|
end
|
|
91
248
|
end
|
|
92
249
|
end
|
|
93
|
-
rescue PG::ConnectionBad
|
|
94
|
-
raise if @retry
|
|
95
|
-
# Let's try to fetch a new connection and reprocess the transaction
|
|
96
|
-
initialize_connection
|
|
97
|
-
@retry = true
|
|
98
|
-
retry
|
|
99
250
|
end
|
|
100
251
|
|
|
101
|
-
def
|
|
102
|
-
|
|
103
|
-
|
|
252
|
+
def audit_event(event)
|
|
253
|
+
return unless event.primary_key
|
|
254
|
+
primary_key = event.primary_key.to_json
|
|
255
|
+
table = event.full_table_name
|
|
104
256
|
|
|
105
|
-
|
|
106
|
-
prefix == Waldit.context_prefix
|
|
107
|
-
end
|
|
257
|
+
audit = [event.transaction_id, event.lsn, table, primary_key, event.context.to_json]
|
|
108
258
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
259
|
+
case event
|
|
260
|
+
when InsertEvent
|
|
261
|
+
new_attributes = clean_attributes(table, event.new)
|
|
262
|
+
@connection.exec_prepared("waldit_insert", audit + [new_attributes.to_json])
|
|
263
|
+
true
|
|
112
264
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
265
|
+
when UpdateEvent
|
|
266
|
+
return if event.diff.without(ignored_columns(table)).empty?
|
|
267
|
+
old_attributes = clean_attributes(table, event.old)
|
|
268
|
+
new_attributes = clean_attributes(table, event.new)
|
|
116
269
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
end
|
|
270
|
+
@connection.exec_prepared("waldit_update", audit + [old_attributes.to_json, new_attributes.to_json])
|
|
271
|
+
true
|
|
120
272
|
|
|
121
|
-
|
|
273
|
+
when DeleteEvent
|
|
274
|
+
case @connection.exec_prepared("waldit_delete_cleanup", [event.transaction_id, table, primary_key]).values
|
|
275
|
+
in [["update", previous_old]]
|
|
276
|
+
@connection.exec_prepared("waldit_delete", audit + [previous_old])
|
|
277
|
+
in []
|
|
278
|
+
@connection.exec_prepared("waldit_delete", audit + [clean_attributes(table, event.old).to_json])
|
|
279
|
+
else
|
|
280
|
+
# Don't need to audit anything on this case
|
|
281
|
+
end
|
|
282
|
+
true
|
|
283
|
+
end
|
|
284
|
+
end
|
|
122
285
|
|
|
123
286
|
def initialize_connection
|
|
124
287
|
@connection = record.connection_pool.checkout.raw_connection
|
|
288
|
+
@streaming_statements_prepared = false
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def ensure_streaming_statements_prepared
|
|
292
|
+
return if @streaming_statements_prepared
|
|
293
|
+
|
|
125
294
|
prepare_insert
|
|
126
295
|
prepare_update
|
|
127
296
|
prepare_delete
|
|
128
297
|
prepare_delete_cleanup
|
|
129
298
|
prepare_finish
|
|
130
299
|
prepare_cleanup
|
|
300
|
+
@streaming_statements_prepared = true
|
|
131
301
|
end
|
|
132
302
|
|
|
133
303
|
def prepare_insert
|
data/lib/waldit.rb
CHANGED
|
@@ -39,6 +39,7 @@ module Waldit
|
|
|
39
39
|
attr_accessor :ignored_columns
|
|
40
40
|
attr_accessor :model
|
|
41
41
|
attr_accessor :context_prefix
|
|
42
|
+
attr_accessor :large_transaction_threshold
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
def self.configure(&block)
|
|
@@ -54,6 +55,8 @@ module Waldit
|
|
|
54
55
|
|
|
55
56
|
config.ignored_columns = -> table { %w[created_at updated_at] }
|
|
56
57
|
|
|
58
|
+
config.large_transaction_threshold = 10_000_000
|
|
59
|
+
|
|
57
60
|
config.model = Class.new(ActiveRecord::Base) do
|
|
58
61
|
include Waldit::Record
|
|
59
62
|
self.table_name = "waldit"
|
data/rbi/waldit.rbi
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
module Waldit
|
|
3
3
|
extend T::Sig
|
|
4
4
|
extend Waldit::Context
|
|
5
|
-
VERSION = "0.0.
|
|
5
|
+
VERSION = "0.0.26"
|
|
6
6
|
|
|
7
7
|
class << self
|
|
8
8
|
sig { returns(String) }
|
|
@@ -19,6 +19,9 @@ module Waldit
|
|
|
19
19
|
|
|
20
20
|
sig { returns(T.class_of(ActiveRecord::Base)) }
|
|
21
21
|
attr_accessor :model
|
|
22
|
+
|
|
23
|
+
sig { returns(Integer) }
|
|
24
|
+
attr_accessor :large_transaction_threshold
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
sig { params(tables: T.any(T::Array[String], T.proc.params(table: String).returns(T::Boolean))).void }
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: waldit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.26
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rodrigo Navarro
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-02-
|
|
10
|
+
date: 2026-02-13 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: wal
|