Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions spec/System/TestDataJewelFileLoader_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
describe("Data jewel file loader", function()
local originalConPrintf
local originalGetScriptPath
local originalInflate
local originalNewFileSearch
local originalOpen
local originalRemove
local originalRename
local files, modifiedTimeByPath
local writePath
local renameFile
local renameOverride
local loadJewelFile
local cachePath
local compressedFilePath

local function setInputs(cacheData, cacheModified, inflatedData)
files[cachePath] = cacheData
modifiedTimeByPath[cachePath] = cacheModified
files[compressedFilePath] = "compressed data"
modifiedTimeByPath[compressedFilePath] = 10
_G.Inflate = function() return inflatedData end
end

before_each(function()
originalConPrintf = _G.ConPrintf
originalGetScriptPath = _G.GetScriptPath
originalInflate = _G.Inflate
originalNewFileSearch = _G.NewFileSearch
originalOpen = io.open
originalRemove = os.remove
originalRename = os.rename
files = { }
modifiedTimeByPath = { }
writePath = nil
cachePath = "./Data/TimelessJewelData/TestJewel.bin"
compressedFilePath = "./Data/TimelessJewelData/TestJewel.zip"

_G.ConPrintf = function() end
_G.GetScriptPath = function() return "." end
_G.NewFileSearch = function(path)
if modifiedTimeByPath[path] == nil then
return
end
return {
GetFileName = function() return path end,
GetFileModifiedTime = function() return modifiedTimeByPath[path] end,
}
end
io.open = function(path, mode)
if mode == "rb" then
if files[path] == nil then
return
end
return {
read = function() return files[path] end,
close = function() return true end,
}
end
if mode == "wb" then
writePath = path
local pendingData
local file = { }
function file:write(data)
pendingData = data
return self
end
function file:close()
files[path] = pendingData or ""
return true
end
return file
end
end
renameFile = function(sourcePath, destinationPath)
if files[sourcePath] == nil then
return nil, "source does not exist"
end
files[destinationPath] = files[sourcePath]
files[sourcePath] = nil
return true
end
os.rename = function(sourcePath, destinationPath)
if renameOverride then
local handled, renameResult, renameError = renameOverride(sourcePath, destinationPath)
if handled then
return renameResult, renameError
end
end
return renameFile(sourcePath, destinationPath)
end
os.remove = function(path)
if files[path] == nil then
return nil, "file does not exist"
end
files[path] = nil
return true
end

loadJewelFile = LoadModule("Modules/DataJewelFileLoader")
end)

after_each(function()
_G.ConPrintf = originalConPrintf
_G.GetScriptPath = originalGetScriptPath
_G.Inflate = originalInflate
_G.NewFileSearch = originalNewFileSearch
io.open = originalOpen
os.remove = originalRemove
os.rename = originalRename
end)

it("falls back from a newer zero-byte cache", function()
setInputs("", 20, "fresh jewel data")

local jewelData = loadJewelFile("TestJewel")

assert.are.equal("fresh jewel data", jewelData)
assert.are.equal("fresh jewel data", files[cachePath])
assert.is_truthy(writePath:find(cachePath .. ".tmp.", 1, true))
end)

it("rejects empty inflation without opening the cache for writing", function()
setInputs("previous jewel data", 5, "")

local jewelData = loadJewelFile("TestJewel")

assert.is_nil(jewelData)
assert.are.equal("previous jewel data", files[cachePath])
assert.is_nil(writePath)
end)

it("restores the previous cache when promotion fails", function()
setInputs("previous jewel data", 5, "fresh jewel data")
local promotionAttempts = 0
renameOverride = function(sourcePath, destinationPath)
if destinationPath == cachePath and sourcePath:find(cachePath .. ".tmp.", 1, true) == 1 then
promotionAttempts = promotionAttempts + 1
return true, nil, "promotion failed"
end
return false
end

local jewelData = loadJewelFile("TestJewel")

assert.are.equal("fresh jewel data", jewelData)
assert.are.equal("previous jewel data", files[cachePath])
assert.are.equal(2, promotionAttempts)
for path in pairs(files) do
assert.is_falsy(path:find(cachePath .. ".tmp.", 1, true))
assert.is_falsy(path:find(cachePath .. ".backup.", 1, true))
end
end)
end)
72 changes: 66 additions & 6 deletions src/Modules/DataJewelFileLoader.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,63 @@
--
local t_concat = table.concat

-- Keep temporary and backup paths beside the cache so renames stay on one
-- volume. A per-write suffix reduces collisions between concurrent writes.
local function makeSiblingPaths(cachePath)
local allocationAddressToken = tostring({ }):match("0x(%x+)") or "unknown"
local suffix = os.time() .. "." .. allocationAddressToken
return cachePath .. ".tmp." .. suffix, cachePath .. ".backup." .. suffix
end

local function promoteCache(cachePath, temporaryPath, backupPath)
local promoted, promoteError = os.rename(temporaryPath, cachePath)
if promoted then
return true
end

-- Windows cannot rename over an existing file. Move the current cache aside
-- before retrying so a failed replacement can restore the previous data.
local backedUp, backupError = os.rename(cachePath, backupPath)
if not backedUp then
os.remove(temporaryPath)
return nil, promoteError or backupError
end

promoted, promoteError = os.rename(temporaryPath, cachePath)
if promoted then
local removed, removeError = os.remove(backupPath)
if not removed then
ConPrintf("Failed to remove jewel data cache backup " .. backupPath .. ": " .. tostring(removeError))
end
return true
end

local restored, restoreError = os.rename(backupPath, cachePath)
os.remove(temporaryPath)
if not restored then
return nil, tostring(promoteError) .. "; cache backup remains at " .. backupPath
.. " after restore failed: " .. tostring(restoreError)
end
return nil, promoteError
end

local function writeCache(cachePath, jewelData)
local temporaryPath, backupPath = makeSiblingPaths(cachePath)
local temporaryFile, openError = io.open(temporaryPath, "wb")
if not temporaryFile then
return nil, openError
end

local written, writeError = temporaryFile:write(jewelData)
local closed, closeError = temporaryFile:close()
if not written or not closed then
os.remove(temporaryPath)
return nil, writeError or closeError
end

return promoteCache(cachePath, temporaryPath, backupPath)
end

local function loadJewelFile(jewelTypeName, cacheUncompressed)
local jewelPath = "/Data/TimelessJewelData/" .. jewelTypeName
local scriptPath = GetScriptPath()
Expand Down Expand Up @@ -42,12 +99,12 @@ local function loadJewelFile(jewelTypeName, cacheUncompressed)
end

if uncompressedFileAttr.modified and uncompressedFileAttr.modified > (compressedFileAttr.modified or 0) then
ConPrintf("Uncompressed jewel data is up-to-date, loading " .. uncompressedFileAttr.fileName)
local uncompressedFile = io.open(scriptPath .. jewelPath .. ".bin", "rb")
if uncompressedFile then
local jewelData = uncompressedFile:read("*a")
uncompressedFile:close()
if jewelData then
if jewelData and jewelData ~= "" then
ConPrintf("Uncompressed jewel data is up-to-date, loading " .. uncompressedFileAttr.fileName)
return jewelData
end
end
Expand Down Expand Up @@ -82,11 +139,14 @@ local function loadJewelFile(jewelTypeName, cacheUncompressed)
end

local jewelData = Inflate(compressedData)
if not jewelData or jewelData == "" then
ConPrintf("Failed to inflate jewel data: " .. jewelTypeName)
return
end
if cacheUncompressed then
local uncompressedFile = io.open(scriptPath .. jewelPath .. ".bin", "wb+")
if uncompressedFile then
uncompressedFile:write(jewelData)
uncompressedFile:close()
local cached, cacheError = writeCache(scriptPath .. jewelPath .. ".bin", jewelData)
if not cached then
ConPrintf("Failed to cache jewel data " .. jewelTypeName .. ": " .. tostring(cacheError))
end
end
return jewelData
Expand Down
Loading