pgtk 0.30.6 → 0.30.7

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.
data/lib/pgtk/stash.rb CHANGED
@@ -21,7 +21,7 @@ require_relative '../pgtk'
21
21
  #
22
22
  # @example Basic usage
23
23
  # pool = Pgtk::Pool.new(...)
24
- # stash = Pgtk::Stash.new(pool, cap: 1000, refill_interval: 30)
24
+ # stash = Pgtk::Stash.new(pool, cap: 1000, refill: 30)
25
25
  # stash.start!
26
26
  # result = stash.exec('SELECT * FROM users WHERE id = $1', [42])
27
27
  #
@@ -43,60 +43,48 @@ class Pgtk::Stash
43
43
  #
44
44
  # Set any of the intervals to nil to disable the cron.
45
45
  #
46
- # @param [Object] pool The underlying connection pool that executes actual database queries
47
- # @param [Hash] stash Internal cache structure containing queries and tables hashes for sharing state
48
- # across transactions
49
- # @param [Float] refill_interval Interval in seconds between background tasks that recalculate stale
50
- # cached queries
51
- # @param [Float] refill_delay A pause in seconds we take before making a refill
52
- # @param [Integer] max_queue_length Maximum number of refilling tasks allowed in the thread pool queue
53
- # before new tasks are skipped
54
- # @param [Integer] threads Number of worker threads in the background thread pool for cache refilling
55
- # operations
56
- # @param [Integer] cap Maximum number of cached query results to retain; oldest queries are evicted when
57
- # this limit is exceeded
58
- # @param [Float] cap_interval Interval in seconds between background tasks that enforce the cache size
59
- # cap by removing old queries
60
- # @param [Integer] retire Maximum age in seconds to keep a query in cache after its latest usage
61
- # @param [Float] retire_interval Interval in seconds between background tasks that remove
62
- # retired queries
63
- # @param [Loog] loog Logger instance for debugging and monitoring cache operations (default: null logger)
64
- # @param [Concurrent::ReentrantReadWriteLock] entrance Read-write lock for thread-safe cache access
65
- # shared across instances
46
+ # @param [Object] pool The underlying connection pool
47
+ # @param [Hash] stash Internal cache structure
48
+ # @param [Float] refill Interval in seconds between background refill tasks
49
+ # @param [Float] delay A pause in seconds before making a refill
50
+ # @param [Integer] maxqueue Maximum number of refilling tasks in the thread pool queue
51
+ # @param [Integer] threads Number of worker threads for cache refilling
52
+ # @param [Integer] cap Maximum number of cached query results to retain
53
+ # @param [Float] capping Interval in seconds between cache cap enforcement tasks
54
+ # @param [Integer] retire Maximum age in seconds to keep a query in cache
55
+ # @param [Float] retirement Interval in seconds between retirement tasks
56
+ # @param [Loog] loog Logger instance
57
+ # @param [Concurrent::ReentrantReadWriteLock] entrance Read-write lock for thread-safe access
66
58
  def initialize(
67
59
  pool,
68
60
  stash: { queries: {}, tables: {} },
69
61
  loog: Loog::NULL,
70
62
  entrance: Concurrent::ReentrantReadWriteLock.new,
71
- refill_interval: 16,
72
- refill_delay: 0,
73
- max_queue_length: 128,
63
+ refill: 16,
64
+ delay: 0,
65
+ maxqueue: 128,
74
66
  threads: 4,
75
67
  cap: 10_000,
76
- cap_interval: 60,
68
+ capping: 60,
77
69
  retire: 15 * 60,
78
- retire_interval: 60
70
+ retirement: 60
79
71
  )
80
72
  @pool = pool
81
73
  @stash = stash
82
74
  @loog = loog
83
75
  @entrance = entrance
84
- @refill_interval = refill_interval
85
- @refill_delay = refill_delay
86
- @max_queue_length = max_queue_length
76
+ @refill = refill
77
+ @delay = delay
78
+ @maxqueue = maxqueue
87
79
  @threads = threads
88
80
  @cap = cap
89
- @cap_interval = cap_interval
81
+ @capping = capping
90
82
  @retire = retire
91
- @retire_interval = retire_interval
83
+ @retirement = retirement
92
84
  end
93
85
 
94
86
  # Start the connection pool and launch background cache management tasks.
95
87
  #
96
- # Initializes background timer tasks for cache refilling and size capping.
97
- # The refill task periodically updates stale cached queries based on popularity.
98
- # The cap task removes oldest queries when cache size exceeds the configured limit.
99
- #
100
88
  # @return [void]
101
89
  def start!
102
90
  @pool.start!
@@ -111,155 +99,177 @@ class Pgtk::Stash
111
99
 
112
100
  # Convert internal state into text.
113
101
  #
114
- # Generates a detailed report of the cache state including query counts,
115
- # popularity scores, stale queries, and thread pool status.
116
- #
117
102
  # @return [String] Multi-line text representation of the current cache state
118
103
  def dump
119
104
  @entrance.with_read_lock do
120
- qq =
121
- @stash[:queries].map do |q, kk|
122
- {
123
- q: q.dup, # the query
124
- c: kk.values.count, # how many keys?
125
- p: kk.values.sum { |vv| vv[:popularity] }, # total popularity of all keys
126
- s: kk.values.count { |vv| vv[:stale] }, # how many stale keys?
127
- u: kk.values.map { |vv| vv[:used] }.max || Time.now # when was it used
128
- }
129
- end
130
- [
131
- @pool.dump,
132
- '',
133
- [
134
- 'Pgtk::Stash (',
135
- [
136
- "threads=#{@threads}",
137
- "max_queue_length=#{@max_queue_length}",
138
- if @refill_interval
139
- [
140
- "refill_interval=#{@refill_interval}s",
141
- "refill_delay=#{@refill_delay}s"
142
- ]
143
- else
144
- 'no refilling'
145
- end,
146
- if @cap_interval
147
- [
148
- "cap_interval=#{@cap_interval}s",
149
- "cap=#{@cap}"
150
- ]
151
- else
152
- 'no capping'
153
- end,
154
- if @retire_interval
155
- [
156
- "retire_interval=#{@retire_interval}s",
157
- "retire=#{@retire}"
158
- ]
159
- else
160
- 'no retirement'
161
- end
162
- ].flatten.join(', '),
163
- '):'
164
- ].join,
165
- if @tpool
166
- " #{@tpool.queue_length} tasks in the thread pool"
167
- else
168
- ' Not launched yet'
169
- end,
170
- " #{stash_size} queries cached (#{stash_size > @cap ? 'above' : 'below'} the cap)",
171
- " #{@stash[:tables].count} tables in cache",
172
- " #{qq.sum { |a| a[:s] }} stale queries in cache:",
173
- qq.select { |a| a[:s].positive? }.sort_by { -_1[:p] }.take(8).map do |a|
174
- " #{a[:c]}/#{a[:p]}p/#{a[:s]}s/#{a[:u].ago}: #{a[:q]}"
175
- end,
176
- " #{qq.count { |a| a[:s].zero? }} other queries in cache:",
177
- qq.select { |a| a[:s].zero? }.sort_by { -_1[:p] }.take(16).map do |a|
178
- " #{a[:c]}/#{a[:p]}p/#{a[:s]}s/#{a[:u].ago}: #{a[:q]}"
179
- end
180
- ].join("\n")
105
+ qq = queries
106
+ body(qq)
181
107
  end
182
108
  end
183
109
 
184
110
  # Execute a SQL query with optional caching.
185
111
  #
186
- # Read queries are cached, while write queries bypass the cache and invalidate related entries.
187
- # Queries containing modification keywords (INSERT, UPDATE, DELETE, etc.) are executed directly
188
- # and trigger invalidation of cached queries for affected tables. Read queries (SELECT)
189
- # are cached by query text and parameter values. Queries containing NOW() are never cached.
190
- #
191
- # @param [String, Array<String>] query The SQL query to execute as a string or array of strings to be joined
192
- # @param [Array] params Query parameters for placeholder substitution in prepared statements (default: empty array)
193
- # @param [Integer] result Result format code where 0 requests text format and 1 requests binary format (default: 0)
194
- # @return [PG::Result] Query result object containing rows and metadata from the database
112
+ # @param [String, Array<String>] query The SQL query
113
+ # @param [Array] params Query parameters
114
+ # @param [Integer] result Result format code
115
+ # @return [PG::Result] Query result object
195
116
  def exec(query, params = [], result = 0)
196
117
  pure = (query.is_a?(Array) ? query.join(' ') : query).gsub(/\s+/, ' ').strip
197
118
  if MODS_RE.match?(pure) || /(^|\s)pg_[a-z_]+\(/.match?(pure)
198
- tables = pure.scan(ALTS_RE).map(&:first).uniq
199
- ret = @pool.exec(pure, params, result)
200
- @entrance.with_write_lock do
201
- tables.each do |t|
202
- @stash[:tables][t]&.each do |q|
203
- @stash[:queries][q]&.each_key do |key|
204
- @stash[:queries][q][key][:stale] = Time.now
205
- end
206
- end
207
- end
208
- end
119
+ modify(pure, params, result)
209
120
  else
210
- key = params.map(&:to_s).join(SEPARATOR)
211
- ret = @stash.dig(:queries, pure, key, :ret)
212
- if ret.nil? || @stash.dig(:queries, pure, key, :stale)
213
- ret = @pool.exec(pure, params, result)
214
- unless pure.include?(' NOW() ')
215
- tables = pure.scan(/(?<=^|\s)(?:FROM|JOIN) ([a-z_]+)(?=\s|;|$)/).map(&:first).uniq
216
- raise "No tables at #{pure.inspect}" if tables.empty?
217
- @entrance.with_write_lock do
218
- tables.each do |t|
219
- @stash[:tables][t] = [] if @stash[:tables][t].nil?
220
- @stash[:tables][t].append(pure).uniq!
221
- end
222
- @stash[:queries][pure] ||= {}
223
- @stash[:queries][pure][key] = { ret:, params:, result:, used: Time.now }
224
- end
225
- end
226
- end
227
- if @stash.dig(:queries, pure, key)
228
- @entrance.with_write_lock do
229
- @stash[:queries][pure][key][:popularity] ||= 0
230
- @stash[:queries][pure][key][:popularity] += 1
231
- @stash[:queries][pure][key][:used] = Time.now
232
- end
233
- end
121
+ select(pure, params, result)
234
122
  end
235
- ret
236
123
  end
237
124
 
238
125
  # Execute a database transaction.
239
126
  #
240
- # Yields a new Stash that shares the same cache but uses the transaction connection.
241
- #
242
127
  # @yield [Pgtk::Stash] A stash connected to the transaction
243
128
  # @return [Object] The result of the block
244
129
  def transaction
245
130
  @pool.transaction do |t|
246
- yield Pgtk::Stash.new(
247
- t,
248
- stash: @stash,
249
- loog: @loog,
250
- entrance: @entrance
251
- )
131
+ yield(Pgtk::Stash.new(t, stash: @stash, loog: @loog, entrance: @entrance))
252
132
  end
253
133
  end
254
134
 
255
135
  private
256
136
 
137
+ def queries
138
+ @stash[:queries].map do |q, kk|
139
+ {
140
+ q: q.dup,
141
+ c: kk.values.count,
142
+ p: kk.values.sum { |vv| vv[:popularity] },
143
+ s: kk.values.count { |vv| vv[:stale] },
144
+ u: kk.values.map { |vv| vv[:used] }.max || Time.now
145
+ }
146
+ end
147
+ end
148
+
149
+ def body(list)
150
+ [
151
+ @pool.dump,
152
+ '',
153
+ header,
154
+ if @tpool
155
+ " #{@tpool.queue_length} tasks in the thread pool"
156
+ else
157
+ ' Not launched yet'
158
+ end,
159
+ " #{cached} queries cached (#{cached > @cap ? 'above' : 'below'} the cap)",
160
+ " #{@stash[:tables].count} tables in cache",
161
+ " #{list.sum { |a| a[:s] }} stale queries in cache:",
162
+ stale(list),
163
+ " #{list.count { |a| a[:s].zero? }} other queries in cache:",
164
+ fresh(list)
165
+ ].join("\n")
166
+ end
167
+
168
+ def header
169
+ [
170
+ 'Pgtk::Stash (',
171
+ [
172
+ "threads=#{@threads}",
173
+ "maxqueue=#{@maxqueue}",
174
+ if @refill
175
+ [
176
+ "refill=#{@refill}s",
177
+ "delay=#{@delay}s"
178
+ ]
179
+ else
180
+ 'no refilling'
181
+ end,
182
+ if @capping
183
+ [
184
+ "capping=#{@capping}s",
185
+ "cap=#{@cap}"
186
+ ]
187
+ else
188
+ 'no capping'
189
+ end,
190
+ if @retirement
191
+ [
192
+ "retirement=#{@retirement}s",
193
+ "retire=#{@retire}"
194
+ ]
195
+ else
196
+ 'no retirement'
197
+ end
198
+ ].flatten.join(', '),
199
+ '):'
200
+ ].join
201
+ end
202
+
203
+ def stale(list)
204
+ items = list.select { |a| a[:s].positive? }.sort_by { -_1[:p] }.take(8)
205
+ items.map! { |a| " #{a[:c]}/#{a[:p]}p/#{a[:s]}s/#{a[:u].ago}: #{a[:q]}" }
206
+ items
207
+ end
208
+
209
+ def fresh(list)
210
+ items = list.select { |a| a[:s].zero? }.sort_by { -_1[:p] }.take(16)
211
+ items.map! { |a| " #{a[:c]}/#{a[:p]}p/#{a[:s]}s/#{a[:u].ago}: #{a[:q]}" }
212
+ items
213
+ end
214
+
215
+ def modify(pure, params, result)
216
+ tables = pure.scan(ALTS_RE).flatten
217
+ tables.uniq!
218
+ ret = @pool.exec(pure, params, result)
219
+ @entrance.with_write_lock do
220
+ tables.each do |t|
221
+ @stash[:tables][t]&.each do |q|
222
+ @stash[:queries][q]&.each_key do |key|
223
+ @stash[:queries][q][key][:stale] = Time.now
224
+ end
225
+ end
226
+ end
227
+ end
228
+ ret
229
+ end
230
+
231
+ def select(pure, params, result)
232
+ key = params.join(SEPARATOR)
233
+ ret = @stash.dig(:queries, pure, key, :ret)
234
+ if ret.nil? || @stash.dig(:queries, pure, key, :stale)
235
+ mark = @stash.dig(:queries, pure, key, :stale)
236
+ ret = @pool.exec(pure, params, result)
237
+ cache(pure, key, params, result, ret, mark) unless pure.include?(' NOW() ')
238
+ end
239
+ bump(pure, key) if @stash.dig(:queries, pure, key)
240
+ ret
241
+ end
242
+
243
+ def cache(pure, key, params, result, ret, mark)
244
+ tables = pure.scan(/(?<=^|\s)(?:FROM|JOIN) ([a-z_]+)(?=\s|;|$)/).flatten
245
+ tables.uniq!
246
+ raise(ArgumentError, "No tables at #{pure.inspect}") if tables.empty?
247
+ @entrance.with_write_lock do
248
+ tables.each do |t|
249
+ @stash[:tables][t] = [] if @stash[:tables][t].nil?
250
+ @stash[:tables][t].append(pure).uniq!
251
+ end
252
+ @stash[:queries][pure] ||= {}
253
+ existing = @stash[:queries][pure][key]
254
+ stale = existing && existing[:stale]
255
+ entry = { ret:, params:, result:, used: Time.now }
256
+ entry[:stale] = stale if stale && stale != mark
257
+ @stash[:queries][pure][key] = entry
258
+ end
259
+ end
260
+
261
+ def bump(pure, key)
262
+ @entrance.with_write_lock do
263
+ @stash[:queries][pure][key][:popularity] ||= 0
264
+ @stash[:queries][pure][key][:popularity] += 1
265
+ @stash[:queries][pure][key][:used] = Time.now
266
+ end
267
+ end
268
+
257
269
  # Calculate total number of cached query results.
258
270
  #
259
- # Counts all cached query-parameter combinations across all queries.
260
- #
261
271
  # @return [Integer] Total count of cached query results
262
- def stash_size
272
+ def cached
263
273
  @entrance.with_write_lock do
264
274
  @stash[:queries].values.sum { |kk| kk.values.size }
265
275
  end
@@ -267,64 +277,69 @@ class Pgtk::Stash
267
277
 
268
278
  # Launch background tasks for cache management.
269
279
  #
270
- # Starts two concurrent timer tasks: one for enforcing cache size cap by removing
271
- # oldest queries, and another for refilling stale cached queries based on popularity.
272
- # This method can only be called once per cache instance.
273
- #
274
280
  # @return [nil]
275
- # @raise [RuntimeError] if background tasks have already been launched on this cache instance
276
281
  def launch!
277
282
  @tpool = Concurrent::FixedThreadPool.new(@threads)
278
- if @cap_interval
279
- Concurrent::TimerTask.execute(execution_interval: @cap_interval, executor: @tpool) do
280
- loop do
281
- break if stash_size <= @cap
282
- @entrance.with_write_lock do
283
- @stash[:queries].each_key do |q|
284
- m = @stash[:queries][q].values.map { |h| h[:used] }.min
285
- next unless m
286
- @stash[:queries][q].delete_if { |_, h| h[:used] == m }
287
- @stash[:queries].delete_if { |_, kk| kk.empty? }
288
- end
289
- end
290
- end
291
- end
292
- end
293
- if @retire_interval
294
- Concurrent::TimerTask.execute(execution_interval: @retire_interval, executor: @tpool) do
283
+ capper! if @capping
284
+ retiree! if @retirement
285
+ refiller! if @refill
286
+ end
287
+
288
+ def capper!
289
+ Concurrent::TimerTask.execute(execution_interval: @capping, executor: @tpool) do
290
+ loop do
291
+ break if cached <= @cap
295
292
  @entrance.with_write_lock do
296
293
  @stash[:queries].each_key do |q|
297
- @stash[:queries][q].delete_if { |_, h| h[:used] < Time.now - @retire }
294
+ m = @stash[:queries][q].values.map { |h| h[:used] }.min
295
+ next unless m
296
+ @stash[:queries][q].delete_if { |_, h| h[:used] == m }
298
297
  @stash[:queries].delete_if { |_, kk| kk.empty? }
299
298
  end
300
299
  end
301
300
  end
302
301
  end
303
- return unless @refill_interval
304
- Concurrent::TimerTask.execute(execution_interval: @refill_interval, executor: @tpool) do
305
- qq =
306
- @entrance.with_write_lock do
307
- @stash[:queries]
308
- .map { |k, v| [k, v.values.sum { |vv| vv[:popularity] }, v.values.any? { |vv| vv[:stale] }] }
302
+ end
303
+
304
+ def retiree!
305
+ Concurrent::TimerTask.execute(execution_interval: @retirement, executor: @tpool) do
306
+ @entrance.with_write_lock do
307
+ @stash[:queries].each_key do |q|
308
+ @stash[:queries][q].delete_if { |_, h| h[:used] < Time.now - @retire }
309
+ @stash[:queries].delete_if { |_, kk| kk.empty? }
309
310
  end
310
- qq =
311
- qq.select { _1[2] }
312
- .sort_by { -_1[1] }
313
- .map { _1[0] }
314
- qq.each do |q|
315
- @entrance.with_write_lock { @stash[:queries][q].keys }.each do |k|
316
- next unless @stash[:queries][q][k][:stale]
317
- next if @stash[:queries][q][k][:stale] > Time.now - @refill_delay
318
- next if @tpool.queue_length >= @max_queue_length
319
- @tpool.post do
320
- h = @stash[:queries][q][k]
321
- ret = @pool.exec(q, h[:params], h[:result])
322
- @entrance.with_write_lock do
323
- h = @stash[:queries][q][k]
324
- h.delete(:stale)
325
- h[:ret] = ret
326
- end
327
- end
311
+ end
312
+ end
313
+ end
314
+
315
+ def refiller!
316
+ Concurrent::TimerTask.execute(execution_interval: @refill, executor: @tpool) do
317
+ ranked.each { |q| replenish(q) }
318
+ end
319
+ end
320
+
321
+ def ranked
322
+ qq =
323
+ @entrance.with_write_lock do
324
+ @stash[:queries]
325
+ .map { |k, v| [k, v.values.sum { |vv| vv[:popularity] }, v.values.any? { |vv| vv[:stale] }] }
326
+ end
327
+ qq.select { _1[2] }.sort_by { -_1[1] }.map { _1[0] }
328
+ end
329
+
330
+ def replenish(query)
331
+ @entrance.with_write_lock { @stash[:queries][query].keys }.each do |k|
332
+ next unless @stash[:queries][query][k][:stale]
333
+ next if @stash[:queries][query][k][:stale] > Time.now - @delay
334
+ next if @tpool.queue_length >= @maxqueue
335
+ @tpool.post do
336
+ h = @stash[:queries][query][k]
337
+ mark = h[:stale]
338
+ ret = @pool.exec(query, h[:params], h[:result])
339
+ @entrance.with_write_lock do
340
+ h = @stash[:queries][query][k]
341
+ h[:ret] = ret
342
+ h.delete(:stale) if h[:stale] == mark
328
343
  end
329
344
  end
330
345
  end
data/lib/pgtk/version.rb CHANGED
@@ -10,6 +10,5 @@ require_relative '../pgtk'
10
10
  # Copyright:: Copyright (c) 2019-2026 Yegor Bugayenko
11
11
  # License:: MIT
12
12
  module Pgtk
13
- # Current version of the library.
14
- VERSION = '0.30.6' unless defined?(VERSION)
13
+ VERSION = '0.30.7' unless defined?(VERSION)
15
14
  end