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:
daniel-c-harvey
2026-06-25 15:46:58 -04:00
parent 79bbbd4956
commit beec36a382
3 changed files with 136 additions and 17 deletions
@@ -57,16 +57,18 @@ public abstract class MediaVault : VaultIndexDirectory
}
/// <summary>
/// Streams an entry's bytes into the vault without ever materializing the whole file in memory:
/// records the supplied <paramref name="metaData"/> in the index, then invokes
/// <paramref name="writeContent"/> to emit bytes directly to the backing <see cref="FileStream"/>.
/// 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 <see cref="FileBinary"/> 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 <see cref="AddEntryAsync"/>; 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 <c>rename(2)</c> —
/// 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.
/// </summary>
public async Task<long> AddEntryStreamingAsync(
string entryId,
@@ -74,17 +76,41 @@ public abstract class MediaVault : VaultIndexDirectory
Func<Stream, CancellationToken, Task> 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;
}
}
/// <summary>