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,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module PgPipeline
6
+ module SessionGuard
7
+ module_function
8
+
9
+ VALID_MODES = %i[default strict].freeze
10
+ ALLOWED_LEADING = %w[select insert update delete merge values with].freeze
11
+ FORBIDDEN_PATTERNS = {
12
+ "set_config" => /\bset_config\s*\(/i,
13
+ "setseed" => /\bsetseed\s*\(/i,
14
+ "currval" => /\bcurrval\s*\(/i,
15
+ "lastval" => /\blastval\s*\(/i,
16
+ "session-advisory-lock" => /\bpg_(?:try_)?advisory_lock(?:_shared)?\s*\(/i,
17
+ "session-advisory-unlock" => /\bpg_advisory_unlock(?:_shared|_all)?\s*\(/i,
18
+ "select-into-temp" => /\binto\s+(?:(?:global|local)\s+)?temp(?:orary)?\b/i,
19
+ "select-into-pg-temp" => /\binto\s+(?:table\s+)?pg_temp(?:_\d+)?\s*\./i
20
+ }.freeze
21
+
22
+ STRICT_FORBIDDEN = {
23
+ "nextval" => /\bnextval\s*\(/i,
24
+ "setval" => /\bsetval\s*\(/i,
25
+ "setseed" => /\bsetseed\s*\(/i,
26
+ "set_config" => /\bset_config\s*\(/i,
27
+ "session-advisory-lock" => /\bpg_(?:try_)?advisory_lock(?:_shared)?\s*\(/i,
28
+ "session-advisory-unlock" => /\bpg_advisory_unlock(?:_shared|_all)?\s*\(/i,
29
+ "pg_export_snapshot" => /\bpg_export_snapshot\s*\(/i
30
+ }.freeze
31
+
32
+ def assert_multiplexable!(sql, mode: :default)
33
+ reason = unsafe_reason(sql, mode: mode)
34
+ return true unless reason
35
+
36
+ raise UnsafeMultiplexError,
37
+ "refusing to multiplex SQL (#{reason}); the shared path only accepts " \
38
+ "session-neutral operations. Use Client#session for session work or " \
39
+ "Client#transaction for an explicit transaction.\n" \
40
+ " offending SQL: #{sql.to_s.strip[0, 160]}"
41
+ end
42
+
43
+ GUARD_CACHE_LIMIT = 2048
44
+
45
+ def unsafe_reason(sql, mode: :default)
46
+ mode = normalize_mode!(mode)
47
+ key = sql.to_s
48
+ cache = guard_cache[mode]
49
+ return cache[key] if cache.key?(key)
50
+
51
+ reason = compute_unsafe_reason(key, mode)
52
+ cache.clear if cache.size >= GUARD_CACHE_LIMIT
53
+ cache[key] = reason
54
+ reason
55
+ end
56
+
57
+ def guard_cache
58
+ @guard_cache ||= { default: {}, strict: {} }
59
+ end
60
+
61
+ def compute_unsafe_reason(sql, mode)
62
+ code = code_only(sql)
63
+ lead = code[/\A\s*([a-zA-Z_]+)/, 1]&.downcase
64
+
65
+ return "empty" unless lead
66
+ return "leading:#{lead}" unless ALLOWED_LEADING.include?(lead)
67
+ return "multiple-statements" if multiple_statements?(code)
68
+
69
+ FORBIDDEN_PATTERNS.each do |name, pattern|
70
+ return name if code.match?(pattern)
71
+ end
72
+
73
+ if mode == :strict
74
+ STRICT_FORBIDDEN.each do |name, pattern|
75
+ return "strict:#{name}" if code.match?(pattern)
76
+ end
77
+ end
78
+
79
+ nil
80
+ end
81
+
82
+ def normalize_mode!(mode)
83
+ normalized = mode.respond_to?(:to_sym) ? mode.to_sym : mode
84
+ return normalized if VALID_MODES.include?(normalized)
85
+
86
+ raise ArgumentError, "guard must be one of: #{VALID_MODES.map(&:inspect).join(", ")}"
87
+ end
88
+
89
+ def code_only(sql)
90
+ source = sql.to_s.b
91
+ output = String.new(capacity: source.bytesize, encoding: Encoding::BINARY)
92
+ index = 0
93
+ block_depth = 0
94
+
95
+ while index < source.bytesize
96
+ if block_depth.positive?
97
+ if source.byteslice(index, 2) == "/*"
98
+ block_depth += 1
99
+ output << " "
100
+ index += 2
101
+ elsif source.byteslice(index, 2) == "*/"
102
+ block_depth -= 1
103
+ output << " "
104
+ index += 2
105
+ else
106
+ output << (source.getbyte(index) == 10 ? "\n" : " ")
107
+ index += 1
108
+ end
109
+ next
110
+ end
111
+
112
+ if source.byteslice(index, 2) == "--"
113
+ newline = source.index("\n", index + 2)
114
+ if newline
115
+ output << " " * (newline - index) << "\n"
116
+ index = newline + 1
117
+ else
118
+ output << " " * (source.bytesize - index)
119
+ break
120
+ end
121
+ next
122
+ end
123
+
124
+ if source.byteslice(index, 2) == "/*"
125
+ block_depth = 1
126
+ output << " "
127
+ index += 2
128
+ next
129
+ end
130
+
131
+ byte = source.getbyte(index)
132
+ if byte == 39
133
+ index = mask_quoted(source, output, index, 39, escape_backslash: escape_string_prefix?(source, index))
134
+ next
135
+ end
136
+
137
+ if byte == 34
138
+ index = mask_quoted(source, output, index, 34, escape_backslash: false)
139
+ next
140
+ end
141
+
142
+ if byte == 36
143
+ remainder = source.byteslice(index, source.bytesize - index)
144
+ tag = remainder.match(/\A\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)&.[](0)
145
+ if tag
146
+ closing = source.index(tag, index + tag.bytesize)
147
+ finish = closing ? closing + tag.bytesize : source.bytesize
148
+ output << " " * (finish - index)
149
+ index = finish
150
+ next
151
+ end
152
+ end
153
+
154
+ output << source.getbyte(index)
155
+ index += 1
156
+ end
157
+
158
+ output
159
+ end
160
+
161
+ def mask_quoted(source, output, index, quote_byte, escape_backslash:)
162
+ output << " "
163
+ index += 1
164
+
165
+ while index < source.bytesize
166
+ byte = source.getbyte(index)
167
+
168
+ if escape_backslash && byte == 92
169
+ output << " "
170
+ index += 1
171
+ if index < source.bytesize
172
+ output << (source.getbyte(index) == 10 ? "\n" : " ")
173
+ index += 1
174
+ end
175
+ elsif byte == quote_byte
176
+ if source.getbyte(index + 1) == quote_byte
177
+ output << " "
178
+ index += 2
179
+ else
180
+ output << " "
181
+ return index + 1
182
+ end
183
+ else
184
+ output << (byte == 10 ? "\n" : " ")
185
+ index += 1
186
+ end
187
+ end
188
+
189
+ index
190
+ end
191
+ private_class_method :mask_quoted
192
+
193
+ def escape_string_prefix?(source, quote_index)
194
+ return false if quote_index.zero?
195
+
196
+ marker = source.getbyte(quote_index - 1)
197
+ return false unless marker == 69 || marker == 101
198
+
199
+ before = quote_index >= 2 ? source.getbyte(quote_index - 2) : nil
200
+ before.nil? || !(before.between?(48, 57) || before.between?(65, 90) || before.between?(97, 122) || before == 95)
201
+ end
202
+ private_class_method :escape_string_prefix?
203
+
204
+ def multiple_statements?(code)
205
+ semicolons = []
206
+ code.each_char.with_index { |char, i| semicolons << i if char == ";" }
207
+ return false if semicolons.empty?
208
+
209
+ last_non_space = code.rstrip.length - 1
210
+ semicolons.length > 1 || semicolons.first != last_non_space
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg"
4
+
5
+ require_relative "session"
6
+
7
+ module PgPipeline
8
+ class Transaction < Session
9
+ attr_accessor :open, :savepoint_seq
10
+
11
+ def initialize(conn)
12
+ super
13
+ @open = false
14
+ @savepoint_seq = 0
15
+ end
16
+
17
+ def run(&block)
18
+ TransactionOps.run(self, &block)
19
+ end
20
+
21
+ def savepoint(name = nil, &block)
22
+ TransactionOps.savepoint(self, name, &block)
23
+ end
24
+
25
+ def open? = @open
26
+ end
27
+
28
+ module TransactionOps
29
+ module_function
30
+
31
+ def run(tx)
32
+ SessionOps.ensure_active!(tx)
33
+ raise Error, "transaction is already open" if tx.open
34
+
35
+ conn = SessionOps.connection(tx)
36
+ conn.exec("BEGIN")
37
+ tx.open = true
38
+ begin
39
+ result = yield tx
40
+ conn.exec("COMMIT")
41
+ tx.open = false
42
+ result
43
+ rescue Exception
44
+ # Only roll back the transaction we opened. Early guard errors
45
+ # ("already open", inactive handle) must not hit this path.
46
+ rollback_quietly(tx)
47
+ raise
48
+ end
49
+ end
50
+
51
+ def savepoint(tx, name)
52
+ SessionOps.ensure_active!(tx)
53
+ raise Error, "savepoint requires an open transaction" unless tx.open
54
+
55
+ conn = SessionOps.connection(tx)
56
+ tx.savepoint_seq += 1
57
+ point = name || "pgp_sp_#{tx.savepoint_seq}"
58
+ ident = conn.quote_ident(point)
59
+
60
+ conn.exec("SAVEPOINT #{ident}")
61
+ begin
62
+ result = yield tx
63
+ conn.exec("RELEASE SAVEPOINT #{ident}")
64
+ result
65
+ rescue Exception
66
+ rollback_to_savepoint(tx, ident)
67
+ raise
68
+ end
69
+ end
70
+
71
+ def rollback_to_savepoint(tx, ident)
72
+ conn = SessionOps.connection(tx)
73
+ return unless conn
74
+
75
+ conn.exec("ROLLBACK TO SAVEPOINT #{ident}")
76
+ conn.exec("RELEASE SAVEPOINT #{ident}")
77
+ rescue PG::Error
78
+ nil
79
+ end
80
+
81
+ def rollback_quietly(tx)
82
+ return unless tx.open
83
+
84
+ conn = SessionOps.connection(tx)
85
+ conn&.exec("ROLLBACK")
86
+ rescue PG::Error
87
+ nil
88
+ ensure
89
+ tx.open = false
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgPipeline
4
+ VERSION = "0.2.1"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pg_pipeline/version"
4
+ require_relative "pg_pipeline/errors"
5
+ require_relative "pg_pipeline/server_caps"
6
+ require_relative "pg_pipeline/session_guard"
7
+ require_relative "pg_pipeline/session"
8
+ require_relative "pg_pipeline/transaction"
9
+ require_relative "pg_pipeline/request"
10
+ require_relative "pg_pipeline/connection_driver"
11
+ require_relative "pg_pipeline/pool"
12
+ require_relative "pg_pipeline/client"
13
+
14
+ module PgPipeline
15
+ end
metadata ADDED
@@ -0,0 +1,155 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pg_pipeline
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Roman Hajdarov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
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
+ - !ruby/object:Gem::Dependency
27
+ name: pg
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.5'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '2'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '1.5'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '2'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rake
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '13.0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '13.0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rspec
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '3.13'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '3.13'
74
+ - !ruby/object:Gem::Dependency
75
+ name: ruby-prof
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '1.7'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '1.7'
88
+ - !ruby/object:Gem::Dependency
89
+ name: stackprof
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '0.2'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '0.2'
102
+ description: |
103
+ A driver-adjacent Ruby control-plane over ruby-pg/libpq. It multiplexes
104
+ independent, session-neutral extended-protocol operations from many Async
105
+ fibers onto a small number of PostgreSQL connections while keeping explicit
106
+ transactions and session-changing work on exclusive pinned connections.
107
+ Control-plane only: all wire work stays in libpq. Zero lines of C.
108
+ email:
109
+ - romanhajdarov@gmail.com
110
+ executables: []
111
+ extensions: []
112
+ extra_rdoc_files: []
113
+ files:
114
+ - CHANGELOG.md
115
+ - DESIGN.md
116
+ - LICENSE.txt
117
+ - README.md
118
+ - lib/pg_pipeline.rb
119
+ - lib/pg_pipeline/bounded_queue.rb
120
+ - lib/pg_pipeline/client.rb
121
+ - lib/pg_pipeline/connection_driver.rb
122
+ - lib/pg_pipeline/errors.rb
123
+ - lib/pg_pipeline/pool.rb
124
+ - lib/pg_pipeline/request.rb
125
+ - lib/pg_pipeline/server_caps.rb
126
+ - lib/pg_pipeline/session.rb
127
+ - lib/pg_pipeline/session_guard.rb
128
+ - lib/pg_pipeline/transaction.rb
129
+ - lib/pg_pipeline/version.rb
130
+ homepage: https://github.com/roman-haidarov/pg_pipeline
131
+ licenses:
132
+ - MIT
133
+ metadata:
134
+ homepage_uri: https://github.com/roman-haidarov/pg_pipeline
135
+ source_code_uri: https://github.com/roman-haidarov/pg_pipeline/tree/main
136
+ changelog_uri: https://github.com/roman-haidarov/pg_pipeline/blob/main/CHANGELOG.md
137
+ bug_tracker_uri: https://github.com/roman-haidarov/pg_pipeline/issues
138
+ rdoc_options: []
139
+ require_paths:
140
+ - lib
141
+ required_ruby_version: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '3.3'
146
+ required_rubygems_version: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - ">="
149
+ - !ruby/object:Gem::Version
150
+ version: '0'
151
+ requirements: []
152
+ rubygems_version: 3.6.7
153
+ specification_version: 4
154
+ summary: Async-native PostgreSQL pipeline multiplexing on top of ruby-pg
155
+ test_files: []