fbe 0.20.0 → 0.21.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c33c559724c991d4aabc2ad9c097170e53de0002e362dcfadfe956d77eed7ec
4
- data.tar.gz: 787dbc8ce12ebacbd0b96cd8174ced19aa11665f29d3d410fb93c397dbb80d87
3
+ metadata.gz: 10045af8e71603e4647d934f3b10afa96385ebca7dc678911ff5e443be0af638
4
+ data.tar.gz: c8fe68b60de2232967d242733764f1025b2c9d0b8884114c6480c7b345d0fd3b
5
5
  SHA512:
6
- metadata.gz: ca43bc96f5c8f426a72fb3ab722684d36b67613526b9d2e1be175abae03581c4fc0318314e92628c9064d2c7ba8637814ed950e349ba86b64caedc768619be8f
7
- data.tar.gz: 81d737f13af364095e871df44000f6293d1c062929d7f859cd3836d23c3cf602a1362a9653fd739f1726c4d1004e0bad1806e3cb3fde23dc4aef081e39dfbd6a
6
+ metadata.gz: fd3fc1d0b158be59858edf4292027e71c73bffd8c1cbc7ef45ae743c9e9b521fe4faf4ab1995999fad3e3c798107d75925d74cc3ae8fd130cc535901259d68fc
7
+ data.tar.gz: d7d494cf9f5c5f4b5899be7caad9d4fdaa5203483c8bdd450117ea86241b0894df164d16d2c42505fda6c7e50627c803ed58f5e9d7910c7406f77cd4cc601dfb
data/Gemfile.lock CHANGED
@@ -180,7 +180,7 @@ GEM
180
180
  regexp_parser (2.10.0)
181
181
  retries (0.0.5)
182
182
  rexml (3.4.1)
183
- rubocop (1.76.1)
183
+ rubocop (1.76.2)
184
184
  json (~> 2.3)
185
185
  language_server-protocol (~> 3.17.0.2)
186
186
  lint_roller (~> 1.1.0)
@@ -188,7 +188,7 @@ GEM
188
188
  parser (>= 3.3.0.2)
189
189
  rainbow (>= 2.2.2, < 4.0)
190
190
  regexp_parser (>= 2.9.3, < 3.0)
191
- rubocop-ast (>= 1.45.0, < 2.0)
191
+ rubocop-ast (>= 1.45.1, < 2.0)
192
192
  ruby-progressbar (~> 1.7)
193
193
  unicode-display_width (>= 2.4.0, < 4.0)
194
194
  rubocop-ast (1.45.1)
@@ -6,26 +6,66 @@
6
6
  require 'time'
7
7
  require 'json'
8
8
  require 'sqlite3'
9
+ require 'loog'
9
10
  require_relative '../../fbe'
10
11
  require_relative '../../fbe/middleware'
11
12
 
12
13
  # Persisted SQLite store for Faraday::HttpCache
13
14
  #
15
+ # This class provides a persistent cache store backed by SQLite for use with
16
+ # Faraday::HttpCache middleware. It's designed to cache HTTP responses from
17
+ # GitHub API calls to reduce API rate limit consumption and improve performance.
18
+ #
19
+ # Key features:
20
+ # - Automatic version management to invalidate cache on version changes
21
+ # - Size-based cache eviction (configurable, defaults to 10MB)
22
+ # - Thread-safe SQLite transactions
23
+ # - JSON serialization for cached values
24
+ # - Filtering of non-cacheable requests (non-GET, URLs with query parameters)
25
+ #
26
+ # Usage example:
27
+ # store = Fbe::Middleware::SqliteStore.new(
28
+ # '/path/to/cache.db',
29
+ # '1.0.0',
30
+ # loog: logger,
31
+ # maxsize: 50 * 1024 * 1024 # 50MB max size
32
+ # )
33
+ #
34
+ # # Use with Faraday
35
+ # Faraday.new do |builder|
36
+ # builder.use Faraday::HttpCache, store: store
37
+ # end
38
+ #
39
+ # The store automatically manages the SQLite database schema and handles
40
+ # cleanup operations when the database grows too large. Old entries are
41
+ # deleted based on their last access time to maintain the configured size limit.
42
+ #
14
43
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
15
44
  # Copyright:: Copyright (c) 2024-2025 Zerocracy
16
45
  # License:: MIT
17
46
  class Fbe::Middleware::SqliteStore
18
47
  attr_reader :path
19
48
 
20
- def initialize(path, version)
49
+ # Initialize the SQLite store.
50
+ # @param path [String] Path to the SQLite database file
51
+ # @param version [String] Version identifier for cache compatibility
52
+ # @param loog [Loog] Logger instance (optional, defaults to Loog::NULL)
53
+ # @param maxsize [Integer] Maximum database size in bytes (optional, defaults to 10MB)
54
+ # @raise [ArgumentError] If path is nil/empty, directory doesn't exist, or version is nil/empty
55
+ def initialize(path, version, loog: Loog::NULL, maxsize: 10 * 1024 * 1024)
21
56
  raise ArgumentError, 'Database path cannot be nil or empty' if path.nil? || path.empty?
22
57
  dir = File.dirname(path)
23
58
  raise ArgumentError, "Directory #{dir} does not exist" unless File.directory?(dir)
24
59
  raise ArgumentError, 'Version cannot be nil or empty' if version.nil? || version.empty?
25
60
  @path = File.absolute_path(path)
26
61
  @version = version
62
+ @loog = loog
63
+ @maxsize = maxsize
27
64
  end
28
65
 
66
+ # Read a value from the cache.
67
+ # @param key [String] The cache key to read
68
+ # @return [Object, nil] The cached value parsed from JSON, or nil if not found
29
69
  def read(key)
30
70
  value = perform do |t|
31
71
  t.execute('UPDATE cache SET touched_at = ?2 WHERE key = ?1;', [key, Time.now.utc.iso8601])
@@ -34,11 +74,20 @@ class Fbe::Middleware::SqliteStore
34
74
  JSON.parse(value) if value
35
75
  end
36
76
 
77
+ # Delete a key from the cache.
78
+ # @param key [String] The cache key to delete
79
+ # @return [nil]
37
80
  def delete(key)
38
81
  perform { _1.execute('DELETE FROM cache WHERE key = ?', [key]) }
39
82
  nil
40
83
  end
41
84
 
85
+ # Write a value to the cache.
86
+ # @param key [String] The cache key to write
87
+ # @param value [Object] The value to cache (will be JSON encoded)
88
+ # @return [nil]
89
+ # @note Values larger than 10KB are not cached
90
+ # @note Non-GET requests and URLs with query parameters are not cached
42
91
  def write(key, value)
43
92
  return if value.is_a?(Array) && value.any? do |vv|
44
93
  req = JSON.parse(vv[0])
@@ -55,6 +104,8 @@ class Fbe::Middleware::SqliteStore
55
104
  nil
56
105
  end
57
106
 
107
+ # Clear all entries from the cache.
108
+ # @return [void]
58
109
  def clear
59
110
  perform do |t|
60
111
  t.execute 'DELETE FROM cache;'
@@ -63,6 +114,8 @@ class Fbe::Middleware::SqliteStore
63
114
  @db.execute 'VACUUM;'
64
115
  end
65
116
 
117
+ # Get all entries from the cache.
118
+ # @return [Array<Array>] Array of [key, value] pairs
66
119
  def all
67
120
  perform { _1.execute('SELECT key, value FROM cache') }
68
121
  end
@@ -73,26 +126,44 @@ class Fbe::Middleware::SqliteStore
73
126
  @db ||=
74
127
  SQLite3::Database.new(@path).tap do |d|
75
128
  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
129
+ t.execute 'CREATE TABLE IF NOT EXISTS cache(key TEXT UNIQUE NOT NULL, value TEXT);'
81
130
  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
131
  t.execute 'CREATE TABLE IF NOT EXISTS meta(key TEXT UNIQUE NOT NULL, value TEXT);'
84
132
  t.execute 'CREATE INDEX IF NOT EXISTS meta_key_idx ON meta(key);'
85
133
  t.execute "INSERT INTO meta(key, value) VALUES('version', ?) ON CONFLICT(key) DO NOTHING;", [@version]
86
134
  end
87
- if d.execute("SELECT value FROM meta WHERE key = 'version' LIMIT 1;").dig(0, 0) != @version
135
+ if d.execute("SELECT 1 FROM pragma_table_info('cache') WHERE name = 'touched_at';").dig(0, 0) != 1
136
+ d.transaction do |t|
137
+ t.execute 'ALTER TABLE cache ADD COLUMN touched_at TEXT;'
138
+ t.execute 'UPDATE cache set touched_at = ?;', [Time.now.utc.iso8601]
139
+ t.execute 'ALTER TABLE cache RENAME TO cache_old;'
140
+ t.execute <<~SQL
141
+ CREATE TABLE IF NOT EXISTS cache(
142
+ key TEXT UNIQUE NOT NULL, value TEXT, touched_at TEXT NOT NULL
143
+ );
144
+ SQL
145
+ t.execute 'INSERT INTO cache SELECT * FROM cache_old;'
146
+ t.execute 'DROP TABLE cache_old;'
147
+ t.execute 'CREATE INDEX IF NOT EXISTS cache_touched_at_idx ON cache(touched_at);'
148
+ end
149
+ d.execute 'VACUUM;'
150
+ end
151
+ found = d.execute("SELECT value FROM meta WHERE key = 'version' LIMIT 1;").dig(0, 0)
152
+ if found != @version
153
+ @loog.info("Version mismatch in SQLite cache: stored '#{found}' != current '#{@version}', cleaning up")
88
154
  d.transaction do |t|
89
155
  t.execute 'DELETE FROM cache;'
90
156
  t.execute "UPDATE meta SET value = ? WHERE key = 'version';", [@version]
91
157
  end
92
158
  d.execute 'VACUUM;'
93
159
  end
94
- if File.size(@path) > 10 * 1024 * 1024
95
- while d.execute(<<~SQL).dig(0, 0) > 10 * 1024 * 1024
160
+ if File.size(@path) > @maxsize
161
+ @loog.info(
162
+ "SQLite cache file size (#{File.size(@path)} bytes) exceeds " \
163
+ "#{@maxsize / 1024 / 1024}MB, cleaning up old entries"
164
+ )
165
+ deleted = 0
166
+ while d.execute(<<~SQL).dig(0, 0) > @maxsize
96
167
  SELECT (page_count - freelist_count) * page_size AS size
97
168
  FROM pragma_page_count(), pragma_freelist_count(), pragma_page_size();
98
169
  SQL
@@ -101,9 +172,11 @@ class Fbe::Middleware::SqliteStore
101
172
  DELETE FROM cache
102
173
  WHERE key IN (SELECT key FROM cache ORDER BY touched_at LIMIT 50)
103
174
  SQL
175
+ deleted += t.changes
104
176
  end
105
177
  end
106
178
  d.execute 'VACUUM;'
179
+ @loog.info("Deleted #{deleted} old cache entries, new file size: #{File.size(@path)} bytes")
107
180
  end
108
181
  at_exit { @db&.close }
109
182
  end
data/lib/fbe/octo.rb CHANGED
@@ -25,6 +25,10 @@ require_relative 'middleware/sqlite_store'
25
25
  # logging, and caching.
26
26
  #
27
27
  # @param [Judges::Options] options The options available globally
28
+ # @option options [String] :github_token GitHub API token for authentication
29
+ # @option options [Boolean] :testing When true, uses FakeOctokit for testing
30
+ # @option options [String] :sqlite_cache Path to SQLite cache file for HTTP responses
31
+ # @option options [Integer] :sqlite_cache_maxsize Maximum size of SQLite cache in bytes (default: 10MB)
28
32
  # @param [Hash] global Hash of global options
29
33
  # @param [Loog] loog Logging facility
30
34
  # @return [Hash] Usually returns a JSON, as it comes from the GitHub API
@@ -77,11 +81,12 @@ def Fbe.octo(options: $options, global: $global, loog: $loog)
77
81
  backoff_factor: 2
78
82
  )
79
83
  if options.sqlite_cache
80
- store = Fbe::Middleware::SqliteStore.new(options.sqlite_cache, Fbe::VERSION)
84
+ maxsize = options.sqlite_cache_maxsize || (10 * 1024 * 1024)
85
+ store = Fbe::Middleware::SqliteStore.new(options.sqlite_cache, Fbe::VERSION, loog:, maxsize:)
81
86
  loog.info(
82
87
  "Using HTTP cache in SQLite file: #{store.path} (" \
83
- "#{File.exist?(store.path) ? "#{File.size(store.path)} bytes" : 'file is absent'}" \
84
- ')'
88
+ "#{File.exist?(store.path) ? "#{File.size(store.path)} bytes" : 'file is absent'}, " \
89
+ "max size: #{maxsize / 1024 / 1024}MB)"
85
90
  )
86
91
  builder.use(
87
92
  Faraday::HttpCache,
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.20.0' unless const_defined?(:VERSION)
13
+ VERSION = '0.21.1' unless const_defined?(:VERSION)
14
14
  end
@@ -182,6 +182,26 @@ class SqliteStoreTest < Fbe::Test
182
182
  end
183
183
  end
184
184
 
185
+ def test_upgrade_sqlite_schema_for_add_touched_at_column
186
+ with_tmpfile('a.db') do |f|
187
+ SQLite3::Database.new(f).tap do |d|
188
+ d.execute 'CREATE TABLE IF NOT EXISTS cache(key TEXT UNIQUE NOT NULL, value TEXT);'
189
+ [
190
+ ['key1', JSON.dump('value1')],
191
+ ['key2', JSON.dump('value2')]
192
+ ].each { d.execute 'INSERT INTO cache(key, value) VALUES(?1, ?2);', _1 }
193
+ d.execute 'CREATE TABLE IF NOT EXISTS meta(key TEXT UNIQUE NOT NULL, value TEXT);'
194
+ d.execute "INSERT INTO meta(key, value) VALUES('version', ?);", ['0.0.1']
195
+ end
196
+ Fbe::Middleware::SqliteStore.new(f, '0.0.1').then do |store|
197
+ assert_equal('value1', store.read('key1'))
198
+ assert_equal('value2', store.read('key2'))
199
+ rescue SQLite3::SQLException => e
200
+ assert_nil(e)
201
+ end
202
+ end
203
+ end
204
+
185
205
  private
186
206
 
187
207
  def with_tmpfile(name = 'test.db', &)
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.20.0
4
+ version: 0.21.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko