mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
4a4b62bbef
Recompute the open file digest and recover previously verified matches without guessing between duplicate files. Preserve stranded statistics across lifecycle retries and advertise plugin version 1.5.3 so installed devices can update. Fixes #1497
140 lines
6.6 KiB
Lua
140 lines
6.6 KiB
Lua
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
|
|
|
|
package.loaded["datastorage"] = { getSettingsDir = function() return "/unused" end }
|
|
package.loaded["dump"] = function() return "{}" end
|
|
package.loaded["libs/libkoreader-lfs"] = {
|
|
attributes = function() return nil end,
|
|
dir = function() return function() end end,
|
|
}
|
|
package.loaded["util"] = {
|
|
makePath = function() return false end,
|
|
writeToFile = function() return false end,
|
|
}
|
|
|
|
local Outbox = require("bookorbit_lifecycle_outbox")
|
|
local TempFilesystem = require("helpers/temp_filesystem")
|
|
|
|
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 fixture = TempFilesystem.new()
|
|
local now = 1000
|
|
local outbox = Outbox.open{
|
|
settings_dir = fixture.path,
|
|
now = function() return now end,
|
|
rename = function(from, to) return fixture:rename(from, to) end,
|
|
remove = os.remove,
|
|
write_file = function(content, path) return fixture:write(content, path) end,
|
|
make_path = function(path) return fixture:makePath(path) end,
|
|
list_dir = function(path) return fixture:list(path) end,
|
|
attributes = function(path, attribute) return fixture:attributes(path, attribute) end,
|
|
serialize = function(value) return fixture:serialize(value) end,
|
|
}
|
|
|
|
local snapshot = {
|
|
digest = "abcdef1234",
|
|
ts = 990,
|
|
file = "/books/moved.epub",
|
|
percentage = 0.5,
|
|
progress = "/4/2",
|
|
annotations = { { datetime = "2026-01-01", pos0 = "/1" } },
|
|
stats_ids = { 17, 23 },
|
|
stats_identity_repaired = true,
|
|
}
|
|
|
|
local first = assert(outbox:enqueue(snapshot, { reason = "close", annotation_sync = true }))
|
|
assertEqual(outbox:status().count, 1, "first entry is durable")
|
|
assertEqual(first.snapshot.annotations[1].pos0, "/1", "captured annotations are stored")
|
|
assertEqual(first.snapshot.stats_ids[2], 23, "stable statistics ids are stored")
|
|
|
|
now = 1001
|
|
local replacement_snapshot = {}
|
|
for key, value in pairs(snapshot) do replacement_snapshot[key] = value end
|
|
replacement_snapshot.stats_ids = { 23, 29 }
|
|
local replacement = assert(outbox:enqueue(replacement_snapshot, { reason = "suspend", annotation_sync = true }))
|
|
assertEqual(replacement.id, first.id, "unstarted snapshot is deduplicated by digest")
|
|
assertEqual(replacement.generation, first.generation + 1, "dedupe publishes a new generation")
|
|
assertEqual(outbox:status().count, 1, "dedupe keeps one logical entry")
|
|
assertEqual(#replacement.snapshot.stats_ids, 3, "unacknowledged statistics rows survive snapshot replacement")
|
|
assertEqual(replacement.snapshot.stats_ids[1], 17, "the oldest pending statistics row is retained")
|
|
assertEqual(replacement.snapshot.stats_ids[2], 23, "duplicate statistics rows are deduplicated")
|
|
assertEqual(replacement.snapshot.stats_ids[3], 29, "the newest statistics row is appended")
|
|
assertEqual(replacement.snapshot.stats_identity_repaired, true,
|
|
"a pending identity-repair replay survives snapshot replacement")
|
|
|
|
fixture.fail_write = true
|
|
local failed, failure = outbox:enqueue(snapshot, { reason = "close" })
|
|
fixture.fail_write = false
|
|
assertEqual(failed, nil, "injected persistence failure is reported")
|
|
assertEqual(failure, "injected_write_failure", "write failure is preserved")
|
|
assertEqual(outbox:readLatest(first.id).generation, replacement.generation,
|
|
"failed publish preserves the previous durable generation")
|
|
|
|
fixture.fail_rename = true
|
|
local rename_failed, rename_failure = outbox:enqueue(snapshot, { reason = "close" })
|
|
fixture.fail_rename = false
|
|
assertEqual(rename_failed, nil, "injected rename failure is reported")
|
|
assertEqual(rename_failure, "injected_rename_failure", "rename failure is preserved")
|
|
assertEqual(outbox:readLatest(first.id).generation, replacement.generation,
|
|
"failed atomic rename preserves the previous durable generation")
|
|
|
|
local started = assert(outbox:markStarted(replacement.id, replacement.generation))
|
|
now = 1002
|
|
local second = assert(outbox:enqueue(snapshot, { reason = "close", annotation_sync = true }))
|
|
assert(second.id ~= started.id, "started entry is not overwritten by a newer snapshot")
|
|
assertEqual(outbox:status().count, 2, "started and newer snapshots coexist")
|
|
|
|
local payload_path = outbox:payloadPath(started.id, started.payload_generation)
|
|
local payload_file = assert(io.open(payload_path, "rb"))
|
|
local payload_before_ack = payload_file:read("*a")
|
|
payload_file:close()
|
|
local generation = started.generation
|
|
for _, phase in ipairs({ "match", "stats", "annotations", "state", "progress" }) do
|
|
local updated = assert(outbox:acknowledge(started.id, generation, phase))
|
|
generation = updated.generation
|
|
end
|
|
payload_file = assert(io.open(payload_path, "rb"))
|
|
local payload_after_ack = payload_file:read("*a")
|
|
payload_file:close()
|
|
assertEqual(payload_after_ack, payload_before_ack,
|
|
"phase acknowledgements do not rewrite the immutable snapshot payload")
|
|
local acknowledged = outbox:readLatest(started.id)
|
|
assertEqual(outbox:isComplete(acknowledged), true, "all phase acknowledgements complete an entry")
|
|
|
|
local pending = assert(outbox:recordRemotePending(started.id, acknowledged.generation,
|
|
"annotations", { to_apply = { add = { { serverId = "remote-1" } } } }))
|
|
assertEqual(outbox:isComplete(pending), false, "unapplied remote changes retain the entry")
|
|
local cleared = assert(outbox:clearRemotePending(started.id, pending.generation, "annotations"))
|
|
assertEqual(outbox:isComplete(cleared), true, "recorded remote outcome can complete the entry")
|
|
|
|
local stale, stale_err = outbox:acknowledge(started.id, started.generation, "progress")
|
|
assertEqual(stale, nil, "stale generation cannot overwrite newer state")
|
|
assertEqual(stale_err, "stale_generation", "stale generation is identified")
|
|
|
|
local fallback_snapshot = {
|
|
digest = "1234abcd",
|
|
ts = 1003,
|
|
annotations = {},
|
|
stats_ids = {},
|
|
}
|
|
local fallback_first = assert(outbox:enqueue(fallback_snapshot, { annotation_sync = false }))
|
|
local fallback_latest = assert(outbox:enqueue(fallback_snapshot, { annotation_sync = false }))
|
|
assert(fixture:write("not valid lua", outbox:path(fallback_latest.id, fallback_latest.generation)))
|
|
assertEqual(outbox:readLatest(fallback_latest.id).generation, fallback_first.generation,
|
|
"recovery falls back to the last readable generation")
|
|
|
|
outbox.hard_entry_limit = outbox:status().count
|
|
local blocked, blocked_err = outbox:enqueue({
|
|
digest = "9999aaaa",
|
|
annotations = {},
|
|
stats_ids = {},
|
|
}, { annotation_sync = false })
|
|
assertEqual(blocked, nil, "hard threshold applies user-visible backpressure")
|
|
assertEqual(blocked_err, "hard_limit", "hard threshold never evicts pending entries")
|
|
|
|
fixture:cleanup()
|
|
print("bookorbit_lifecycle_outbox_test.lua: ok")
|