pg_pipeline 0.2.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.
@@ -0,0 +1,420 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg"
4
+ require "async"
5
+ require "async/semaphore"
6
+ require "async/notification"
7
+
8
+ require_relative "errors"
9
+ require_relative "connection_driver"
10
+ require_relative "server_caps"
11
+
12
+ module PgPipeline
13
+ class Pool
14
+ DEFAULT_PIPELINE_SIZE = 4
15
+ DEFAULT_PINNED_SIZE = 2
16
+ DEFAULT_RECONNECT_INTERVAL = 0.5
17
+ DEFAULT_RECONNECT_BACKOFF_MAX = 30.0
18
+ DEFAULT_HEALTH_INTERVAL = 10.0
19
+ DEFAULT_HEALTH_TIMEOUT = 5.0
20
+ DISCARD_SEQUENCES_SERVER_VERSION = 90_400
21
+
22
+ attr_reader :reconnects, :pipeline_size
23
+
24
+ def initialize(connection_args,
25
+ pipeline_size: DEFAULT_PIPELINE_SIZE,
26
+ pinned_size: DEFAULT_PINNED_SIZE,
27
+ max_pending: ConnectionDriver::DEFAULT_MAX_PENDING,
28
+ max_in_flight: ConnectionDriver::DEFAULT_MAX_IN_FLIGHT,
29
+ reconnect: true,
30
+ reconnect_interval: DEFAULT_RECONNECT_INTERVAL,
31
+ reconnect_backoff_max: DEFAULT_RECONNECT_BACKOFF_MAX,
32
+ health_check: true,
33
+ health_interval: DEFAULT_HEALTH_INTERVAL,
34
+ health_timeout: DEFAULT_HEALTH_TIMEOUT,
35
+ cancel_pinned_on_abort: true)
36
+ @connection_args = connection_args
37
+ @pipeline_size = PoolOps.positive_integer!(pipeline_size, :pipeline_size)
38
+ @pinned_size = PoolOps.nonnegative_integer!(pinned_size, :pinned_size)
39
+ @max_pending = max_pending
40
+ @max_in_flight = max_in_flight
41
+ @reconnect = reconnect
42
+ @reconnect_interval = Float(reconnect_interval)
43
+ @reconnect_backoff_max = Float(reconnect_backoff_max)
44
+ @health_check = health_check
45
+ @health_interval = Float(health_interval)
46
+ @health_timeout = Float(health_timeout)
47
+ @cancel_pinned_on_abort = cancel_pinned_on_abort
48
+
49
+ @drivers = []
50
+ @driver_backoff = []
51
+ @driver_attempts = []
52
+ @driver_last_health = []
53
+ @rr = 0
54
+ @reconnects = 0
55
+ @health_failures = 0
56
+ @supervisor_error = nil
57
+ @supervisor = nil
58
+
59
+ @pinned_free = []
60
+ @pinned_in_use = {}
61
+ @pinned_gate = nil
62
+ @pinned_error = nil
63
+ @pinned_active = 0
64
+ @pinned_owners = Hash.new(0)
65
+ @pinned_idle = Async::Notification.new
66
+
67
+ @started = false
68
+ @closing = false
69
+ end
70
+
71
+ def start(parent: Async::Task.current)
72
+ raise Error, "pool already started" if @started
73
+
74
+ begin
75
+ @pipeline_size.times { @drivers << start_pipeline_driver(parent) }
76
+ @driver_backoff = Array.new(@drivers.size, 0.0)
77
+ @driver_attempts = Array.new(@drivers.size, 0)
78
+ @driver_last_health = Array.new(@drivers.size, monotonic)
79
+ @pinned_gate = Async::Semaphore.new(@pinned_size) if @pinned_size.positive?
80
+ rescue Exception
81
+ cleanup_partial_start
82
+ raise
83
+ end
84
+
85
+ @started = true
86
+ @supervisor = parent.async { supervise } if @reconnect || @health_check
87
+ self
88
+ end
89
+
90
+ def closing? = @closing
91
+
92
+ def pipeline_driver
93
+ ensure_available!
94
+
95
+ driver, @rr = PoolOps.select_driver(@drivers, @rr)
96
+ raise NotDispatchedError, "no live pipeline connections; request was not dispatched" unless driver
97
+
98
+ driver
99
+ end
100
+
101
+ def with_pinned
102
+ ensure_available!
103
+ raise @pinned_error if @pinned_error
104
+ raise Error, "pinned pool is disabled (pinned_size=0)" if @pinned_size.zero?
105
+
106
+ @pinned_gate.acquire do
107
+ raise @pinned_error if @pinned_error
108
+ raise ShutdownError, "pool is closing" if @closing
109
+
110
+ owner = Async::Task.current
111
+ conn = nil
112
+ @pinned_active += 1
113
+ @pinned_owners[owner] += 1
114
+
115
+ begin
116
+ conn = @pinned_free.pop || PoolOps.new_connection(@connection_args)
117
+ raise @pinned_error if @pinned_error
118
+ raise ShutdownError, "pool is closing" if @closing
119
+
120
+ @pinned_in_use[conn] = owner
121
+ yield conn
122
+ ensure
123
+ @pinned_in_use.delete(conn) if conn
124
+ release_pinned(owner, conn)
125
+ end
126
+ end
127
+ end
128
+
129
+ def stats
130
+ {
131
+ pipeline: {
132
+ size: @pipeline_size,
133
+ live: @drivers.count(&:available?),
134
+ drivers: @drivers.map(&:stats)
135
+ },
136
+ pinned: {
137
+ size: @pinned_size,
138
+ active: @pinned_active,
139
+ free: @pinned_free.size,
140
+ in_use: @pinned_in_use.size
141
+ },
142
+ reconnects: @reconnects,
143
+ health_failures: @health_failures,
144
+ supervisor_error: @supervisor_error&.message,
145
+ closing: @closing,
146
+ pinned_error: @pinned_error&.message
147
+ }
148
+ end
149
+
150
+ def graceful_close
151
+ return unless @started
152
+
153
+ if @pinned_owners[Async::Task.current].positive?
154
+ raise Error, "cannot close the pool from inside Client#session/transaction"
155
+ end
156
+
157
+ @closing = true
158
+ @started = false
159
+ stop_supervisor
160
+ @drivers.each(&:graceful_close)
161
+ wait_for_pinned_idle
162
+ close_free_pinned
163
+ nil
164
+ end
165
+
166
+ def abort!
167
+ return unless @started || @closing
168
+
169
+ @closing = true
170
+ @started = false
171
+ stop_supervisor
172
+ cancel_in_use_pinned if @cancel_pinned_on_abort
173
+ @drivers.each(&:abort!)
174
+ close_free_pinned
175
+ nil
176
+ end
177
+
178
+ private
179
+
180
+ def supervise
181
+ parent = Async::Task.current
182
+ until @closing
183
+ begin
184
+ reap_and_replace(parent) if @reconnect
185
+ health_probe if @health_check
186
+ @supervisor_error = nil
187
+ rescue StandardError => e
188
+ # Keep the loop alive: one probe/reconnect failure must not permanently
189
+ # disable health checks and replacement for the worker lifetime.
190
+ @supervisor_error = e
191
+ end
192
+ parent.sleep(@reconnect_interval)
193
+ end
194
+ end
195
+
196
+ def reap_and_replace(parent)
197
+ now = monotonic
198
+ ensure_driver_slots!
199
+
200
+ @drivers.each_index do |index|
201
+ driver = @drivers[index]
202
+ next if driver.available?
203
+ next unless driver.dead?
204
+ next if now < (@driver_backoff[index] || 0.0)
205
+
206
+ begin
207
+ @drivers[index] = start_pipeline_driver(parent)
208
+ @driver_backoff[index] = 0.0
209
+ @driver_attempts[index] = 0
210
+ @driver_last_health[index] = monotonic
211
+ @reconnects += 1
212
+ rescue StandardError
213
+ @driver_attempts[index] = (@driver_attempts[index] || 0) + 1
214
+ @driver_backoff[index] = now + next_backoff(@driver_attempts[index])
215
+ end
216
+ end
217
+ end
218
+
219
+ def health_probe
220
+ now = monotonic
221
+ ensure_driver_slots!
222
+
223
+ @drivers.each_index do |index|
224
+ driver = @drivers[index]
225
+ next unless driver.available?
226
+ next unless driver.load.zero?
227
+ next if now - (@driver_last_health[index] || 0.0) < @health_interval
228
+
229
+ @driver_last_health[index] = now
230
+ next if driver.health_check(@health_timeout)
231
+
232
+ @health_failures += 1
233
+ driver.abort!
234
+ end
235
+ end
236
+
237
+ def ensure_driver_slots!
238
+ size = @drivers.size
239
+ @driver_backoff = Array.new(size, 0.0) if @driver_backoff.nil? || @driver_backoff.size != size
240
+ @driver_attempts = Array.new(size, 0) if @driver_attempts.nil? || @driver_attempts.size != size
241
+ @driver_last_health = Array.new(size, 0.0) if @driver_last_health.nil? || @driver_last_health.size != size
242
+ end
243
+
244
+ def next_backoff(attempts)
245
+ delay = @reconnect_interval * (2**(attempts - 1))
246
+ delay < @reconnect_backoff_max ? delay : @reconnect_backoff_max
247
+ end
248
+
249
+ def cancel_in_use_pinned
250
+ @pinned_in_use.keys.each do |conn|
251
+ conn.cancel if conn.respond_to?(:cancel)
252
+ rescue StandardError
253
+ nil
254
+ end
255
+ end
256
+
257
+ def stop_supervisor
258
+ @supervisor&.stop
259
+ @supervisor = nil
260
+ rescue StandardError
261
+ nil
262
+ end
263
+
264
+ def ensure_available!
265
+ raise Error, "pool not started" unless @started
266
+ raise ShutdownError, "pool is closing" if @closing
267
+ end
268
+
269
+ def start_pipeline_driver(parent)
270
+ conn = PoolOps.new_connection(@connection_args)
271
+ driver = ConnectionDriver.new(conn, max_pending: @max_pending, max_in_flight: @max_in_flight)
272
+ driver.start(parent: parent)
273
+ rescue Exception
274
+ PoolOps.safe_close(conn) if conn
275
+ raise
276
+ end
277
+
278
+ def release_pinned(owner, conn)
279
+ begin
280
+ if conn
281
+ if @closing
282
+ PoolOps.safe_close(conn)
283
+ else
284
+ recycled = recycle_pinned_connection(conn)
285
+ @pinned_free << recycled if recycled
286
+ end
287
+ end
288
+ ensure
289
+ @pinned_active -= 1
290
+ @pinned_owners[owner] -= 1
291
+ @pinned_owners.delete(owner) if @pinned_owners[owner].zero?
292
+ @pinned_idle.signal if @pinned_active.zero?
293
+ end
294
+ end
295
+
296
+ def recycle_pinned_connection(conn)
297
+ if conn.server_version < DISCARD_SEQUENCES_SERVER_VERSION
298
+ PoolOps.safe_close(conn)
299
+ return PoolOps.new_connection(@connection_args)
300
+ end
301
+
302
+ PoolOps.sanitize_pinned_connection(conn)
303
+ conn
304
+ rescue StandardError => cleanup_error
305
+ PoolOps.safe_close(conn)
306
+
307
+ begin
308
+ PoolOps.new_connection(@connection_args)
309
+ rescue StandardError => replacement_error
310
+ @pinned_error = ConnectionLostError.new(
311
+ "pinned connection reset failed (#{cleanup_error.class}: #{cleanup_error.message}) " \
312
+ "and replacement failed (#{replacement_error.class}: #{replacement_error.message})"
313
+ )
314
+ nil
315
+ end
316
+ end
317
+
318
+ def wait_for_pinned_idle
319
+ @pinned_idle.wait while @pinned_active.positive?
320
+ end
321
+
322
+ def cleanup_partial_start
323
+ @drivers.each do |driver|
324
+ driver.abort!
325
+ rescue StandardError
326
+ nil
327
+ end
328
+ @drivers.clear
329
+ close_free_pinned
330
+ @started = false
331
+ @closing = false
332
+ end
333
+
334
+ def close_free_pinned
335
+ @pinned_free.each { |conn| PoolOps.safe_close(conn) }
336
+ @pinned_free.clear
337
+ end
338
+
339
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
340
+ end
341
+
342
+ module PoolOps
343
+ module_function
344
+
345
+ def positive_integer!(value, name)
346
+ integer = Integer(value)
347
+ raise ArgumentError, "#{name} must be >= 1" if integer < 1
348
+
349
+ integer
350
+ rescue ArgumentError, TypeError
351
+ raise ArgumentError, "#{name} must be an integer >= 1"
352
+ end
353
+
354
+ def nonnegative_integer!(value, name)
355
+ integer = Integer(value)
356
+ raise ArgumentError, "#{name} must be >= 0" if integer.negative?
357
+
358
+ integer
359
+ rescue ArgumentError, TypeError
360
+ raise ArgumentError, "#{name} must be an integer >= 0"
361
+ end
362
+
363
+ def select_driver(drivers, rr)
364
+ min_load = nil
365
+ count = 0
366
+ drivers.each do |driver|
367
+ next unless driver.available?
368
+
369
+ load = driver.load
370
+ if min_load.nil? || load < min_load
371
+ min_load = load
372
+ count = 1
373
+ elsif load == min_load
374
+ count += 1
375
+ end
376
+ end
377
+ return [nil, rr] if count.zero?
378
+
379
+ target = rr % count
380
+ index = 0
381
+ drivers.each do |driver|
382
+ next unless driver.available?
383
+ next unless driver.load == min_load
384
+
385
+ return [driver, rr + 1] if index == target
386
+
387
+ index += 1
388
+ end
389
+ [nil, rr]
390
+ end
391
+
392
+ def new_connection(connection_args)
393
+ connection_args.nil? ? PG::Connection.new : PG::Connection.new(connection_args)
394
+ end
395
+
396
+ def sanitize_pinned_connection(conn)
397
+ raise ConnectionLostError, "pinned connection is closed" if conn.finished?
398
+ raise ConnectionLostError, "pinned connection is bad" unless conn.status == PG::CONNECTION_OK
399
+
400
+ case conn.transaction_status
401
+ when PG::PQTRANS_IDLE
402
+ nil
403
+ when PG::PQTRANS_INTRANS, PG::PQTRANS_INERROR
404
+ conn.exec("ROLLBACK")
405
+ else
406
+ raise ConnectionLostError,
407
+ "pinned connection returned in unsafe transaction state #{conn.transaction_status}"
408
+ end
409
+
410
+ conn.exec("DISCARD ALL")
411
+ true
412
+ end
413
+
414
+ def safe_close(conn)
415
+ conn.close unless conn.finished?
416
+ rescue StandardError
417
+ nil
418
+ end
419
+ end
420
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/notification"
4
+
5
+ require_relative "errors"
6
+
7
+ module PgPipeline
8
+ class Request
9
+ attr_reader :sql, :params, :condition
10
+ attr_accessor :state, :cancelled, :settled, :result_seen, :query_boundary_seen,
11
+ :result, :error
12
+
13
+ def initialize(sql:, params: nil)
14
+ @sql = sql.to_s.dup.freeze
15
+ @params = RequestOps.snapshot_params(params)
16
+ @state = :new
17
+ @condition = Async::Notification.new
18
+ @cancelled = false
19
+ @settled = false
20
+ @result_seen = false
21
+ @query_boundary_seen = false
22
+ @result = nil
23
+ @error = nil
24
+ end
25
+
26
+ def queued! = RequestOps.transition!(self, :new, :queued)
27
+ def dispatched! = RequestOps.transition!(self, :queued, :dispatched)
28
+ def accept_result(result) = RequestOps.accept_result(self, result)
29
+ def record_error!(error, result: nil) = RequestOps.record_error!(self, error, result)
30
+ def query_boundary! = RequestOps.query_boundary!(self)
31
+ def finish! = RequestOps.finish!(self)
32
+ def reject!(error) = RequestOps.reject!(self, error)
33
+ def cancel! = RequestOps.cancel!(self)
34
+ def wait = RequestOps.wait(self)
35
+
36
+ def query_boundary_seen? = @query_boundary_seen
37
+ def cancelled? = @cancelled
38
+ def settled? = @settled
39
+ end
40
+
41
+ module RequestOps
42
+ module_function
43
+
44
+ def snapshot_params(params)
45
+ values = params.nil? ? [] : params
46
+ raise ArgumentError, "params must be an Array" unless values.is_a?(Array)
47
+
48
+ values.map { |value| snapshot_value(value) }.freeze
49
+ end
50
+
51
+ def snapshot_value(value)
52
+ case value
53
+ when String
54
+ value.dup.freeze
55
+ when Hash
56
+ value.each_with_object({}) do |(key, item), copy|
57
+ copy[key] = item.is_a?(String) ? item.dup.freeze : item
58
+ end.freeze
59
+ else
60
+ value
61
+ end
62
+ end
63
+
64
+ def transition!(req, from, to)
65
+ unless req.state == from
66
+ raise ProtocolError, "invalid request transition #{req.state.inspect} -> #{to.inspect}"
67
+ end
68
+
69
+ req.state = to
70
+ end
71
+
72
+ def accept_result(req, result)
73
+ assert_result_slot!(req)
74
+ req.result_seen = true
75
+
76
+ if req.cancelled
77
+ clear_result(result)
78
+ else
79
+ req.result = result
80
+ end
81
+ end
82
+
83
+ def record_error!(req, error, result)
84
+ assert_result_slot!(req)
85
+ req.result_seen = true
86
+
87
+ if req.cancelled
88
+ clear_result(result)
89
+ else
90
+ req.error ||= error
91
+ end
92
+ end
93
+
94
+ def query_boundary!(req)
95
+ raise ProtocolError, "query boundary before query result" unless req.result_seen
96
+ raise ProtocolError, "duplicate query boundary" if req.query_boundary_seen
97
+
98
+ req.query_boundary_seen = true
99
+ end
100
+
101
+ def finish!(req)
102
+ raise ProtocolError, "sync arrived before query boundary" unless req.query_boundary_seen
103
+ return if req.settled
104
+
105
+ req.settled = true
106
+ req.state = :done
107
+ req.condition.signal unless req.cancelled
108
+ end
109
+
110
+ def reject!(req, error)
111
+ return if req.settled
112
+
113
+ clear_result(req.result)
114
+ req.result = nil
115
+ req.error ||= error
116
+ req.settled = true
117
+ req.state = :done
118
+ req.condition.signal unless req.cancelled
119
+ end
120
+
121
+ def cancel!(req)
122
+ return if req.cancelled
123
+
124
+ req.cancelled = true
125
+ clear_result(req.result)
126
+ req.result = nil
127
+ req.error.clear_result! if req.error.respond_to?(:clear_result!)
128
+ end
129
+
130
+ def wait(req)
131
+ req.condition.wait unless req.settled
132
+ raise req.error if req.error
133
+
134
+ req.result
135
+ end
136
+
137
+ def assert_result_slot!(req)
138
+ raise ProtocolError, "result arrived after query boundary" if req.query_boundary_seen
139
+ raise ProtocolError, "multiple results for one pipeline unit" if req.result_seen
140
+ end
141
+
142
+ def clear_result(result)
143
+ result.clear if result.respond_to?(:clear)
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg"
4
+
5
+ require_relative "errors"
6
+
7
+ module PgPipeline
8
+ class ServerCaps
9
+ REQUIRED_LIBPQ_VERSION = 140_000
10
+ FAST_SYNC_LIBPQ_VERSION = 170_000
11
+ PROTOCOL_VERSION = 3
12
+
13
+ attr_reader :libpq_version, :protocol_version, :pipeline_api,
14
+ :fast_sync_api, :raw_pipeline_sync_api
15
+
16
+ def initialize(libpq_version:, protocol_version:, pipeline_api: true,
17
+ fast_sync_api: false, raw_pipeline_sync_api: false)
18
+ @libpq_version = Integer(libpq_version)
19
+ @protocol_version = Integer(protocol_version)
20
+ @pipeline_api = pipeline_api
21
+ @fast_sync_api = fast_sync_api
22
+ @raw_pipeline_sync_api = raw_pipeline_sync_api
23
+ end
24
+
25
+ def self.from_connection(conn) = Caps.from_connection(conn)
26
+ def supported? = Caps.supported?(self)
27
+ def assert_supported! = Caps.assert_supported!(self)
28
+ def fast_sync? = Caps.fast_sync?(self)
29
+ def place_sync(conn) = Caps.place_sync(self, conn)
30
+ end
31
+
32
+ module Caps
33
+ module_function
34
+
35
+ def from_connection(conn)
36
+ ServerCaps.new(
37
+ libpq_version: PG.library_version,
38
+ protocol_version: conn.protocol_version,
39
+ pipeline_api: pipeline_api?(conn),
40
+ fast_sync_api: conn.respond_to?(:send_pipeline_sync),
41
+ raw_pipeline_sync_api: conn.respond_to?(:sync_pipeline_sync)
42
+ )
43
+ end
44
+
45
+ def pipeline_api?(conn)
46
+ conn.respond_to?(:enter_pipeline_mode) &&
47
+ conn.respond_to?(:exit_pipeline_mode) &&
48
+ conn.respond_to?(:pipeline_status) &&
49
+ (conn.respond_to?(:sync_pipeline_sync) || conn.respond_to?(:pipeline_sync))
50
+ end
51
+
52
+ def supported?(caps)
53
+ caps.libpq_version >= ServerCaps::REQUIRED_LIBPQ_VERSION &&
54
+ caps.protocol_version == ServerCaps::PROTOCOL_VERSION &&
55
+ caps.pipeline_api
56
+ end
57
+
58
+ def assert_supported!(caps)
59
+ return true if supported?(caps)
60
+
61
+ raise UnsupportedServerError,
62
+ "pg_pipeline requires libpq >= 14, PostgreSQL protocol v3, and " \
63
+ "ruby-pg pipeline bindings; libpq=#{caps.libpq_version}, " \
64
+ "protocol=#{caps.protocol_version}, pipeline_api=#{caps.pipeline_api}"
65
+ end
66
+
67
+ def fast_sync?(caps)
68
+ caps.libpq_version >= ServerCaps::FAST_SYNC_LIBPQ_VERSION && caps.fast_sync_api
69
+ end
70
+
71
+ def place_sync(caps, conn)
72
+ if fast_sync?(caps)
73
+ conn.send_pipeline_sync
74
+ elsif caps.raw_pipeline_sync_api
75
+ conn.sync_pipeline_sync
76
+ else
77
+ conn.pipeline_sync
78
+ end
79
+
80
+ nil
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module PgPipeline
6
+ class Session
7
+ attr_reader :owner_fiber
8
+
9
+ def initialize(conn)
10
+ @conn = conn
11
+ @active = true
12
+ @owner_fiber = Fiber.current
13
+ end
14
+
15
+ def query(sql, params = []) = SessionOps.query(self, sql, params)
16
+ def exec(sql) = SessionOps.exec(self, sql)
17
+ def prepare(name, sql, param_types = nil) = SessionOps.prepare(self, name, sql, param_types)
18
+ def exec_prepared(name, params = []) = SessionOps.exec_prepared(self, name, params)
19
+ def active? = @active
20
+
21
+ private
22
+
23
+ attr_accessor :conn, :active
24
+ end
25
+
26
+ module SessionOps
27
+ module_function
28
+
29
+ def ensure_active!(session)
30
+ conn = connection(session)
31
+ raise Error, "session handle is no longer active" unless session.active? && conn
32
+ return if Fiber.current.equal?(session.owner_fiber)
33
+
34
+ raise Error, "session handle is fiber-local and cannot be used from another fiber"
35
+ end
36
+
37
+ def query(session, sql, params)
38
+ ensure_active!(session)
39
+ connection(session).exec_params(sql, params)
40
+ end
41
+
42
+ def exec(session, sql)
43
+ ensure_active!(session)
44
+ connection(session).exec(sql)
45
+ end
46
+
47
+ def prepare(session, name, sql, param_types)
48
+ ensure_active!(session)
49
+ if param_types
50
+ connection(session).prepare(name, sql, param_types)
51
+ else
52
+ connection(session).prepare(name, sql)
53
+ end
54
+ end
55
+
56
+ def exec_prepared(session, name, params)
57
+ ensure_active!(session)
58
+ connection(session).exec_prepared(name, params)
59
+ end
60
+
61
+ def close!(session)
62
+ session.__send__(:active=, false)
63
+ session.__send__(:conn=, nil)
64
+ nil
65
+ end
66
+
67
+ def connection(session)
68
+ session.__send__(:conn)
69
+ end
70
+ end
71
+ end