diff --git a/DeepDrftContent/FileDatabase/Services/FileDatabase.cs b/DeepDrftContent/FileDatabase/Services/FileDatabase.cs
index 0294734..25e54d3 100644
--- a/DeepDrftContent/FileDatabase/Services/FileDatabase.cs
+++ b/DeepDrftContent/FileDatabase/Services/FileDatabase.cs
@@ -206,9 +206,13 @@ public class FileDatabase : DirectoryIndexDirectory, IDisposable
}
catch (Exception ex)
{
- // Swallow and return false, matching RegisterResourceAsync. Logged (unlike the buffered
- // path) because a streamed write failure can leave a partial backing file worth noticing.
- _logger.LogError(ex, "RegisterResourceStreamingAsync failed for vault {VaultId} entry {EntryId}", vaultId, entryId);
+ // Swallow and return false, matching RegisterResourceAsync. Log at error for real failures
+ // only — a normal client cancel (OperationCanceledException) is not an error condition and
+ // would spam the error log on every client disconnect during a large upload or replace.
+ if (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "RegisterResourceStreamingAsync failed for vault {VaultId} entry {EntryId}", vaultId, entryId);
+ }
}
return false;
diff --git a/DeepDrftContent/FileDatabase/Services/MediaVault.cs b/DeepDrftContent/FileDatabase/Services/MediaVault.cs
index 8e88b2c..a7dcf9d 100644
--- a/DeepDrftContent/FileDatabase/Services/MediaVault.cs
+++ b/DeepDrftContent/FileDatabase/Services/MediaVault.cs
@@ -57,16 +57,18 @@ public abstract class MediaVault : VaultIndexDirectory
}
///
- /// Streams an entry's bytes into the vault without ever materializing the whole file in memory:
- /// records the supplied in the index, then invokes
- /// to emit bytes directly to the backing .
+ /// Streams an entry's bytes into the vault without ever materializing the whole file in memory.
/// The metadata is supplied by the caller (there is no in-memory to infer
/// it from) — the store path (upload / replace-audio) sources its bytes from a staging file, not a
/// buffer. Returns the number of bytes written, for the caller to log.
///
- /// Index-then-file ordering matches ; a mid-write failure therefore
- /// leaves an index entry over a partial/missing file, the same exposure the buffered path has on
- /// an I/O fault. The caller treats a thrown exception as a failed register.
+ /// Write ordering (atomic-replace guarantee): bytes are streamed to a temp file in the same vault
+ /// directory, the temp file is renamed over the final backing-file path (POSIX rename(2) —
+ /// atomic on the Linux prod host), and the index is updated only after the rename succeeds.
+ /// This ordering ensures: (a) the index never advertises a not-yet-present file; (b) a client
+ /// disconnect or I/O fault during the write leaves any prior backing file intact and the index
+ /// unchanged; (c) the temp file is cleaned up best-effort on any failure before re-throwing so the
+ /// vault directory stays tidy. The caller treats a thrown exception as a failed register.
///
public async Task AddEntryStreamingAsync(
string entryId,
@@ -74,17 +76,41 @@ public abstract class MediaVault : VaultIndexDirectory
Func writeContent,
CancellationToken cancellationToken = default)
{
- var mediaPath = GetMediaPathFromEntryKey(entryId, metaData.Extension);
+ var finalPath = GetMediaPathFromEntryKey(entryId, metaData.Extension);
+ var tempPath = Path.Combine(RootPath, Path.GetRandomFileName() + ".tmp");
- await AddToIndexAsync(entryId, metaData);
+ try
+ {
+ long bytesWritten;
+ await using (var tempStream = new FileStream(
+ tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
+ bufferSize: 81920, useAsync: true))
+ {
+ await writeContent(tempStream, cancellationToken);
+ await tempStream.FlushAsync(cancellationToken);
+ bytesWritten = tempStream.Length;
+ }
- await using var fileStream = new FileStream(
- mediaPath, FileMode.Create, FileAccess.Write, FileShare.None,
- bufferSize: 81920, useAsync: true);
- await writeContent(fileStream, cancellationToken);
- await fileStream.FlushAsync(cancellationToken);
+ // Rename into place — atomic on the Linux prod host (POSIX rename(2)); overwrites any
+ // existing same-extension backing file safely on the replace path.
+ File.Move(tempPath, finalPath, overwrite: true);
- return fileStream.Length;
+ // Update the index only after the file is durably in place. A crash between Move and
+ // AddToIndexAsync leaves an unreferenced file on disk (a harmless orphan recoverable
+ // by a vault scan); a crash or cancel during the temp write leaves the original backing
+ // file and the index both unchanged.
+ await AddToIndexAsync(entryId, metaData);
+
+ return bytesWritten;
+ }
+ catch
+ {
+ // Best-effort temp-file cleanup. After a successful rename tempPath is gone and the
+ // delete is a no-op. After a write failure or cancel tempPath holds partial bytes that
+ // must be removed so the vault directory stays tidy.
+ try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { /* best-effort */ }
+ throw;
+ }
}
///
diff --git a/DeepDrftTests/AudioStoreStreamingTests.cs b/DeepDrftTests/AudioStoreStreamingTests.cs
index 765968e..01d8a16 100644
--- a/DeepDrftTests/AudioStoreStreamingTests.cs
+++ b/DeepDrftTests/AudioStoreStreamingTests.cs
@@ -2,6 +2,7 @@ using System.Text;
using DeepDrftContent;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
+using DeepDrftContent.FileDatabase.Services;
using DeepDrftContent.Processors;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
@@ -217,6 +218,94 @@ public class AudioStoreStreamingTests
});
}
+ // -- atomic-write safety (cancel / fault on replace path) ---------------------------------
+
+ ///
+ /// A client disconnect (OperationCanceledException) during the streamed write must leave any
+ /// pre-existing backing file byte-identical. The atomic temp→rename ordering ensures the rename
+ /// never happens on an incomplete write, so the original file is never truncated or overwritten.
+ ///
+ [Test]
+ public async Task AddEntryStreamingAsync_CancelMidWrite_OriginalBackingFileUnchanged()
+ {
+ var vault = await AudioVault.FromAsync(_testDir);
+ Assert.That(vault, Is.Not.Null);
+
+ const string entryId = "replace-target";
+ var original = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE };
+ var originalMeta = MetaDataFactory.CreateAudioMetaData(entryId, ".wav", 10.0, 1411);
+
+ // Write the original entry — this is the backing file that must survive a cancelled replace.
+ await vault!.AddEntryStreamingAsync(entryId, originalMeta,
+ async (s, ct) => await s.WriteAsync(original, ct));
+
+ var backingPath = Path.Combine(_testDir, "replace-target.wav");
+ Assert.That(File.Exists(backingPath), Is.True, "Pre-condition: original backing file must exist");
+ var originalOnDisk = await File.ReadAllBytesAsync(backingPath);
+ Assert.That(originalOnDisk, Is.EqualTo(original), "Pre-condition: backing file must hold original bytes");
+
+ // Attempt a replace whose writeContent cancels after writing only a portion.
+ using var cts = new CancellationTokenSource();
+ var replacement = new byte[10_000];
+ Array.Fill(replacement, (byte)0xFF);
+ var replaceMeta = MetaDataFactory.CreateAudioMetaData(entryId, ".wav", 5.0, 1411);
+
+ Assert.ThrowsAsync(async () =>
+ await vault.AddEntryStreamingAsync(entryId, replaceMeta,
+ async (s, ct) =>
+ {
+ await s.WriteAsync(replacement.AsMemory(0, 100), ct);
+ await cts.CancelAsync();
+ ct.ThrowIfCancellationRequested(); // surfaces the cancel from the token
+ },
+ cts.Token));
+
+ // The original backing file must be byte-identical — no truncation, no partial replacement.
+ var afterContent = await File.ReadAllBytesAsync(backingPath);
+ Assert.That(afterContent, Is.EqualTo(original),
+ "Original backing file must be byte-identical after a cancelled replace");
+
+ // No stray temp files should remain in the vault directory.
+ var tmpFiles = Directory.GetFiles(_testDir, "*.tmp");
+ Assert.That(tmpFiles, Is.Empty, "Temp file must be cleaned up after cancel");
+ }
+
+ ///
+ /// An I/O fault during the streamed write must leave any pre-existing backing file intact.
+ /// The atomic temp→rename ordering ensures a faulting write never reaches rename, so the
+ /// original file is never touched.
+ ///
+ [Test]
+ public async Task AddEntryStreamingAsync_FaultMidWrite_OriginalBackingFileUnchanged()
+ {
+ var vault = await AudioVault.FromAsync(_testDir);
+ Assert.That(vault, Is.Not.Null);
+
+ const string entryId = "fault-target";
+ var original = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
+ var originalMeta = MetaDataFactory.CreateAudioMetaData(entryId, ".wav", 10.0, 1411);
+
+ await vault!.AddEntryStreamingAsync(entryId, originalMeta,
+ async (s, ct) => await s.WriteAsync(original, ct));
+
+ var backingPath = Path.Combine(_testDir, "fault-target.wav");
+ var originalOnDisk = await File.ReadAllBytesAsync(backingPath);
+ Assert.That(originalOnDisk, Is.EqualTo(original), "Pre-condition: backing file must hold original bytes");
+
+ // Attempt a replace whose writeContent throws a simulated I/O fault.
+ Assert.ThrowsAsync(async () =>
+ await vault.AddEntryStreamingAsync(entryId, originalMeta,
+ (_, _) => throw new IOException("Simulated I/O fault")));
+
+ // The original backing file must be intact.
+ var afterContent = await File.ReadAllBytesAsync(backingPath);
+ Assert.That(afterContent, Is.EqualTo(original),
+ "Original backing file must be byte-identical after a faulting replace");
+
+ var tmpFiles = Directory.GetFiles(_testDir, "*.tmp");
+ Assert.That(tmpFiles, Is.Empty, "Temp file must be cleaned up after fault");
+ }
+
// -- builders -----------------------------------------------------------------------------
private async Task WriteAsync(byte[] bytes, string extension)