mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(stats): repair stale KOReader file identities
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
This commit is contained in:
@@ -94,10 +94,15 @@ function BookOrbitBookSync.capture(plugin)
|
||||
metadata = BookOrbitStatsReader.primeIdentity(digest)
|
||||
end
|
||||
metadata = metadata or {}
|
||||
local stats_ambiguous = metadata.metadata_ambiguous == true
|
||||
local current_stats_id = stats and tonumber(stats.id_curr_book) or nil
|
||||
local stats_ambiguous = current_stats_id == nil and metadata.metadata_ambiguous == true
|
||||
local stats_ids = {}
|
||||
for _, id in ipairs(metadata.ids or {}) do
|
||||
table.insert(stats_ids, id)
|
||||
if current_stats_id then
|
||||
table.insert(stats_ids, current_stats_id)
|
||||
else
|
||||
for _, id in ipairs(metadata.ids or {}) do
|
||||
table.insert(stats_ids, id)
|
||||
end
|
||||
end
|
||||
logger.dbg(string.format("BookOrbit: lifecycle capture identity=%s elapsedMs=%.1f",
|
||||
cached and "cached" or "uncached", elapsedMs(started_ms)))
|
||||
@@ -109,6 +114,8 @@ function BookOrbitBookSync.capture(plugin)
|
||||
last_open = metadata.last_open or ts,
|
||||
metadata_ambiguous = false,
|
||||
stats_metadata_ambiguous = stats_ambiguous,
|
||||
stats_identity_repaired = plugin.bookorbit_document_digest
|
||||
and plugin.bookorbit_document_digest.repaired == true or false,
|
||||
stats_ids = stats_ids,
|
||||
percentage = plugin:getLastPercent(),
|
||||
progress = plugin:getLastProgress(),
|
||||
@@ -293,6 +300,7 @@ stepMatch = function(ctx)
|
||||
authors = ctx.snap.authors,
|
||||
last_open = ctx.snap.last_open,
|
||||
source = "current_file",
|
||||
book_file_id = book and book.fileId or nil,
|
||||
metadata_ambiguous = ctx.snap.metadata_ambiguous,
|
||||
},
|
||||
})
|
||||
@@ -344,7 +352,10 @@ stepStats = function(ctx)
|
||||
return step(ctx, stepAnnotations)
|
||||
end
|
||||
|
||||
local watermark = book.statsWatermark or 0
|
||||
if ctx.stats_watermark == nil then
|
||||
ctx.stats_watermark = ctx.snap.stats_identity_repaired and 0 or (book.statsWatermark or 0)
|
||||
end
|
||||
local watermark = ctx.stats_watermark
|
||||
local events = BookOrbitStatsReader.getEventsAfter(ctx.stat_ids, watermark, STATS_BATCH)
|
||||
if not events or #events == 0 then
|
||||
if not acknowledge(ctx, "stats", false) then return end
|
||||
@@ -365,6 +376,7 @@ stepStats = function(ctx)
|
||||
end
|
||||
|
||||
local more = BookOrbitState.applyStatsAck(book, events, body, ctx.snap.digest, STATS_BATCH, watermark)
|
||||
ctx.stats_watermark = book.statsWatermark or watermark
|
||||
ctx.counts.page_stats = ctx.counts.page_stats + #events
|
||||
if more then
|
||||
return step(ctx, stepStats)
|
||||
|
||||
@@ -42,6 +42,19 @@ local function copyArray(values)
|
||||
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
|
||||
@@ -408,6 +421,13 @@ function Outbox:enqueue(snapshot, opts)
|
||||
local stored_snapshot = {}
|
||||
for key, value in pairs(snapshot) do stored_snapshot[key] = value end
|
||||
stored_snapshot.stats_ids = copyArray(snapshot.stats_ids)
|
||||
local existing_with_payload = existing and self:attachPayload(existing) or nil
|
||||
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 = {
|
||||
|
||||
@@ -20,6 +20,8 @@ local util = require("util")
|
||||
local T = require("ffi/util").template
|
||||
local _ = require("gettext")
|
||||
|
||||
local BookOrbitStateManager = require("bookorbit_state_manager")
|
||||
|
||||
local API_CALL_DEBOUNCE_DELAY = time.s(25)
|
||||
|
||||
-- Assigned from the plugin class on install; shared with the menu module.
|
||||
@@ -54,16 +56,31 @@ end
|
||||
-- partial MD5 of the file, so the filename checksum method does not exist here.
|
||||
function ProgressSync:getDocumentDigest()
|
||||
if not self.ui or not self.ui.document then return nil end
|
||||
local doc_settings = self.ui.doc_settings
|
||||
local digest = doc_settings and doc_settings:readSetting("partial_md5_checksum") or nil
|
||||
if digest then return digest end
|
||||
|
||||
local file = self.ui.document.file
|
||||
if not file then return nil end
|
||||
if self.bookorbit_document_digest and self.bookorbit_document_digest.file == file then
|
||||
return self.bookorbit_document_digest.digest
|
||||
end
|
||||
|
||||
local doc_settings = self.ui.doc_settings
|
||||
local cached = doc_settings and doc_settings:readSetting("partial_md5_checksum") or nil
|
||||
local ok, computed = pcall(util.partialMD5, file)
|
||||
if not ok or not computed then return nil end
|
||||
if doc_settings then
|
||||
doc_settings:saveSetting("partial_md5_checksum", computed)
|
||||
if not ok or not computed then return cached end
|
||||
|
||||
self.bookorbit_document_digest = {
|
||||
file = file,
|
||||
digest = computed,
|
||||
repaired = cached ~= nil and cached ~= computed,
|
||||
}
|
||||
if cached ~= computed then
|
||||
if doc_settings then
|
||||
doc_settings:saveSetting("partial_md5_checksum", computed)
|
||||
end
|
||||
local repaired, repair_err = pcall(
|
||||
BookOrbitStateManager.repairFileIdentity, file, cached, computed)
|
||||
if not repaired then
|
||||
logger.warn("BookOrbit: could not repair cached file identity:", repair_err)
|
||||
end
|
||||
end
|
||||
return computed
|
||||
end
|
||||
|
||||
@@ -166,6 +166,34 @@ function BookOrbitState:rememberFile(file, md5)
|
||||
end
|
||||
end
|
||||
|
||||
-- Repairs a path whose cached digest no longer matches the file on disk. A
|
||||
-- server identity is never copied between digests: only an identity already
|
||||
-- verified for the recomputed digest may be used to recover an ambiguous
|
||||
-- server match.
|
||||
function BookOrbitState:repairFileIdentity(file, old_md5, new_md5)
|
||||
if not file or not new_md5 then return nil end
|
||||
self.files[file] = new_md5
|
||||
|
||||
if old_md5 and old_md5 ~= new_md5 then
|
||||
local old_book = self.books[old_md5]
|
||||
if old_book and old_book.file == file then
|
||||
old_book.file = nil
|
||||
BookOrbitState.expireMatch(old_book)
|
||||
end
|
||||
self.unmatched[new_md5] = nil
|
||||
end
|
||||
|
||||
local new_book = self.books[new_md5]
|
||||
if new_book then
|
||||
new_book.file = file
|
||||
if old_md5 ~= new_md5 then
|
||||
BookOrbitState.expireMatch(new_book)
|
||||
new_book.statsWatermark = 0
|
||||
end
|
||||
end
|
||||
return new_book
|
||||
end
|
||||
|
||||
function BookOrbitState:flush()
|
||||
if self.flush_handler then
|
||||
return self.flush_handler(self)
|
||||
|
||||
@@ -415,6 +415,24 @@ function StateManager.linkFile(digest, book_file_id, book_id, file)
|
||||
})
|
||||
end
|
||||
|
||||
function StateManager.repairFileIdentity(file, old_digest, new_digest)
|
||||
if not file or not new_digest then return generation end
|
||||
if old_digest == new_digest and ensure().files[file] == new_digest then
|
||||
return generation
|
||||
end
|
||||
local digests = { new_digest }
|
||||
if old_digest and old_digest ~= new_digest then
|
||||
table.insert(digests, old_digest)
|
||||
end
|
||||
return StateManager.mutateScoped({
|
||||
digests = digests,
|
||||
files = { file },
|
||||
global = false,
|
||||
}, function(session)
|
||||
session:repairFileIdentity(file, old_digest, new_digest)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Test seam: forgets the shared instance and every derived cache.
|
||||
function StateManager.reset()
|
||||
shared = nil
|
||||
|
||||
@@ -445,6 +445,20 @@ local function historyEntry(ctx, file)
|
||||
|
||||
local file_exists = lfs.attributes(file, "mode") == "file"
|
||||
local md5 = ctx.state.files[file]
|
||||
local mapped_book = md5 and ctx.state:getBook(md5) or nil
|
||||
local identity_conflict = mapped_book and mapped_book.file and mapped_book.file ~= file
|
||||
if identity_conflict and file_exists then
|
||||
local started = nowMs()
|
||||
local ok, computed = pcall(util.partialMD5, file)
|
||||
ctx.timing.partial_md5_ms = ctx.timing.partial_md5_ms + elapsedMs(started)
|
||||
ctx.timing.partial_md5_count = ctx.timing.partial_md5_count + 1
|
||||
if ok and computed then
|
||||
if computed ~= md5 then
|
||||
ctx.state:repairFileIdentity(file, md5, computed)
|
||||
end
|
||||
md5 = computed
|
||||
end
|
||||
end
|
||||
if not md5 and file_exists and DocSettings:hasSidecarFile(file) then
|
||||
local doc_settings = DocSettings:open(file)
|
||||
md5 = doc_settings:readSetting("partial_md5_checksum")
|
||||
@@ -476,6 +490,9 @@ local function historyEntry(ctx, file)
|
||||
end
|
||||
ctx.candidates[md5] = cand
|
||||
local book = ctx.state:getBook(md5)
|
||||
if book and file_exists then
|
||||
cand.book_file_id = book.fileId
|
||||
end
|
||||
if book and file_exists and not book.file then
|
||||
book.file = file
|
||||
end
|
||||
|
||||
@@ -48,7 +48,7 @@ local BookOrbitProgressSync = require("bookorbit_progress_sync")
|
||||
local BookOrbitSweep = require("bookorbit_sweep")
|
||||
local BookOrbitUpdater = require("bookorbit_updater")
|
||||
|
||||
local PLUGIN_VERSION = "1.5.2"
|
||||
local PLUGIN_VERSION = "1.5.3"
|
||||
|
||||
local SYNC_STRATEGY = {
|
||||
PROMPT = 1,
|
||||
@@ -938,7 +938,8 @@ function BookOrbit:matchOpenBookForAutoSync(on_done)
|
||||
digests = { digest },
|
||||
files = { self.ui.document.file },
|
||||
})
|
||||
local had_local_match = state:getBook(digest) ~= nil
|
||||
local local_book = state:getBook(digest)
|
||||
local had_local_match = local_book ~= nil
|
||||
-- Any local match used to end this path, with no freshness bound at all, so
|
||||
-- a book whose server-side file was deleted or re-imported could stay
|
||||
-- wrongly matched forever. The bound here is a correctness fix.
|
||||
@@ -956,6 +957,7 @@ function BookOrbit:matchOpenBookForAutoSync(on_done)
|
||||
[digest] = {
|
||||
title = titleFromFile(self.ui.document.file),
|
||||
source = "current_file",
|
||||
book_file_id = local_book and local_book.fileId or nil,
|
||||
},
|
||||
})
|
||||
if not body then
|
||||
|
||||
@@ -8,6 +8,9 @@ local FakeScheduler = require("helpers/fake_scheduler")
|
||||
local scheduler
|
||||
|
||||
local calls
|
||||
local last_match_candidates
|
||||
local stats_watermarks
|
||||
local page_stat_events
|
||||
local match_fresh = false
|
||||
local exchange_skippable = false
|
||||
|
||||
@@ -49,8 +52,9 @@ package.loaded["bookorbit_api"] = {
|
||||
new = function()
|
||||
return {
|
||||
isConfigured = function() return true end,
|
||||
matchCheck = function()
|
||||
matchCheck = function(_, _, candidates)
|
||||
table.insert(calls, "match")
|
||||
last_match_candidates = candidates
|
||||
return {
|
||||
libraryVersion = "lib-v1",
|
||||
matches = { { hash = "abcdef", bookFileId = 2, bookId = 1 } },
|
||||
@@ -60,6 +64,13 @@ package.loaded["bookorbit_api"] = {
|
||||
table.insert(calls, "state")
|
||||
return { results = {} }
|
||||
end,
|
||||
uploadPageStats = function(_, books)
|
||||
table.insert(calls, "stats")
|
||||
return {
|
||||
results = { { hash = books[1].hash, watermark = books[1].events[#books[1].events].startTime } },
|
||||
unmatched = {},
|
||||
}
|
||||
end,
|
||||
updateProgress = function()
|
||||
table.insert(calls, "progress")
|
||||
return {}
|
||||
@@ -106,7 +117,10 @@ package.loaded["bookorbit_state"] = {
|
||||
}
|
||||
package.loaded["bookorbit_stats_reader"] = {
|
||||
getBookIds = function() return {} end,
|
||||
getEventsAfter = function() return {} end,
|
||||
getEventsAfter = function(_, watermark)
|
||||
table.insert(stats_watermarks, watermark)
|
||||
return page_stat_events
|
||||
end,
|
||||
}
|
||||
package.loaded["bookorbit_sweep"] = { isRunning = function() return false end }
|
||||
|
||||
@@ -121,7 +135,15 @@ end
|
||||
local function run(opts)
|
||||
scheduler = FakeScheduler.new()
|
||||
calls = {}
|
||||
book = { bookId = 1, fileId = 2, file = "/books/a.epub", statsWatermark = 0, annWatermark = "" }
|
||||
stats_watermarks = {}
|
||||
page_stat_events = opts.page_stat_events or {}
|
||||
book = {
|
||||
bookId = 1,
|
||||
fileId = 2,
|
||||
file = "/books/a.epub",
|
||||
statsWatermark = opts.stats_watermark or 0,
|
||||
annWatermark = "",
|
||||
}
|
||||
local acknowledged = {}
|
||||
local finished
|
||||
assert(BookSync.run{
|
||||
@@ -130,6 +152,7 @@ local function run(opts)
|
||||
digest = "abcdef",
|
||||
file = "/books/a.epub",
|
||||
stats_ids = { 42 },
|
||||
stats_identity_repaired = opts.stats_identity_repaired == true,
|
||||
annotations = {},
|
||||
ann_count = 0,
|
||||
ann_signature = "0::0:0",
|
||||
@@ -164,6 +187,8 @@ exchange_skippable = false
|
||||
local requests, acks = run{}
|
||||
assertEqual(requests, "match,annotations,progress",
|
||||
"a book with no usable local freshness still performs the full request chain")
|
||||
assertEqual(last_match_candidates.abcdef.book_file_id, 2,
|
||||
"current-book recovery carries the previously verified server file id")
|
||||
assertEqual(acks, "match,stats,annotations,state,progress", "every phase is acknowledged")
|
||||
|
||||
match_fresh = true
|
||||
@@ -188,4 +213,14 @@ assertEqual(requests, "state,progress",
|
||||
"a locally changed state uploads without forcing a pull")
|
||||
state_payload = nil
|
||||
|
||||
match_fresh = true
|
||||
exchange_skippable = true
|
||||
requests = run{
|
||||
stats_identity_repaired = true,
|
||||
stats_watermark = 900,
|
||||
page_stat_events = { { page = 1, startTime = 500, durationSeconds = 60, totalPages = 100 } },
|
||||
}
|
||||
assertEqual(stats_watermarks[1], 0, "identity repair replays exact-row history before the stored watermark")
|
||||
assertEqual(requests, "stats,progress", "the stranded historical event is uploaded under the repaired digest")
|
||||
|
||||
print("bookorbit_book_sync_fast_path_test.lua: ok")
|
||||
|
||||
@@ -103,6 +103,7 @@ local plugin = {
|
||||
doc_settings = { readSetting = function() return {} end },
|
||||
statistics = {
|
||||
settings = { is_enabled = true },
|
||||
id_curr_book = 42,
|
||||
insertDB = function() flushes = flushes + 1 end,
|
||||
},
|
||||
},
|
||||
@@ -144,15 +145,21 @@ local warm = BookSync.capture(plugin)
|
||||
assertEqual(sqlite.opens, 1, "the fallback primes the cache for the next handler")
|
||||
assertEqual(warm.stats_ids[1], 42, "the warmed capture carries the same row ids")
|
||||
|
||||
-- A book with no statistics row yet is a cached miss, not a cache bypass; the
|
||||
-- drain resolves it through getBookIds() instead.
|
||||
-- A repaired digest can be a cached miss while KOReader still has historical
|
||||
-- events attached to the current row's old digest. The live row id is exact,
|
||||
-- so capture carries it under the recomputed file hash without a second query.
|
||||
book_rows = {}
|
||||
plugin.ui.statistics.id_curr_book = 115
|
||||
plugin.bookorbit_document_digest = { file = "/books/a.epub", digest = "abc123", repaired = true }
|
||||
BookOrbitStatsReader.forgetIdentity()
|
||||
sqlite.opens = 0
|
||||
BookOrbitStatsReader.primeIdentity("abc123")
|
||||
local missing = BookSync.capture(plugin)
|
||||
assertEqual(sqlite.opens, 1, "a cached miss is not re-queried by the handler")
|
||||
assertEqual(#missing.stats_ids, 0, "a missing statistics row captures no ids")
|
||||
assertEqual(#missing.stats_ids, 1, "a repaired digest retains the live statistics row")
|
||||
assertEqual(missing.stats_ids[1], 115, "historical events follow the exact current-book row")
|
||||
assertEqual(missing.stats_metadata_ambiguous, false, "the exact current row is not ambiguous")
|
||||
assertEqual(missing.stats_identity_repaired, true, "the snapshot requests an unwatermarked history replay")
|
||||
local entry, was_cached = BookOrbitStatsReader.cachedIdentity("abc123")
|
||||
assertEqual(entry, nil, "the cached miss stores no row")
|
||||
assertEqual(was_cached, true, "a miss is still cached, so the handler stops looking")
|
||||
|
||||
@@ -42,6 +42,7 @@ local snapshot = {
|
||||
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 }))
|
||||
@@ -50,10 +51,19 @@ assertEqual(first.snapshot.annotations[1].pos0, "/1", "captured annotations are
|
||||
assertEqual(first.snapshot.stats_ids[2], 23, "stable statistics ids are stored")
|
||||
|
||||
now = 1001
|
||||
local replacement = assert(outbox:enqueue(snapshot, { reason = "suspend", annotation_sync = true }))
|
||||
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" })
|
||||
|
||||
@@ -27,9 +27,16 @@ package.loaded["ui/uimanager"] = {
|
||||
unschedule = function() end,
|
||||
getElapsedTimeSinceBoot = function() return 0 end,
|
||||
}
|
||||
package.loaded["logger"] = { dbg = function() end }
|
||||
package.loaded["logger"] = { dbg = function() end, warn = function() end }
|
||||
package.loaded["ui/time"] = { s = function(value) return value end }
|
||||
|
||||
local repairs = {}
|
||||
package.loaded["bookorbit_state_manager"] = {
|
||||
repairFileIdentity = function(file, old_digest, new_digest)
|
||||
table.insert(repairs, { file = file, old_digest = old_digest, new_digest = new_digest })
|
||||
end,
|
||||
}
|
||||
|
||||
local partial_calls = 0
|
||||
package.loaded["util"] = {
|
||||
partialMD5 = function(file)
|
||||
@@ -53,14 +60,16 @@ ProgressSync.install(plugin)
|
||||
|
||||
plugin.ui = {
|
||||
document = {
|
||||
file = "/tmp/book.epub",
|
||||
file = "/books/book.epub",
|
||||
info = { has_pages = true },
|
||||
},
|
||||
}
|
||||
assertEqual(plugin:getDocumentDigest(), "computed:/tmp/book.epub", "digest computes without doc settings")
|
||||
assertEqual(plugin:getDocumentDigest(), "computed:/books/book.epub", "digest computes without doc settings")
|
||||
assertEqual(partial_calls, 1, "partial md5 called once")
|
||||
assertEqual(#repairs, 1, "missing cached identity is recorded")
|
||||
|
||||
local saved_digest
|
||||
plugin.bookorbit_document_digest = nil
|
||||
plugin.ui.doc_settings = {
|
||||
readSetting = function()
|
||||
return nil
|
||||
@@ -69,15 +78,26 @@ plugin.ui.doc_settings = {
|
||||
saved_digest = value
|
||||
end,
|
||||
}
|
||||
assertEqual(plugin:getDocumentDigest(), "computed:/tmp/book.epub", "digest computes with empty doc settings")
|
||||
assertEqual(saved_digest, "computed:/tmp/book.epub", "computed digest is cached when possible")
|
||||
assertEqual(plugin:getDocumentDigest(), "computed:/books/book.epub", "digest computes with empty doc settings")
|
||||
assertEqual(saved_digest, "computed:/books/book.epub", "computed digest is cached when possible")
|
||||
|
||||
plugin.bookorbit_document_digest = nil
|
||||
plugin.ui.doc_settings = {
|
||||
readSetting = function()
|
||||
return "cached"
|
||||
end,
|
||||
saveSetting = function(_, _, value)
|
||||
saved_digest = value
|
||||
end,
|
||||
}
|
||||
assertEqual(plugin:getDocumentDigest(), "cached", "cached digest is returned")
|
||||
local calls_before_repair = partial_calls
|
||||
assertEqual(plugin:getDocumentDigest(), "computed:/books/book.epub", "stale cached digest is repaired")
|
||||
assertEqual(saved_digest, "computed:/books/book.epub", "recomputed digest replaces the stale sidecar value")
|
||||
assertEqual(repairs[#repairs].old_digest, "cached", "repair receives the stale digest")
|
||||
assertEqual(repairs[#repairs].new_digest, "computed:/books/book.epub", "repair receives the actual digest")
|
||||
assertEqual(plugin.bookorbit_document_digest.repaired, true, "the open document remembers that statistics recovery is required")
|
||||
assertEqual(plugin:getDocumentDigest(), "computed:/books/book.epub", "actual digest is cached for the open document")
|
||||
assertEqual(partial_calls, calls_before_repair + 1, "open document is hashed only once")
|
||||
|
||||
plugin.ui = nil
|
||||
assertEqual(plugin:getDocumentDigest(), nil, "missing UI returns nil")
|
||||
|
||||
@@ -49,9 +49,10 @@ package.loaded["ui/uimanager"] = {
|
||||
unschedule = function() end,
|
||||
getElapsedTimeSinceBoot = function() return 10000 end,
|
||||
}
|
||||
package.loaded["logger"] = { dbg = function() end }
|
||||
package.loaded["logger"] = { dbg = function() end, warn = function() end }
|
||||
package.loaded["ui/time"] = { s = function(value) return value end }
|
||||
package.loaded["util"] = { partialMD5 = function(file) return "digest:" .. tostring(file) end }
|
||||
package.loaded["bookorbit_state_manager"] = { repairFileIdentity = function() end }
|
||||
|
||||
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
|
||||
|
||||
|
||||
@@ -139,6 +139,47 @@ assertEqual(after_batch.byFileId[66], "/books/e.epub", "the second batched link
|
||||
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 })
|
||||
@@ -146,7 +187,7 @@ state:setUnmatched("bbb")
|
||||
state:flush()
|
||||
attribute_calls = 0
|
||||
local rebuilt = StateManager.onDeviceMaps()
|
||||
assertEqual(attribute_calls, 5, "an external write forces one rebuild")
|
||||
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")
|
||||
|
||||
|
||||
@@ -253,6 +253,41 @@ do
|
||||
assertEqual(#harness.calls.annotation_exchanges, 1, "the acknowledged sidecar is not exchanged again")
|
||||
end
|
||||
|
||||
-- A path mapped to another matched book is a stale identity signal. The sweep
|
||||
-- samples only that conflicted file, repairs the map, and carries the file id
|
||||
-- already verified for the actual digest into the recovery match.
|
||||
do
|
||||
local file = "/books/repaired.epub"
|
||||
local actual = "md5-" .. file
|
||||
local harness = SweepHarness.install{
|
||||
books = { { md5 = actual, id = 9, title = "Recovered", last_open = 500 } },
|
||||
history = { { file = file, time = 500, text = "Recovered" } },
|
||||
library_version = "v1",
|
||||
state = {
|
||||
books = {
|
||||
stale = {
|
||||
bookId = 1, fileId = 11, file = "/books/different.epub",
|
||||
matchVerifiedAt = NOW, matchVerifiedVersion = "v1",
|
||||
},
|
||||
[actual] = {
|
||||
bookId = 2, fileId = 22, file = file,
|
||||
matchVerifiedAt = NOW - 2 * DAY, matchVerifiedVersion = "v1",
|
||||
},
|
||||
},
|
||||
files = { [file] = "stale" },
|
||||
global = { libraryVersion = "v1" },
|
||||
},
|
||||
}
|
||||
local Sweep = require("bookorbit_sweep")
|
||||
|
||||
startSweep(harness, Sweep)
|
||||
harness.scheduler:drain()
|
||||
|
||||
assertEqual(harness.state.files[file], actual, "sweep repairs the stale path digest")
|
||||
assertEqual(harness.calls.match_candidates[1][actual].book_file_id, 22,
|
||||
"sweep recovery carries only the actual digest's verified file id")
|
||||
end
|
||||
|
||||
-- Cancellation stops the run at its next yield: the step already scheduled
|
||||
-- runs, sees it is no longer the current generation and writes nothing.
|
||||
do
|
||||
|
||||
@@ -78,8 +78,9 @@ local function installClient(handle)
|
||||
local responses = handle.responses
|
||||
return {
|
||||
isConfigured = function() return true end,
|
||||
matchCheck = function(_, hashes)
|
||||
matchCheck = function(_, hashes, candidates)
|
||||
table.insert(handle.calls.match, hashes)
|
||||
table.insert(handle.calls.match_candidates, candidates)
|
||||
if responses.match_error then return nil, responses.match_error end
|
||||
local matches = {}
|
||||
for _, hash in ipairs(hashes) do
|
||||
@@ -158,6 +159,7 @@ function SweepHarness.install(opts)
|
||||
},
|
||||
calls = {
|
||||
match = {},
|
||||
match_candidates = {},
|
||||
page_stats = {},
|
||||
book_states = {},
|
||||
progress = {},
|
||||
|
||||
Reference in New Issue
Block a user