feat: access to DB as lazily as possible (#180)

* fix: fix prop attributes and names

* feat: load DB as lazily as possible

* fix: move util function to test module

* feat: use one coroutine to access DB

* test: fix to wait the table to be ready

* fix: avoid race conditions

Before this, it can run require("frecency").new() duplicatedly to wait
until frecency:setup() finishes.
This commit is contained in:
JINNOUCHI Yasushi 2024-03-21 16:23:45 +09:00 committed by GitHub
parent 747894efd1
commit dde0b71e40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 125 additions and 72 deletions

View File

@ -1,5 +1,5 @@
local Table = require "frecency.database.table"
local FileLock = require "frecency.file_lock" local FileLock = require "frecency.file_lock"
local wait = require "frecency.wait"
local watcher = require "frecency.watcher" local watcher = require "frecency.watcher"
local log = require "plenary.log" local log = require "plenary.log"
local async = require "plenary.async" --[[@as PlenaryAsync]] local async = require "plenary.async" --[[@as PlenaryAsync]]
@ -19,23 +19,15 @@ local Path = require "plenary.path" --[[@as PlenaryPath]]
---@field score number ---@field score number
---@class FrecencyDatabase ---@class FrecencyDatabase
---@field config FrecencyDatabaseConfig ---@field tx PlenaryAsyncControlChannelTx
---@field file_lock FrecencyFileLock ---@field private config FrecencyDatabaseConfig
---@field filename string ---@field private file_lock FrecencyFileLock
---@field fs FrecencyFS ---@field private filename string
---@field new fun(fs: FrecencyFS, config: FrecencyDatabaseConfig): FrecencyDatabase ---@field private fs FrecencyFS
---@field table FrecencyDatabaseTable ---@field private tbl FrecencyDatabaseTable
---@field version "v1" ---@field private version "v1"
local Database = {} local Database = {}
---@class FrecencyDatabaseTable
---@field version string
---@field records table<string,FrecencyDatabaseRecord>
---@class FrecencyDatabaseRecord
---@field count integer
---@field timestamps integer[]
---@param fs FrecencyFS ---@param fs FrecencyFS
---@param config FrecencyDatabaseConfig ---@param config FrecencyDatabaseConfig
---@return FrecencyDatabase ---@return FrecencyDatabase
@ -44,21 +36,29 @@ Database.new = function(fs, config)
local self = setmetatable({ local self = setmetatable({
config = config, config = config,
fs = fs, fs = fs,
table = { version = version, records = {} }, tbl = Table.new(version),
version = version, version = version,
}, { __index = Database }) }, { __index = Database })
self.filename = Path.new(self.config.root, "file_frecency.bin").filename self.filename = Path.new(self.config.root, "file_frecency.bin").filename
self.file_lock = FileLock.new(self.filename) self.file_lock = FileLock.new(self.filename)
local tx, rx = async.control.channel.counter() local rx
watcher.watch(self.filename, tx) self.tx, rx = async.control.channel.mpsc()
wait(function() self.tx.send "load"
self:load() watcher.watch(self.filename, function()
self.tx.send "load"
end) end)
async.void(function() async.void(function()
while true do while true do
rx.last() local mode = rx.recv()
log.debug "file changed. loading..." log.debug("DB coroutine start:", mode)
if mode == "load" then
self:load() self:load()
elseif mode == "save" then
self:save()
else
log.error("unknown mode: " .. mode)
end
log.debug("DB coroutine end:", mode)
end end
end)() end)()
return self return self
@ -66,7 +66,7 @@ end
---@return boolean ---@return boolean
function Database:has_entry() function Database:has_entry()
return not vim.tbl_isempty(self.table.records) return not vim.tbl_isempty(self.tbl.records)
end end
---@param paths string[] ---@param paths string[]
@ -76,17 +76,15 @@ function Database:insert_files(paths)
return return
end end
for _, path in ipairs(paths) do for _, path in ipairs(paths) do
self.table.records[path] = { count = 1, timestamps = { 0 } } self.tbl.records[path] = { count = 1, timestamps = { 0 } }
end end
wait(function() self.tx.send "save"
self:save()
end)
end end
---@return string[] ---@return string[]
function Database:unlinked_entries() function Database:unlinked_entries()
local paths = {} local paths = {}
for file in pairs(self.table.records) do for file in pairs(self.tbl.records) do
if not self.fs:is_valid_path(file) then if not self.fs:is_valid_path(file) then
table.insert(paths, file) table.insert(paths, file)
end end
@ -97,18 +95,16 @@ end
---@param paths string[] ---@param paths string[]
function Database:remove_files(paths) function Database:remove_files(paths)
for _, file in ipairs(paths) do for _, file in ipairs(paths) do
self.table.records[file] = nil self.tbl.records[file] = nil
end end
wait(function() self.tx.send "save"
self:save()
end)
end end
---@param path string ---@param path string
---@param max_count integer ---@param max_count integer
---@param datetime string? ---@param datetime string?
function Database:update(path, max_count, datetime) function Database:update(path, max_count, datetime)
local record = self.table.records[path] or { count = 0, timestamps = {} } local record = self.tbl.records[path] or { count = 0, timestamps = {} }
record.count = record.count + 1 record.count = record.count + 1
local now = self:now(datetime) local now = self:now(datetime)
table.insert(record.timestamps, now) table.insert(record.timestamps, now)
@ -119,10 +115,8 @@ function Database:update(path, max_count, datetime)
end end
record.timestamps = new_table record.timestamps = new_table
end end
self.table.records[path] = record self.tbl.records[path] = record
wait(function() self.tx.send "save"
self:save()
end)
end end
---@param workspace string? ---@param workspace string?
@ -131,7 +125,7 @@ end
function Database:get_entries(workspace, datetime) function Database:get_entries(workspace, datetime)
local now = self:now(datetime) local now = self:now(datetime)
local items = {} local items = {}
for path, record in pairs(self.table.records) do for path, record in pairs(self.tbl.records) do
if self.fs:starts_with(path, workspace) then if self.fs:starts_with(path, workspace) then
table.insert(items, { table.insert(items, {
path = path, path = path,
@ -154,12 +148,8 @@ function Database:now(datetime)
if not datetime then if not datetime then
return os.time() return os.time()
end end
local epoch
wait(function()
local tz_fix = datetime:gsub("+(%d%d):(%d%d)$", "+%1%2") local tz_fix = datetime:gsub("+(%d%d):(%d%d)$", "+%1%2")
epoch = require("frecency.tests.util").time_piece(tz_fix) return require("frecency.tests.util").time_piece(tz_fix)
end)
return epoch
end end
---@async ---@async
@ -182,10 +172,8 @@ function Database:load()
return data return data
end) end)
assert(not err, err) assert(not err, err)
local tbl = loadstring(data or "")() --[[@as FrecencyDatabaseTable?]] local tbl = vim.F.npcall(loadstring(data or ""))
if tbl and tbl.version == self.version then self.tbl:set(tbl)
self.table = tbl
end
log.debug(("load() takes %f seconds"):format(os.clock() - start)) log.debug(("load() takes %f seconds"):format(os.clock() - start))
end end
@ -194,7 +182,7 @@ end
function Database:save() function Database:save()
local start = os.clock() local start = os.clock()
local err = self.file_lock:with(function() local err = self.file_lock:with(function()
self:raw_save(self.table) self:raw_save(self.tbl:raw())
local err, stat = async.uv.fs_stat(self.filename) local err, stat = async.uv.fs_stat(self.filename)
assert(not err, err) assert(not err, err)
watcher.update(stat) watcher.update(stat)
@ -205,7 +193,7 @@ function Database:save()
end end
---@async ---@async
---@param tbl FrecencyDatabaseTable ---@param tbl FrecencyDatabaseRawTable
function Database:raw_save(tbl) function Database:raw_save(tbl)
local f = assert(load("return " .. vim.inspect(tbl))) local f = assert(load("return " .. vim.inspect(tbl)))
local data = string.dump(f) local data = string.dump(f)
@ -218,13 +206,11 @@ end
---@param path string ---@param path string
---@return boolean ---@return boolean
function Database:remove_entry(path) function Database:remove_entry(path)
if not self.table.records[path] then if not self.tbl.records[path] then
return false return false
end end
self.table.records[path] = nil self.tbl.records[path] = nil
wait(function() self.tx.send "save"
self:save()
end)
return true return true
end end

View File

@ -0,0 +1,55 @@
local log = require "plenary.log"
---@class FrecencyDatabaseRecord
---@field count integer
---@field timestamps integer[]
---@class FrecencyDatabaseRawTable
---@field version string
---@field records table<string,FrecencyDatabaseRecord>
---@class FrecencyDatabaseTable: FrecencyDatabaseRawTable
---@field private is_ready boolean
local Table = {}
---@param version string
---@return FrecencyDatabaseTable
Table.new = function(version)
return setmetatable({ is_ready = false, version = version }, { __index = Table.__index })
end
function Table:__index(key)
if key == "records" and not rawget(self, "is_ready") then
local start = os.clock()
log.debug "waiting start"
Table.wait_ready(self)
log.debug(("waiting until DB become clean takes %f seconds"):format(os.clock() - start))
end
log.debug(("is_ready: %s, key: %s, value: %s"):format(rawget(self, "is_ready"), key, rawget(self, key)))
return vim.F.if_nil(rawget(self, key), Table[key])
end
function Table:raw()
return { version = self.version, records = self.records }
end
---@param raw_table? FrecencyDatabaseRawTable
---@return nil
function Table:set(raw_table)
local tbl = raw_table or { version = self.version, records = {} }
if self.version ~= tbl.version then
error "Invalid version"
end
self.is_ready = true
self.records = tbl.records
end
---This is for internal or testing use only.
---@return nil
function Table:wait_ready()
vim.wait(2000, function()
return rawget(self, "is_ready")
end)
end
return Table

View File

@ -27,6 +27,7 @@ local function with_files(files, cb_or_config, callback)
log.debug(cfg) log.debug(cfg)
config.setup(cfg) config.setup(cfg)
local frecency = Frecency.new() local frecency = Frecency.new()
frecency.database.tbl:wait_ready()
frecency.picker = Picker.new( frecency.picker = Picker.new(
frecency.database, frecency.database,
frecency.entry_maker, frecency.entry_maker,

View File

@ -2,6 +2,7 @@ local uv = vim.uv or vim.loop
local async = require "plenary.async" --[[@as PlenaryAsync]] local async = require "plenary.async" --[[@as PlenaryAsync]]
local Path = require "plenary.path" local Path = require "plenary.path"
local Job = require "plenary.job" local Job = require "plenary.job"
local wait = require "frecency.tests.wait"
---@return PlenaryPath ---@return PlenaryPath
---@return fun(): nil close swwp all entries ---@return fun(): nil close swwp all entries
@ -37,9 +38,13 @@ end, 2)
-- NOTE: vim.fn.strptime cannot be used in Lua loop -- NOTE: vim.fn.strptime cannot be used in Lua loop
local function time_piece(iso8601) local function time_piece(iso8601)
local epoch
wait(function()
local stdout, code = local stdout, code =
AsyncJob { "perl", "-MTime::Piece", "-e", "print Time::Piece->strptime('" .. iso8601 .. "', '%FT%T%z')->epoch" } AsyncJob { "perl", "-MTime::Piece", "-e", "print Time::Piece->strptime('" .. iso8601 .. "', '%FT%T%z')->epoch" }
return code == 0 and tonumber(stdout) or nil epoch = code == 0 and tonumber(stdout) or nil
end)
return epoch
end end
---@param source table<string,{ count: integer, timestamps: string[] }> ---@param source table<string,{ count: integer, timestamps: string[] }>

View File

@ -36,8 +36,8 @@ Watcher.new = function()
end end
---@param path string ---@param path string
---@param tx PlenaryAsyncControlChannelTx ---@param cb fun(): nil
function Watcher:watch(path, tx) function Watcher:watch(path, cb)
if self.handler then if self.handler then
self.handler:stop() self.handler:stop()
end end
@ -60,7 +60,7 @@ function Watcher:watch(path, tx)
if self.mtime ~= mtime then if self.mtime ~= mtime then
log.debug(("mtime changed: %s -> %s"):format(self.mtime, mtime)) log.debug(("mtime changed: %s -> %s"):format(self.mtime, mtime))
self.mtime = mtime self.mtime = mtime
tx.send() cb()
end end
end)() end)()
end) end)
@ -70,11 +70,11 @@ local watcher = Watcher.new()
return { return {
---@param path string ---@param path string
---@param tx PlenaryAsyncControlChannelTx ---@param cb fun(): nil
---@return nil ---@return nil
watch = function(path, tx) watch = function(path, cb)
log.debug("watch path: " .. path) log.debug("watch path: " .. path)
watcher:watch(path, tx) watcher:watch(path, cb)
end, end,
---@param stat FsStat ---@param stat FsStat

View File

@ -8,15 +8,21 @@
---@field start fun(opts: FrecencyPickerOptions?): nil ---@field start fun(opts: FrecencyPickerOptions?): nil
---@field validate_database fun(force: boolean?): nil ---@field validate_database fun(force: boolean?): nil
local frecency = setmetatable({}, { local frecency = setmetatable({}, {
---@param self FrecencyInstance
---@param key "complete"|"delete"|"register"|"start"|"validate_database"
---@return function
__index = function(self, key) __index = function(self, key)
return function(...) ---@return Frecency
local instance = rawget(self, "instance") --[[@as Frecency?]] local function instance()
if not instance then return rawget(self, "instance")
instance = require("frecency").new()
instance:setup()
rawset(self, "instance", instance)
end end
return instance[key](instance, ...)
return function(...)
if not instance() then
rawset(self, "instance", require("frecency").new())
instance():setup()
end
return instance()[key](instance(), ...)
end end
end, end,
}) })