mirror of
https://github.com/koreader/koreader.git
synced 2025-12-13 20:36:53 +01:00
Add CacheSQLite (#13131)
Persist is intended for larger objects, which works best for things like cached tiles with some metadata. For many small to medium entries that you nevertheless don't necessarily want to keep around in memory, that seems a bit inefficient. SQLite then, is effectively a way of minimizing the amount of writes in this scenario. Relates to #13061. Depends on #13122.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
--[[
|
||||
A LRU cache, based on https://github.com/starius/lua-lru
|
||||
]]--
|
||||
--[[--
|
||||
An LRU cache, based on https://github.com/starius/lua-lru
|
||||
|
||||
@module cache
|
||||
--]]
|
||||
|
||||
local lfs = require("libs/libkoreader-lfs")
|
||||
local logger = require("logger")
|
||||
|
||||
213
frontend/cachesqlite.lua
Normal file
213
frontend/cachesqlite.lua
Normal file
@@ -0,0 +1,213 @@
|
||||
--[[--
|
||||
An SQLite-based cache implementation, with an interface similar to @{cache|Cache}.
|
||||
|
||||
Example:
|
||||
|
||||
local CacheSQLite = require("cachesqlite")
|
||||
local cache = CacheSQLite:new{
|
||||
size = 1024 * 1024 * 10, -- 10 MB
|
||||
-- Set to :memory: for an in-memory database.
|
||||
-- In that case, set auto_close to false.
|
||||
db_path = "/path/to/cache.db",
|
||||
}
|
||||
cache:insert("key", {value = "data"})
|
||||
local data = cache:check("key")
|
||||
|
||||
@module cachesqlite
|
||||
--]]
|
||||
|
||||
local Persist = require("persist")
|
||||
local SQ3 = require("lua-ljsqlite3/init")
|
||||
local UIManager = require("ui/uimanager")
|
||||
local logger = require("logger")
|
||||
|
||||
local CacheSQLite = {
|
||||
--- Max storage space, in bytes.
|
||||
size = nil,
|
||||
--- Database file path. Set to :memory: for an in-memory database.
|
||||
db_path = nil,
|
||||
--- Compression codec from Persist.
|
||||
codec = "zstd",
|
||||
--- Whether to automatically close the DB connection after each operation. Set to false for batch operations or when using :memory:.
|
||||
auto_close = true,
|
||||
}
|
||||
|
||||
function CacheSQLite:new(o)
|
||||
o = o or {}
|
||||
setmetatable(o, self)
|
||||
self.__index = self
|
||||
if o.init then o:init() end
|
||||
return o
|
||||
end
|
||||
|
||||
local is_connected = false
|
||||
|
||||
function CacheSQLite:init()
|
||||
if self.db_path == ":memory:" and self.auto_close == true then
|
||||
logger.warn("CacheSQLite: using in-memory database, forcing auto_close = false")
|
||||
self.auto_close = false
|
||||
end
|
||||
self:openDB()
|
||||
|
||||
-- Create cache table if it doesn't exist
|
||||
self.db:exec[[
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
value BLOB,
|
||||
size INTEGER,
|
||||
last_access INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_last_access ON cache(last_access);
|
||||
]]
|
||||
|
||||
-- Initialize size tracking
|
||||
self.current_size = self.db:rowexec([[
|
||||
SELECT
|
||||
ROUND(page_count * page_size, 0) as db_size_bytes,
|
||||
ROUND((page_count - freelist_count) * page_size, 0) as data_size_bytes
|
||||
FROM pragma_page_count(),
|
||||
pragma_page_size(),
|
||||
pragma_freelist_count();
|
||||
]]) or 0
|
||||
logger.dbg("CacheSQLite:", self.db_path, "current size:", self.current_size)
|
||||
self:closeDB()
|
||||
|
||||
self._persist = Persist.getCodec(self.codec)
|
||||
end
|
||||
|
||||
--- Opens the SQLite database.
|
||||
--- This is normally done internally, but can be called manually if needed.
|
||||
function CacheSQLite:openDB()
|
||||
if not is_connected then
|
||||
self.db = SQ3.open(self.db_path)
|
||||
is_connected = true
|
||||
end
|
||||
end
|
||||
|
||||
--- Closes the SQLite database.
|
||||
--- This is normally done internally, but can be called manually if needed.
|
||||
--- @param[opt=false] explicit boolean When auto_close is false, this must be set to true to close the DB.
|
||||
function CacheSQLite:closeDB(explicit)
|
||||
if is_connected and (self.auto_close or explicit) then
|
||||
self.db:close()
|
||||
is_connected = false
|
||||
end
|
||||
end
|
||||
|
||||
--- Retrieves the connected state of the database.
|
||||
--- This is normally done internally, but can be called manually if needed.
|
||||
--- @return boolean
|
||||
function CacheSQLite:isConnected()
|
||||
return is_connected
|
||||
end
|
||||
|
||||
--- Inserts an object into the cache.
|
||||
--- @param key string
|
||||
--- @param object any
|
||||
--- @return boolean success, number size
|
||||
function CacheSQLite:insert(key, object)
|
||||
self:openDB()
|
||||
local codec = Persist.getCodec(self.codec)
|
||||
local size
|
||||
object, size = codec.serialize(object)
|
||||
if type(size) == "cdata" then
|
||||
size = tonumber(size)
|
||||
end
|
||||
if not size then
|
||||
size = #object
|
||||
end
|
||||
|
||||
if not self:willAccept(size) then
|
||||
logger.warn("Too much memory would be claimed by caching", key)
|
||||
return false, 0
|
||||
end
|
||||
|
||||
-- Ensure we have enough space by removing old entries
|
||||
while self.current_size + size > self.size do
|
||||
local oldest = self.db:rowexec([[
|
||||
SELECT key, size FROM cache
|
||||
ORDER BY last_access ASC LIMIT 1
|
||||
]])
|
||||
if oldest then
|
||||
self:remove(oldest[1])
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
local stmt = self.db:prepare([[
|
||||
INSERT OR REPLACE INTO cache (key, value, size, last_access)
|
||||
VALUES (?, ?, ?, ?);
|
||||
]])
|
||||
local ok, err = pcall(function()
|
||||
stmt:reset():bind(key, object, size, UIManager:getTime()):step()
|
||||
end)
|
||||
if not ok and err then
|
||||
err = err:gsub("\n.*", "") -- remove stacktrace
|
||||
logger.err("CacheSQLite:insert() failed:", err)
|
||||
end
|
||||
|
||||
self.current_size = self.current_size + size
|
||||
self:closeDB()
|
||||
return ok, size
|
||||
end
|
||||
|
||||
--- Retrieves an object if it is in the cache and updates its access time.
|
||||
--- @param key string
|
||||
--- @return any
|
||||
function CacheSQLite:check(key)
|
||||
self:openDB()
|
||||
-- Update access time and retrieve value
|
||||
local stmt = self.db:prepare([[
|
||||
UPDATE cache SET last_access = ? WHERE key = ?
|
||||
RETURNING value;
|
||||
]])
|
||||
local row = stmt:reset():bind(UIManager:getTime(), key):step()
|
||||
self:closeDB()
|
||||
|
||||
if row then
|
||||
return self._persist.deserialize(row[1])
|
||||
end
|
||||
end
|
||||
|
||||
--- Retrieves an object if it is in the cache without updating its access time.
|
||||
--- @param key string
|
||||
--- @return any
|
||||
function CacheSQLite:get(key)
|
||||
self:openDB()
|
||||
local stmt = self.db:prepare("SELECT value FROM cache WHERE key = ?")
|
||||
local row = stmt:reset():bind(key):step()
|
||||
self:closeDB()
|
||||
if row then
|
||||
return self._persist.deserialize(row[1])
|
||||
end
|
||||
end
|
||||
|
||||
--- Removes an object from the cache.
|
||||
function CacheSQLite:remove(key)
|
||||
self:openDB()
|
||||
local stmt = self.db:prepare("SELECT size FROM cache WHERE key = ?")
|
||||
local row = stmt:reset():bind(key):step()
|
||||
if row then
|
||||
self.current_size = self.current_size - row[1]
|
||||
local delete_stmt = self.db:prepare("DELETE FROM cache WHERE key = ?")
|
||||
delete_stmt:reset():bind(key):step()
|
||||
end
|
||||
self:closeDB()
|
||||
end
|
||||
|
||||
--- Queries whether the cache will accept an object of a given size in bytes.
|
||||
function CacheSQLite:willAccept(size)
|
||||
-- We only allow a single object to fill 50% of the cache
|
||||
return size*4 < self.size*2
|
||||
end
|
||||
|
||||
--- Clears the entire cache.
|
||||
function CacheSQLite:clear()
|
||||
self:openDB()
|
||||
self.db:exec("DELETE FROM cache")
|
||||
self.current_size = 0
|
||||
self:closeDB()
|
||||
end
|
||||
|
||||
return CacheSQLite
|
||||
96
spec/unit/cachesqlite_spec.lua
Normal file
96
spec/unit/cachesqlite_spec.lua
Normal file
@@ -0,0 +1,96 @@
|
||||
describe("CacheSQLite module", function()
|
||||
local CacheSQLite
|
||||
local cache
|
||||
describe("CacheSQLite on disk", function()
|
||||
setup(function()
|
||||
require("commonrequire")
|
||||
CacheSQLite = require("cachesqlite")
|
||||
cache = CacheSQLite:new{
|
||||
db_path = "test.db",
|
||||
size = 1024 * 1024 * 1024,
|
||||
}
|
||||
end)
|
||||
after_each(function()
|
||||
cache:clear()
|
||||
cache.size = 1024 * 1024 * 1024
|
||||
end)
|
||||
|
||||
it("should clear cache", function()
|
||||
cache:clear()
|
||||
end)
|
||||
|
||||
it("should insert and get", function()
|
||||
local object = {a = 1, b = 2}
|
||||
cache:insert("test", object)
|
||||
local deserialized = cache:get("test")
|
||||
assert.are.same(object, deserialized)
|
||||
end)
|
||||
|
||||
it("should remove object", function()
|
||||
local object = {a = 1, b = 2}
|
||||
cache:insert("test", object)
|
||||
cache:remove("test")
|
||||
local deserialized = cache:get("test")
|
||||
assert.is_nil(deserialized)
|
||||
end)
|
||||
|
||||
it("should accept regular object", function()
|
||||
assert.is_true(cache:willAccept(100))
|
||||
cache.size = 1024
|
||||
assert.is_true(cache:insert("test", {a = 1, b = 2}))
|
||||
end)
|
||||
|
||||
it("should reject giant object", function()
|
||||
assert.is_false(cache:willAccept(1024 * 1024 * 1024))
|
||||
cache.size = 10
|
||||
assert.is_false(cache:insert("test", {a = 1, b = 2}))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("CacheSQLite in memory", function()
|
||||
setup(function()
|
||||
require("commonrequire")
|
||||
CacheSQLite = require("cachesqlite")
|
||||
cache = CacheSQLite:new{
|
||||
db_path = ":memory:",
|
||||
auto_close = false,
|
||||
size = 1024 * 1024 * 1024,
|
||||
}
|
||||
end)
|
||||
after_each(function()
|
||||
cache:clear()
|
||||
cache.size = 1024 * 1024 * 1024
|
||||
end)
|
||||
|
||||
it("should clear cache", function()
|
||||
cache:clear()
|
||||
end)
|
||||
|
||||
it("should insert and get", function()
|
||||
local object = {a = 1, b = 2}
|
||||
cache:insert("test", object)
|
||||
local deserialized = cache:get("test")
|
||||
assert.are.same(object, deserialized)
|
||||
end)
|
||||
|
||||
it("should remove object", function()
|
||||
local object = {a = 1, b = 2}
|
||||
cache:insert("test", object)
|
||||
cache:remove("test")
|
||||
local deserialized = cache:get("test")
|
||||
assert.is_nil(deserialized)
|
||||
end)
|
||||
|
||||
it("should accept regular object", function()
|
||||
assert.is_true(cache:willAccept(100))
|
||||
cache.size = 1024
|
||||
assert.is_true(cache:insert("test", {a = 1, b = 2}))
|
||||
end)
|
||||
|
||||
it("should reject giant object", function()
|
||||
assert.is_false(cache:willAccept(1024 * 1024 * 1024))
|
||||
cache.size = 10
|
||||
assert.is_false(cache:insert("test", {a = 1, b = 2}))
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
Reference in New Issue
Block a user