pg_pipeline 0.2.4 → 0.3.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/CHANGELOG.md +85 -0
- data/DESIGN.md +40 -0
- data/README.md +105 -1
- data/lib/pg_pipeline/bounded_queue.rb +10 -12
- data/lib/pg_pipeline/client.rb +14 -21
- data/lib/pg_pipeline/connection_driver.rb +215 -109
- data/lib/pg_pipeline/pool.rb +111 -61
- data/lib/pg_pipeline/prepared_statement.rb +1 -1
- data/lib/pg_pipeline/request.rb +69 -11
- data/lib/pg_pipeline/runtime/notification.rb +32 -0
- data/lib/pg_pipeline/runtime/queue.rb +31 -0
- data/lib/pg_pipeline/runtime/semaphore.rb +138 -0
- data/lib/pg_pipeline/runtime/task.rb +124 -0
- data/lib/pg_pipeline/runtime.rb +98 -0
- data/lib/pg_pipeline/session.rb +4 -2
- data/lib/pg_pipeline/session_guard.rb +40 -24
- data/lib/pg_pipeline/version.rb +1 -1
- data/lib/pg_pipeline.rb +1 -0
- metadata +26 -19
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgPipeline
|
|
4
|
+
module Runtime
|
|
5
|
+
class Task
|
|
6
|
+
attr_reader :fiber, :name, :error
|
|
7
|
+
|
|
8
|
+
def self.spawn(name: nil, &block)
|
|
9
|
+
raise ArgumentError, "block required" unless block
|
|
10
|
+
|
|
11
|
+
task = new(name: name)
|
|
12
|
+
task.__send__(:start, &block)
|
|
13
|
+
task
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(name: nil)
|
|
17
|
+
@fiber, @scheduler, @blocker, @result, @error = nil, nil, nil, nil, nil
|
|
18
|
+
@done, @cancelled = false, false
|
|
19
|
+
|
|
20
|
+
@name = name
|
|
21
|
+
@waiters = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def wait(timeout = nil)
|
|
25
|
+
Runtime.with_timeout(timeout) { join }
|
|
26
|
+
raise @error if @error
|
|
27
|
+
|
|
28
|
+
@result
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def stop
|
|
32
|
+
return false if @done
|
|
33
|
+
|
|
34
|
+
@cancelled = true
|
|
35
|
+
|
|
36
|
+
return true if interrupt_fiber
|
|
37
|
+
return true if release_blocker
|
|
38
|
+
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def cancelled? = @cancelled
|
|
43
|
+
|
|
44
|
+
def raise_if_cancelled!
|
|
45
|
+
raise Cancel, "task stopped" if @cancelled
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def enter_block(waiter)
|
|
49
|
+
@blocker = waiter
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def exit_block
|
|
53
|
+
@blocker = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def finished? = @done
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def start(&block)
|
|
61
|
+
@scheduler = Runtime.scheduler!
|
|
62
|
+
this = self
|
|
63
|
+
|
|
64
|
+
scheduled = Fiber.schedule do
|
|
65
|
+
this.__send__(:adopt_fiber, Fiber.current)
|
|
66
|
+
Thread.current[Runtime::CURRENT_TASK_KEY] = this
|
|
67
|
+
begin
|
|
68
|
+
this.__send__(:complete, block.call, nil)
|
|
69
|
+
rescue Cancel
|
|
70
|
+
this.__send__(:complete, nil, nil)
|
|
71
|
+
rescue Exception => e
|
|
72
|
+
this.__send__(:complete, nil, e)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@fiber ||= scheduled
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def adopt_fiber(fiber)
|
|
80
|
+
@fiber ||= fiber
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def interrupt_fiber
|
|
84
|
+
fiber = @fiber
|
|
85
|
+
scheduler = @scheduler
|
|
86
|
+
return false unless fiber&.alive?
|
|
87
|
+
return false unless scheduler&.respond_to?(:fiber_interrupt)
|
|
88
|
+
|
|
89
|
+
result = scheduler.fiber_interrupt(fiber, Cancel.new("task stopped"))
|
|
90
|
+
result != false
|
|
91
|
+
rescue FiberError
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def release_blocker
|
|
96
|
+
waiter = @blocker or return false
|
|
97
|
+
Runtime.wake(waiter, waiter[:blocker])
|
|
98
|
+
waiter[:fiber].alive?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def complete(result, error)
|
|
102
|
+
return if @done
|
|
103
|
+
|
|
104
|
+
@result = result
|
|
105
|
+
@error = error
|
|
106
|
+
@done = true
|
|
107
|
+
wake_waiters
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def join
|
|
111
|
+
return if @done
|
|
112
|
+
|
|
113
|
+
Runtime.with_waiter(self, @waiters) do |waiter|
|
|
114
|
+
Runtime.park(self, waiter) { @done }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def wake_waiters
|
|
119
|
+
pending, @waiters = @waiters, []
|
|
120
|
+
pending.each { |waiter| Runtime.wake_dequeued(waiter, self) }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module PgPipeline
|
|
8
|
+
module Runtime
|
|
9
|
+
class Cancel < Exception; end
|
|
10
|
+
class TimeoutError < Error; end
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
CURRENT_TASK_KEY = :pg_pipeline_current_task
|
|
15
|
+
WAITER_KEY = :pg_pipeline_waiter
|
|
16
|
+
|
|
17
|
+
def spawn(name: nil, &block)
|
|
18
|
+
Task.spawn(name: name, &block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def scheduler!
|
|
22
|
+
Fiber.scheduler or raise Error, "operation requires an active Fiber scheduler"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def current_task
|
|
26
|
+
Thread.current[CURRENT_TASK_KEY]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_waiter(blocker)
|
|
30
|
+
waiter = Thread.current[WAITER_KEY] ||= {
|
|
31
|
+
fiber: nil, scheduler: nil, ready: false, blocker: nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if waiter[:blocker]
|
|
35
|
+
raise Error, "waiter already parked on #{waiter[:blocker].class}; " \
|
|
36
|
+
"a fiber may only park in one place at a time"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
waiter[:fiber] = Fiber.current
|
|
40
|
+
waiter[:scheduler] = scheduler!
|
|
41
|
+
waiter[:ready] = false
|
|
42
|
+
waiter[:queued] = false
|
|
43
|
+
waiter[:blocker] = blocker
|
|
44
|
+
waiter
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def park(blocker, waiter)
|
|
48
|
+
scheduler = waiter[:scheduler]
|
|
49
|
+
task = current_task
|
|
50
|
+
task&.enter_block(waiter)
|
|
51
|
+
|
|
52
|
+
until waiter[:ready] || yield
|
|
53
|
+
scheduler.block(blocker, nil)
|
|
54
|
+
task&.raise_if_cancelled!
|
|
55
|
+
end
|
|
56
|
+
nil
|
|
57
|
+
ensure
|
|
58
|
+
waiter[:blocker] = nil
|
|
59
|
+
task&.exit_block
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def with_waiter(blocker, waiters)
|
|
63
|
+
waiter = build_waiter(blocker)
|
|
64
|
+
waiter[:queued] = true
|
|
65
|
+
waiters << waiter
|
|
66
|
+
yield waiter
|
|
67
|
+
ensure
|
|
68
|
+
waiters.delete(waiter) if waiter[:queued]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def wake_dequeued(waiter, blocker)
|
|
72
|
+
waiter[:queued] = false
|
|
73
|
+
wake(waiter, blocker)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def with_timeout(duration)
|
|
77
|
+
return yield if duration.nil?
|
|
78
|
+
|
|
79
|
+
timeout = Float(duration)
|
|
80
|
+
raise ArgumentError, "timeout must be non-negative and finite" unless timeout.finite? && timeout >= 0
|
|
81
|
+
|
|
82
|
+
::Timeout.timeout(timeout, TimeoutError) { yield }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def wake(waiter, blocker)
|
|
86
|
+
waiter[:ready] = true
|
|
87
|
+
fiber = waiter[:fiber]
|
|
88
|
+
return unless fiber.alive?
|
|
89
|
+
|
|
90
|
+
waiter[:scheduler].unblock(blocker, fiber)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
require_relative "runtime/notification"
|
|
96
|
+
require_relative "runtime/queue"
|
|
97
|
+
require_relative "runtime/semaphore"
|
|
98
|
+
require_relative "runtime/task"
|
data/lib/pg_pipeline/session.rb
CHANGED
|
@@ -4,6 +4,8 @@ require_relative "errors"
|
|
|
4
4
|
|
|
5
5
|
module PgPipeline
|
|
6
6
|
class Session
|
|
7
|
+
EMPTY_PARAMS = [].freeze
|
|
8
|
+
|
|
7
9
|
attr_reader :owner_fiber
|
|
8
10
|
|
|
9
11
|
def initialize(conn)
|
|
@@ -12,10 +14,10 @@ module PgPipeline
|
|
|
12
14
|
@owner_fiber = Fiber.current
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
def query(sql, params =
|
|
17
|
+
def query(sql, params = EMPTY_PARAMS) = SessionOps.query(self, sql, params)
|
|
16
18
|
def exec(sql, params = nil) = SessionOps.exec(self, sql, params)
|
|
17
19
|
def prepare(name, sql, param_types = nil) = SessionOps.prepare(self, name, sql, param_types)
|
|
18
|
-
def exec_prepared(name, params =
|
|
20
|
+
def exec_prepared(name, params = EMPTY_PARAMS) = SessionOps.exec_prepared(self, name, params)
|
|
19
21
|
def active? = @active
|
|
20
22
|
|
|
21
23
|
private
|
|
@@ -29,6 +29,11 @@ module PgPipeline
|
|
|
29
29
|
"pg_export_snapshot" => /\bpg_export_snapshot\s*\(/i
|
|
30
30
|
}.freeze
|
|
31
31
|
|
|
32
|
+
PATTERN_PREFILTER = /\binto\b/i
|
|
33
|
+
NEEDS_MASK = /['"]|--|\/\*|\$[A-Za-z_0-9]*\$/
|
|
34
|
+
LEADING_KEYWORD = /\A\s*([a-zA-Z_]+)/
|
|
35
|
+
WHITESPACE_BYTES = [9, 10, 11, 12, 13, 32].freeze
|
|
36
|
+
|
|
32
37
|
def assert_multiplexable!(sql, mode: :default)
|
|
33
38
|
assert_multiplexable_normalized!(sql, mode: normalize_mode!(mode))
|
|
34
39
|
end
|
|
@@ -45,6 +50,7 @@ module PgPipeline
|
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
GUARD_CACHE_LIMIT = 2048
|
|
53
|
+
SAFE = :safe
|
|
48
54
|
|
|
49
55
|
def unsafe_reason(sql, mode: :default)
|
|
50
56
|
unsafe_reason_normalized(sql, mode: normalize_mode!(mode))
|
|
@@ -53,11 +59,12 @@ module PgPipeline
|
|
|
53
59
|
def unsafe_reason_normalized(sql, mode:)
|
|
54
60
|
key = sql.to_s
|
|
55
61
|
cache = guard_cache.fetch(mode)
|
|
56
|
-
|
|
62
|
+
cached = cache[key]
|
|
63
|
+
return (cached.equal?(SAFE) ? nil : cached) if cached
|
|
57
64
|
|
|
58
65
|
reason = compute_unsafe_reason(key, mode)
|
|
59
|
-
cache.
|
|
60
|
-
cache[key] = reason
|
|
66
|
+
cache.shift if cache.size >= GUARD_CACHE_LIMIT
|
|
67
|
+
cache[key] = reason || SAFE
|
|
61
68
|
reason
|
|
62
69
|
end
|
|
63
70
|
|
|
@@ -66,12 +73,13 @@ module PgPipeline
|
|
|
66
73
|
end
|
|
67
74
|
|
|
68
75
|
def compute_unsafe_reason(sql, mode)
|
|
69
|
-
code = code_only(sql)
|
|
70
|
-
lead = code[
|
|
76
|
+
code = NEEDS_MASK.match?(sql) ? code_only(sql) : sql
|
|
77
|
+
lead = code[LEADING_KEYWORD, 1]&.downcase
|
|
71
78
|
|
|
72
79
|
return "empty" unless lead
|
|
73
80
|
return "leading:#{lead}" unless ALLOWED_LEADING.include?(lead)
|
|
74
81
|
return "multiple-statements" if multiple_statements?(code)
|
|
82
|
+
return nil unless code.include?("(") || code.match?(PATTERN_PREFILTER)
|
|
75
83
|
|
|
76
84
|
FORBIDDEN_PATTERNS.each do |name, pattern|
|
|
77
85
|
return name if code.match?(pattern)
|
|
@@ -95,47 +103,50 @@ module PgPipeline
|
|
|
95
103
|
|
|
96
104
|
def code_only(sql)
|
|
97
105
|
source = sql.to_s.b
|
|
98
|
-
|
|
106
|
+
size = source.bytesize
|
|
107
|
+
output = String.new(capacity: size, encoding: Encoding::BINARY)
|
|
99
108
|
index = 0
|
|
100
109
|
block_depth = 0
|
|
101
110
|
|
|
102
|
-
while index <
|
|
111
|
+
while index < size
|
|
112
|
+
byte = source.getbyte(index)
|
|
113
|
+
nxt = index + 1 < size ? source.getbyte(index + 1) : nil
|
|
114
|
+
|
|
103
115
|
if block_depth.positive?
|
|
104
|
-
if
|
|
116
|
+
if byte == 47 && nxt == 42
|
|
105
117
|
block_depth += 1
|
|
106
118
|
output << " "
|
|
107
119
|
index += 2
|
|
108
|
-
elsif
|
|
120
|
+
elsif byte == 42 && nxt == 47
|
|
109
121
|
block_depth -= 1
|
|
110
122
|
output << " "
|
|
111
123
|
index += 2
|
|
112
124
|
else
|
|
113
|
-
output << (
|
|
125
|
+
output << (byte == 10 ? 10 : 32)
|
|
114
126
|
index += 1
|
|
115
127
|
end
|
|
116
128
|
next
|
|
117
129
|
end
|
|
118
130
|
|
|
119
|
-
if
|
|
131
|
+
if byte == 45 && nxt == 45
|
|
120
132
|
newline = source.index("\n", index + 2)
|
|
121
133
|
if newline
|
|
122
|
-
output << " " * (newline - index) << "\n"
|
|
134
|
+
output << (" " * (newline - index)) << "\n"
|
|
123
135
|
index = newline + 1
|
|
124
136
|
else
|
|
125
|
-
output << " " * (
|
|
137
|
+
output << (" " * (size - index))
|
|
126
138
|
break
|
|
127
139
|
end
|
|
128
140
|
next
|
|
129
141
|
end
|
|
130
142
|
|
|
131
|
-
if
|
|
143
|
+
if byte == 47 && nxt == 42
|
|
132
144
|
block_depth = 1
|
|
133
145
|
output << " "
|
|
134
146
|
index += 2
|
|
135
147
|
next
|
|
136
148
|
end
|
|
137
149
|
|
|
138
|
-
byte = source.getbyte(index)
|
|
139
150
|
if byte == 39
|
|
140
151
|
index = mask_quoted(source, output, index, 39, escape_backslash: escape_string_prefix?(source, index))
|
|
141
152
|
next
|
|
@@ -147,18 +158,18 @@ module PgPipeline
|
|
|
147
158
|
end
|
|
148
159
|
|
|
149
160
|
if byte == 36
|
|
150
|
-
remainder = source.byteslice(index,
|
|
161
|
+
remainder = source.byteslice(index, size - index)
|
|
151
162
|
tag = remainder.match(/\A\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)&.[](0)
|
|
152
163
|
if tag
|
|
153
164
|
closing = source.index(tag, index + tag.bytesize)
|
|
154
|
-
finish = closing ? closing + tag.bytesize :
|
|
155
|
-
output << " " * (finish - index)
|
|
165
|
+
finish = closing ? closing + tag.bytesize : size
|
|
166
|
+
output << (" " * (finish - index))
|
|
156
167
|
index = finish
|
|
157
168
|
next
|
|
158
169
|
end
|
|
159
170
|
end
|
|
160
171
|
|
|
161
|
-
output <<
|
|
172
|
+
output << byte
|
|
162
173
|
index += 1
|
|
163
174
|
end
|
|
164
175
|
|
|
@@ -209,12 +220,17 @@ module PgPipeline
|
|
|
209
220
|
private_class_method :escape_string_prefix?
|
|
210
221
|
|
|
211
222
|
def multiple_statements?(code)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
223
|
+
first = code.index(";")
|
|
224
|
+
return false unless first
|
|
225
|
+
|
|
226
|
+
index = first + 1
|
|
227
|
+
size = code.bytesize
|
|
228
|
+
while index < size
|
|
229
|
+
return true unless WHITESPACE_BYTES.include?(code.getbyte(index))
|
|
215
230
|
|
|
216
|
-
|
|
217
|
-
|
|
231
|
+
index += 1
|
|
232
|
+
end
|
|
233
|
+
false
|
|
218
234
|
end
|
|
219
235
|
end
|
|
220
236
|
end
|
data/lib/pg_pipeline/version.rb
CHANGED
data/lib/pg_pipeline.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pg_pipeline
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Roman Hajdarov
|
|
@@ -9,20 +9,6 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: async
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - "~>"
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '2.42'
|
|
19
|
-
type: :runtime
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - "~>"
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: '2.42'
|
|
26
12
|
- !ruby/object:Gem::Dependency
|
|
27
13
|
name: pg
|
|
28
14
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -43,6 +29,20 @@ dependencies:
|
|
|
43
29
|
- - "<"
|
|
44
30
|
- !ruby/object:Gem::Version
|
|
45
31
|
version: '2'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: async
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '2.42'
|
|
39
|
+
type: :development
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '2.42'
|
|
46
46
|
- !ruby/object:Gem::Dependency
|
|
47
47
|
name: rake
|
|
48
48
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -101,10 +101,12 @@ dependencies:
|
|
|
101
101
|
version: '0.2'
|
|
102
102
|
description: |
|
|
103
103
|
A driver-adjacent Ruby control-plane over ruby-pg/libpq. It multiplexes
|
|
104
|
-
independent, session-neutral extended-protocol operations from many
|
|
105
|
-
|
|
104
|
+
independent, session-neutral extended-protocol operations from many fibers
|
|
105
|
+
onto a small number of PostgreSQL connections while keeping explicit
|
|
106
106
|
transactions and session-changing work on exclusive pinned connections.
|
|
107
|
-
|
|
107
|
+
Requires any Fiber::Scheduler host (Async::Scheduler, Itsi::Scheduler, …);
|
|
108
|
+
the gem does not depend on a particular reactor. Control-plane only: all
|
|
109
|
+
wire work stays in libpq.
|
|
108
110
|
email:
|
|
109
111
|
- romanhajdarov@gmail.com
|
|
110
112
|
executables: []
|
|
@@ -123,6 +125,11 @@ files:
|
|
|
123
125
|
- lib/pg_pipeline/pool.rb
|
|
124
126
|
- lib/pg_pipeline/prepared_statement.rb
|
|
125
127
|
- lib/pg_pipeline/request.rb
|
|
128
|
+
- lib/pg_pipeline/runtime.rb
|
|
129
|
+
- lib/pg_pipeline/runtime/notification.rb
|
|
130
|
+
- lib/pg_pipeline/runtime/queue.rb
|
|
131
|
+
- lib/pg_pipeline/runtime/semaphore.rb
|
|
132
|
+
- lib/pg_pipeline/runtime/task.rb
|
|
126
133
|
- lib/pg_pipeline/server_caps.rb
|
|
127
134
|
- lib/pg_pipeline/session.rb
|
|
128
135
|
- lib/pg_pipeline/session_guard.rb
|
|
@@ -152,5 +159,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
152
159
|
requirements: []
|
|
153
160
|
rubygems_version: 3.6.7
|
|
154
161
|
specification_version: 4
|
|
155
|
-
summary:
|
|
162
|
+
summary: Fiber-scheduler PostgreSQL pipeline multiplexing on top of ruby-pg
|
|
156
163
|
test_files: []
|