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
99 changes: 99 additions & 0 deletions LiteDB.Tests/Issues/Issue1940_Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using FluentAssertions;
using Xunit;

namespace LiteDB.Tests.Issues
{
public class Issue1940_Tests
{
[Fact]
public void OpeningDatabaseWithLegacyCorruptFreeListInWalShouldAutoHeal()
{
var tempDirectory = Path.Combine(Path.GetTempPath(), $"litedb-issue1940-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDirectory);

try
{
ZipFile.ExtractToDirectory(
Path.Combine(AppContext.BaseDirectory, "Resources", "Issue1940_CorruptFreeEmptyList.zip"),
tempDirectory);
Comment on lines +20 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Assert that the corrupt fixture was really extracted.

LiteEngine.Open() only enters the new heal path when the WAL exists, but this test never proves that issue1940.db and issue1940-log.db came out of the archive. If the zip layout regresses, new LiteDatabase(databasePath) just creates a clean database and this still passes.

Add explicit fixture checks right after extraction
                 ZipFile.ExtractToDirectory(
                     Path.Combine(AppContext.BaseDirectory, "Resources", "Issue1940_CorruptFreeEmptyList.zip"),
                     tempDirectory);
+
+                File.Exists(databasePath).Should().BeTrue("the corrupt datafile fixture must be present");
+                File.Exists(logPath).Should().BeTrue("the auto-heal path only runs when the WAL fixture is present");
+                new FileInfo(logPath).Length.Should().BeGreaterThan(0, "the fixture should start with a non-empty WAL");

Also applies to: 40-43

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@LiteDB.Tests/Issues/Issue1940_Tests.cs` around lines 23 - 25, After
extracting the ZIP with ZipFile.ExtractToDirectory, add explicit assertions that
the expected fixture files were actually extracted: check
File.Exists(Path.Combine(tempDirectory, "issue1940.db")) and
File.Exists(Path.Combine(tempDirectory, "issue1940-log.db")) (and optionally
that their lengths > 0) immediately after the ExtractToDirectory call so
LiteEngine.Open / new LiteDatabase(databasePath) will run against the real
WAL/db files; apply the same existence checks to the other extraction sites
referenced around lines 40-43 to prevent regressions in ZIP layout.


var databasePath = Path.Combine(tempDirectory, "Issue1940_CorruptFreeEmptyList.db");
var logPath = Path.Combine(tempDirectory, "Issue1940_CorruptFreeEmptyList-log.db");

File.Exists(databasePath).Should().BeTrue();
File.Exists(logPath).Should().BeTrue();

Action firstOpen = () =>
{
using var db = new LiteDatabase(databasePath);
var col = db.GetCollection<LegacyCorruptWalDoc>("verify");

col.EnsureIndex(x => x.Tags);
col.Insert(this.CreateDocs());

db.Checkpoint();
};

firstOpen.Should().NotThrow();

if (File.Exists(logPath))
{
new FileInfo(logPath).Length.Should().Be(0);
}

Action secondOpen = () =>
{
using var db = new LiteDatabase(databasePath);
var col = db.GetCollection<LegacyCorruptWalDoc>("verify");

col.Insert(this.CreateDocs());
};

secondOpen.Should().NotThrow();
}
finally
{
if (Directory.Exists(tempDirectory))
{
Directory.Delete(tempDirectory, true);
}
}
}

private IEnumerable<LegacyCorruptWalDoc> CreateDocs()
{
yield return new LegacyCorruptWalDoc
{
Number = 1,
Payload = new string('a', 2048),
Tags = new List<string> { "alpha", "beta" },
Values = new List<string> { "one", "two" }
};

yield return new LegacyCorruptWalDoc
{
Number = 2,
Payload = new string('b', 1536),
Tags = new List<string> { "gamma", "delta" },
Values = new List<string> { "three", "four" }
};
}

private class LegacyCorruptWalDoc
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();

public int Number { get; set; }

public string Payload { get; set; } = string.Empty;

public List<string> Tags { get; set; } = new List<string>();

public List<string> Values { get; set; } = new List<string>();
}
}
}
3 changes: 3 additions & 0 deletions LiteDB.Tests/LiteDB.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
<None Update="Resources\ingest-20250922-234735.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Resources\Issue1940_CorruptFreeEmptyList.zip">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

<ItemGroup Condition="'$(Configuration)' == 'Release'">
Expand Down
Binary file not shown.
105 changes: 104 additions & 1 deletion LiteDB/Engine/Engine/Recovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,109 @@ namespace LiteDB.Engine
{
public partial class LiteEngine
{
private void HealCorruptedFreeEmptyPageList()
{
if (_header.FreeEmptyPageList == uint.MaxValue)
{
return;
}

using var reader = _disk.GetReader();

var current = _header.FreeEmptyPageList;
var visited = new HashSet<uint>();

while (current != uint.MaxValue)
{
if (current > _header.LastPageID || visited.Add(current) == false)
{
this.RepairFreeEmptyPageList(current, null);
return;
}

var page = this.ReadLatestPage(current, reader);

try
{
if (page.PageType != PageType.Empty)
{
this.RepairFreeEmptyPageList(current, page.PageType);
return;
}

current = page.NextPageID;
}
finally
{
page.Buffer.Release();
}
}
Comment on lines +33 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unreadable pages still bypass the heal path.

Because the try begins after ReadLatestPage(current, reader) returns, any read/parse failure from the corrupt chain still escapes Open() instead of resetting the free list. Please treat page-read/page-decode failures during this scan as corruption and route them through RepairFreeEmptyPageList(...) too.

Also applies to: 52-61

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@LiteDB/Engine/Engine/Recovery.cs` around lines 33 - 49, The scan currently
calls ReadLatestPage(...) outside the try/finally so any read or decode
exception escapes Open() instead of being treated as corruption; move the try
(and its finally that releases page.Buffer) to wrap the ReadLatestPage(...)
invocation so that exceptions thrown by ReadLatestPage or subsequent page
parsing are caught and handled by calling RepairFreeEmptyPageList(current,
page.PageType) (or the appropriate overload) and then rethrow or return as the
existing logic expects; apply the same change to the other loop block referenced
(the similar code at lines handling the second scan) so both
page-read/page-decode failures are routed through RepairFreeEmptyPageList(...)
and buffer.Release() still runs in the finally.

}

private BasePage ReadLatestPage(uint pageID, DiskReader reader)
{
var position = _walIndex.GetPageIndex(pageID, int.MaxValue, out _);

if (position != long.MaxValue)
{
return new BasePage(reader.ReadPage(position, false, FileOrigin.Log));
}

return new BasePage(reader.ReadPage(BasePage.GetPagePosition(pageID), false, FileOrigin.Data));
}

private void RepairFreeEmptyPageList(uint pageID, PageType? pageType)
{
LOG(
pageType.HasValue
? $"detected legacy corruption in free empty page list at page {pageID} ({pageType.Value}); resetting header free list"
: $"detected legacy corruption in free empty page list near page {pageID}; resetting header free list",
"RECOVERY");

lock (_header)
{
var savepoint = _header.Savepoint();

try
{
_header.FreeEmptyPageList = uint.MaxValue;
_header.TransactionID = uint.MaxValue;
_header.IsConfirmed = false;
_header.UpdateBuffer();

if (_settings.ReadOnly == false)
{
this.PersistRecoveredHeader();
}
}
catch
{
_header.Restore(savepoint);
throw;
}
}
}

private void PersistRecoveredHeader()
{
var transactionID = _walIndex.NextTransactionID();

_header.TransactionID = transactionID;
_header.IsConfirmed = true;

var buffer = _header.UpdateBuffer();
var clone = _disk.NewPage();

Buffer.BlockCopy(buffer.Array, buffer.Offset, clone.Array, clone.Offset, clone.Count);

_disk.WriteLogDisk(new[] { clone });
_walIndex.ConfirmTransaction(transactionID, new[] { new PagePosition(0, clone.Position) });

_header.TransactionID = uint.MaxValue;
_header.IsConfirmed = false;
_header.UpdateBuffer();
}

/// <summary>
/// Recovery datafile using a rebuild process. Run only on "Open" database
/// </summary>
Expand All @@ -28,4 +131,4 @@ private void Recovery(Collation collation)
rebuilder.Rebuild(options);
}
}
}
}
4 changes: 3 additions & 1 deletion LiteDB/Engine/LiteEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ internal bool Open()
if (_disk.GetFileLength(FileOrigin.Log) > 0)
{
_walIndex.RestoreIndex(ref _header);

this.HealCorruptedFreeEmptyPageList();
}

// initialize sort temp disk
Expand Down Expand Up @@ -266,4 +268,4 @@ protected virtual void Dispose(bool disposing)
this.Close();
}
}
}
}
Loading