pgx 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
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: