Files
neon edc2a2ccfc feat(reader): make KOReader plugin sync non-blocking and resumable
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.
2026-07-28 02:06:50 +00:00

119 lines
4.6 KiB
Lua

-- The sweep queues drain through a head index, not table.remove(queue, 1).
-- Front removal shifts every remaining element, so consuming a library-sized
-- queue that way is quadratic. These assertions check the mechanics that make
-- it linear: consuming never moves an element, progress comes from explicit
-- counters rather than the array length, and everything that walks the queue
-- starts at the head.
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local BookOrbitQueue = require("bookorbit_queue")
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
local function build(count)
local items = {}
for index = 1, count do
table.insert(items, index)
end
return BookOrbitQueue.new(items)
end
-- Consuming advances a cursor; the remaining elements keep their slots.
do
local queue = build(10)
assertEqual(queue:pop(), 1, "the first pop returns the first item")
assertEqual(queue:pop(), 2, "the second pop returns the second item")
assertEqual(queue.items[1], nil, "a consumed slot is released rather than back-filled")
assertEqual(queue.items[3], 3, "surviving elements are not shifted down")
assertEqual(queue.head, 3, "the head cursor is what moved")
assertEqual(queue:peek(), 3, "peek reads through the head")
end
-- Progress counters, which is what the stats and annotation progress lines use.
do
local queue = build(5)
assertEqual(queue:total(), 5, "the total is everything ever queued")
assertEqual(queue:remaining(), 5, "nothing is consumed yet")
assertEqual(queue:done(), 0, "nothing is done yet")
queue:pop()
queue:pop()
assertEqual(queue:remaining(), 3, "remaining tracks the head")
assertEqual(queue:done(), 2, "done is derived from the counters, not the array length")
queue:push(6)
assertEqual(queue:total(), 6, "a late push raises the total")
assertEqual(queue:remaining(), 4, "and the remaining work")
end
-- The legacy annotation fallback returns the in-flight entry to the front.
do
local queue = build(4)
local entry = queue:pop()
assertEqual(queue:done(), 1, "the entry counts as done while it is in flight")
queue:requeue(entry)
assertEqual(queue:remaining(), 4, "re-queuing restores the remaining work")
assertEqual(queue:done(), 0, "and the done count")
assertEqual(queue:total(), 4, "a retry does not inflate the total")
assertEqual(queue:pop(), 1, "the re-queued entry comes back first")
assertEqual(queue:pop(), 2, "the rest of the order is preserved")
end
-- Re-queuing with nothing consumed still has to work.
do
local queue = build(2)
queue:requeue(0)
assertEqual(queue:remaining(), 3, "the entry is added at the front")
assertEqual(queue:pop(), 0, "and comes out first")
assertEqual(queue:pop(), 1, "ahead of the original head")
end
-- buildLegacyAnnotationChunks walks the queue while entries are already
-- consumed, so iteration must start at the head and not at index 1.
do
local queue = build(5)
queue:pop()
queue:pop()
local walked = {}
for item in queue:iter() do
table.insert(walked, item)
end
assertEqual(table.concat(walked, ","), "3,4,5", "iteration skips consumed slots")
queue:clear()
assertEqual(queue:isEmpty(), true, "clearing empties the queue")
assertEqual(queue:total(), 0, "and resets the counters")
end
-- Compaction reclaims consumed slots without disturbing order or counters.
do
local queue = build(600)
for index = 1, 500 do
assertEqual(queue:pop(), index, "items come out in order across a compaction")
end
assert(queue.head < 501, "consumed slots are reclaimed rather than kept forever")
assertEqual(queue:remaining(), 100, "the remaining work survives compaction")
assertEqual(queue:done(), 500, "and so does the done count")
assertEqual(queue:pop(), 501, "the order is unchanged after compaction")
end
-- Draining a large queue must stay linear. Front removal of 50,000 items costs
-- roughly half a second of pure shifting on a development machine; the bound
-- here is deliberately loose so it fails only on a quadratic regression.
do
local queue = build(50000)
local started = os.clock()
local count = 0
while not queue:isEmpty() do
queue:pop()
count = count + 1
end
local elapsed = os.clock() - started
assertEqual(count, 50000, "every item is consumed")
assert(elapsed < 0.2, string.format("draining 50,000 items took %.3fs, expected linear behavior", elapsed))
end
print("bookorbit_queue_test.lua: ok")