Skip to content
Closed
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
45 changes: 42 additions & 3 deletions server/RdtClient.Data/Data/DownloadData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,19 +130,58 @@ public async Task UpdateFileName(Guid downloadId, String fileName)
await dataContext.SaveChangesAsync();
}

public async Task UpdatePath(Guid downloadId, String path)
public async Task<Boolean> UpdatePath(Guid downloadId, String path)
{
var dbDownload = await dataContext.Downloads
.FirstOrDefaultAsync(m => m.DownloadId == downloadId);

if (dbDownload == null)
{
return;
return false;
}

if (String.Equals(dbDownload.Path, path, StringComparison.Ordinal))
{
return true;
}

var pathAlreadyTaken = await dataContext.Downloads
.AsNoTracking()
.AnyAsync(m => m.TorrentId == dbDownload.TorrentId
&& m.DownloadId != downloadId
&& m.Path == path);

if (pathAlreadyTaken)
{
logger?.LogWarning("Skipped updating path because another download of the same torrent already uses it. DownloadId: {downloadId}, TorrentId: {torrentId}, Path: {path}",
downloadId,
dbDownload.TorrentId,
path);

return false;
}

dbDownload.Path = path;

await dataContext.SaveChangesAsync();
try
{
await dataContext.SaveChangesAsync();
}
catch (DbUpdateException ex)
{
dataContext.Entry(dbDownload).State = EntityState.Detached;

if (IsDuplicateDownloadViolation(ex))
{
logger?.LogWarning("Skipped updating path after a concurrent duplicate path write. DownloadId: {downloadId}, Path: {path}", downloadId, path);

return false;
}

throw;
}

return true;
}

public async Task UpdateDownloadStarted(Guid downloadId, DateTimeOffset? dateTime)
Expand Down
205 changes: 205 additions & 0 deletions server/RdtClient.Service.Test/Regression/DownloadPathCollisionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using RdtClient.Data.Data;
using RdtClient.Data.Models.Data;

namespace RdtClient.Service.Test.Regression;

public class DownloadPathCollisionTests : IAsyncLifetime
{
private readonly String _databasePath = Path.Combine(Path.GetTempPath(), $"rdt-client-path-collision-{Guid.NewGuid():N}.sqlite");

[Fact]
public async Task UpdatePath_WhenSiblingAlreadyUsesPath_SkipsWriteAndKeepsOriginalPath()
{
var torrentId = await SeedTorrentWithDownloadsAsync();

await using var context = CreateContext();

var downloadData = new DownloadData(context);

var downloadB = await context.Downloads.AsNoTracking().FirstAsync(m => m.TorrentId == torrentId && m.Path == "https://example.invalid/b");

var updated = await downloadData.UpdatePath(downloadB.DownloadId, "https://example.invalid/a");

Assert.False(updated);

await using var verifyContext = CreateContext();

var reloaded = await verifyContext.Downloads.AsNoTracking().FirstAsync(m => m.DownloadId == downloadB.DownloadId);

Assert.Equal("https://example.invalid/b", reloaded.Path);
}

[Fact]
public async Task UpdatePath_WhenSiblingUsesPath_DoesNotPoisonTheChangeTracker()
{
var torrentId = await SeedTorrentWithDownloadsAsync();

await using var context = CreateContext();

var downloadData = new DownloadData(context);

var downloads = await context.Downloads.AsNoTracking()
.Where(m => m.TorrentId == torrentId)
.OrderBy(m => m.Path)
.ToListAsync();

var updated = await downloadData.UpdatePath(downloads[1].DownloadId, "https://example.invalid/a");

Assert.False(updated);

var completedAt = DateTimeOffset.UtcNow;

Exception? exception;

try
{
await downloadData.UpdateCompleted(downloads[1].DownloadId, completedAt);

exception = null;
}
catch (Exception ex)
{
exception = ex;
}

Assert.Null(exception);

await using var verifyContext = CreateContext();

var reloaded = await verifyContext.Downloads.AsNoTracking().FirstAsync(m => m.DownloadId == downloads[1].DownloadId);

Assert.Equal("https://example.invalid/b", reloaded.Path);
Assert.NotNull(reloaded.Completed);
}

[Fact]
public async Task UpdatePath_WhenPathIsFree_UpdatesAndReturnsTrue()
{
var torrentId = await SeedTorrentWithDownloadsAsync();

await using var context = CreateContext();

var downloadData = new DownloadData(context);

var downloadB = await context.Downloads.AsNoTracking().FirstAsync(m => m.TorrentId == torrentId && m.Path == "https://example.invalid/b");

var updated = await downloadData.UpdatePath(downloadB.DownloadId, "https://example.invalid/refreshed-b");

Assert.True(updated);

await using var verifyContext = CreateContext();

var reloaded = await verifyContext.Downloads.AsNoTracking().FirstAsync(m => m.DownloadId == downloadB.DownloadId);

Assert.Equal("https://example.invalid/refreshed-b", reloaded.Path);
}

[Fact]
public async Task UpdatePath_WhenPathIsUnchanged_ReturnsTrueWithoutWriting()
{
var torrentId = await SeedTorrentWithDownloadsAsync();

await using var context = CreateContext();

var downloadData = new DownloadData(context);

var downloadA = await context.Downloads.AsNoTracking().FirstAsync(m => m.TorrentId == torrentId && m.Path == "https://example.invalid/a");

var updated = await downloadData.UpdatePath(downloadA.DownloadId, "https://example.invalid/a");

Assert.True(updated);
}

[Fact]
public async Task SeedSchema_EnforcesUniqueTorrentIdAndPathIndex()
{
var torrentId = await SeedTorrentWithDownloadsAsync();

await using var context = CreateContext();

var downloadB = await context.Downloads.AsNoTracking().FirstAsync(m => m.TorrentId == torrentId && m.Path == "https://example.invalid/b");

context.Downloads.Add(new Download
{
DownloadId = Guid.NewGuid(),
TorrentId = downloadB.TorrentId,
Path = downloadB.Path,
Added = DateTimeOffset.UtcNow
});

await Assert.ThrowsAsync<DbUpdateException>(() => context.SaveChangesAsync());
}

public async Task InitializeAsync()
{
await using var context = CreateContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();
}

public Task DisposeAsync()
{
SqliteConnection.ClearAllPools();

if (File.Exists(_databasePath))
{
File.Delete(_databasePath);
}

return Task.CompletedTask;
}

private DataContext CreateContext()
{
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = _databasePath,
ForeignKeys = true
}.ToString();

var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(connectionString)
.Options;

return new(options);
}

private async Task<Guid> SeedTorrentWithDownloadsAsync()
{
var torrentId = Guid.NewGuid();

await using var context = CreateContext();

context.Torrents.Add(new()
{
TorrentId = torrentId,
Hash = Guid.NewGuid().ToString("N"),
Added = DateTimeOffset.UtcNow
});

context.Downloads.Add(new()
{
DownloadId = Guid.NewGuid(),
TorrentId = torrentId,
FileName = "download-a.bin",
Path = "https://example.invalid/a",
Added = DateTimeOffset.UtcNow,
Completed = DateTimeOffset.UtcNow
});

context.Downloads.Add(new()
{
DownloadId = Guid.NewGuid(),
TorrentId = torrentId,
FileName = "download-b.bin",
Path = "https://example.invalid/b",
Added = DateTimeOffset.UtcNow
});

await context.SaveChangesAsync();

return torrentId;
}
}
27 changes: 26 additions & 1 deletion server/RdtClient.Service/BackgroundServices/TaskRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
await startupRunner.Initialize();
}

var consecutiveFailures = 0;

while (!stoppingToken.IsCancellationRequested)
{
using var scope = serviceProvider.CreateScope();
Expand All @@ -32,6 +34,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
try
{
await torrentRunner.Tick();

consecutiveFailures = 0;
}
catch (DbUpdateConcurrencyException ex)
{
Expand All @@ -56,12 +60,33 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
catch (Exception ex)
{
consecutiveFailures++;

logger.LogError(ex, $"Unexpected error occurred in TaskRunner: {ex.Message}");
}

await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
var delay = GetTickDelay(consecutiveFailures);

if (delay > TimeSpan.FromSeconds(1))
{
logger.LogWarning("TorrentRunner tick failed {consecutiveFailures} times in a row, backing off for {delay}", consecutiveFailures, delay);
}

await Task.Delay(delay, stoppingToken);
}

logger.LogInformation("TaskRunner stopped.");
}

private static TimeSpan GetTickDelay(Int32 consecutiveFailures)
{
if (consecutiveFailures <= 1)
{
return TimeSpan.FromSeconds(1);
}

var seconds = Math.Min(300d, 5d * Math.Pow(2, Math.Min(consecutiveFailures - 2, 6)));

return TimeSpan.FromSeconds(seconds);
}
}
4 changes: 2 additions & 2 deletions server/RdtClient.Service/Services/Downloads.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ public async Task UpdateUnrestrictedLink(Guid downloadId, String unrestrictedLin
await downloadData.UpdateUnrestrictedLink(downloadId, unrestrictedLink);
}

public async Task UpdatePath(Guid downloadId, String path)
public async Task<Boolean> UpdatePath(Guid downloadId, String path)
{
await downloadData.UpdatePath(downloadId, path);
return await downloadData.UpdatePath(downloadId, path);
}

public async Task UpdateFileName(Guid downloadId, String fileName)
Expand Down
2 changes: 1 addition & 1 deletion server/RdtClient.Service/Services/IDownloads.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public interface IDownloads
Task<Download?> Get(Guid torrentId, String path);
Task<DownloadAddResult> TryAddForTorrent(Guid torrentId, DownloadInfo downloadInfo);
Task UpdateUnrestrictedLink(Guid downloadId, String unrestrictedLink);
Task UpdatePath(Guid downloadId, String path);
Task<Boolean> UpdatePath(Guid downloadId, String path);
Task UpdateFileName(Guid downloadId, String fileName);
Task UpdateDownloadStarted(Guid downloadId, DateTimeOffset? dateTime);
Task UpdateDownloadFinished(Guid downloadId, DateTimeOffset? dateTime);
Expand Down
Loading