Files
duplicati/Duplicati/UnitTest/DatabaseLockTrackerTests.cs
T
Kenneth Skovhede f690297d1b Server database lock
This PR adds a new locking service that keeps track of which local databases are currently in use by the server. If a database is in use due to a running task, immediate calls will now be rejected.

Prior to this change, immediate calls could interfere with the running task, causing a backup to fail abruptly if the user attempted to check the logs or restore.
2026-07-11 12:11:30 +02:00

226 lines
8.2 KiB
C#

// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.WebserverCore.Services;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
namespace Duplicati.UnitTest
{
/// <summary>
/// Tests for <see cref="DatabaseLockTracker"/>, which coordinates access to local
/// backup database files between queued and immediate operations.
/// </summary>
[TestFixture]
public class DatabaseLockTrackerTests
{
/// <summary>
/// A simple value-returning async disposable used in tests.
/// </summary>
private static async Task DisposeAsync(IAsyncDisposable disposable)
=> await disposable.DisposeAsync().ConfigureAwait(false);
[Test]
public async Task TryAcquireReturnsLockWhenFree()
{
var tracker = new DatabaseLockTracker();
var handle = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(handle, "TryAcquire should succeed when the database is not locked");
await DisposeAsync(handle!);
}
[Test]
public async Task TryAcquireReturnsNullWhenAlreadyLocked()
{
var tracker = new DatabaseLockTracker();
var first = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(first, "First TryAcquire should succeed");
var second = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNull(second, "Second TryAcquire should fail when the database is locked");
await DisposeAsync(first!);
}
[Test]
public async Task AcquireAsyncWaitsForExistingLock()
{
var tracker = new DatabaseLockTracker();
var first = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(first);
var acquireTask = tracker.AcquireAsync("/tmp/test.sqlite", CancellationToken.None);
Assert.IsFalse(acquireTask.IsCompleted, "AcquireAsync should be waiting for the lock");
await DisposeAsync(first!);
var second = await acquireTask;
Assert.IsNotNull(second, "AcquireAsync should complete after the lock is released");
await DisposeAsync(second);
}
[Test]
public async Task ReleaseAllowsSubsequentAcquire()
{
var tracker = new DatabaseLockTracker();
var first = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(first);
await DisposeAsync(first!);
var second = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(second, "TryAcquire should succeed after the lock is released");
await DisposeAsync(second!);
}
[Test]
public async Task DifferentPathsAreIndependent()
{
var tracker = new DatabaseLockTracker();
var lockA = tracker.TryAcquire("/tmp/a.sqlite");
Assert.IsNotNull(lockA);
var lockB = tracker.TryAcquire("/tmp/b.sqlite");
Assert.IsNotNull(lockB, "TryAcquire on a different path should succeed independently");
await DisposeAsync(lockA!);
await DisposeAsync(lockB!);
}
[Test]
public async Task AcquireAsyncCancelsWhenTokenFires()
{
var tracker = new DatabaseLockTracker();
var first = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(first);
using var cts = new CancellationTokenSource();
var acquireTask = tracker.AcquireAsync("/tmp/test.sqlite", cts.Token);
Assert.IsFalse(acquireTask.IsCompleted);
cts.Cancel();
Assert.ThrowsAsync<System.OperationCanceledException>(async () => await acquireTask);
await DisposeAsync(first!);
}
[Test]
public async Task DoubleDisposeIsSafe()
{
var tracker = new DatabaseLockTracker();
var handle = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(handle);
await DisposeAsync(handle!);
await DisposeAsync(handle!);
var next = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(next, "After double-dispose, the lock should be releasable exactly once");
await DisposeAsync(next!);
}
[Test]
public async Task TryAcquireOnLockedReleasesCorrectly()
{
var tracker = new DatabaseLockTracker();
var first = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(first);
var second = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNull(second);
await DisposeAsync(first!);
var third = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(third, "After releasing the first lock, TryAcquire should succeed");
await DisposeAsync(third!);
}
[Test]
public async Task RelativeAndAbsolutePathMapToSameLock()
{
var tracker = new DatabaseLockTracker();
var absolute = tracker.TryAcquire(System.IO.Path.GetFullPath("/tmp/test.sqlite"));
Assert.IsNotNull(absolute);
var relative = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNull(relative, "Path normalization should map relative and absolute paths to the same lock");
await DisposeAsync(absolute!);
}
[Test]
public async Task AcquireAsyncPreservesOrder()
{
var tracker = new DatabaseLockTracker();
var first = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(first);
var order = new System.Collections.Generic.List<int>();
var t2 = Task.Run(async () =>
{
await using var h = await tracker.AcquireAsync("/tmp/test.sqlite", CancellationToken.None);
order.Add(2);
});
var t3 = Task.Run(async () =>
{
await using var h = await tracker.AcquireAsync("/tmp/test.sqlite", CancellationToken.None);
order.Add(3);
});
await Task.Delay(50);
order.Add(1);
await DisposeAsync(first!);
await Task.WhenAll(t2, t3);
Assert.AreEqual(1, order[0], "First lock holder should complete first");
}
[Test]
public void TryAcquireEmptyPathThrows()
{
var tracker = new DatabaseLockTracker();
Assert.Throws<ArgumentException>(() => tracker.TryAcquire(""));
}
[Test]
public void TryAcquireWhitespacePathThrows()
{
var tracker = new DatabaseLockTracker();
Assert.Throws<ArgumentException>(() => tracker.TryAcquire(" "));
}
[Test]
public void AcquireAsyncEmptyPathThrows()
{
var tracker = new DatabaseLockTracker();
Assert.ThrowsAsync<ArgumentException>(async () => await tracker.AcquireAsync("", CancellationToken.None));
}
}
}