Files
neon 80244ea4e2 feat(koreader): rebuild the plugin interface and fix four-way annotation sync
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
2026-10-20 16:05:27 -06:00

573 lines
20 KiB
Lua

--[[--
Durable outbox for lifecycle (close/suspend) book syncs.
Close and suspend handlers must not run network I/O, so they capture a snapshot
and persist it here instead. A later drain replays it. Each entry is stored as
an immutable payload file (the snapshot) plus a small metadata file per
generation, so phase acknowledgements never rewrite the snapshot.
Entries that keep failing are retried with backoff and eventually parked as
"blocked" rather than removed: automatic drains skip them so they cannot block
newer entries, while a manual sync retries everything.
]]
local DataStorage = require("datastorage")
local dump = require("dump")
local lfs = require("libs/libkoreader-lfs")
local util = require("util")
local OUTBOX_DIR = "bookorbit-lifecycle-outbox"
local ENTRY_VERSION = 1
local SOFT_ENTRY_LIMIT = 100
local SOFT_BYTE_LIMIT = 50 * 1024 * 1024
local HARD_ENTRY_LIMIT = 1000
local HARD_BYTE_LIMIT = 200 * 1024 * 1024
local MAX_ATTEMPTS = 5
local RETRY_BASE_DELAY = 60
local RETRY_MAX_DELAY = 3600
local KEEP_GENERATIONS = 2
local PHASES = { "match", "stats", "annotations", "state", "progress" }
local Outbox = {}
Outbox.__index = Outbox
local next_id = 0
local function copyArray(values)
local result = {}
for _, value in ipairs(values or {}) do
table.insert(result, value)
end
return result
end
local function mergeUniqueArrays(first, second)
local result, seen = {}, {}
for _, values in ipairs({ first or {}, second or {} }) do
for _, value in ipairs(values) do
if not seen[value] then
seen[value] = true
table.insert(result, value)
end
end
end
return result
end
local function validDigest(digest)
return type(digest) == "string" and digest ~= "" and digest:match("^%x+$") ~= nil
end
local function validId(id)
return type(id) == "string" and id:match("^entry_%x+_%d+_%d+$") ~= nil
end
local function generationFilename(id, generation)
return string.format("%s_g%d.lua", id, generation)
end
local function payloadFilename(id, generation)
return string.format("%s_payload_g%d.lua", id, generation)
end
local function safeLoad(path)
local chunk = loadfile(path)
if not chunk then return nil end
setfenv(chunk, {})
local ok, value = pcall(chunk)
if not ok or type(value) ~= "table" then return nil end
return value
end
local function descending(a, b) return a > b end
function Outbox.open(opts)
opts = opts or {}
local settings_dir = opts.settings_dir or DataStorage:getSettingsDir()
local dir = settings_dir .. "/" .. OUTBOX_DIR
local self = setmetatable({
dir = dir,
now = opts.now or os.time,
rename = opts.rename or os.rename,
remove = opts.remove or os.remove,
write_file = opts.write_file or function(content, path)
return util.writeToFile(content, path, true, false, true)
end,
make_path = opts.make_path or util.makePath,
list_dir = opts.list_dir or lfs.dir,
attributes = opts.attributes or lfs.attributes,
load_entry = opts.load_entry or safeLoad,
serialize = opts.serialize or function(entry)
return "return " .. dump(entry) .. "\n"
end,
serialize_payload = opts.serialize_payload or opts.serialize or function(payload)
return "return " .. dump(payload) .. "\n"
end,
soft_entry_limit = opts.soft_entry_limit or SOFT_ENTRY_LIMIT,
soft_byte_limit = opts.soft_byte_limit or SOFT_BYTE_LIMIT,
hard_entry_limit = opts.hard_entry_limit or HARD_ENTRY_LIMIT,
hard_byte_limit = opts.hard_byte_limit or HARD_BYTE_LIMIT,
max_attempts = opts.max_attempts or MAX_ATTEMPTS,
retry_base_delay = opts.retry_base_delay or RETRY_BASE_DELAY,
retry_max_delay = opts.retry_max_delay or RETRY_MAX_DELAY,
}, Outbox)
return self
end
function Outbox:ensureDirectory()
if self.attributes(self.dir, "mode") == "directory" then return true end
self.scan_cache = nil
return self.make_path(self.dir) == true
end
function Outbox:path(id, generation)
assert(validId(id), "invalid outbox entry id")
assert(type(generation) == "number" and generation >= 1, "invalid outbox generation")
return self.dir .. "/" .. generationFilename(id, generation)
end
function Outbox:payloadPath(id, generation)
assert(validId(id), "invalid outbox entry id")
assert(type(generation) == "number" and generation >= 1, "invalid outbox payload generation")
return self.dir .. "/" .. payloadFilename(id, generation)
end
-- One directory listing feeds every lookup below. The close handler calls into
-- this repeatedly, so the result is cached until something is written.
function Outbox:scan()
if self.scan_cache then return self.scan_cache end
local grouped = { entries = {}, bytes = 0 }
if self.attributes(self.dir, "mode") ~= "directory" then
self.scan_cache = grouped
return grouped
end
local ok, iterator, dir_obj = pcall(self.list_dir, self.dir)
if not ok or not iterator then
self.scan_cache = grouped
return grouped
end
local function entryFor(id)
local record = grouped.entries[id]
if not record then
record = { generations = {}, payloads = {} }
grouped.entries[id] = record
end
return record
end
for filename in iterator, dir_obj do
local id, generation = filename:match("^(entry_%x+_%d+_%d+)_g(%d+)%.lua$")
if id then
table.insert(entryFor(id).generations, tonumber(generation))
else
local payload_id, payload_generation = filename:match(
"^(entry_%x+_%d+_%d+)_payload_g(%d+)%.lua$")
if payload_id then
table.insert(entryFor(payload_id).payloads, tonumber(payload_generation))
end
end
if filename:match("^entry_.*%.lua$") then
grouped.bytes = grouped.bytes
+ (tonumber(self.attributes(self.dir .. "/" .. filename, "size")) or 0)
end
end
self.scan_cache = grouped
return grouped
end
function Outbox:invalidateScan()
self.scan_cache = nil
end
function Outbox:generationsFor(id)
local record = self:scan().entries[id]
return record and record.generations or {}
end
local function validMetadata(metadata, id, generation)
return metadata and metadata.version == ENTRY_VERSION and metadata.id == id
and metadata.generation == generation and validDigest(metadata.digest)
and type(metadata.payload_generation) == "number"
and type(metadata.acknowledged) == "table"
end
function Outbox:readLatestMetadata(id, generations)
generations = generations or copyArray(self:generationsFor(id))
table.sort(generations, descending)
for _, generation in ipairs(generations) do
local metadata = self.load_entry(self:path(id, generation))
if validMetadata(metadata, id, generation) then return metadata end
end
end
function Outbox:attachPayload(metadata)
if not metadata then return nil end
local payload = self.load_entry(self:payloadPath(metadata.id, metadata.payload_generation))
if payload and payload.version == ENTRY_VERSION and payload.id == metadata.id
and payload.digest == metadata.digest and type(payload.snapshot) == "table" then
metadata.snapshot = payload.snapshot
return metadata
end
end
function Outbox:readLatest(id, generations)
generations = generations or copyArray(self:generationsFor(id))
table.sort(generations, descending)
for _, generation in ipairs(generations) do
local metadata = self.load_entry(self:path(id, generation))
if validMetadata(metadata, id, generation) then
local entry = self:attachPayload(metadata)
if entry then return entry end
end
end
end
function Outbox:listMetadata()
local entries = {}
for id, record in pairs(self:scan().entries) do
local entry = self:readLatestMetadata(id, copyArray(record.generations))
if entry then table.insert(entries, entry) end
end
table.sort(entries, function(a, b)
if a.enqueued_at == b.enqueued_at then return a.id < b.id end
return a.enqueued_at < b.enqueued_at
end)
return entries
end
function Outbox:list()
local entries = {}
for _, metadata in ipairs(self:listMetadata()) do
local entry = self:attachPayload(metadata)
if entry then table.insert(entries, entry) end
end
return entries
end
function Outbox:isBlocked(entry)
return entry ~= nil and (entry.attempts or 0) >= self.max_attempts
end
-- Automatic drains only take entries that are not parked and not backing off,
-- so a permanently undeliverable entry cannot stall the ones behind it.
function Outbox:isDrainable(entry, now)
if not entry then return false end
if self.active_id and entry.id == self.active_id then return false end
if self:isBlocked(entry) then return false end
if entry.next_attempt_at and entry.next_attempt_at > (now or self.now()) then return false end
return true
end
function Outbox:nextEntry(opts)
opts = opts or {}
local now = self.now()
for _, metadata in ipairs(self:listMetadata()) do
if opts.include_blocked or self:isDrainable(metadata, now) then
local entry = self:attachPayload(metadata)
if entry then return entry end
end
end
end
-- Earliest time an entry that is backing off becomes drainable again; nil when
-- none is waiting. Parked entries only move on a sync the reader asks for.
function Outbox:nextRetryAt()
local now = self.now()
local earliest
for _, metadata in ipairs(self:listMetadata()) do
local at = metadata.next_attempt_at
if at and at > now and not self:isBlocked(metadata) and (earliest == nil or at < earliest) then
earliest = at
end
end
return earliest
end
function Outbox:status()
local scan = self:scan()
local count = 0
for _, record in pairs(scan.entries) do
if #record.generations > 0 and #record.payloads > 0 then count = count + 1 end
end
local bytes = scan.bytes
return {
count = count,
bytes = bytes,
soft_limit = count >= self.soft_entry_limit or bytes >= self.soft_byte_limit,
hard_limit = count >= self.hard_entry_limit or bytes >= self.hard_byte_limit,
}
end
-- Diagnostics needs the per-entry attempt counts, which means reading metadata.
-- Only the menu calls this, never the close path.
function Outbox:diagnostics()
local status = self:status()
local blocked, drainable = 0, 0
local now = self.now()
for _, metadata in ipairs(self:listMetadata()) do
if self:isBlocked(metadata) then
blocked = blocked + 1
elseif self:isDrainable(metadata, now) then
drainable = drainable + 1
end
end
status.blocked = blocked
status.drainable = drainable
return status
end
function Outbox:pruneOldGenerations(id, keep_generation)
local generations = copyArray(self:generationsFor(id))
table.sort(generations, descending)
local kept = 0
local surviving = {}
for _, generation in ipairs(generations) do
if generation > keep_generation then
table.insert(surviving, generation)
else
kept = kept + 1
if kept > KEEP_GENERATIONS then
self.remove(self:path(id, generation))
self:invalidateScan()
else
table.insert(surviving, generation)
end
end
end
local payloads_to_keep = {}
for _, generation in ipairs(surviving) do
local metadata = self.load_entry(self:path(id, generation))
if metadata and type(metadata.payload_generation) == "number" then
payloads_to_keep[metadata.payload_generation] = true
end
end
for _, payload_generation in ipairs(copyArray((self:scan().entries[id] or {}).payloads)) do
if not payloads_to_keep[payload_generation] then
self.remove(self:payloadPath(id, payload_generation))
self:invalidateScan()
end
end
end
function Outbox:publish(content, path)
if not self:ensureDirectory() then return nil, "directory_failed" end
local temporary = path .. ".tmp"
self.remove(temporary)
local ok, err = self.write_file(content, temporary)
if not ok then
self.remove(temporary)
return nil, err or "write_failed"
end
local renamed, rename_err = self.rename(temporary, path)
self:invalidateScan()
if not renamed then
self.remove(temporary)
return nil, rename_err or "rename_failed"
end
return true
end
function Outbox:savePayload(entry, content)
local payload = {
version = ENTRY_VERSION,
id = entry.id,
digest = entry.digest,
snapshot = entry.snapshot,
}
return self:publish(
content or self.serialize_payload(payload),
self:payloadPath(entry.id, entry.payload_generation))
end
function Outbox:save(entry)
local metadata = {}
for key, value in pairs(entry) do
if key ~= "snapshot" then metadata[key] = value end
end
local saved, err = self:publish(
self.serialize(metadata),
self:path(entry.id, entry.generation))
if not saved then return nil, err end
self:pruneOldGenerations(entry.id, entry.generation)
return entry
end
local function allAcknowledged(entry)
for _, phase in ipairs(PHASES) do
if entry.acknowledged[phase] ~= true then return false end
end
return entry.remote_pending == nil
end
function Outbox:isComplete(entry)
return entry ~= nil and allAcknowledged(entry)
end
function Outbox:allocateId(digest, now)
local entries = self:scan().entries
for _ = 1, 1000 do
next_id = next_id + 1
local candidate = string.format("entry_%s_%d_%d", digest, now, next_id)
if not entries[candidate] then return candidate end
end
end
function Outbox:enqueue(snapshot, opts)
opts = opts or {}
if type(snapshot) ~= "table" or not validDigest(snapshot.digest) then
return nil, "invalid_snapshot"
end
local status = self:status()
local existing, existing_with_payload
for _, entry in ipairs(self:listMetadata()) do
if entry.digest == snapshot.digest and entry.id ~= self.active_id then
local candidate = self:attachPayload(entry)
if candidate and candidate.snapshot.file == snapshot.file then
existing = entry
existing_with_payload = candidate
end
end
end
if status.hard_limit and not existing then return nil, "hard_limit" end
local now = self.now()
local id = existing and existing.id or self:allocateId(snapshot.digest, now)
if not id then return nil, "id_exhausted" end
local stored_snapshot = {}
for key, value in pairs(snapshot) do stored_snapshot[key] = value end
stored_snapshot.stats_ids = copyArray(snapshot.stats_ids)
if existing_with_payload and existing_with_payload.acknowledged.stats ~= true then
stored_snapshot.stats_ids = mergeUniqueArrays(
existing_with_payload.snapshot.stats_ids, stored_snapshot.stats_ids)
stored_snapshot.stats_identity_repaired = existing_with_payload.snapshot.stats_identity_repaired == true
or stored_snapshot.stats_identity_repaired == true
end
local annotation_sync = opts.annotation_sync ~= false
local entry = {
version = ENTRY_VERSION,
id = id,
generation = existing and existing.generation + 1 or 1,
payload_generation = existing and existing.payload_generation + 1 or 1,
digest = stored_snapshot.digest,
enqueued_at = existing and existing.enqueued_at or now,
captured_at = stored_snapshot.ts or now,
reason = opts.reason or "lifecycle",
snapshot = stored_snapshot,
acknowledged = {
match = false,
stats = stored_snapshot.stats_metadata_ambiguous == true,
annotations = false,
state = false,
progress = type(stored_snapshot.percentage) ~= "number",
},
annotation_sync = annotation_sync,
-- A fresh snapshot supersedes earlier failures, so the retry budget
-- resets. Remote changes that still need applying are carried over.
attempts = 0,
remote_pending = existing and existing.remote_pending or nil,
}
local payload_content = self.serialize_payload({
version = ENTRY_VERSION,
id = entry.id,
digest = entry.digest,
snapshot = entry.snapshot,
})
local projected_count = status.count + (existing and 0 or 1)
local projected_bytes = status.bytes + #payload_content
if projected_count > self.hard_entry_limit or projected_bytes > self.hard_byte_limit then
return nil, "hard_limit"
end
local payload_saved, payload_err = self:savePayload(entry, payload_content)
if not payload_saved then return nil, payload_err end
local saved, err = self:save(entry)
if not saved then return nil, err end
return saved, {
count = projected_count,
bytes = projected_bytes,
soft_limit = projected_count >= self.soft_entry_limit
or projected_bytes >= self.soft_byte_limit,
hard_limit = false,
}
end
function Outbox:update(id, expected_generation, mutate)
local entry = self:readLatestMetadata(id)
if not entry then return nil, "not_found" end
if expected_generation and entry.generation ~= expected_generation then
return nil, "stale_generation"
end
mutate(entry)
entry.generation = entry.generation + 1
return self:save(entry)
end
function Outbox:retryDelay(attempts)
local delay = self.retry_base_delay * 2 ^ math.max(0, attempts - 1)
return math.min(delay, self.retry_max_delay)
end
function Outbox:markStarted(id, generation)
local now = self.now()
local updated, err = self:update(id, generation, function(entry)
entry.started_at = entry.started_at or now
entry.last_attempt_at = now
entry.attempts = (entry.attempts or 0) + 1
entry.next_attempt_at = now + self:retryDelay(entry.attempts)
end)
if not updated then return nil, err end
self.active_id = id
return self:readLatest(id)
end
function Outbox:markFinished(id)
if self.active_id == id then self.active_id = nil end
end
function Outbox:acknowledge(id, generation, phase)
return self:update(id, generation, function(entry)
assert(entry.acknowledged[phase] ~= nil, "invalid outbox phase")
entry.acknowledged[phase] = true
entry.last_ack_at = self.now()
end)
end
function Outbox:recordRemotePending(id, generation, kind, payload)
return self:update(id, generation, function(entry)
entry.remote_pending = entry.remote_pending or {}
entry.remote_pending[kind] = payload or true
end)
end
function Outbox:clearRemotePending(id, generation, kind)
return self:update(id, generation, function(entry)
if entry.remote_pending then
entry.remote_pending[kind] = nil
if next(entry.remote_pending) == nil then entry.remote_pending = nil end
end
end)
end
function Outbox:removeEntry(id)
local record = self:scan().entries[id]
if not record then
self:markFinished(id)
return false
end
local removed = false
for _, generation in ipairs(copyArray(record.generations)) do
removed = self.remove(self:path(id, generation)) or removed
end
for _, generation in ipairs(copyArray(record.payloads)) do
removed = self.remove(self:payloadPath(id, generation)) or removed
end
self:invalidateScan()
self:markFinished(id)
return removed
end
return Outbox