pgtk 0.17.4 → 0.18.0
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 +67 -0
- data/lib/pgtk/retry.rb +94 -0
- data/lib/pgtk/version.rb +1 -1
- data/test/test_retry.rb +232 -0
- metadata +3 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e3c09e42b393c24a720d0ca3c63da7d4618cfa430fd40122a516525ef64e0e9c
|
4
|
+
data.tar.gz: 0bf318a887749f9edf00e78a5b229321d77804a36e1afb4a248e8952c757e5b3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c4ff1a0b73c3d8c7a7f9d917b08284e534f38cd09034b8bb8a031c6ad3412546faf10cbb79a5e66f1f991098bf221642303949fa08d5490e584ed0064b76f70c
|
7
|
+
data.tar.gz: 7632624121254f3c0c8a428c3a0187ec112c813f7ac7fadd1a271ecc11db65b92afe8c27cfe45dadb9aa42893849129dd7e0f0d56783c9ac91640face5b1ec97
|
data/README.md
CHANGED
@@ -173,6 +173,42 @@ pool = Pgtk::Spy.new(pool) do |sql|
|
|
173
173
|
end
|
174
174
|
```
|
175
175
|
|
176
|
+
## Query Timeouts with `Pgtk::Impatient`
|
177
|
+
|
178
|
+
To prevent queries from running indefinitely, use `Pgtk::Impatient` to enforce
|
179
|
+
timeouts on database operations:
|
180
|
+
|
181
|
+
```ruby
|
182
|
+
require 'pgtk/impatient'
|
183
|
+
# Wrap the pool with a 5-second timeout for all queries
|
184
|
+
impatient = Pgtk::Impatient.new(pool, 5)
|
185
|
+
```
|
186
|
+
|
187
|
+
The impatient decorator ensures queries don't hang your application:
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
begin
|
191
|
+
# This query will be terminated if it takes longer than 5 seconds
|
192
|
+
impatient.exec('SELECT * FROM large_table WHERE complex_condition')
|
193
|
+
rescue Pgtk::Impatient::TooSlow => e
|
194
|
+
puts "Query timed out: #{e.message}"
|
195
|
+
end
|
196
|
+
```
|
197
|
+
|
198
|
+
You can exclude specific queries from timeout enforcement using regex patterns:
|
199
|
+
|
200
|
+
```ruby
|
201
|
+
# Don't timeout any SELECT queries or specific maintenance operations
|
202
|
+
impatient = Pgtk::Impatient.new(pool, 2, /^SELECT/, /^VACUUM/)
|
203
|
+
```
|
204
|
+
|
205
|
+
Key features:
|
206
|
+
|
207
|
+
1. Configurable timeout in seconds for each query
|
208
|
+
2. Raises `Pgtk::Impatient::TooSlow` exception when timeout is exceeded
|
209
|
+
3. Can exclude queries matching specific patterns from timeout checks
|
210
|
+
4. Also sets PostgreSQL's `statement_timeout` for transactions
|
211
|
+
|
176
212
|
## Query Caching with `Pgtk::Stash`
|
177
213
|
|
178
214
|
For applications with frequent read queries,
|
@@ -205,6 +241,37 @@ for simple queries:
|
|
205
241
|
3. Write operations (`INSERT`, `UPDATE`, `DELETE`) bypass
|
206
242
|
the cache and invalidate all cached queries for affected tables
|
207
243
|
|
244
|
+
## Automatic Retries with `Pgtk::Retry`
|
245
|
+
|
246
|
+
For resilient database operations, `Pgtk::Retry` provides automatic retry
|
247
|
+
functionality for failed `SELECT` queries:
|
248
|
+
|
249
|
+
```ruby
|
250
|
+
require 'pgtk/retry'
|
251
|
+
# Wrap the pool with retry functionality (default: 3 attempts)
|
252
|
+
retry_pool = Pgtk::Retry.new(pgsql)
|
253
|
+
# Or specify custom number of attempts
|
254
|
+
retry_pool = Pgtk::Retry.new(pgsql, attempts: 5)
|
255
|
+
```
|
256
|
+
|
257
|
+
The retry decorator automatically retries `SELECT` queries that fail due to
|
258
|
+
transient errors (network issues, connection problems, etc.):
|
259
|
+
|
260
|
+
```ruby
|
261
|
+
# This SELECT will be retried up to 3 times if it fails
|
262
|
+
users = retry_pool.exec('SELECT * FROM users WHERE active = true')
|
263
|
+
|
264
|
+
# Non-SELECT queries are NOT retried to prevent duplicate writes
|
265
|
+
retry_pool.exec('INSERT INTO logs (message) VALUES ($1)', ['User logged in'])
|
266
|
+
```
|
267
|
+
|
268
|
+
Key features:
|
269
|
+
|
270
|
+
1. Only `SELECT` queries are retried (to prevent duplicate data modifications)
|
271
|
+
2. Retries happen immediately without delay
|
272
|
+
3. The original error is raised after all retry attempts are exhausted
|
273
|
+
4. Works seamlessly with other decorators like `Pgtk::Spy` and `Pgtk::Impatient`
|
274
|
+
|
208
275
|
## Some Examples
|
209
276
|
|
210
277
|
This library works in
|
data/lib/pgtk/retry.rb
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) 2019-2025 Yegor Bugayenko
|
4
|
+
# SPDX-License-Identifier: MIT
|
5
|
+
|
6
|
+
require_relative '../pgtk'
|
7
|
+
|
8
|
+
# Retry is a decorator for Pool that automatically retries failed SELECT queries.
|
9
|
+
# It provides fault tolerance for transient database errors by retrying read-only
|
10
|
+
# operations a configurable number of times before giving up.
|
11
|
+
#
|
12
|
+
# This class implements the same interface as Pool but adds retry logic specifically
|
13
|
+
# for SELECT queries. Non-SELECT queries are executed without retry to maintain
|
14
|
+
# data integrity and avoid unintended side effects from duplicate writes.
|
15
|
+
#
|
16
|
+
# Basic usage:
|
17
|
+
#
|
18
|
+
# # Create and configure a regular pool
|
19
|
+
# pool = Pgtk::Pool.new(wire).start(4)
|
20
|
+
#
|
21
|
+
# # Wrap the pool in a retry decorator with 3 attempts
|
22
|
+
# retry_pool = Pgtk::Retry.new(pool, attempts: 3)
|
23
|
+
#
|
24
|
+
# # SELECT queries are automatically retried on failure
|
25
|
+
# begin
|
26
|
+
# retry_pool.exec('SELECT * FROM users WHERE id = $1', [42])
|
27
|
+
# rescue PG::Error => e
|
28
|
+
# puts "Query failed after 3 attempts: #{e.message}"
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
# # Non-SELECT queries are not retried
|
32
|
+
# retry_pool.exec('UPDATE users SET active = true WHERE id = $1', [42])
|
33
|
+
#
|
34
|
+
# # Transactions pass through without retry logic
|
35
|
+
# retry_pool.transaction do |t|
|
36
|
+
# t.exec('SELECT * FROM accounts') # No retry within transaction
|
37
|
+
# t.exec('UPDATE accounts SET balance = balance + 100')
|
38
|
+
# end
|
39
|
+
#
|
40
|
+
# # Combining with other decorators
|
41
|
+
# impatient = Pgtk::Impatient.new(retry_pool, 5)
|
42
|
+
# spy = Pgtk::Spy.new(impatient) do |sql, duration|
|
43
|
+
# puts "Query: #{sql} (#{duration}s)"
|
44
|
+
# end
|
45
|
+
#
|
46
|
+
# Author:: Yegor Bugayenko (yegor256@gmail.com)
|
47
|
+
# Copyright:: Copyright (c) 2019-2025 Yegor Bugayenko
|
48
|
+
# License:: MIT
|
49
|
+
class Pgtk::Retry
|
50
|
+
# Constructor.
|
51
|
+
#
|
52
|
+
# @param [Pgtk::Pool] pool The pool to decorate
|
53
|
+
# @param [Integer] attempts Number of attempts to make (default: 3)
|
54
|
+
def initialize(pool, attempts: 3)
|
55
|
+
@pool = pool
|
56
|
+
@attempts = attempts
|
57
|
+
end
|
58
|
+
|
59
|
+
# Get the version of PostgreSQL server.
|
60
|
+
#
|
61
|
+
# @return [String] Version of PostgreSQL server
|
62
|
+
def version
|
63
|
+
@pool.version
|
64
|
+
end
|
65
|
+
|
66
|
+
# Execute a SQL query with automatic retry for SELECT queries.
|
67
|
+
#
|
68
|
+
# @param [String] sql The SQL query with params inside (possibly)
|
69
|
+
# @param [Array] args List of arguments
|
70
|
+
# @return [Array] Result rows
|
71
|
+
def exec(sql, *args)
|
72
|
+
query = sql.is_a?(Array) ? sql.join(' ') : sql
|
73
|
+
if query.strip.upcase.start_with?('SELECT')
|
74
|
+
attempt = 0
|
75
|
+
begin
|
76
|
+
@pool.exec(sql, *args)
|
77
|
+
rescue StandardError => e
|
78
|
+
attempt += 1
|
79
|
+
raise e if attempt >= @attempts
|
80
|
+
retry
|
81
|
+
end
|
82
|
+
else
|
83
|
+
@pool.exec(sql, *args)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Run a transaction without retry logic.
|
88
|
+
#
|
89
|
+
# @yield [Object] Yields the transaction object
|
90
|
+
# @return [Object] Result of the block
|
91
|
+
def transaction(&block)
|
92
|
+
@pool.transaction(&block)
|
93
|
+
end
|
94
|
+
end
|
data/lib/pgtk/version.rb
CHANGED
data/test/test_retry.rb
ADDED
@@ -0,0 +1,232 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) 2019-2025 Yegor Bugayenko
|
4
|
+
# SPDX-License-Identifier: MIT
|
5
|
+
|
6
|
+
require 'loog'
|
7
|
+
require 'pg'
|
8
|
+
require 'qbash'
|
9
|
+
require 'rake'
|
10
|
+
require 'tmpdir'
|
11
|
+
require 'yaml'
|
12
|
+
require_relative 'test__helper'
|
13
|
+
require_relative '../lib/pgtk/pool'
|
14
|
+
require_relative '../lib/pgtk/retry'
|
15
|
+
|
16
|
+
# Retry test.
|
17
|
+
# Author:: Yegor Bugayenko (yegor256@gmail.com)
|
18
|
+
# Copyright:: Copyright (c) 2017-2025 Yegor Bugayenko
|
19
|
+
# License:: MIT
|
20
|
+
class TestRetry < Pgtk::Test
|
21
|
+
def test_takes_version
|
22
|
+
fake_pool do |pool|
|
23
|
+
v = Pgtk::Retry.new(pool).version
|
24
|
+
refute_nil(v)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def test_executes_select_without_error
|
29
|
+
fake_pool do |pool|
|
30
|
+
retry_pool = Pgtk::Retry.new(pool, attempts: 3)
|
31
|
+
result = retry_pool.exec('SELECT 1 as value')
|
32
|
+
assert_equal('1', result.first['value'])
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def test_retries_select_on_failure
|
37
|
+
fake_pool do |pool|
|
38
|
+
counter = 0
|
39
|
+
stub_pool = Object.new
|
40
|
+
def stub_pool.version
|
41
|
+
'stub'
|
42
|
+
end
|
43
|
+
stub_pool.define_singleton_method(:exec) do |sql, *args|
|
44
|
+
counter += 1
|
45
|
+
raise PG::Error, 'Connection lost' if counter < 3
|
46
|
+
pool.exec(sql, *args)
|
47
|
+
end
|
48
|
+
retry_pool = Pgtk::Retry.new(stub_pool, attempts: 3)
|
49
|
+
result = retry_pool.exec('SELECT 2 as num')
|
50
|
+
assert_equal('2', result.first['num'])
|
51
|
+
assert_equal(3, counter)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def test_fails_after_max_attempts
|
56
|
+
fake_pool do |_pool|
|
57
|
+
stub_pool = Object.new
|
58
|
+
def stub_pool.version
|
59
|
+
'stub'
|
60
|
+
end
|
61
|
+
stub_pool.define_singleton_method(:exec) do |_sql, *_args|
|
62
|
+
raise PG::Error, 'Persistent failure'
|
63
|
+
end
|
64
|
+
retry_pool = Pgtk::Retry.new(stub_pool, attempts: 2)
|
65
|
+
assert_raises(PG::Error) do
|
66
|
+
retry_pool.exec('SELECT * FROM users')
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def test_does_not_retry_insert
|
72
|
+
fake_pool do |_pool|
|
73
|
+
counter = 0
|
74
|
+
stub_pool = Object.new
|
75
|
+
def stub_pool.version
|
76
|
+
'stub'
|
77
|
+
end
|
78
|
+
stub_pool.define_singleton_method(:exec) do |_sql, *_args|
|
79
|
+
counter += 1
|
80
|
+
raise PG::Error, 'Insert failed'
|
81
|
+
end
|
82
|
+
retry_pool = Pgtk::Retry.new(stub_pool, attempts: 3)
|
83
|
+
assert_raises(PG::Error) do
|
84
|
+
retry_pool.exec('INSERT INTO book (title) VALUES ($1)', ['Test Book'])
|
85
|
+
end
|
86
|
+
assert_equal(1, counter)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def test_does_not_retry_update
|
91
|
+
fake_pool do |_pool|
|
92
|
+
counter = 0
|
93
|
+
stub_pool = Object.new
|
94
|
+
def stub_pool.version
|
95
|
+
'stub'
|
96
|
+
end
|
97
|
+
stub_pool.define_singleton_method(:exec) do |_sql, *_args|
|
98
|
+
counter += 1
|
99
|
+
raise PG::Error, 'Update failed'
|
100
|
+
end
|
101
|
+
retry_pool = Pgtk::Retry.new(stub_pool, attempts: 3)
|
102
|
+
assert_raises(PG::Error) do
|
103
|
+
retry_pool.exec('UPDATE book SET title = $1 WHERE id = $2', ['New Title', 1])
|
104
|
+
end
|
105
|
+
assert_equal(1, counter)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def test_does_not_retry_delete
|
110
|
+
fake_pool do |_pool|
|
111
|
+
counter = 0
|
112
|
+
stub_pool = Object.new
|
113
|
+
def stub_pool.version
|
114
|
+
'stub'
|
115
|
+
end
|
116
|
+
stub_pool.define_singleton_method(:exec) do |_sql, *_args|
|
117
|
+
counter += 1
|
118
|
+
raise PG::Error, 'Delete failed'
|
119
|
+
end
|
120
|
+
retry_pool = Pgtk::Retry.new(stub_pool, attempts: 3)
|
121
|
+
assert_raises(PG::Error) do
|
122
|
+
retry_pool.exec('DELETE FROM book WHERE id = $1', [1])
|
123
|
+
end
|
124
|
+
assert_equal(1, counter)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def test_handles_select_with_leading_whitespace
|
129
|
+
fake_pool do |pool|
|
130
|
+
counter = 0
|
131
|
+
stub_pool = Object.new
|
132
|
+
def stub_pool.version
|
133
|
+
'stub'
|
134
|
+
end
|
135
|
+
stub_pool.define_singleton_method(:exec) do |sql, *args|
|
136
|
+
counter += 1
|
137
|
+
raise PG::Error, 'Connection lost' if counter < 2
|
138
|
+
pool.exec(sql, *args)
|
139
|
+
end
|
140
|
+
retry_pool = Pgtk::Retry.new(stub_pool, attempts: 3)
|
141
|
+
result = retry_pool.exec(' SELECT 3 as value')
|
142
|
+
assert_equal('3', result.first['value'])
|
143
|
+
assert_equal(2, counter)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def test_handles_select_case_insensitive
|
148
|
+
fake_pool do |pool|
|
149
|
+
counter = 0
|
150
|
+
stub_pool = Object.new
|
151
|
+
def stub_pool.version
|
152
|
+
'stub'
|
153
|
+
end
|
154
|
+
stub_pool.define_singleton_method(:exec) do |sql, *args|
|
155
|
+
counter += 1
|
156
|
+
raise PG::Error, 'Connection lost' if counter < 2
|
157
|
+
pool.exec(sql, *args)
|
158
|
+
end
|
159
|
+
retry_pool = Pgtk::Retry.new(stub_pool, attempts: 3)
|
160
|
+
result = retry_pool.exec('select 4 as value')
|
161
|
+
assert_equal('4', result.first['value'])
|
162
|
+
assert_equal(2, counter)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def test_handles_array_sql
|
167
|
+
fake_pool do |pool|
|
168
|
+
counter = 0
|
169
|
+
stub_pool = Object.new
|
170
|
+
def stub_pool.version
|
171
|
+
'stub'
|
172
|
+
end
|
173
|
+
stub_pool.define_singleton_method(:exec) do |sql, *args|
|
174
|
+
counter += 1
|
175
|
+
raise PG::Error, 'Connection lost' if counter < 2
|
176
|
+
pool.exec(sql, *args)
|
177
|
+
end
|
178
|
+
retry_pool = Pgtk::Retry.new(stub_pool, attempts: 3)
|
179
|
+
result = retry_pool.exec(%w[SELECT 5 as value])
|
180
|
+
assert_equal('5', result.first['value'])
|
181
|
+
assert_equal(2, counter)
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def test_transaction_passes_through
|
186
|
+
fake_pool do |pool|
|
187
|
+
retry_pool = Pgtk::Retry.new(pool)
|
188
|
+
retry_pool.transaction do |t|
|
189
|
+
id = t.exec(
|
190
|
+
'INSERT INTO book (title) VALUES ($1) RETURNING id',
|
191
|
+
['Transaction Book']
|
192
|
+
).first['id'].to_i
|
193
|
+
assert_predicate(id, :positive?)
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def test_preserves_original_error_type
|
199
|
+
fake_pool do |_pool|
|
200
|
+
stub_pool = Object.new
|
201
|
+
def stub_pool.version
|
202
|
+
'stub'
|
203
|
+
end
|
204
|
+
stub_pool.define_singleton_method(:exec) do |_sql, *_args|
|
205
|
+
raise ArgumentError, 'Invalid argument'
|
206
|
+
end
|
207
|
+
retry_pool = Pgtk::Retry.new(stub_pool, attempts: 2)
|
208
|
+
assert_raises(ArgumentError) do
|
209
|
+
retry_pool.exec('SELECT * FROM table')
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
def test_retries_with_unicode_query
|
215
|
+
fake_pool do |pool|
|
216
|
+
counter = 0
|
217
|
+
stub_pool = Object.new
|
218
|
+
def stub_pool.version
|
219
|
+
'stub'
|
220
|
+
end
|
221
|
+
stub_pool.define_singleton_method(:exec) do |sql, *args|
|
222
|
+
counter += 1
|
223
|
+
raise PG::Error, 'Connection lost' if counter < 2
|
224
|
+
pool.exec(sql, *args)
|
225
|
+
end
|
226
|
+
retry_pool = Pgtk::Retry.new(stub_pool, attempts: 3)
|
227
|
+
result = retry_pool.exec('SELECT \'привет\' as greeting')
|
228
|
+
assert_equal('привет', result.first['greeting'])
|
229
|
+
assert_equal(2, counter)
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: pgtk
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.18.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Yegor Bugayenko
|
@@ -176,6 +176,7 @@ files:
|
|
176
176
|
- lib/pgtk/liquibase_task.rb
|
177
177
|
- lib/pgtk/pgsql_task.rb
|
178
178
|
- lib/pgtk/pool.rb
|
179
|
+
- lib/pgtk/retry.rb
|
179
180
|
- lib/pgtk/spy.rb
|
180
181
|
- lib/pgtk/stash.rb
|
181
182
|
- lib/pgtk/version.rb
|
@@ -190,6 +191,7 @@ files:
|
|
190
191
|
- test/test_liquibase_task.rb
|
191
192
|
- test/test_pgsql_task.rb
|
192
193
|
- test/test_pool.rb
|
194
|
+
- test/test_retry.rb
|
193
195
|
- test/test_stash.rb
|
194
196
|
- test/test_wire.rb
|
195
197
|
homepage: https://github.com/yegor256/pgtk
|