mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
edc2a2ccfc
Lifecycle handlers previously blocked on network work, so closing or suspending a book stalled the device on slow or offline links. Persist snapshots first and drain them through a lifecycle outbox with connected-state gates and phase-aware retries, so the UI stays responsive and pending work survives restarts. Move persistence behind a state manager with atomic flushes, and chunk sweeps, bulk downloads, and annotation exchange through a background queue with checkpoints and transfer policies, so large libraries make progress incrementally instead of restarting from scratch after an interruption. Server side, add bulk manifest and page stats endpoints with batched queries and catalog query filtering so the plugin can reconcile many books per round trip rather than one request per book.
142 lines
5.0 KiB
Lua
142 lines
5.0 KiB
Lua
-- Library-sized sweep phases must yield. Enumeration and sidecar scanning run
|
|
-- in bounded chunks, and because the user can open a book between chunks, each
|
|
-- phase iterates a snapshot of its keys and re-resolves every entry when its
|
|
-- chunk actually runs. A ReadHistory reorder mid-sweep must therefore neither
|
|
-- skip nor double-process an entry.
|
|
|
|
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
|
|
|
|
local SweepHarness = require("helpers/sweep_harness")
|
|
|
|
local HISTORY_CHUNK = 40
|
|
local SIDECAR_CHUNK = 20
|
|
|
|
local function assertEqual(actual, expected, label)
|
|
if actual ~= expected then
|
|
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
|
|
end
|
|
end
|
|
|
|
-- Runs one scheduled callback at a time and reports how much work each did, so
|
|
-- "the phase yields" is proven by the slice sizes rather than assumed.
|
|
local function drainMeasuring(harness, count)
|
|
local slices = {}
|
|
local previous = count()
|
|
while harness.scheduler:runOne() do
|
|
local current = count()
|
|
if current > previous then
|
|
table.insert(slices, current - previous)
|
|
previous = current
|
|
end
|
|
end
|
|
return slices
|
|
end
|
|
|
|
local function maxOf(values)
|
|
local largest = 0
|
|
for _, value in ipairs(values) do
|
|
if value > largest then largest = value end
|
|
end
|
|
return largest
|
|
end
|
|
|
|
-- ReadHistory resolution.
|
|
do
|
|
local history = {}
|
|
for index = 1, 100 do
|
|
table.insert(history, { file = string.format("/books/%03d.epub", index), time = index, text = "Book" })
|
|
end
|
|
|
|
local harness = SweepHarness.install{
|
|
history = history,
|
|
has_sidecar_files = true,
|
|
library_version = "v1",
|
|
}
|
|
|
|
local seen, total = {}, 0
|
|
package.loaded["util"].partialMD5 = function(file)
|
|
seen[file] = (seen[file] or 0) + 1
|
|
total = total + 1
|
|
if total == HISTORY_CHUNK then
|
|
-- Opening a book mid-sweep reorders the live list and can drop an
|
|
-- entry the snapshot still names.
|
|
local reordered = {}
|
|
for index = #harness.history, 1, -1 do
|
|
local item = harness.history[index]
|
|
if item.file ~= "/books/041.epub" then
|
|
table.insert(reordered, item)
|
|
end
|
|
end
|
|
package.loaded["readhistory"].hist = reordered
|
|
end
|
|
return "md5-" .. file
|
|
end
|
|
|
|
local Sweep = require("bookorbit_sweep")
|
|
assertEqual(Sweep.run{ api = {}, interactive = true, annotation_sync = false }, true, "the sweep starts")
|
|
|
|
local slices = drainMeasuring(harness, function() return total end)
|
|
|
|
assertEqual(total, 99, "every entry that still exists is resolved exactly once")
|
|
assertEqual(seen["/books/041.epub"], nil, "an entry removed mid-sweep is tolerated, not resolved")
|
|
for index = 1, 100 do
|
|
local file = string.format("/books/%03d.epub", index)
|
|
if file ~= "/books/041.epub" then
|
|
assertEqual(seen[file], 1, "no entry is processed twice after the list reorders")
|
|
end
|
|
end
|
|
|
|
assert(#slices > 1, "resolution is split across scheduled callbacks")
|
|
assert(maxOf(slices) <= HISTORY_CHUNK,
|
|
"no single callback resolves more than the chunk size, got " .. maxOf(slices))
|
|
end
|
|
|
|
-- Matched-book sidecar scanning.
|
|
do
|
|
local books = {}
|
|
for index = 1, 50 do
|
|
books[string.format("digest%02d", index)] = {
|
|
bookId = index,
|
|
fileId = 100 + index,
|
|
file = string.format("/books/%02d.epub", index),
|
|
statsWatermark = 0,
|
|
annWatermark = "",
|
|
annCount = 0,
|
|
sidecarMtime = 1,
|
|
ratingSyncedKnown = true,
|
|
reviewSyncedKnown = true,
|
|
matchVerifiedAt = os.time(),
|
|
matchVerifiedVersion = "v1",
|
|
}
|
|
end
|
|
|
|
local scanned, total = {}, 0
|
|
local harness = SweepHarness.install{
|
|
library_version = "v1",
|
|
state = { books = books, global = { libraryVersion = "v1" } },
|
|
sidecar = {
|
|
sidecarMtime = function(file)
|
|
scanned[file] = (scanned[file] or 0) + 1
|
|
total = total + 1
|
|
return 1
|
|
end,
|
|
},
|
|
}
|
|
|
|
local Sweep = require("bookorbit_sweep")
|
|
assertEqual(Sweep.run{ api = {}, interactive = true, annotation_sync = false }, true, "the sweep starts")
|
|
|
|
local slices = drainMeasuring(harness, function() return total end)
|
|
|
|
assertEqual(total, 50, "every matched book is scanned exactly once")
|
|
for index = 1, 50 do
|
|
assertEqual(scanned[string.format("/books/%02d.epub", index)], 1, "no book is scanned twice")
|
|
end
|
|
assert(#slices > 1, "sidecar scanning is split across scheduled callbacks")
|
|
assert(maxOf(slices) <= SIDECAR_CHUNK,
|
|
"no single callback scans more than the chunk size, got " .. maxOf(slices))
|
|
assertEqual(#harness.calls.match, 0, "books verified inside the age bound issue no match request")
|
|
end
|
|
|
|
print("bookorbit_sweep_chunking_test.lua: ok")
|