mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -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
306 lines
13 KiB
Lua
306 lines
13 KiB
Lua
-- Proves the central state manager shares one parse, caches the combined
|
|
-- on-device maps against a generation, patches them forward after a link and
|
|
-- refuses a mutation prepared against an older generation.
|
|
|
|
local function deepCopy(value)
|
|
if type(value) ~= "table" then return value end
|
|
local copy = {}
|
|
for key, item in pairs(value) do
|
|
copy[key] = deepCopy(item)
|
|
end
|
|
return copy
|
|
end
|
|
|
|
local disk = { books = {}, unmatched = {}, files = {}, global = {} }
|
|
local parses = 0
|
|
local flushes = 0
|
|
local fail_flush = false
|
|
|
|
package.loaded["datastorage"] = {
|
|
getSettingsDir = function()
|
|
return "/tmp/bookorbit-state-manager-test"
|
|
end,
|
|
}
|
|
package.loaded["luasettings"] = {
|
|
open = function(_, _)
|
|
parses = parses + 1
|
|
local data = deepCopy(disk)
|
|
return {
|
|
readSetting = function(_, key, default)
|
|
if data[key] == nil then data[key] = default end
|
|
return data[key]
|
|
end,
|
|
atomicFlush = function()
|
|
if fail_flush then error("disk full") end
|
|
flushes = flushes + 1
|
|
disk = deepCopy(data)
|
|
return true
|
|
end,
|
|
}
|
|
end,
|
|
}
|
|
package.loaded["dump"] = function()
|
|
return "{}"
|
|
end
|
|
package.loaded["ffi/util"] = {
|
|
fsyncOpenedFile = function() end,
|
|
fsyncDirectory = function() end,
|
|
}
|
|
|
|
local existing_files = {}
|
|
local attribute_calls = 0
|
|
package.loaded["libs/libkoreader-lfs"] = {
|
|
attributes = function(path, attribute)
|
|
attribute_calls = attribute_calls + 1
|
|
if not existing_files[path] then return nil end
|
|
return attribute == "mode" and "file" or { mode = "file" }
|
|
end,
|
|
}
|
|
|
|
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
|
|
|
|
local StateManager = require("bookorbit_state_manager")
|
|
|
|
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
|
|
|
|
disk.books = {
|
|
aaa = { bookId = 1, fileId = 11, file = "/books/a.epub" },
|
|
bbb = { bookId = 2, fileId = 22, file = "/books/b.epub" },
|
|
ccc = { bookId = 3, fileId = 33, file = "/books/gone.epub" },
|
|
}
|
|
existing_files["/books/a.epub"] = true
|
|
existing_files["/books/b.epub"] = true
|
|
|
|
-- Sessions are isolated while the manager still parses the settings file once.
|
|
parses = 0
|
|
local first = StateManager.state()
|
|
local second = StateManager.state()
|
|
assertEqual(first == second, false, "state() returns isolated sessions")
|
|
assertEqual(parses, 1, "sessions share one parsed persistent owner")
|
|
|
|
-- Both maps come from a single pass with one existence check per stored file.
|
|
attribute_calls = 0
|
|
local maps = StateManager.onDeviceMaps()
|
|
assertEqual(attribute_calls, 3, "one existence check per matched book")
|
|
assertEqual(maps.byBookId[1], "/books/a.epub", "book id map")
|
|
assertEqual(maps.byFileId[22], "/books/b.epub", "book file id map")
|
|
assertEqual(maps.byBookId[3], nil, "missing file is not on device")
|
|
|
|
-- A repaint reuses the cached maps: no rescan, no filesystem probe.
|
|
attribute_calls = 0
|
|
local repeated = StateManager.onDeviceMaps()
|
|
assertEqual(attribute_calls, 0, "cached maps do not rescan")
|
|
assertEqual(repeated, maps, "cached maps are reused")
|
|
assertEqual(StateManager.generation(), maps.generation, "generation is stable between reads")
|
|
|
|
-- Explicit invalidation advances the cache generation, so a catalog holding
|
|
-- the old maps can detect that they are no longer current.
|
|
local old_generation = maps.generation
|
|
StateManager.invalidate()
|
|
assertEqual(StateManager.generation() > old_generation, true, "invalidation advances generation")
|
|
local adopted, adopt_err = StateManager.adoptOnDeviceMaps(maps)
|
|
assertEqual(adopted, nil, "stale derived maps are refused")
|
|
assertEqual(adopt_err, "stale_generation", "stale maps report their generation")
|
|
maps = StateManager.onDeviceMaps()
|
|
|
|
-- Linking a downloaded file patches the maps forward instead of rescanning.
|
|
existing_files["/books/c.epub"] = true
|
|
attribute_calls = 0
|
|
flushes = 0
|
|
local linked_generation = StateManager.linkFile("ddd", 44, 4, "/books/c.epub")
|
|
assertEqual(flushes, 1, "linking flushes once")
|
|
assertEqual(attribute_calls, 0, "linking does not rescan the matched library")
|
|
local after_link = StateManager.onDeviceMaps()
|
|
assertEqual(attribute_calls, 0, "maps after linking are still cached")
|
|
assertEqual(after_link.byBookId[4], "/books/c.epub", "linked book id is on device")
|
|
assertEqual(after_link.byFileId[44], "/books/c.epub", "linked book file id is on device")
|
|
assertEqual(after_link.generation, linked_generation, "patched maps carry the new generation")
|
|
assertEqual(disk.books.ddd.bookId, 4, "link is persisted")
|
|
|
|
-- Bulk linking applies a whole batch in one flush, because every flush rewrites
|
|
-- the entire sync-state file.
|
|
existing_files["/books/d.epub"] = true
|
|
existing_files["/books/e.epub"] = true
|
|
attribute_calls = 0
|
|
flushes = 0
|
|
local batch_generation = StateManager.linkFiles({
|
|
{ digest = "eee", bookFileId = 55, bookId = 5, file = "/books/d.epub" },
|
|
{ digest = "fff", bookFileId = 66, bookId = 6, file = "/books/e.epub" },
|
|
})
|
|
assertEqual(flushes, 1, "a link batch flushes once for every file in it")
|
|
assertEqual(attribute_calls, 0, "batched linking does not rescan the matched library")
|
|
local after_batch = StateManager.onDeviceMaps()
|
|
assertEqual(after_batch.byBookId[5], "/books/d.epub", "the first batched link is on device")
|
|
assertEqual(after_batch.byFileId[66], "/books/e.epub", "the second batched link is on device")
|
|
assertEqual(after_batch.generation, batch_generation, "patched maps carry the batch generation")
|
|
assertEqual(disk.books.fff.bookId, 6, "every batched link is persisted")
|
|
|
|
-- A stale path mapping moves to the recomputed digest without copying an
|
|
-- identity between hashes. An identity already verified for the new digest is
|
|
-- retained and expired so the next match check can establish a server link.
|
|
StateManager.mutate(function(current)
|
|
current.books.old = {
|
|
bookId = 70,
|
|
fileId = 700,
|
|
file = "/books/repaired.epub",
|
|
matchVerifiedAt = 100,
|
|
matchVerifiedVersion = "old-version",
|
|
}
|
|
current.books.actual = {
|
|
bookId = 71,
|
|
fileId = 701,
|
|
file = "/books/other.epub",
|
|
statsWatermark = 250,
|
|
matchVerifiedAt = 200,
|
|
matchVerifiedVersion = "current-version",
|
|
}
|
|
current.unmatched.actual = 300
|
|
current.files["/books/repaired.epub"] = "old"
|
|
end)
|
|
flushes = 0
|
|
StateManager.repairFileIdentity("/books/repaired.epub", "old", "actual")
|
|
assertEqual(flushes, 1, "identity repair is persisted once")
|
|
assertEqual(disk.files["/books/repaired.epub"], "actual", "path points at the recomputed digest")
|
|
assertEqual(disk.books.old.file, nil, "stale digest releases the repaired path")
|
|
assertEqual(disk.books.actual.file, "/books/repaired.epub", "known actual digest owns the path")
|
|
assertEqual(disk.books.actual.fileId, 701, "actual digest keeps its independently verified file id")
|
|
assertEqual(disk.books.actual.matchVerifiedAt, nil, "actual digest is due for explicit recovery")
|
|
assertEqual(disk.books.actual.statsWatermark, 0, "identity repair replays the possibly stranded history")
|
|
assertEqual(disk.unmatched.actual, nil, "an old unmatched stamp cannot suppress recovery")
|
|
|
|
StateManager.mutate(function(current)
|
|
current.books.orphaned = { bookId = 80, fileId = 800, file = "/books/unknown.epub" }
|
|
current.files["/books/unknown.epub"] = "orphaned"
|
|
end)
|
|
StateManager.repairFileIdentity("/books/unknown.epub", "orphaned", "unknown")
|
|
assertEqual(disk.books.unknown, nil, "identity is never copied to an unknown digest")
|
|
assertEqual(disk.books.orphaned.file, nil, "unknown replacement still releases the stale path")
|
|
|
|
-- A session write publishes a new generation, so the next read rebuilds rather
|
|
-- than serving a stale map.
|
|
local state = StateManager.session({ digests = { "bbb" }, global = false })
|
|
state:setUnmatched("bbb")
|
|
state:flush()
|
|
attribute_calls = 0
|
|
local rebuilt = StateManager.onDeviceMaps()
|
|
assertEqual(attribute_calls, 6, "an external write forces one rebuild")
|
|
assertEqual(rebuilt.byBookId[2], nil, "unmatched book leaves the map")
|
|
assertEqual(rebuilt.byBookId[1], "/books/a.epub", "unaffected entries survive")
|
|
|
|
-- Nested mutations join the outermost change and flush once.
|
|
flushes = 0
|
|
StateManager.mutate(function(outer)
|
|
outer.global.marker = "outer"
|
|
StateManager.mutate(function(inner)
|
|
inner.global.nested = "inner"
|
|
end)
|
|
end)
|
|
assertEqual(flushes, 1, "nested mutation flushes once")
|
|
assertEqual(disk.global.marker, "outer", "outer mutation persisted")
|
|
assertEqual(disk.global.nested, "inner", "nested mutation persisted")
|
|
|
|
-- A failed mutation is discarded instead of leaking into a later flush.
|
|
local failed = pcall(function()
|
|
StateManager.mutate(function(current)
|
|
current.global.leaked = "partial"
|
|
error("stop")
|
|
end)
|
|
end)
|
|
assertEqual(failed, false, "failed mutation is reported")
|
|
StateManager.mutate(function(current)
|
|
current.global.after_failure = true
|
|
end)
|
|
assertEqual(disk.global.leaked, nil, "failed mutation is never persisted")
|
|
assertEqual(disk.global.after_failure, true, "later mutation still succeeds")
|
|
|
|
-- A persistence failure reloads the last durable generation instead of
|
|
-- leaving the failed mutation live for a later unrelated flush.
|
|
fail_flush = true
|
|
local persisted = pcall(function()
|
|
StateManager.mutate(function(current)
|
|
current.global.not_durable = true
|
|
end)
|
|
end)
|
|
fail_flush = false
|
|
assertEqual(persisted, false, "flush failure is reported")
|
|
StateManager.mutate(function(current)
|
|
current.global.after_disk_recovery = true
|
|
end)
|
|
assertEqual(disk.global.not_durable, nil, "failed flush is rolled back in memory")
|
|
assertEqual(disk.global.after_disk_recovery, true, "writes recover after flush failure")
|
|
|
|
-- A long-running scoped session merges its unrelated field changes with a
|
|
-- newer published update to the same book.
|
|
local book_session = StateManager.session({ digests = { "aaa" }, global = false })
|
|
book_session.books.aaa.annCount = 7
|
|
StateManager.mutateScoped({ digests = { "aaa" }, global = false }, function(current)
|
|
current.books.aaa.fileId = 99
|
|
end)
|
|
book_session:flush()
|
|
assertEqual(disk.books.aaa.annCount, 7, "session field is committed")
|
|
assertEqual(disk.books.aaa.fileId, 99, "newer concurrent field is preserved")
|
|
|
|
-- A caller that prepared its change before another path published is refused.
|
|
local stale_generation = StateManager.generation()
|
|
StateManager.mutate(function(current)
|
|
current.global.winner = "newer"
|
|
end)
|
|
flushes = 0
|
|
local committed, err = StateManager.commit(stale_generation, function(current)
|
|
current.global.winner = "older"
|
|
end)
|
|
assertEqual(committed, nil, "stale generation is refused")
|
|
assertEqual(err, "stale_generation", "stale generation reports why")
|
|
assertEqual(flushes, 0, "refused mutation does not write")
|
|
assertEqual(disk.global.winner, "newer", "newer state survives the stale writer")
|
|
|
|
local fresh = StateManager.commit(StateManager.generation(), function(current)
|
|
current.global.winner = "current"
|
|
end)
|
|
assertEqual(type(fresh), "number", "current generation commits")
|
|
assertEqual(disk.global.winner, "current", "current generation writes")
|
|
|
|
-- Reload replaces only the persistent owner. Existing sessions remain isolated
|
|
-- and cannot overwrite the new value when they later flush.
|
|
local holder = StateManager.session()
|
|
disk.global.winner = "external"
|
|
parses = 0
|
|
StateManager.reload()
|
|
assertEqual(parses, 1, "reload reparses once")
|
|
assertEqual(holder.global.winner, "current", "existing session remains isolated")
|
|
holder.global.local_only = true
|
|
holder:flush()
|
|
assertEqual(disk.global.winner, "external", "reload value survives stale session flush")
|
|
assertEqual(disk.global.local_only, true, "non-conflicting session field is merged")
|
|
|
|
-- A catalog-only scan can be computed before the persistent owner is opened.
|
|
-- Adopting and reading those maps does not parse state again in the parent.
|
|
StateManager.reset()
|
|
parses = 0
|
|
local computed = StateManager.computeOnDeviceMaps()
|
|
assertEqual(parses, 1, "standalone map computation opens state once")
|
|
assertEqual(computed.byBookId, nil, "transport does not expose a sparse book id map")
|
|
assertEqual(computed.byFileId, nil, "transport does not expose a sparse file id map")
|
|
assertEqual(type(computed.bookEntries[1].id), "number", "book ids travel as record values")
|
|
assertEqual(type(computed.bookEntries[1].file), "string", "book paths travel as record values")
|
|
assertEqual(type(computed.fileEntries[1].id), "number", "file ids travel as record values")
|
|
assertEqual(StateManager.adoptOnDeviceMaps(computed) ~= nil, true, "fresh maps are adopted")
|
|
local transported_maps = StateManager.onDeviceMaps()
|
|
assertEqual(parses, 1, "adopted maps do not open the persistent owner")
|
|
assertEqual(transported_maps.byBookId[1], "/books/a.epub", "adoption rebuilds the numeric book id map")
|
|
assertEqual(transported_maps.byFileId[99], "/books/a.epub", "adoption rebuilds the sparse numeric file id map")
|
|
|
|
local invalid_maps, invalid_maps_err = StateManager.adoptOnDeviceMaps({
|
|
bookEntries = { { id = "1", file = "/books/a.epub" } },
|
|
fileEntries = {},
|
|
generation = StateManager.generation(),
|
|
})
|
|
assertEqual(invalid_maps, nil, "malformed transport records are refused")
|
|
assertEqual(invalid_maps_err, "invalid_maps", "malformed maps report their error")
|
|
|
|
print("bookorbit_state_manager_test.lua: ok")
|