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 +4 -4
- data/Gemfile.lock +2 -2
- data/lib/fbe/middleware/sqlite_store.rb +83 -10
- data/lib/fbe/octo.rb +8 -3
- data/lib/fbe.rb +1 -1
- data/test/fbe/middleware/test_sqlite_store.rb +20 -0
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 10045af8e71603e4647d934f3b10afa96385ebca7dc678911ff5e443be0af638
|
4
|
+
data.tar.gz: c8fe68b60de2232967d242733764f1025b2c9d0b8884114c6480c7b345d0fd3b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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.
|
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
|
-
|
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
|
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
|
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) >
|
95
|
-
|
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
|
-
|
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
@@ -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', &)
|