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.
@@ -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"
@@ -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 = []) = SessionOps.query(self, 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 = []) = SessionOps.exec_prepared(self, 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
- return cache[key] if cache.key?(key)
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.clear if cache.size >= GUARD_CACHE_LIMIT
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[/\A\s*([a-zA-Z_]+)/, 1]&.downcase
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
- output = String.new(capacity: source.bytesize, encoding: Encoding::BINARY)
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 < source.bytesize
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 source.byteslice(index, 2) == "/*"
116
+ if byte == 47 && nxt == 42
105
117
  block_depth += 1
106
118
  output << " "
107
119
  index += 2
108
- elsif source.byteslice(index, 2) == "*/"
120
+ elsif byte == 42 && nxt == 47
109
121
  block_depth -= 1
110
122
  output << " "
111
123
  index += 2
112
124
  else
113
- output << (source.getbyte(index) == 10 ? "\n" : " ")
125
+ output << (byte == 10 ? 10 : 32)
114
126
  index += 1
115
127
  end
116
128
  next
117
129
  end
118
130
 
119
- if source.byteslice(index, 2) == "--"
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 << " " * (source.bytesize - index)
137
+ output << (" " * (size - index))
126
138
  break
127
139
  end
128
140
  next
129
141
  end
130
142
 
131
- if source.byteslice(index, 2) == "/*"
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, source.bytesize - 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 : source.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 << source.getbyte(index)
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
- semicolons = []
213
- code.each_char.with_index { |char, i| semicolons << i if char == ";" }
214
- return false if semicolons.empty?
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
- last_non_space = code.rstrip.length - 1
217
- semicolons.length > 1 || semicolons.first != last_non_space
231
+ index += 1
232
+ end
233
+ false
218
234
  end
219
235
  end
220
236
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgPipeline
4
- VERSION = "0.2.4"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/pg_pipeline.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "pg_pipeline/version"
4
4
  require_relative "pg_pipeline/errors"
5
+ require_relative "pg_pipeline/runtime"
5
6
  require_relative "pg_pipeline/server_caps"
6
7
  require_relative "pg_pipeline/session_guard"
7
8
  require_relative "pg_pipeline/session"
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.2.4
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 Async
105
- fibers onto a small number of PostgreSQL connections while keeping explicit
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
- Control-plane only: all wire work stays in libpq. Zero lines of C.
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: Async-native PostgreSQL pipeline multiplexing on top of ruby-pg
162
+ summary: Fiber-scheduler PostgreSQL pipeline multiplexing on top of ruby-pg
156
163
  test_files: []