fix(vault): atomic streamed write via temp→rename, suppress OCE log noise
AddEntryStreamingAsync now writes to a temp file in the same vault directory, renames it into place (POSIX rename(2) — atomic on Linux), and updates the index only after the rename succeeds. A client disconnect or I/O fault during the write leaves the original backing file intact and the index unchanged; the temp file is cleaned up best-effort on failure. Fixes the data-corruption regression on the replace path where a cancelled write could truncate the live backing file after the index update and FileMode.Create already ran. Also filters OperationCanceledException from error-level logging in RegisterResourceStreamingAsync — a normal client disconnect is not an error. Two tests added to AudioStoreStreamingTests covering cancel and fault on the replace path.
This commit is contained in:
@@ -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) ---------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<OperationCanceledException>(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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<IOException>(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<string> WriteAsync(byte[] bytes, string extension)
|
||||
|
||||
Reference in New Issue
Block a user