Implement checkpoint modes with docs/tests and reorganize project file layout
This commit is contained in:
21
README.md
21
README.md
@@ -104,6 +104,27 @@ Operational procedures, diagnostics, and escalation are documented in:
|
|||||||
- [`docs/runbook.md`](docs/runbook.md)
|
- [`docs/runbook.md`](docs/runbook.md)
|
||||||
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
||||||
|
|
||||||
|
## Checkpoint Modes
|
||||||
|
|
||||||
|
`DocumentDbContext` and `StorageEngine` support explicit checkpoint modes via `CheckpointMode`:
|
||||||
|
|
||||||
|
- `Passive`: non-blocking; skips if checkpoint lock is contended.
|
||||||
|
- `Full`: applies committed WAL pages and appends a checkpoint marker, without truncating WAL.
|
||||||
|
- `Truncate`: applies committed WAL pages and truncates WAL.
|
||||||
|
- `Restart`: truncate + WAL writer restart.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ZB.MOM.WW.CBDD.Core.Transactions;
|
||||||
|
|
||||||
|
var result = db.Checkpoint(CheckpointMode.Full);
|
||||||
|
if (!result.Executed)
|
||||||
|
{
|
||||||
|
// passive checkpoint can be skipped under contention
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Security And Compliance Posture
|
## Security And Compliance Posture
|
||||||
|
|
||||||
- CBDD relies on host and process-level access controls.
|
- CBDD relies on host and process-level access controls.
|
||||||
|
|||||||
@@ -29,6 +29,24 @@ Non-goals:
|
|||||||
- `WriteAheadLog`
|
- `WriteAheadLog`
|
||||||
- Storage engine modules under `src/CBDD.Core/Storage`
|
- Storage engine modules under `src/CBDD.Core/Storage`
|
||||||
|
|
||||||
|
Checkpoint APIs:
|
||||||
|
- `DocumentDbContext.Checkpoint(CheckpointMode mode = CheckpointMode.Truncate)`
|
||||||
|
- `DocumentDbContext.CheckpointAsync(CheckpointMode mode = CheckpointMode.Truncate, CancellationToken ct = default)`
|
||||||
|
- `StorageEngine.Checkpoint(CheckpointMode mode)`
|
||||||
|
- `StorageEngine.CheckpointAsync(CheckpointMode mode, CancellationToken ct = default)`
|
||||||
|
|
||||||
|
Checkpoint modes:
|
||||||
|
- `Passive`: non-blocking best-effort checkpoint. Returns `Executed = false` when lock is contended.
|
||||||
|
- `Full`: applies committed WAL pages and appends a WAL checkpoint marker without truncating WAL.
|
||||||
|
- `Truncate`: applies committed WAL pages and truncates WAL (default behavior).
|
||||||
|
- `Restart`: same as truncate, then reinitializes WAL writer session.
|
||||||
|
|
||||||
|
Usage guidance:
|
||||||
|
- Use `Passive` for background/low-priority maintenance where latency matters more than immediate WAL cleanup.
|
||||||
|
- Use `Full` when you want durable page-file sync but prefer to preserve WAL history until a later truncate.
|
||||||
|
- Use `Truncate` for routine manual checkpoints and disk-space recovery.
|
||||||
|
- Use `Restart` for aggressive maintenance boundaries (for example after incident remediation flows).
|
||||||
|
|
||||||
## Permissions And Data Handling
|
## Permissions And Data Handling
|
||||||
|
|
||||||
- Database files require host-managed filesystem access controls.
|
- Database files require host-managed filesystem access controls.
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ Database startup fails or recovery path throws WAL/storage errors.
|
|||||||
### Resolution
|
### Resolution
|
||||||
- Pin consumers to last known-good package.
|
- Pin consumers to last known-good package.
|
||||||
- Apply fix and add regression coverage in recovery/transaction tests.
|
- Apply fix and add regression coverage in recovery/transaction tests.
|
||||||
|
- If WAL growth is the issue, run `CheckpointMode.Truncate` (or `Restart`) instead of `Full`.
|
||||||
|
- If foreground latency is a concern, schedule `CheckpointMode.Passive` retries and use `Truncate` during maintenance windows.
|
||||||
|
|
||||||
## Query And Index Issues
|
## Query And Index Issues
|
||||||
|
|
||||||
|
|||||||
@@ -312,10 +312,10 @@ public abstract partial class DocumentDbContext : IDisposable, ITransactionHolde
|
|||||||
/// Commits the current transaction asynchronously if one is active.
|
/// Commits the current transaction asynchronously if one is active.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">The cancellation token.</param>
|
/// <param name="ct">The cancellation token.</param>
|
||||||
public async Task SaveChangesAsync(CancellationToken ct = default)
|
public async Task SaveChangesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
throw new ObjectDisposedException(nameof(DocumentDbContext));
|
throw new ObjectDisposedException(nameof(DocumentDbContext));
|
||||||
if (CurrentTransaction != null)
|
if (CurrentTransaction != null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -325,13 +325,40 @@ public abstract partial class DocumentDbContext : IDisposable, ITransactionHolde
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
CurrentTransaction = null;
|
CurrentTransaction = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a point-in-time snapshot of compression telemetry counters.
|
/// Executes a checkpoint using the requested mode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="mode">Checkpoint mode to execute.</param>
|
||||||
|
/// <returns>The checkpoint execution result.</returns>
|
||||||
|
public CheckpointResult Checkpoint(CheckpointMode mode = CheckpointMode.Truncate)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
throw new ObjectDisposedException(nameof(DocumentDbContext));
|
||||||
|
|
||||||
|
return Engine.Checkpoint(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes a checkpoint asynchronously using the requested mode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="mode">Checkpoint mode to execute.</param>
|
||||||
|
/// <param name="ct">The cancellation token.</param>
|
||||||
|
/// <returns>The checkpoint execution result.</returns>
|
||||||
|
public Task<CheckpointResult> CheckpointAsync(CheckpointMode mode = CheckpointMode.Truncate, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
throw new ObjectDisposedException(nameof(DocumentDbContext));
|
||||||
|
|
||||||
|
return Engine.CheckpointAsync(mode, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a point-in-time snapshot of compression telemetry counters.
|
||||||
|
/// </summary>
|
||||||
public CompressionStats GetCompressionStats()
|
public CompressionStats GetCompressionStats()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
@@ -3,16 +3,16 @@ using ZB.MOM.WW.CBDD.Bson;
|
|||||||
|
|
||||||
namespace ZB.MOM.WW.CBDD.Core.Query;
|
namespace ZB.MOM.WW.CBDD.Core.Query;
|
||||||
|
|
||||||
internal static class BsonExpressionEvaluator
|
internal static class BsonExpressionEvaluator
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to compile a LINQ predicate expression into a BSON reader predicate.
|
/// Attempts to compile a LINQ predicate expression into a BSON reader predicate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">The entity type of the original expression.</typeparam>
|
/// <typeparam name="T">The entity type of the original expression.</typeparam>
|
||||||
/// <param name="expression">The lambda expression to compile.</param>
|
/// <param name="expression">The lambda expression to compile.</param>
|
||||||
/// <returns>A compiled predicate when supported; otherwise, <see langword="null"/>.</returns>
|
/// <returns>A compiled predicate when supported; otherwise, <see langword="null"/>.</returns>
|
||||||
public static Func<BsonSpanReader, bool>? TryCompile<T>(LambdaExpression expression)
|
public static Func<BsonSpanReader, bool>? TryCompile<T>(LambdaExpression expression)
|
||||||
{
|
{
|
||||||
// Simple optimization for: x => x.Prop op Constant
|
// Simple optimization for: x => x.Prop op Constant
|
||||||
if (expression.Body is BinaryExpression binary)
|
if (expression.Body is BinaryExpression binary)
|
||||||
{
|
{
|
||||||
@@ -34,10 +34,10 @@ internal static class BsonExpressionEvaluator
|
|||||||
if (member.Expression == expression.Parameters[0])
|
if (member.Expression == expression.Parameters[0])
|
||||||
{
|
{
|
||||||
var propertyName = member.Member.Name.ToLowerInvariant();
|
var propertyName = member.Member.Name.ToLowerInvariant();
|
||||||
var value = constant.Value;
|
var value = constant.Value;
|
||||||
|
|
||||||
// Handle Id mapping?
|
// Handle Id mapping?
|
||||||
// If property is "id", Bson field is "_id"
|
// If property is "id", Bson field is "_id"
|
||||||
if (propertyName == "id") propertyName = "_id";
|
if (propertyName == "id") propertyName = "_id";
|
||||||
|
|
||||||
return CreatePredicate(propertyName, value, nodeType);
|
return CreatePredicate(propertyName, value, nodeType);
|
||||||
@@ -58,31 +58,31 @@ internal static class BsonExpressionEvaluator
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static Func<BsonSpanReader, bool>? CreatePredicate(string propertyName, object? targetValue, ExpressionType op)
|
private static Func<BsonSpanReader, bool>? CreatePredicate(string propertyName, object? targetValue, ExpressionType op)
|
||||||
{
|
{
|
||||||
// We need to return a delegate that searches for propertyName in BsonSpanReader and compares
|
// We need to return a delegate that searches for propertyName in BsonSpanReader and compares
|
||||||
|
|
||||||
return reader =>
|
return reader =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
reader.ReadDocumentSize();
|
reader.ReadDocumentSize();
|
||||||
while (reader.Remaining > 0)
|
while (reader.Remaining > 0)
|
||||||
{
|
{
|
||||||
var type = reader.ReadBsonType();
|
var type = reader.ReadBsonType();
|
||||||
if (type == 0) break;
|
if (type == 0) break;
|
||||||
|
|
||||||
var name = reader.ReadElementHeader();
|
var name = reader.ReadElementHeader();
|
||||||
|
|
||||||
if (name == propertyName)
|
if (name == propertyName)
|
||||||
{
|
{
|
||||||
// Found it! Read value and compare
|
// Found -> read value and compare
|
||||||
return Compare(ref reader, type, targetValue, op);
|
return Compare(ref reader, type, targetValue, op);
|
||||||
}
|
}
|
||||||
|
|
||||||
reader.SkipValue(type);
|
reader.SkipValue(type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -91,10 +91,10 @@ internal static class BsonExpressionEvaluator
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool Compare(ref BsonSpanReader reader, BsonType type, object? target, ExpressionType op)
|
private static bool Compare(ref BsonSpanReader reader, BsonType type, object? target, ExpressionType op)
|
||||||
{
|
{
|
||||||
// This is complex because we need to handle types.
|
// This is complex because we need to handle types.
|
||||||
// For MVP, handle Int32, String, ObjectId
|
// For MVP, handle Int32, String, ObjectId
|
||||||
|
|
||||||
if (type == BsonType.Int32)
|
if (type == BsonType.Int32)
|
||||||
{
|
{
|
||||||
var val = reader.ReadInt32();
|
var val = reader.ReadInt32();
|
||||||
@@ -132,12 +132,12 @@ internal static class BsonExpressionEvaluator
|
|||||||
}
|
}
|
||||||
else if (type == BsonType.ObjectId && target is ObjectId targetId)
|
else if (type == BsonType.ObjectId && target is ObjectId targetId)
|
||||||
{
|
{
|
||||||
var val = reader.ReadObjectId();
|
var val = reader.ReadObjectId();
|
||||||
// ObjectId only supports Equal check easily unless we implement complex logic
|
// ObjectId only supports Equal check easily unless we implement complex logic
|
||||||
if (op == ExpressionType.Equal) return val.Equals(targetId);
|
if (op == ExpressionType.Equal) return val.Equals(targetId);
|
||||||
if (op == ExpressionType.NotEqual) return !val.Equals(targetId);
|
if (op == ExpressionType.NotEqual) return !val.Equals(targetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ using ZB.MOM.WW.CBDD.Core.Transactions;
|
|||||||
|
|
||||||
namespace ZB.MOM.WW.CBDD.Core.Storage;
|
namespace ZB.MOM.WW.CBDD.Core.Storage;
|
||||||
|
|
||||||
public sealed partial class StorageEngine
|
public sealed partial class StorageEngine
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the current size of the WAL file.
|
/// Gets the current size of the WAL file.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -29,151 +29,244 @@ public sealed partial class StorageEngine
|
|||||||
_wal.Flush();
|
_wal.Flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs a checkpoint: merges WAL into PageFile.
|
/// Performs a truncate checkpoint by default.
|
||||||
/// Uses in-memory WAL index for efficiency and consistency.
|
/// </summary>
|
||||||
/// </summary>
|
public void Checkpoint()
|
||||||
/// <summary>
|
|
||||||
/// Performs a checkpoint: merges WAL into PageFile.
|
|
||||||
/// Uses in-memory WAL index for efficiency and consistency.
|
|
||||||
/// </summary>
|
|
||||||
public void Checkpoint()
|
|
||||||
{
|
|
||||||
_commitLock.Wait();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
CheckpointInternal();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_commitLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CheckpointInternal()
|
|
||||||
{
|
{
|
||||||
if (_walIndex.IsEmpty)
|
_ = Checkpoint(CheckpointMode.Truncate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs a checkpoint using the requested mode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="mode">Checkpoint mode to execute.</param>
|
||||||
|
/// <returns>The checkpoint execution result.</returns>
|
||||||
|
public CheckpointResult Checkpoint(CheckpointMode mode)
|
||||||
|
{
|
||||||
|
bool lockAcquired;
|
||||||
|
if (mode == CheckpointMode.Passive)
|
||||||
{
|
{
|
||||||
// WAL may still contain begin/commit records for read-only transactions.
|
lockAcquired = _commitLock.Wait(0);
|
||||||
|
if (!lockAcquired)
|
||||||
|
{
|
||||||
|
var walSize = _wal.GetCurrentSize();
|
||||||
|
return new CheckpointResult(mode, false, 0, walSize, walSize, false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_commitLock.Wait();
|
||||||
|
lockAcquired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return CheckpointInternal(mode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (lockAcquired)
|
||||||
|
{
|
||||||
|
_commitLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CheckpointInternal()
|
||||||
|
=> _ = CheckpointInternal(CheckpointMode.Truncate);
|
||||||
|
|
||||||
|
private CheckpointResult CheckpointInternal(CheckpointMode mode)
|
||||||
|
{
|
||||||
|
var walBytesBefore = _wal.GetCurrentSize();
|
||||||
|
var appliedPages = 0;
|
||||||
|
var truncated = false;
|
||||||
|
var restarted = false;
|
||||||
|
|
||||||
|
// 1. Write all committed pages from index to PageFile.
|
||||||
|
foreach (var kvp in _walIndex)
|
||||||
|
{
|
||||||
|
_pageFile.WritePage(kvp.Key, kvp.Value);
|
||||||
|
appliedPages++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Flush PageFile to ensure durability.
|
||||||
|
if (appliedPages > 0)
|
||||||
|
{
|
||||||
|
_pageFile.Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Clear in-memory WAL index (now persisted).
|
||||||
|
_walIndex.Clear();
|
||||||
|
|
||||||
|
// 4. Apply mode-specific WAL handling.
|
||||||
|
switch (mode)
|
||||||
|
{
|
||||||
|
case CheckpointMode.Passive:
|
||||||
|
case CheckpointMode.Full:
|
||||||
|
if (walBytesBefore > 0 || appliedPages > 0)
|
||||||
|
{
|
||||||
|
_wal.WriteCheckpointRecord();
|
||||||
|
_wal.Flush();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CheckpointMode.Truncate:
|
||||||
|
if (walBytesBefore > 0)
|
||||||
|
{
|
||||||
|
_wal.Truncate();
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CheckpointMode.Restart:
|
||||||
|
_wal.Restart();
|
||||||
|
truncated = true;
|
||||||
|
restarted = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported checkpoint mode.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var walBytesAfter = _wal.GetCurrentSize();
|
||||||
|
return new CheckpointResult(mode, true, appliedPages, walBytesBefore, walBytesAfter, truncated, restarted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs a truncate checkpoint asynchronously by default.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">The cancellation token.</param>
|
||||||
|
public async Task CheckpointAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_ = await CheckpointAsync(CheckpointMode.Truncate, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs a checkpoint asynchronously using the requested mode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="mode">Checkpoint mode to execute.</param>
|
||||||
|
/// <param name="ct">The cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous checkpoint operation.</returns>
|
||||||
|
public async Task<CheckpointResult> CheckpointAsync(CheckpointMode mode, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
bool lockAcquired;
|
||||||
|
if (mode == CheckpointMode.Passive)
|
||||||
|
{
|
||||||
|
lockAcquired = await _commitLock.WaitAsync(0, ct);
|
||||||
|
if (!lockAcquired)
|
||||||
|
{
|
||||||
|
var walSize = _wal.GetCurrentSize();
|
||||||
|
return new CheckpointResult(mode, false, 0, walSize, walSize, false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _commitLock.WaitAsync(ct);
|
||||||
|
lockAcquired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Checkpoint work is synchronous over MMF/page writes for now.
|
||||||
|
return CheckpointInternal(mode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (lockAcquired)
|
||||||
|
{
|
||||||
|
_commitLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recovers from crash by replaying WAL.
|
||||||
|
/// Applies committed transactions to PageFile in deterministic WAL order, then truncates WAL.
|
||||||
|
/// </summary>
|
||||||
|
public void Recover()
|
||||||
|
{
|
||||||
|
_commitLock.Wait();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. Read WAL and locate the latest checkpoint boundary.
|
||||||
|
var records = _wal.ReadAll();
|
||||||
|
var startIndex = 0;
|
||||||
|
for (var i = records.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (records[i].Type == WalRecordType.Checkpoint)
|
||||||
|
{
|
||||||
|
startIndex = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Replay WAL in source order with deterministic commit application.
|
||||||
|
var pendingWrites = new Dictionary<ulong, List<(uint pageId, byte[] data)>>();
|
||||||
|
var appliedAny = false;
|
||||||
|
|
||||||
|
for (var i = startIndex; i < records.Count; i++)
|
||||||
|
{
|
||||||
|
var record = records[i];
|
||||||
|
switch (record.Type)
|
||||||
|
{
|
||||||
|
case WalRecordType.Begin:
|
||||||
|
if (!pendingWrites.ContainsKey(record.TransactionId))
|
||||||
|
{
|
||||||
|
pendingWrites[record.TransactionId] = new List<(uint, byte[])>();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case WalRecordType.Write:
|
||||||
|
if (record.AfterImage == null)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingWrites.TryGetValue(record.TransactionId, out var writes))
|
||||||
|
{
|
||||||
|
writes = new List<(uint, byte[])>();
|
||||||
|
pendingWrites[record.TransactionId] = writes;
|
||||||
|
}
|
||||||
|
|
||||||
|
writes.Add((record.PageId, record.AfterImage));
|
||||||
|
break;
|
||||||
|
case WalRecordType.Commit:
|
||||||
|
if (!pendingWrites.TryGetValue(record.TransactionId, out var committedWrites))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (pageId, data) in committedWrites)
|
||||||
|
{
|
||||||
|
_pageFile.WritePage(pageId, data);
|
||||||
|
appliedAny = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingWrites.Remove(record.TransactionId);
|
||||||
|
break;
|
||||||
|
case WalRecordType.Abort:
|
||||||
|
pendingWrites.Remove(record.TransactionId);
|
||||||
|
break;
|
||||||
|
case WalRecordType.Checkpoint:
|
||||||
|
pendingWrites.Clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Flush PageFile to ensure durability.
|
||||||
|
if (appliedAny)
|
||||||
|
{
|
||||||
|
_pageFile.Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Clear in-memory WAL index (redundant since we just recovered).
|
||||||
|
_walIndex.Clear();
|
||||||
|
|
||||||
|
// 5. Truncate WAL (all changes now in PageFile).
|
||||||
if (_wal.GetCurrentSize() > 0)
|
if (_wal.GetCurrentSize() > 0)
|
||||||
{
|
{
|
||||||
_wal.Truncate();
|
_wal.Truncate();
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
// 1. Write all committed pages from index to PageFile
|
|
||||||
foreach (var kvp in _walIndex)
|
|
||||||
{
|
|
||||||
_pageFile.WritePage(kvp.Key, kvp.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Flush PageFile to ensure durability
|
|
||||||
_pageFile.Flush();
|
|
||||||
|
|
||||||
// 3. Clear in-memory WAL index (now persisted)
|
|
||||||
_walIndex.Clear();
|
|
||||||
|
|
||||||
// 4. Truncate WAL (all changes now in PageFile)
|
|
||||||
_wal.Truncate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Performs a checkpoint asynchronously by merging WAL pages into the page file.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">The cancellation token.</param>
|
|
||||||
/// <returns>A task that represents the asynchronous checkpoint operation.</returns>
|
|
||||||
public async Task CheckpointAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await _commitLock.WaitAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
if (_walIndex.IsEmpty)
|
|
||||||
{
|
|
||||||
if (_wal.GetCurrentSize() > 0)
|
|
||||||
{
|
|
||||||
_wal.Truncate();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Write all committed pages from index to PageFile
|
|
||||||
// PageFile writes are sync (MMF), but that's fine as per plan (ValueTask strategy for MMF)
|
|
||||||
foreach (var kvp in _walIndex)
|
|
||||||
{
|
|
||||||
_pageFile.WritePage(kvp.Key, kvp.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Flush PageFile to ensure durability
|
|
||||||
_pageFile.Flush();
|
|
||||||
|
|
||||||
// 3. Clear in-memory WAL index (now persisted)
|
|
||||||
_walIndex.Clear();
|
|
||||||
|
|
||||||
// 4. Truncate WAL (all changes now in PageFile)
|
|
||||||
// WAL truncation involves file resize and flush
|
|
||||||
// TODO: Add TruncateAsync to WAL? For now Truncate is sync.
|
|
||||||
_wal.Truncate();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_commitLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recovers from crash by replaying WAL.
|
|
||||||
/// Applies all committed transactions to PageFile, then truncates WAL.
|
|
||||||
/// </summary>
|
|
||||||
public void Recover()
|
|
||||||
{
|
|
||||||
_commitLock.Wait();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 1. Read WAL and identify committed transactions
|
|
||||||
var records = _wal.ReadAll();
|
|
||||||
var committedTxns = new HashSet<ulong>();
|
|
||||||
var txnWrites = new Dictionary<ulong, List<(uint pageId, byte[] data)>>();
|
|
||||||
|
|
||||||
foreach (var record in records)
|
|
||||||
{
|
|
||||||
if (record.Type == WalRecordType.Commit)
|
|
||||||
committedTxns.Add(record.TransactionId);
|
|
||||||
else if (record.Type == WalRecordType.Write)
|
|
||||||
{
|
|
||||||
if (!txnWrites.ContainsKey(record.TransactionId))
|
|
||||||
txnWrites[record.TransactionId] = new List<(uint, byte[])>();
|
|
||||||
|
|
||||||
if (record.AfterImage != null)
|
|
||||||
{
|
|
||||||
txnWrites[record.TransactionId].Add((record.PageId, record.AfterImage));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Apply committed transactions to PageFile
|
|
||||||
foreach (var txnId in committedTxns)
|
|
||||||
{
|
|
||||||
if (!txnWrites.ContainsKey(txnId))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
foreach (var (pageId, data) in txnWrites[txnId])
|
|
||||||
{
|
|
||||||
_pageFile.WritePage(pageId, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Flush PageFile to ensure durability
|
|
||||||
_pageFile.Flush();
|
|
||||||
|
|
||||||
// 4. Clear in-memory WAL index (redundant since we just recovered)
|
|
||||||
_walIndex.Clear();
|
|
||||||
|
|
||||||
// 5. Truncate WAL (all changes now in PageFile)
|
|
||||||
_wal.Truncate();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_commitLock.Release();
|
_commitLock.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,52 @@
|
|||||||
namespace ZB.MOM.WW.CBDD.Core.Transactions;
|
namespace ZB.MOM.WW.CBDD.Core.Transactions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Defines checkpoint modes for WAL (Write-Ahead Log) checkpointing.
|
/// Defines checkpoint modes for WAL (Write-Ahead Log) checkpointing.
|
||||||
/// Similar to SQLite's checkpoint strategies.
|
/// Similar to SQLite's checkpoint strategies.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum CheckpointMode
|
public enum CheckpointMode
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Passive checkpoint: Non-blocking, best-effort transfer from WAL to database.
|
/// Passive checkpoint: non-blocking, best-effort transfer from WAL to database.
|
||||||
/// Does not wait for readers or writers. May not checkpoint all frames.
|
/// If the checkpoint lock is busy, the operation is skipped.
|
||||||
/// </summary>
|
/// WAL content is preserved and a checkpoint marker is appended when work is applied.
|
||||||
|
/// </summary>
|
||||||
Passive = 0,
|
Passive = 0,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Full checkpoint: Waits for concurrent readers/writers, then checkpoints all
|
/// Full checkpoint: waits for the checkpoint lock, transfers committed pages to
|
||||||
/// committed transactions from WAL to database. Blocks until complete.
|
/// the page file, and preserves WAL content by appending a checkpoint marker.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Full = 1,
|
Full = 1,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Truncate checkpoint: Same as Full, but also truncates the WAL file after
|
/// Truncate checkpoint: same as <see cref="Full"/> but truncates WAL after
|
||||||
/// successful checkpoint. Use this to reclaim disk space.
|
/// successfully applying committed pages. Use this to reclaim disk space.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Truncate = 2,
|
Truncate = 2,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Restart checkpoint: Truncates WAL and restarts with a new WAL file.
|
/// Restart checkpoint: same as <see cref="Truncate"/> and then reinitializes
|
||||||
/// Forces a fresh start. Most aggressive mode.
|
/// the WAL stream for a fresh writer session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Restart = 3
|
Restart = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a checkpoint execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Mode">Requested checkpoint mode.</param>
|
||||||
|
/// <param name="Executed">True when checkpoint logic ran; false when skipped (for passive mode contention).</param>
|
||||||
|
/// <param name="AppliedPages">Number of pages copied from WAL index to page file.</param>
|
||||||
|
/// <param name="WalBytesBefore">WAL size before the operation.</param>
|
||||||
|
/// <param name="WalBytesAfter">WAL size after the operation.</param>
|
||||||
|
/// <param name="Truncated">True when WAL was truncated by this operation.</param>
|
||||||
|
/// <param name="Restarted">True when WAL stream was restarted by this operation.</param>
|
||||||
|
public readonly record struct CheckpointResult(
|
||||||
|
CheckpointMode Mode,
|
||||||
|
bool Executed,
|
||||||
|
int AppliedPages,
|
||||||
|
long WalBytesBefore,
|
||||||
|
long WalBytesAfter,
|
||||||
|
bool Truncated,
|
||||||
|
bool Restarted);
|
||||||
|
|||||||
@@ -214,15 +214,70 @@ public sealed class WriteAheadLog : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WriteAbortRecordInternal(ulong transactionId)
|
private void WriteAbortRecordInternal(ulong transactionId)
|
||||||
{
|
{
|
||||||
Span<byte> buffer = stackalloc byte[17]; // type(1) + txnId(8) + timestamp(8)
|
Span<byte> buffer = stackalloc byte[17]; // type(1) + txnId(8) + timestamp(8)
|
||||||
buffer[0] = (byte)WalRecordType.Abort;
|
buffer[0] = (byte)WalRecordType.Abort;
|
||||||
BitConverter.TryWriteBytes(buffer[1..9], transactionId);
|
BitConverter.TryWriteBytes(buffer[1..9], transactionId);
|
||||||
BitConverter.TryWriteBytes(buffer[9..17], DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
BitConverter.TryWriteBytes(buffer[9..17], DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||||
|
|
||||||
_walStream!.Write(buffer);
|
_walStream!.Write(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a checkpoint marker record.
|
||||||
|
/// </summary>
|
||||||
|
public void WriteCheckpointRecord()
|
||||||
|
{
|
||||||
|
_lock.Wait();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WriteCheckpointRecordInternal();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a checkpoint marker record asynchronously.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">The cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous write operation.</returns>
|
||||||
|
public async ValueTask WriteCheckpointRecordAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _lock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var buffer = System.Buffers.ArrayPool<byte>.Shared.Rent(17);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
buffer[0] = (byte)WalRecordType.Checkpoint;
|
||||||
|
BitConverter.TryWriteBytes(buffer.AsSpan(1, 8), 0UL);
|
||||||
|
BitConverter.TryWriteBytes(buffer.AsSpan(9, 8), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||||
|
await _walStream!.WriteAsync(new ReadOnlyMemory<byte>(buffer, 0, 17), ct);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
System.Buffers.ArrayPool<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteCheckpointRecordInternal()
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[17]; // type(1) + reserved(8) + timestamp(8)
|
||||||
|
buffer[0] = (byte)WalRecordType.Checkpoint;
|
||||||
|
BitConverter.TryWriteBytes(buffer[1..9], 0UL);
|
||||||
|
BitConverter.TryWriteBytes(buffer[9..17], DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||||
|
_walStream!.Write(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -381,10 +436,10 @@ public sealed class WriteAheadLog : IDisposable
|
|||||||
/// Truncates the WAL file (removes all content).
|
/// Truncates the WAL file (removes all content).
|
||||||
/// Should only be called after successful checkpoint.
|
/// Should only be called after successful checkpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Truncate()
|
public void Truncate()
|
||||||
{
|
{
|
||||||
_lock.Wait();
|
_lock.Wait();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_walStream != null)
|
if (_walStream != null)
|
||||||
{
|
{
|
||||||
@@ -395,9 +450,31 @@ public sealed class WriteAheadLog : IDisposable
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_lock.Release();
|
_lock.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Truncates and reopens the WAL stream to start a fresh writer session.
|
||||||
|
/// </summary>
|
||||||
|
public void Restart()
|
||||||
|
{
|
||||||
|
_lock.Wait();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_walStream?.Dispose();
|
||||||
|
_walStream = new FileStream(
|
||||||
|
_walPath,
|
||||||
|
FileMode.Create,
|
||||||
|
FileAccess.ReadWrite,
|
||||||
|
FileShare.None,
|
||||||
|
bufferSize: 64 * 1024);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -437,9 +514,10 @@ public sealed class WriteAheadLog : IDisposable
|
|||||||
|
|
||||||
switch (type)
|
switch (type)
|
||||||
{
|
{
|
||||||
case WalRecordType.Begin:
|
case WalRecordType.Begin:
|
||||||
case WalRecordType.Commit:
|
case WalRecordType.Commit:
|
||||||
case WalRecordType.Abort:
|
case WalRecordType.Abort:
|
||||||
|
case WalRecordType.Checkpoint:
|
||||||
// Read common fields (txnId + timestamp = 16 bytes)
|
// Read common fields (txnId + timestamp = 16 bytes)
|
||||||
var bytesRead = _walStream.Read(headerBuf);
|
var bytesRead = _walStream.Read(headerBuf);
|
||||||
if (bytesRead < 16)
|
if (bytesRead < 16)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netstandard2.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<AssemblyName>ZB.MOM.WW.CBDD.SourceGenerators</AssemblyName>
|
<AssemblyName>ZB.MOM.WW.CBDD.SourceGenerators</AssemblyName>
|
||||||
<RootNamespace>ZB.MOM.WW.CBDD.SourceGenerators</RootNamespace>
|
<RootNamespace>ZB.MOM.WW.CBDD.SourceGenerators</RootNamespace>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||||
<IsRoslynComponent>true</IsRoslynComponent>
|
<IsRoslynComponent>true</IsRoslynComponent>
|
||||||
<PackageId>ZB.MOM.WW.CBDD.SourceGenerators</PackageId>
|
<PackageId>ZB.MOM.WW.CBDD.SourceGenerators</PackageId>
|
||||||
<Version>1.3.1</Version>
|
<Version>1.3.1</Version>
|
||||||
<Authors>CBDD Team</Authors>
|
<Authors>CBDD Team</Authors>
|
||||||
<Description>Source Generators for CBDD High-Performance BSON Database Engine</Description>
|
<Description>Source Generators for CBDD High-Performance BSON Database Engine</Description>
|
||||||
@@ -29,12 +29,10 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
|
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
|
||||||
|
|
||||||
<None Include="_._" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ using ZB.MOM.WW.CBDD.Bson;
|
|||||||
using ZB.MOM.WW.CBDD.Core;
|
using ZB.MOM.WW.CBDD.Core;
|
||||||
using ZB.MOM.WW.CBDD.Core.Collections;
|
using ZB.MOM.WW.CBDD.Core.Collections;
|
||||||
using ZB.MOM.WW.CBDD.Core.Compression;
|
using ZB.MOM.WW.CBDD.Core.Compression;
|
||||||
using ZB.MOM.WW.CBDD.Core.Indexing;
|
using ZB.MOM.WW.CBDD.Core.Indexing;
|
||||||
using ZB.MOM.WW.CBDD.Core.Metadata;
|
using ZB.MOM.WW.CBDD.Core.Metadata;
|
||||||
using ZB.MOM.WW.CBDD.Core.Storage;
|
using ZB.MOM.WW.CBDD.Core.Storage;
|
||||||
|
using ZB.MOM.WW.CBDD.Core.Transactions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.CBDD.Shared;
|
namespace ZB.MOM.WW.CBDD.Shared;
|
||||||
|
|
||||||
@@ -235,16 +236,25 @@ public partial class TestDbContext : DocumentDbContext
|
|||||||
modelBuilder.Entity<TemporalEntity>().ToCollection("temporal_entities").HasKey(e => e.Id);
|
modelBuilder.Entity<TemporalEntity>().ToCollection("temporal_entities").HasKey(e => e.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executes ForceCheckpoint.
|
/// Executes ForceCheckpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ForceCheckpoint()
|
public void ForceCheckpoint()
|
||||||
{
|
{
|
||||||
Engine.Checkpoint();
|
Engine.Checkpoint();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the Storage.
|
/// Executes ForceCheckpoint with the requested checkpoint mode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="mode">Checkpoint mode to execute.</param>
|
||||||
|
public CheckpointResult ForceCheckpoint(CheckpointMode mode)
|
||||||
|
{
|
||||||
|
return Engine.Checkpoint(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Storage.
|
||||||
|
/// </summary>
|
||||||
public StorageEngine Storage => Engine;
|
public StorageEngine Storage => Engine;
|
||||||
}
|
}
|
||||||
228
tests/CBDD.Tests/Storage/CheckpointModeTests.cs
Normal file
228
tests/CBDD.Tests/Storage/CheckpointModeTests.cs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using ZB.MOM.WW.CBDD.Bson;
|
||||||
|
using ZB.MOM.WW.CBDD.Core.Storage;
|
||||||
|
using ZB.MOM.WW.CBDD.Core.Transactions;
|
||||||
|
using ZB.MOM.WW.CBDD.Shared;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.CBDD.Tests;
|
||||||
|
|
||||||
|
public class CheckpointModeTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies default checkpoint mode truncates WAL.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Checkpoint_Default_ShouldUseTruncate()
|
||||||
|
{
|
||||||
|
var dbPath = NewDbPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var db = new TestDbContext(dbPath);
|
||||||
|
db.Users.Insert(new User { Name = "checkpoint-default", Age = 42 });
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
db.Storage.GetWalSize().ShouldBeGreaterThan(0);
|
||||||
|
var result = db.Checkpoint();
|
||||||
|
|
||||||
|
result.Mode.ShouldBe(CheckpointMode.Truncate);
|
||||||
|
result.Executed.ShouldBeTrue();
|
||||||
|
result.Truncated.ShouldBeTrue();
|
||||||
|
db.Storage.GetWalSize().ShouldBe(0);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CleanupFiles(dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies passive mode skips when checkpoint lock is contended.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Checkpoint_Passive_ShouldSkip_WhenLockIsContended()
|
||||||
|
{
|
||||||
|
var dbPath = NewDbPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var storage = new StorageEngine(dbPath, PageFileConfig.Default);
|
||||||
|
var gate = GetCommitGate(storage);
|
||||||
|
gate.Wait(TestContext.Current.CancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = storage.Checkpoint(CheckpointMode.Passive);
|
||||||
|
result.Mode.ShouldBe(CheckpointMode.Passive);
|
||||||
|
result.Executed.ShouldBeFalse();
|
||||||
|
result.Truncated.ShouldBeFalse();
|
||||||
|
result.Restarted.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CleanupFiles(dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies full checkpoint applies data and appends a checkpoint marker without truncating WAL.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Checkpoint_Full_ShouldAppendMarker_AndPreserveWal()
|
||||||
|
{
|
||||||
|
var dbPath = NewDbPath();
|
||||||
|
var walPath = Path.ChangeExtension(dbPath, ".wal");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var db = new TestDbContext(dbPath))
|
||||||
|
{
|
||||||
|
db.Users.Insert(new User { Name = "checkpoint-full", Age = 50 });
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
var walBefore = db.Storage.GetWalSize();
|
||||||
|
walBefore.ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
|
var result = db.Checkpoint(CheckpointMode.Full);
|
||||||
|
result.Mode.ShouldBe(CheckpointMode.Full);
|
||||||
|
result.Executed.ShouldBeTrue();
|
||||||
|
result.Truncated.ShouldBeFalse();
|
||||||
|
result.WalBytesAfter.ShouldBeGreaterThan(0);
|
||||||
|
db.Storage.GetWalSize().ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var wal = new WriteAheadLog(walPath);
|
||||||
|
wal.ReadAll().Any(r => r.Type == WalRecordType.Checkpoint).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CleanupFiles(dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies restart checkpoint clears WAL and allows subsequent writes.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Checkpoint_Restart_ShouldResetWal_AndAcceptNewWrites()
|
||||||
|
{
|
||||||
|
var dbPath = NewDbPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var db = new TestDbContext(dbPath);
|
||||||
|
db.Users.Insert(new User { Name = "restart-before", Age = 30 });
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
db.Storage.GetWalSize().ShouldBeGreaterThan(0);
|
||||||
|
var result = db.Checkpoint(CheckpointMode.Restart);
|
||||||
|
result.Mode.ShouldBe(CheckpointMode.Restart);
|
||||||
|
result.Executed.ShouldBeTrue();
|
||||||
|
result.Truncated.ShouldBeTrue();
|
||||||
|
result.Restarted.ShouldBeTrue();
|
||||||
|
db.Storage.GetWalSize().ShouldBe(0);
|
||||||
|
|
||||||
|
db.Users.Insert(new User { Name = "restart-after", Age = 31 });
|
||||||
|
db.SaveChanges();
|
||||||
|
db.Storage.GetWalSize().ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CleanupFiles(dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies recovery remains deterministic after a full checkpoint boundary.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Recover_AfterFullCheckpoint_ShouldApplyLatestCommitDeterministically()
|
||||||
|
{
|
||||||
|
var dbPath = NewDbPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
uint pageId;
|
||||||
|
|
||||||
|
using (var storage = new StorageEngine(dbPath, PageFileConfig.Default))
|
||||||
|
{
|
||||||
|
pageId = storage.AllocatePage();
|
||||||
|
|
||||||
|
using (var tx1 = storage.BeginTransaction())
|
||||||
|
{
|
||||||
|
var first = new byte[storage.PageSize];
|
||||||
|
first[0] = 1;
|
||||||
|
storage.WritePage(pageId, tx1.TransactionId, first);
|
||||||
|
tx1.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.Checkpoint(CheckpointMode.Full);
|
||||||
|
|
||||||
|
using (var tx2 = storage.BeginTransaction())
|
||||||
|
{
|
||||||
|
var second = new byte[storage.PageSize];
|
||||||
|
second[0] = 2;
|
||||||
|
storage.WritePage(pageId, tx2.TransactionId, second);
|
||||||
|
tx2.Commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var recovered = new StorageEngine(dbPath, PageFileConfig.Default))
|
||||||
|
{
|
||||||
|
var buffer = new byte[recovered.PageSize];
|
||||||
|
recovered.ReadPage(pageId, 0, buffer);
|
||||||
|
buffer[0].ShouldBe((byte)2);
|
||||||
|
recovered.GetWalSize().ShouldBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CleanupFiles(dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies asynchronous mode-based checkpoints return expected result metadata.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckpointAsync_Full_ShouldReturnResult()
|
||||||
|
{
|
||||||
|
var dbPath = NewDbPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var db = new TestDbContext(dbPath);
|
||||||
|
db.Users.Insert(new User { Name = "checkpoint-async", Age = 38 });
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
var result = await db.CheckpointAsync(CheckpointMode.Full, TestContext.Current.CancellationToken);
|
||||||
|
result.Mode.ShouldBe(CheckpointMode.Full);
|
||||||
|
result.Executed.ShouldBeTrue();
|
||||||
|
result.Truncated.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CleanupFiles(dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SemaphoreSlim GetCommitGate(StorageEngine storage)
|
||||||
|
{
|
||||||
|
var field = typeof(StorageEngine).GetField("_commitLock", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
field.ShouldNotBeNull();
|
||||||
|
return (SemaphoreSlim)field!.GetValue(storage)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NewDbPath()
|
||||||
|
=> Path.Combine(Path.GetTempPath(), $"checkpoint_mode_{Guid.NewGuid():N}.db");
|
||||||
|
|
||||||
|
private static void CleanupFiles(string dbPath)
|
||||||
|
{
|
||||||
|
if (File.Exists(dbPath)) File.Delete(dbPath);
|
||||||
|
|
||||||
|
var walPath = Path.ChangeExtension(dbPath, ".wal");
|
||||||
|
if (File.Exists(walPath)) File.Delete(walPath);
|
||||||
|
|
||||||
|
var markerPath = $"{dbPath}.compact.state";
|
||||||
|
if (File.Exists(markerPath)) File.Delete(markerPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user