pgx 1.0.1

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.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/pgx.rb +464 -0
  3. metadata +47 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 7795026fac56ac284b9a0d68eedb9428c6484b03
4
+ data.tar.gz: 570fbd7c002ad55e4b7f06dacad93a4f399d8afe
5
+ SHA512:
6
+ metadata.gz: 099637afbd1eaa729e890ee47b8d03545a7bcb62bb6d1e7db898a00614da7b6311b3341217eae48cb7aed475d21a57bda42f1657e5a16367c6a11e53ace26e90
7
+ data.tar.gz: f3d5e351210936ad2fadd526e8cc2333f4447b7013aef23719a3eeb85450914792d87112db6bb651cb19342d7ac0c26ac7a5f17a34ec41c384524d53f80a1185
data/lib/pgx.rb ADDED
@@ -0,0 +1,464 @@
1
+ module PGx
2
+ require 'pg'
3
+ require 'logger'
4
+
5
+ # report all these error fieldcodes on errors
6
+ # these are all the fieldcodes error_field() can report
7
+ # see http://deveiate.org/code/pg/PG/Result.html#method-i-error_field
8
+ PGx.const_set('ERROR_FIELDCODES',
9
+ SEVERITY: PG::Result::PG_DIAG_SEVERITY,
10
+ SQLSTATE: PG::Result::PG_DIAG_SQLSTATE,
11
+ MESSAGE_PRIMARY: PG::Result::PG_DIAG_MESSAGE_PRIMARY,
12
+ MESSAGE_DETAIL: PG::Result::PG_DIAG_MESSAGE_DETAIL,
13
+ MESSAGE_HINT: PG::Result::PG_DIAG_MESSAGE_HINT,
14
+ STATEMENT_POSITION: PG::Result::PG_DIAG_STATEMENT_POSITION,
15
+ INTERNAL_POSITION: PG::Result::PG_DIAG_INTERNAL_POSITION,
16
+ INTERNAL_QUERY: PG::Result::PG_DIAG_INTERNAL_QUERY,
17
+ CONTEXT: PG::Result::PG_DIAG_CONTEXT,
18
+ SOURCE_FILE: PG::Result::PG_DIAG_SOURCE_FILE,
19
+ SOURCE_LINE: PG::Result::PG_DIAG_SOURCE_LINE,
20
+ SOURCE_FUNCTION: PG::Result::PG_DIAG_SOURCE_FUNCTION
21
+ )
22
+
23
+ # all of our connection parameters and their defaults
24
+ PGx.const_set('CONNECT_ARGS',
25
+ logger: lambda {
26
+ log = Logger.new(STDOUT)
27
+ log.level = Logger::DEBUG
28
+ return log
29
+ },
30
+ connect_init: lambda { |con| }, # noop
31
+ connect_retry_sleep: 60,
32
+ method_try_count_max: 3,
33
+ method_retriable_error_states: [
34
+ '40001', # serialization_failure
35
+ '40P01' # deadlock_detected
36
+ ]
37
+ )
38
+
39
+ # @author Kiriakos Georgiou, http://www.mockbites.com/about
40
+ # Extends the base class PG::Connection methods by adding logging and
41
+ # retrying capabilities (synchronous methods only.)
42
+ # The arguments and method behaviors are
43
+ # identical, except for the 'new' method.
44
+ # @see http://www.mockbites.com/articles/tech/pgx pgx documentation
45
+ # @see http://deveiate.org/code/pg/PG/Connection.html Base class methods
46
+ class PGx::Connection < PG::Connection
47
+
48
+ # @param [Logger] logger: a logger object, or a lambda returning the
49
+ # logger object. Note that a lambda returning the logger is
50
+ # preferable because it makes logging of the log object itself
51
+ # easier on the eyes. This parameter can be a single logger
52
+ # object, or an array of logger objects in case you want to log to
53
+ # multiple logs (eg: stdout and a log file.)
54
+ # @param [Proc] connect_init: a Proc that contains code that will
55
+ # always be executed every time a new connection is established.
56
+ # It is also executed after a reset().
57
+ # May be useful for things such as setting the search_path.
58
+ # @param [Proc, Integer] connect_retry_sleep: a Proc or Integer.
59
+ # If a Proc is passed, the Proc will be called with one argument,
60
+ # try_count, which starts at 1 and increments by one with every
61
+ # unccesseful connection attempt. The Proc should implement the
62
+ # algorithm for sleeping between connection attempts.
63
+ # If an Integer is passed, it indicates how long to sleep between
64
+ # connection attempts, in seconds.
65
+ # If nil is passed, no attempts to reconnect will be made, it
66
+ # simply raises an exception.
67
+ # @param [Integer] method_try_count_max: the maximum number of times a
68
+ # single method, or transaction block, can be tried
69
+ # @param [Array<String>] method_retriable_error_states: the set of
70
+ # error states for which a single method, or transaction block,
71
+ # should be retried
72
+ # @param ... The rest of the arguments are the same as the base
73
+ # class method arguments
74
+ # @see http://deveiate.org/code/pg/PG/Connection.html#method-c-new
75
+ # Base class method.
76
+ # @example
77
+ # log = Logger.new('/tmp/debug.log');
78
+ # log.level = Logger::DEBUG
79
+ # connect_sleeper = lambda { |try_count|
80
+ # sleep [try_count ** 2, 120].min
81
+ # }
82
+ # con = PGx::Connection.new(
83
+ # logger: log,
84
+ # connect_retry_sleep: connect_sleeper,
85
+ # dbname: 'test'
86
+ # )
87
+ def initialize(*args)
88
+ connect_args = PGx.const_get('CONNECT_ARGS')
89
+ (our_args, parent_args) = extract_our_args(connect_args, args)
90
+
91
+ log = arg_with_default(connect_args, our_args, :logger)
92
+ @logger = [ log.is_a?(Proc) ? log.call : log ].flatten
93
+ @connect_init = arg_with_default(connect_args, our_args, :connect_init)
94
+ @connect_retry_sleep = arg_with_default(connect_args, our_args, :connect_retry_sleep)
95
+ @method_try_count_max = arg_with_default(connect_args, our_args, :method_try_count_max)
96
+ @method_retriable_error_states = arg_with_default(connect_args, our_args, :method_retriable_error_states)
97
+
98
+ # gets set in connect_log_and_try() to the connection handle
99
+ # needed by reset() to be able to execute the connect_init proc
100
+ @connection = nil
101
+
102
+ # always include invalid_sql_statement_name (required for prepared statements to work after re-connects)
103
+ @method_retriable_error_states.push('26000') unless @method_retriable_error_states.include?('26000')
104
+
105
+ @in_transaction_block = false
106
+
107
+ @prepared_statements = {} # hash of all statements ever prepared, key = statement name, value = sql
108
+ @prepared_live = {} # keeps track which prepared statements are currently prepared
109
+ @transaction_reprepare = nil # placeholder for statement to be prepared before transaction retry
110
+
111
+ @connect_proc = lambda {
112
+ @prepared_live = {} # reset on connect
113
+ connect_log_and_try(
114
+ lambda { super(*parent_args) },
115
+ our_args,
116
+ parent_args
117
+ )
118
+ }
119
+ connect_loop()
120
+ end
121
+
122
+ # @example
123
+ # con.transaction do
124
+ # con.exec "create temp table mytable(x text)"
125
+ # con.exec "insert into mytable(x) values('a')"
126
+ # con.exec "insert into mytable(x) values('b')"
127
+ # end
128
+ def transaction(*args)
129
+ @in_transaction_block = true
130
+ sql_log_and_try(lambda { super }, __method__, args)
131
+ @in_transaction_block = false
132
+ end
133
+
134
+ # @example
135
+ # con.exec "insert into mytable(x) values('test value')"
136
+ # con.exec 'insert into mytable(x) values($1)', ['test value']
137
+ def exec(*args)
138
+ sql_log_and_try(lambda { super }, __method__, args)
139
+ end
140
+
141
+ # Alias for exec
142
+ def query(*args)
143
+ sql_log_and_try(lambda { super }, __method__, args)
144
+ end
145
+
146
+ # @example
147
+ # con.exec_params 'select * from mytable where x = $1 limit $2', [1, 100] do |rs|
148
+ # puts rs.fields.collect { |fname| "%-15s" % [fname] }.join('')
149
+ # rs.values.collect { |row|
150
+ # puts row.collect { |col| "%-15s" % [col] }.join('')
151
+ # }
152
+ # end
153
+ def exec_params(*args)
154
+ sql_log_and_try(lambda { super }, __method__, args)
155
+ end
156
+
157
+ # @example
158
+ # con.prepare 'sql1', 'select * from mytable limit $1'
159
+ # con.exec_prepared 'sql1', [100] do |rs|
160
+ # puts rs.fields.collect { |fname| "%-15s" % [fname] }.join('')
161
+ # rs.values.collect { |row|
162
+ # puts row.collect { |col| "%-15s" % [col] }.join('')
163
+ # }
164
+ # end
165
+ def prepare(*args)
166
+ # it's possible to call prepare() on an statement that is already prepared
167
+ # if you have a prepare() inside a transaction, and the transaction is retried
168
+ # thus we check if it's already live
169
+ s = args[0]
170
+ if @prepared_live.has_key?(s)
171
+ log_debug %Q{prepared statement #{s} is already live, skipping re-preparing it}
172
+ else
173
+ sql_log_and_try(lambda { super }, __method__, args)
174
+ @prepared_statements[s] = args[1]
175
+ @prepared_live[s] = true
176
+ end
177
+ end
178
+
179
+ # @example
180
+ # con.prepare 'sql1', 'select * from mytable limit $1'
181
+ # con.exec_prepared 'sql1', [100] do |rs|
182
+ # puts rs.fields.collect { |fname| "%-15s" % [fname] }.join('')
183
+ # rs.values.collect { |row|
184
+ # puts row.collect { |col| "%-15s" % [col] }.join('')
185
+ # }
186
+ # end
187
+ def exec_prepared(*args)
188
+ sql_log_and_try(lambda { super }, __method__, args)
189
+ end
190
+
191
+ def cancel(*args)
192
+ sql_log_and_try(lambda { super }, __method__, args)
193
+ end
194
+
195
+ def close(*args)
196
+ sql_log_and_try(lambda { super }, __method__, args)
197
+ end
198
+
199
+ def finish(*args)
200
+ sql_log_and_try(lambda { super }, __method__, args)
201
+ end
202
+
203
+ def reset(*args)
204
+ sql_log_and_try(lambda { super }, __method__, args)
205
+ @prepared_live = {} # reset on connect
206
+ @connect_init.call(@connection)
207
+ end
208
+
209
+ def wait_for_notify(*args)
210
+ sql_log_and_try(lambda { super }, __method__, args)
211
+ end
212
+
213
+ private
214
+
215
+ # returns the value of an argument (symbol) from our_args,
216
+ # or if it's not defined, it returns the default value
217
+ def arg_with_default(all_our_possible_args, our_args, symbol)
218
+ return our_args.has_key?(symbol) ? our_args[symbol] : all_our_possible_args[symbol]
219
+ end
220
+
221
+ # extract our arguments defined in all_our_possible_args from args
222
+ # returns an array containing our arguments (hash) and the aguments
223
+ # for the parent method
224
+ def extract_our_args(all_our_possible_args, args)
225
+ our_args = {}
226
+ if args[0].is_a?(Hash)
227
+ all_our_possible_args.each_key do |x|
228
+ our_args[x] = args[0].delete(x) if args[0].has_key?(x)
229
+ end
230
+ args.shift if args[0].empty?
231
+ end
232
+ return [our_args, args]
233
+ end
234
+
235
+ # Returns true or false depending on whether the connection is OK or
236
+ # not. This has to be executed right after one of the above public
237
+ # methods, since it doesn't really ping the server on its own.
238
+ def connected?
239
+ begin
240
+ return(status() == PG::CONNECTION_OK ? true : false)
241
+ rescue PGError => e
242
+ return false
243
+ end
244
+ end
245
+
246
+ # Will not return until a database connection has been established.
247
+ def connect_loop
248
+ @connect_proc.call
249
+ end
250
+
251
+ # Given a function name string and an arguments array, it returns a
252
+ # string representing the function call. This is used in the logging
253
+ # functions.
254
+ def function_call_string(func, args)
255
+ sprintf "%s.%s(%s)",
256
+ self.class,
257
+ func.to_s,
258
+ ( args.is_a?(Array) ? args : [args] ).map { |x|
259
+ if x.is_a?(Hash) and x.empty?
260
+ nil
261
+ else
262
+ # Inspect does not expand \n, \r and \t, so we expand them
263
+ # ourselves via gsubs in order to get more readable logging output.
264
+ x.inspect.gsub('\n', "\n").gsub('\r', "\r").gsub('\t', "\t")
265
+ end
266
+ }.join(', ')
267
+ end
268
+
269
+ # sanitize the values for certain pgx-specific arguments so they don't clog the logs
270
+ # returns the sanitized arg hash
271
+ def sanitize_our_connect_args(arg_hash)
272
+ arg_hash.merge(arg_hash) do |k, ov|
273
+ case k
274
+ when :logger
275
+ ov.is_a?(Proc) ? ov : '...'
276
+ else
277
+ ov
278
+ end
279
+ end
280
+ end
281
+
282
+ # sanitize parent arguments, eg: blank the password so it's not in the logs
283
+ def sanitize_parent_connect_args(parent_args)
284
+ if parent_args.empty?
285
+ return parent_args
286
+ elsif parent_args.length == 1
287
+ if parent_args[0].is_a?(Hash) # hash method
288
+ return [ parent_args[0].merge(parent_args[0]) { |k, ov| k == :password ? '...' : ov } ]
289
+ end
290
+ if parent_args[0].is_a?(String) # string method
291
+ return [ parent_args[0].gsub(/(password\s*=\s*)\S+/, '\1...') ]
292
+ end
293
+ else # positional arguments method
294
+ return [ parent_args.map.with_index { |x, i| i == 6 ? '...' : x } ]
295
+ end
296
+ end
297
+
298
+ # merge as follows:
299
+ # hash and hash -> hash
300
+ # hash and array -> array
301
+ def merge_connect_args(our_args, parent_args)
302
+ if our_args.empty?
303
+ return parent_args
304
+ elsif parent_args.empty?
305
+ return our_args
306
+ elsif parent_args.length == 1 and parent_args[0].is_a?(Hash)
307
+ return our_args.merge(parent_args[0]) # connection hash style
308
+ else
309
+ return [our_args].concat(parent_args)
310
+ end
311
+ end
312
+
313
+ # log and retry the connect/new method
314
+ def connect_log_and_try(caller_super, our_args, parent_args)
315
+ try_count = 0
316
+ all_args = merge_connect_args( sanitize_our_connect_args(our_args),
317
+ sanitize_parent_connect_args(parent_args) )
318
+ begin
319
+ log_debug function_call_string('connect', all_args)
320
+ @connection = caller_super.call # run the parent 'pg' function, new()
321
+ log_debug 'calling connection initialization proc'
322
+ @connect_init.call(@connection)
323
+ rescue PGError => e
324
+ try_count = try_count + 1
325
+ log_error %Q{#{e.class}, #{e.message.chomp}}
326
+ if @connect_retry_sleep.nil? or e.message =~ /^FATAL/
327
+ raise # just raise the error, do not retry to connect
328
+ elsif @connect_retry_sleep.is_a?(Proc)
329
+ @connect_retry_sleep.call(try_count)
330
+ elsif @connect_retry_sleep.is_a?(Integer)
331
+ sleep(@connect_retry_sleep)
332
+ end
333
+ retry
334
+ end
335
+ end
336
+
337
+ # log and retry statement related methods
338
+ def sql_log_and_try(caller_super, caller_method, args)
339
+ try_count = 0
340
+ begin # re-prepare prepared statements after a failed transaction
341
+ transaction_reprepare(caller_method) # just prior to retrying the transaction
342
+ log_debug function_call_string(caller_method, args)
343
+ caller_super.call # run the parent 'pg' function
344
+ rescue PGError => e
345
+ if connected? # if we are connected, it's an SQL related error
346
+ if not method_in_transaction_block(caller_method) # do not log errors and retry
347
+ try_count = try_count + 1 # methods within a transaction
348
+ log_error(get_error_fields(e) + "\n" + get_error_message(e)) # block because the transaction
349
+ error_handlers(e, caller_method, args) # itself will log the error and
350
+ retry if sql_retriable?(e, try_count) # retry the transaction block
351
+ elsif caller_method.to_s == 'exec_prepared' # exec_prepared failed within a transaction
352
+ @transaction_reprepare = args[0] # make a note to re-prepare before retrying
353
+ end # the transaction
354
+ else # not connected
355
+ log_warn 'bad database connection'
356
+ connect_loop()
357
+ retry
358
+ end
359
+ raise # raise to caller if we can't retry
360
+ end
361
+ end
362
+
363
+ # special handling of certain error states (not in a transaction)
364
+ def error_handlers(pgerr, caller_method, args)
365
+ begin
366
+ state = pgerr.result.error_field(PG::Result::PG_DIAG_SQLSTATE)
367
+ rescue PGError => e
368
+ return # just return if we can't get the state; lost connection?
369
+ end
370
+
371
+ case state
372
+ when '26000'
373
+ reprepare_statement(args[0]) if caller_method.to_s == 'exec_prepared'
374
+ end # case
375
+ end
376
+
377
+ def transaction_reprepare(caller_method)
378
+ if caller_method.to_s == 'transaction' and not @transaction_reprepare.nil?
379
+ reprepare_statement(@transaction_reprepare)
380
+ @transaction_reprepare = nil
381
+ end
382
+ end
383
+
384
+ def reprepare_statement(name)
385
+ if @prepared_statements.has_key?(name)
386
+ log_debug "Re-preparing statement '#{name}'"
387
+ prepare(name, @prepared_statements[name])
388
+ end
389
+ end
390
+
391
+ # return true if any method call, except transaction(), is called
392
+ # from within a transaction() block
393
+ def method_in_transaction_block(method)
394
+ return(method.to_s != 'transaction' and @in_transaction_block)
395
+ end
396
+
397
+ # determine if a single statement or transaction block that resulted
398
+ # in an error can be retried
399
+ def sql_retriable?(pgerr, try_count)
400
+ begin
401
+ state = pgerr.result.error_field(PG::Result::PG_DIAG_SQLSTATE)
402
+ rescue PGError => e
403
+ return true # retry if we can't get the state; lost connection?
404
+ end
405
+
406
+ if @method_retriable_error_states.include?(state)
407
+ if try_count < @method_try_count_max
408
+ log_debug(%Q{State #{state} is retriable, retrying...})
409
+ return true
410
+ else
411
+ log_debug(%Q{State #{state} is retriable, but the upper limit (#{
412
+ @method_try_count_max}) of tries has been reached})
413
+ return false
414
+ end
415
+ else
416
+ return false
417
+ end
418
+ end
419
+
420
+ # error reporting helper
421
+ def get_error_fields(pgerr)
422
+ PGx.const_get('ERROR_FIELDCODES').map { |key, value|
423
+ begin
424
+ errval = pgerr.result.error_field(value)
425
+ rescue PGError => e
426
+ errval = '?'
427
+ end
428
+ %Q{#{key}=#{errval}}
429
+ }.join(', ')
430
+ end
431
+
432
+ # error reporting helper
433
+ def get_error_message(pgerr)
434
+ begin
435
+ errval = pgerr.result.error_message
436
+ rescue PGError => e
437
+ errval = '?'
438
+ end
439
+ errval.chomp
440
+ end
441
+
442
+ # logging helper
443
+ def log_error(str)
444
+ @logger.each { |log| log.error { str } }
445
+ end
446
+
447
+ # logging helper
448
+ def log_warn(str)
449
+ @logger.each { |log| log.warn { str } }
450
+ end
451
+
452
+ # logging helper
453
+ def log_info(str)
454
+ @logger.each { |log| log.info { str } }
455
+ end
456
+
457
+ # logging helper
458
+ def log_debug(str)
459
+ @logger.each { |log| log.debug { str } }
460
+ end
461
+
462
+ end # PGx::Connection class
463
+
464
+ end # module PGx
metadata ADDED
@@ -0,0 +1,47 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pgx
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Kiriakos Georgiou
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-01-10 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |2
14
+ A pure ruby "thin" extension of pg that provides logging,
15
+ transaction retrying, and database reconnecting functionality.
16
+ email: kg.pgx@olympiakos.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - lib/pgx.rb
22
+ homepage: http://www.mockbites.com/articles/tech/pgx
23
+ licenses:
24
+ - MIT
25
+ metadata: {}
26
+ post_install_message:
27
+ rdoc_options: []
28
+ require_paths:
29
+ - lib
30
+ required_ruby_version: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: 1.9.3
35
+ required_rubygems_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ requirements: []
41
+ rubyforge_project:
42
+ rubygems_version: 2.4.5.1
43
+ signing_key:
44
+ specification_version: 4
45
+ summary: A thin extension of pg
46
+ test_files: []
47
+ has_rdoc: