Skip to content

Commit 0dc995c

Browse files
committed
Implement auto-heal for legacy corrupt free list in WAL and add corresponding tests
1 parent 49f74a6 commit 0dc995c

5 files changed

Lines changed: 207 additions & 2 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.IO.Compression;
5+
using FluentAssertions;
6+
using Xunit;
7+
8+
namespace LiteDB.Tests.Issues
9+
{
10+
public class Issue1940_Tests
11+
{
12+
[Fact]
13+
public void OpeningDatabaseWithLegacyCorruptFreeListInWalShouldAutoHeal()
14+
{
15+
var tempDirectory = Path.Combine(Path.GetTempPath(), $"litedb-issue1940-{Guid.NewGuid():N}");
16+
Directory.CreateDirectory(tempDirectory);
17+
18+
try
19+
{
20+
var databasePath = Path.Combine(tempDirectory, "issue1940.db");
21+
var logPath = Path.Combine(tempDirectory, "issue1940-log.db");
22+
23+
ZipFile.ExtractToDirectory(
24+
Path.Combine(AppContext.BaseDirectory, "Resources", "Issue1940_CorruptFreeEmptyList.zip"),
25+
tempDirectory,
26+
overwriteFiles: true);
27+
28+
Action firstOpen = () =>
29+
{
30+
using var db = new LiteDatabase(databasePath);
31+
var col = db.GetCollection<LegacyCorruptWalDoc>("verify");
32+
33+
col.EnsureIndex(x => x.Tags);
34+
col.Insert(this.CreateDocs());
35+
36+
db.Checkpoint();
37+
};
38+
39+
firstOpen.Should().NotThrow();
40+
41+
if (File.Exists(logPath))
42+
{
43+
new FileInfo(logPath).Length.Should().Be(0);
44+
}
45+
46+
Action secondOpen = () =>
47+
{
48+
using var db = new LiteDatabase(databasePath);
49+
var col = db.GetCollection<LegacyCorruptWalDoc>("verify");
50+
51+
col.Insert(this.CreateDocs());
52+
};
53+
54+
secondOpen.Should().NotThrow();
55+
}
56+
finally
57+
{
58+
if (Directory.Exists(tempDirectory))
59+
{
60+
Directory.Delete(tempDirectory, true);
61+
}
62+
}
63+
}
64+
65+
private IEnumerable<LegacyCorruptWalDoc> CreateDocs()
66+
{
67+
yield return new LegacyCorruptWalDoc
68+
{
69+
Number = 1,
70+
Payload = new string('a', 2048),
71+
Tags = new List<string> { "alpha", "beta" },
72+
Values = new List<string> { "one", "two" }
73+
};
74+
75+
yield return new LegacyCorruptWalDoc
76+
{
77+
Number = 2,
78+
Payload = new string('b', 1536),
79+
Tags = new List<string> { "gamma", "delta" },
80+
Values = new List<string> { "three", "four" }
81+
};
82+
}
83+
84+
private class LegacyCorruptWalDoc
85+
{
86+
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
87+
88+
public int Number { get; set; }
89+
90+
public string Payload { get; set; } = string.Empty;
91+
92+
public List<string> Tags { get; set; } = new List<string>();
93+
94+
public List<string> Values { get; set; } = new List<string>();
95+
}
96+
}
97+
}

LiteDB.Tests/LiteDB.Tests.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
<None Update="Resources\ingest-20250922-234735.json">
2626
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
2727
</None>
28+
<None Update="Resources\Issue1940_CorruptFreeEmptyList.zip">
29+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
30+
</None>
2831
</ItemGroup>
2932

3033
<ItemGroup Condition="'$(Configuration)' == 'Release'">
32.8 KB
Binary file not shown.

LiteDB/Engine/Engine/Recovery.cs

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,109 @@ namespace LiteDB.Engine
1010
{
1111
public partial class LiteEngine
1212
{
13+
private void HealCorruptedFreeEmptyPageList()
14+
{
15+
if (_header.FreeEmptyPageList == uint.MaxValue)
16+
{
17+
return;
18+
}
19+
20+
using var reader = _disk.GetReader();
21+
22+
var current = _header.FreeEmptyPageList;
23+
var visited = new HashSet<uint>();
24+
25+
while (current != uint.MaxValue)
26+
{
27+
if (current > _header.LastPageID || visited.Add(current) == false)
28+
{
29+
this.RepairFreeEmptyPageList(current, null);
30+
return;
31+
}
32+
33+
var page = this.ReadLatestPage(current, reader);
34+
35+
try
36+
{
37+
if (page.PageType != PageType.Empty)
38+
{
39+
this.RepairFreeEmptyPageList(current, page.PageType);
40+
return;
41+
}
42+
43+
current = page.NextPageID;
44+
}
45+
finally
46+
{
47+
page.Buffer.Release();
48+
}
49+
}
50+
}
51+
52+
private BasePage ReadLatestPage(uint pageID, DiskReader reader)
53+
{
54+
var position = _walIndex.GetPageIndex(pageID, int.MaxValue, out _);
55+
56+
if (position != long.MaxValue)
57+
{
58+
return new BasePage(reader.ReadPage(position, false, FileOrigin.Log));
59+
}
60+
61+
return new BasePage(reader.ReadPage(BasePage.GetPagePosition(pageID), false, FileOrigin.Data));
62+
}
63+
64+
private void RepairFreeEmptyPageList(uint pageID, PageType? pageType)
65+
{
66+
LOG(
67+
pageType.HasValue
68+
? $"detected legacy corruption in free empty page list at page {pageID} ({pageType.Value}); resetting header free list"
69+
: $"detected legacy corruption in free empty page list near page {pageID}; resetting header free list",
70+
"RECOVERY");
71+
72+
lock (_header)
73+
{
74+
var savepoint = _header.Savepoint();
75+
76+
try
77+
{
78+
_header.FreeEmptyPageList = uint.MaxValue;
79+
_header.TransactionID = uint.MaxValue;
80+
_header.IsConfirmed = false;
81+
_header.UpdateBuffer();
82+
83+
if (_settings.ReadOnly == false)
84+
{
85+
this.PersistRecoveredHeader();
86+
}
87+
}
88+
catch
89+
{
90+
_header.Restore(savepoint);
91+
throw;
92+
}
93+
}
94+
}
95+
96+
private void PersistRecoveredHeader()
97+
{
98+
var transactionID = _walIndex.NextTransactionID();
99+
100+
_header.TransactionID = transactionID;
101+
_header.IsConfirmed = true;
102+
103+
var buffer = _header.UpdateBuffer();
104+
var clone = _disk.NewPage();
105+
106+
Buffer.BlockCopy(buffer.Array, buffer.Offset, clone.Array, clone.Offset, clone.Count);
107+
108+
_disk.WriteLogDisk(new[] { clone });
109+
_walIndex.ConfirmTransaction(transactionID, new[] { new PagePosition(0, clone.Position) });
110+
111+
_header.TransactionID = uint.MaxValue;
112+
_header.IsConfirmed = false;
113+
_header.UpdateBuffer();
114+
}
115+
13116
/// <summary>
14117
/// Recovery datafile using a rebuild process. Run only on "Open" database
15118
/// </summary>
@@ -28,4 +131,4 @@ private void Recovery(Collation collation)
28131
rebuilder.Rebuild(options);
29132
}
30133
}
31-
}
134+
}

LiteDB/Engine/LiteEngine.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ internal bool Open()
143143
if (_disk.GetFileLength(FileOrigin.Log) > 0)
144144
{
145145
_walIndex.RestoreIndex(ref _header);
146+
147+
this.HealCorruptedFreeEmptyPageList();
146148
}
147149

148150
// initialize sort temp disk
@@ -266,4 +268,4 @@ protected virtual void Dispose(bool disposing)
266268
this.Close();
267269
}
268270
}
269-
}
271+
}

0 commit comments

Comments
 (0)