fbe 0.19.2 → 0.20.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2d7532cc824357975d405225601f1f0873721e96b5328a9cd4e1d57191008a9
4
- data.tar.gz: 1c64cade131c3f2751c0dffaaa9bd157cdc4ff41a70b0acf5efe8a8f2788e08b
3
+ metadata.gz: 8c33c559724c991d4aabc2ad9c097170e53de0002e362dcfadfe956d77eed7ec
4
+ data.tar.gz: 787dbc8ce12ebacbd0b96cd8174ced19aa11665f29d3d410fb93c397dbb80d87
5
5
  SHA512:
6
- metadata.gz: 742ec0b8b9970a86549a8292ad5390cb5dcdb4addbc4c45590f3ff10526a67d4038c65cc9534d818859558a0915efeeef30d165816cd24a9a12cda2ec83cc87e
7
- data.tar.gz: 70eb53d6eb30817c735ef3ab2ea62e050106b616a8384a647106c0ef4b40e861deb62d6a3dfc43a32471157144c9225425e092e4a4df614e91679444c5219f64
6
+ metadata.gz: ca43bc96f5c8f426a72fb3ab722684d36b67613526b9d2e1be175abae03581c4fc0318314e92628c9064d2c7ba8637814ed950e349ba86b64caedc768619be8f
7
+ data.tar.gz: 81d737f13af364095e871df44000f6293d1c062929d7f859cd3836d23c3cf602a1362a9653fd739f1726c4d1004e0bad1806e3cb3fde23dc4aef081e39dfbd6a
data/Gemfile CHANGED
@@ -8,6 +8,7 @@ gemspec
8
8
 
9
9
  gem 'minitest', '~>5.25', require: false
10
10
  gem 'minitest-reporters', '~>1.7', require: false
11
+ gem 'minitest-stub-const', '~>0.6', require: false
11
12
  gem 'os', '~>1.1', require: false
12
13
  gem 'qbash', '~>0.4', require: false
13
14
  gem 'rake', '~>13.3', require: false
data/Gemfile.lock CHANGED
@@ -143,6 +143,7 @@ GEM
143
143
  builder
144
144
  minitest (>= 5.0)
145
145
  ruby-progressbar
146
+ minitest-stub-const (0.6)
146
147
  moments (0.3.0)
147
148
  multipart-post (2.4.1)
148
149
  net-http (0.6.0)
@@ -179,7 +180,7 @@ GEM
179
180
  regexp_parser (2.10.0)
180
181
  retries (0.0.5)
181
182
  rexml (3.4.1)
182
- rubocop (1.76.0)
183
+ rubocop (1.76.1)
183
184
  json (~> 2.3)
184
185
  language_server-protocol (~> 3.17.0.2)
185
186
  lint_roller (~> 1.1.0)
@@ -190,7 +191,7 @@ GEM
190
191
  rubocop-ast (>= 1.45.0, < 2.0)
191
192
  ruby-progressbar (~> 1.7)
192
193
  unicode-display_width (>= 2.4.0, < 4.0)
193
- rubocop-ast (1.45.0)
194
+ rubocop-ast (1.45.1)
194
195
  parser (>= 3.3.7.2)
195
196
  prism (~> 1.4)
196
197
  rubocop-minitest (0.38.1)
@@ -258,6 +259,7 @@ DEPENDENCIES
258
259
  fbe!
259
260
  minitest (~> 5.25)
260
261
  minitest-reporters (~> 1.7)
262
+ minitest-stub-const (~> 0.6)
261
263
  os (~> 1.1)
262
264
  qbash (~> 0.4)
263
265
  rake (~> 13.3)
@@ -3,6 +3,7 @@
3
3
  # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Zerocracy
4
4
  # SPDX-License-Identifier: MIT
5
5
 
6
+ require 'time'
6
7
  require 'json'
7
8
  require 'sqlite3'
8
9
  require_relative '../../fbe'
@@ -16,18 +17,20 @@ require_relative '../../fbe/middleware'
16
17
  class Fbe::Middleware::SqliteStore
17
18
  attr_reader :path
18
19
 
19
- def initialize(path)
20
+ def initialize(path, version)
20
21
  raise ArgumentError, 'Database path cannot be nil or empty' if path.nil? || path.empty?
21
22
  dir = File.dirname(path)
22
23
  raise ArgumentError, "Directory #{dir} does not exist" unless File.directory?(dir)
24
+ raise ArgumentError, 'Version cannot be nil or empty' if version.nil? || version.empty?
23
25
  @path = File.absolute_path(path)
24
- open
25
- prepare
26
- at_exit { close }
26
+ @version = version
27
27
  end
28
28
 
29
29
  def read(key)
30
- value = perform { _1.execute('SELECT value FROM cache WHERE key = ? LIMIT 1', [key]) }.dig(0, 0)
30
+ value = perform do |t|
31
+ t.execute('UPDATE cache SET touched_at = ?2 WHERE key = ?1;', [key, Time.now.utc.iso8601])
32
+ t.execute('SELECT value FROM cache WHERE key = ? LIMIT 1;', [key])
33
+ end.dig(0, 0)
31
34
  JSON.parse(value) if value
32
35
  end
33
36
 
@@ -37,40 +40,27 @@ class Fbe::Middleware::SqliteStore
37
40
  end
38
41
 
39
42
  def write(key, value)
43
+ return if value.is_a?(Array) && value.any? do |vv|
44
+ req = JSON.parse(vv[0])
45
+ req['url'].include?('?') || req['method'] != 'get'
46
+ end
40
47
  value = JSON.dump(value)
41
- perform do |tdb|
42
- tdb.execute(<<~SQL, [key, value])
43
- INSERT INTO cache(key, value) VALUES(?1, ?2)
44
- ON CONFLICT(key) DO UPDATE SET value = ?2
48
+ return if value.bytesize > 10_000
49
+ perform do |t|
50
+ t.execute(<<~SQL, [key, value, Time.now.utc.iso8601])
51
+ INSERT INTO cache(key, value, touched_at) VALUES(?1, ?2, ?3)
52
+ ON CONFLICT(key) DO UPDATE SET value = ?2, touched_at = ?3
45
53
  SQL
46
54
  end
47
55
  nil
48
56
  end
49
57
 
50
- def open
51
- return if @db
52
- @db = SQLite3::Database.new(@path)
53
- end
54
-
55
- def prepare
56
- perform do |tdb|
57
- tdb.execute 'CREATE TABLE IF NOT EXISTS cache(key TEXT UNIQUE NOT NULL, value TEXT);'
58
- tdb.execute 'CREATE INDEX IF NOT EXISTS key_idx ON cache(key);'
59
- end
60
- end
61
-
62
- def close
63
- return if !@db || @db.closed?
64
- @db.close
65
- @db = nil
66
- end
67
-
68
58
  def clear
69
- perform { _1.execute 'DELETE FROM cache;' }
70
- end
71
-
72
- def drop
73
- perform { _1.execute 'DROP TABLE IF EXISTS cache;' }
59
+ perform do |t|
60
+ t.execute 'DELETE FROM cache;'
61
+ t.execute "UPDATE meta SET value = ? WHERE key = 'version';", [@version]
62
+ end
63
+ @db.execute 'VACUUM;'
74
64
  end
75
65
 
76
66
  def all
@@ -80,6 +70,43 @@ class Fbe::Middleware::SqliteStore
80
70
  private
81
71
 
82
72
  def perform(&)
73
+ @db ||=
74
+ SQLite3::Database.new(@path).tap do |d|
75
+ d.transaction do |t|
76
+ t.execute <<~SQL
77
+ CREATE TABLE IF NOT EXISTS cache(
78
+ key TEXT UNIQUE NOT NULL, value TEXT, touched_at TEXT NOT NULL
79
+ );
80
+ SQL
81
+ t.execute 'CREATE INDEX IF NOT EXISTS cache_key_idx ON cache(key);'
82
+ t.execute 'CREATE INDEX IF NOT EXISTS cache_touched_at_idx ON cache(touched_at);'
83
+ t.execute 'CREATE TABLE IF NOT EXISTS meta(key TEXT UNIQUE NOT NULL, value TEXT);'
84
+ t.execute 'CREATE INDEX IF NOT EXISTS meta_key_idx ON meta(key);'
85
+ t.execute "INSERT INTO meta(key, value) VALUES('version', ?) ON CONFLICT(key) DO NOTHING;", [@version]
86
+ end
87
+ if d.execute("SELECT value FROM meta WHERE key = 'version' LIMIT 1;").dig(0, 0) != @version
88
+ d.transaction do |t|
89
+ t.execute 'DELETE FROM cache;'
90
+ t.execute "UPDATE meta SET value = ? WHERE key = 'version';", [@version]
91
+ end
92
+ d.execute 'VACUUM;'
93
+ end
94
+ if File.size(@path) > 10 * 1024 * 1024
95
+ while d.execute(<<~SQL).dig(0, 0) > 10 * 1024 * 1024
96
+ SELECT (page_count - freelist_count) * page_size AS size
97
+ FROM pragma_page_count(), pragma_freelist_count(), pragma_page_size();
98
+ SQL
99
+ d.transaction do |t|
100
+ t.execute <<~SQL
101
+ DELETE FROM cache
102
+ WHERE key IN (SELECT key FROM cache ORDER BY touched_at LIMIT 50)
103
+ SQL
104
+ end
105
+ end
106
+ d.execute 'VACUUM;'
107
+ end
108
+ at_exit { @db&.close }
109
+ end
83
110
  @db.transaction(&)
84
111
  end
85
112
  end
data/lib/fbe/octo.rb CHANGED
@@ -76,16 +76,24 @@ def Fbe.octo(options: $options, global: $global, loog: $loog)
76
76
  methods: [:get],
77
77
  backoff_factor: 2
78
78
  )
79
- serializer = Marshal
80
79
  if options.sqlite_cache
81
- store = Fbe::Middleware::SqliteStore.new(options.sqlite_cache)
82
- serializer = JSON
83
- loog.info("Using HTTP cache in SQLite file: #{store.path}")
80
+ store = Fbe::Middleware::SqliteStore.new(options.sqlite_cache, Fbe::VERSION)
81
+ loog.info(
82
+ "Using HTTP cache in SQLite file: #{store.path} (" \
83
+ "#{File.exist?(store.path) ? "#{File.size(store.path)} bytes" : 'file is absent'}" \
84
+ ')'
85
+ )
86
+ builder.use(
87
+ Faraday::HttpCache,
88
+ store:, serializer: JSON, shared_cache: false, logger: Loog::NULL
89
+ )
90
+ else
91
+ loog.info("No HTTP cache in SQLite file, because 'sqlite_cache' option is not provided")
92
+ builder.use(
93
+ Faraday::HttpCache,
94
+ serializer: Marshal, shared_cache: false, logger: Loog::NULL
95
+ )
84
96
  end
85
- builder.use(
86
- Faraday::HttpCache,
87
- store: store, serializer: serializer, shared_cache: false, logger: Loog::NULL
88
- )
89
97
  builder.use(Octokit::Response::RaiseError)
90
98
  builder.use(Faraday::Response::Logger, loog, formatter: Fbe::Middleware::Formatter)
91
99
  builder.use(Fbe::Middleware::Trace, trace)
data/lib/fbe.rb CHANGED
@@ -10,5 +10,5 @@
10
10
  # License:: MIT
11
11
  module Fbe
12
12
  # Current version of the gem (changed by +.rultor.yml+ on every release)
13
- VERSION = '0.19.2' unless const_defined?(:VERSION)
13
+ VERSION = '0.20.0' unless const_defined?(:VERSION)
14
14
  end
@@ -3,6 +3,7 @@
3
3
  # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Zerocracy
4
4
  # SPDX-License-Identifier: MIT
5
5
 
6
+ require 'qbash'
6
7
  require_relative '../../test__helper'
7
8
  require_relative '../../../lib/fbe/middleware'
8
9
  require_relative '../../../lib/fbe/middleware/sqlite_store'
@@ -12,24 +13,180 @@ require_relative '../../../lib/fbe/middleware/sqlite_store'
12
13
  # Copyright:: Copyright (c) 2024-2025 Zerocracy
13
14
  # License:: MIT
14
15
  class SqliteStoreTest < Fbe::Test
15
- def test_sqlite_store
16
- Dir.mktmpdir do |dir|
17
- store = Fbe::Middleware::SqliteStore.new(File.expand_path('test.db', dir))
18
- assert_nil(store.read('my_key'))
19
- assert_nil(store.delete('my_key'))
20
- assert_nil(store.write('my_key', 'some value'))
21
- assert_equal('some value', store.read('my_key'))
22
- assert_nil(store.write('my_key', 'some value 2'))
23
- assert_equal('some value 2', store.read('my_key'))
24
- assert_nil(store.delete('my_key'))
25
- assert_nil(store.read('my_key'))
16
+ def test_simple_caching_algorithm
17
+ with_tmpfile('x.db') do |f|
18
+ store = Fbe::Middleware::SqliteStore.new(f, '0.0.0')
19
+ k = 'some-key'
20
+ assert_nil(store.read(k))
21
+ assert_nil(store.delete(k))
22
+ v1 = 'first value to save'
23
+ assert_nil(store.write(k, v1))
24
+ assert_equal(v1, store.read(k))
25
+ v2 = 'another value to save'
26
+ assert_nil(store.write(k, v2))
27
+ assert_equal(v2, store.read(k))
28
+ assert_nil(store.delete(k))
29
+ assert_nil(store.read(k))
30
+ assert_path_exists(f)
26
31
  end
27
32
  end
28
33
 
29
- def test_sqlite_store_empty_all
30
- Dir.mktmpdir do |dir|
31
- store = Fbe::Middleware::SqliteStore.new(File.expand_path('test.db', dir))
34
+ def test_returns_empty_list
35
+ with_tmpfile('b.db') do |f|
36
+ store = Fbe::Middleware::SqliteStore.new(f, '0.0.0')
37
+ assert_empty(store.all)
38
+ end
39
+ end
40
+
41
+ def test_clear_all_keys
42
+ with_tmpfile('a.db') do |f|
43
+ store = Fbe::Middleware::SqliteStore.new(f, '0.0.0')
44
+ k = 'a key'
45
+ store.write(k, 'some value')
46
+ store.clear
47
+ assert_empty(store.all)
48
+ end
49
+ end
50
+
51
+ def test_empty_all_if_not_written
52
+ with_tmpfile do |f|
53
+ store = Fbe::Middleware::SqliteStore.new(f, '0.0.0')
32
54
  assert_empty(store.all)
33
55
  end
34
56
  end
57
+
58
+ def test_wrong_db_path
59
+ assert_raises(ArgumentError) do
60
+ Fbe::Middleware::SqliteStore.new(nil, '0.0.0').read('my_key')
61
+ end
62
+ assert_raises(ArgumentError) do
63
+ Fbe::Middleware::SqliteStore.new('', '0.0.0').read('my_key')
64
+ end
65
+ assert_raises(ArgumentError) do
66
+ Fbe::Middleware::SqliteStore.new('/fakepath/fakefolder/test.db', '0.0.0').read('my_key')
67
+ end
68
+ end
69
+
70
+ def test_not_db_file
71
+ with_tmpfile do |f|
72
+ File.binwrite(f, Array.new(20) { rand(0..255) }.pack('C*'))
73
+ ex =
74
+ assert_raises(SQLite3::NotADatabaseException) do
75
+ Fbe::Middleware::SqliteStore.new(f, '0.0.0').read('my_key')
76
+ end
77
+ assert_match('file is not a database', ex.message)
78
+ end
79
+ end
80
+
81
+ def test_defer_db_close_callback
82
+ txt = <<~RUBY
83
+ require 'tempfile'
84
+ require 'sqlite3'
85
+ require 'fbe/middleware/sqlite_store'
86
+
87
+ SQLite3::Database.class_eval do
88
+ prepend(Module.new do
89
+ def close
90
+ super
91
+ puts 'closed sqlite after process exit'
92
+ end
93
+ end)
94
+ end
95
+
96
+ Tempfile.open('test.db') do |f|
97
+ Fbe::Middleware::SqliteStore.new(f.path, '0.0.0').then do |s|
98
+ s.write('my_key', 'my_value')
99
+ s.read('my_key')
100
+ end
101
+ end
102
+ RUBY
103
+ out =
104
+ qbash(
105
+ 'bundle exec ruby ' \
106
+ "-I#{Shellwords.escape(File.expand_path('../../../lib', __dir__))} " \
107
+ "-e #{Shellwords.escape(txt)} 2>&1"
108
+ )
109
+ assert_match('closed sqlite after process exit', out)
110
+ end
111
+
112
+ def test_different_versions
113
+ with_tmpfile('d.db') do |f|
114
+ Fbe::Middleware::SqliteStore.new(f, '0.0.1').then do |store|
115
+ store.write('kkk1', 'some value')
116
+ store.write('kkk2', 'another value')
117
+ end
118
+ Fbe::Middleware::SqliteStore.new(f, '0.0.1').then do |store|
119
+ assert_equal('some value', store.read('kkk1'))
120
+ assert_equal('another value', store.read('kkk2'))
121
+ end
122
+ Fbe::Middleware::SqliteStore.new(f, '0.0.2').then do |store|
123
+ assert_nil(store.read('kkk1'))
124
+ assert_nil(store.read('kkk2'))
125
+ end
126
+ end
127
+ end
128
+
129
+ def test_initialize_wrong_version
130
+ with_tmpfile('e.db') do |f|
131
+ msg = 'Version cannot be nil or empty'
132
+ assert_raises(ArgumentError) { Fbe::Middleware::SqliteStore.new(f, nil) }.then do |ex|
133
+ assert_match(msg, ex.message)
134
+ end
135
+ assert_raises(ArgumentError) { Fbe::Middleware::SqliteStore.new(f, '') }.then do |ex|
136
+ assert_match(msg, ex.message)
137
+ end
138
+ end
139
+ end
140
+
141
+ def test_skip_write_if_value_more_then_10k_bytes
142
+ with_tmpfile('a.db') do |f|
143
+ Fbe::Middleware::SqliteStore.new(f, '0.0.1').then do |store|
144
+ store.write('a', 'a' * 9_997)
145
+ store.write('b', 'b' * 9_998)
146
+ store.write('c', 'c' * 9_999)
147
+ store.write('d', 'd' * 10_000)
148
+ assert_equal('a' * 9_997, store.read('a'))
149
+ assert_equal('b' * 9_998, store.read('b'))
150
+ assert_nil(store.read('c'))
151
+ assert_nil(store.read('d'))
152
+ end
153
+ end
154
+ end
155
+
156
+ def test_shrink_cache_if_more_then_10_mb
157
+ with_tmpfile('large.db') do |f|
158
+ Fbe::Middleware::SqliteStore.new(f, '0.0.1').then do |store|
159
+ key = 'aaa'
160
+ alpha = ('a'..'z').to_a
161
+ store.write('a', 'aa')
162
+ Time.stub(:now, (Time.now - (5 * 60 * 60)).round) do
163
+ store.write('b', 'bb')
164
+ store.write('c', 'cc')
165
+ end
166
+ assert_equal('cc', store.read('c'))
167
+ Time.stub(:now, rand((Time.now - (5 * 60 * 60))..Time.now).round) do
168
+ 10_240.times do
169
+ value = alpha.sample * rand(1024..2048)
170
+ store.write(key, value)
171
+ key = key.next
172
+ end
173
+ end
174
+ end
175
+ assert_operator(File.size(f), :>, 10 * 1024 * 1024)
176
+ Fbe::Middleware::SqliteStore.new(f, '0.0.1').then do |store|
177
+ assert_equal('aa', store.read('a'))
178
+ assert_nil(store.read('b'))
179
+ assert_equal('cc', store.read('c'))
180
+ assert_operator(File.size(f), :<=, 10 * 1024 * 1024)
181
+ end
182
+ end
183
+ end
184
+
185
+ private
186
+
187
+ def with_tmpfile(name = 'test.db', &)
188
+ Dir.mktmpdir do |dir|
189
+ yield File.expand_path(name, dir)
190
+ end
191
+ end
35
192
  end
@@ -256,7 +256,10 @@ class TestOcto < Fbe::Test
256
256
  end
257
257
 
258
258
  def test_reads_quota
259
- WebMock.enable_net_connect!
259
+ WebMock.disable_net_connect!
260
+ stub_request(:get, 'https://api.github.com/rate_limit').to_return(
261
+ { body: '{}', headers: { 'X-RateLimit-Remaining' => '222' } }
262
+ )
260
263
  o = Fbe.octo(loog: Loog::VERBOSE, global: {}, options: Judges::Options.new({ 'github_api_pause' => 0.01 }))
261
264
  refute_nil(o.off_quota?)
262
265
  end
@@ -365,7 +368,7 @@ class TestOcto < Fbe::Test
365
368
  assert_includes second_output, 'GitHub API trace is empty'
366
369
  end
367
370
 
368
- def test_sqlite_store
371
+ def test_works_via_sqlite_store
369
372
  WebMock.disable_net_connect!
370
373
  Dir.mktmpdir do |dir|
371
374
  global = {}
@@ -402,4 +405,45 @@ class TestOcto < Fbe::Test
402
405
  assert_path_exists(file)
403
406
  end
404
407
  end
408
+
409
+ def test_sqlite_store_for_use_in_different_versions
410
+ WebMock.disable_net_connect!
411
+ Dir.mktmpdir do |dir|
412
+ global = {}
413
+ stub =
414
+ stub_request(:get, 'https://api.github.com/user/42')
415
+ .to_return(
416
+ status: 200,
417
+ body: { login: 'user1' }.to_json,
418
+ headers: {
419
+ 'Content-Type' => 'application/json',
420
+ 'Cache-Control' => 'public, max-age=60, s-maxage=60',
421
+ 'Etag' => 'W/"2ff9dd4c3153f006830b2b8b721f6a4bb400a1eb81a2e1fa0a3b846ad349b9ec"',
422
+ 'Last-Modified' => 'Wed, 01 May 2025 20:00:00 GMT'
423
+ }
424
+ )
425
+ sqlite_cache = File.expand_path('test.db', dir)
426
+ Fbe.stub_const(:VERSION, '0.0.1') do
427
+ o = Fbe.octo(loog: Loog::NULL, global:, options: Judges::Options.new({ 'sqlite_cache' => sqlite_cache }))
428
+ assert_equal('user1', o.user_name_by_id(42))
429
+ end
430
+ WebMock.remove_request_stub(stub)
431
+ stub_request(:get, 'https://api.github.com/user/42')
432
+ .to_return(
433
+ status: 200,
434
+ body: { login: 'user2' }.to_json,
435
+ headers: {
436
+ 'Content-Type' => 'application/json',
437
+ 'Cache-Control' => 'public, max-age=60, s-maxage=60',
438
+ 'Etag' => 'W/"2ff9dd4c3153f006830b2b8b721f6a4bb400a1eb81a2e1fa0a3b846ad349b9ec"',
439
+ 'Last-Modified' => 'Wed, 01 May 2025 20:00:00 GMT'
440
+ }
441
+ )
442
+ global = {}
443
+ Fbe.stub_const(:VERSION, '0.0.2') do
444
+ o = Fbe.octo(loog: Loog::NULL, global:, options: Judges::Options.new({ 'sqlite_cache' => sqlite_cache }))
445
+ assert_equal('user2', o.user_name_by_id(42))
446
+ end
447
+ end
448
+ end
405
449
  end
data/test/test__helper.rb CHANGED
@@ -31,6 +31,7 @@ end
31
31
  require 'minitest/reporters'
32
32
  Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new]
33
33
 
34
+ require 'minitest/stub_const'
34
35
  require 'minitest/autorun'
35
36
  require 'webmock/minitest'
36
37
  require_relative '../lib/fbe'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fbe
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.2
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko