103 lines
4 KiB
C#
103 lines
4 KiB
C#
using AcDream.Content.Pak;
|
|
|
|
namespace AcDream.Bake;
|
|
|
|
/// <summary>
|
|
/// Publishes a bake as one same-volume filesystem transaction. Work and
|
|
/// validation happen against a unique adjacent temporary file; failure or
|
|
/// cancellation leaves an existing destination untouched.
|
|
/// </summary>
|
|
public static class BakeOutputTransaction
|
|
{
|
|
internal const string StagingMarker = ".acdream-bake.";
|
|
|
|
public static TResult WriteValidateAndPublish<TResult>(
|
|
string destinationPath,
|
|
Func<string, TResult> writeTemporary,
|
|
Action<string, TResult> validateTemporary,
|
|
CancellationToken cancellationToken = default)
|
|
=> WriteValidateAndPublish(
|
|
destinationPath,
|
|
writeTemporary,
|
|
validateTemporary,
|
|
beforePublicationLock: null,
|
|
beforePromotion: null,
|
|
cancellationToken);
|
|
|
|
internal static TResult WriteValidateAndPublish<TResult>(
|
|
string destinationPath,
|
|
Func<string, TResult> writeTemporary,
|
|
Action<string, TResult> validateTemporary,
|
|
Action? beforePublicationLock,
|
|
Action? beforePromotion,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
|
|
ArgumentNullException.ThrowIfNull(writeTemporary);
|
|
ArgumentNullException.ThrowIfNull(validateTemporary);
|
|
|
|
string fullDestination = Path.GetFullPath(destinationPath);
|
|
string? directory = Path.GetDirectoryName(fullDestination);
|
|
if (string.IsNullOrEmpty(directory))
|
|
throw new InvalidOperationException("destination has no parent directory");
|
|
Directory.CreateDirectory(directory);
|
|
|
|
string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid());
|
|
|
|
try
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
TResult result = writeTemporary(temporaryPath);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
validateTemporary(temporaryPath, result);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
beforePublicationLock?.Invoke();
|
|
using IDisposable? publication =
|
|
BakePublicationGuard.AcquireIfRequested(
|
|
fullDestination,
|
|
cancellationToken);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
beforePromotion?.Invoke();
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
// Same-volume MoveFileEx/rename is the publication primitive.
|
|
// File.Replace additionally performs destination metadata/backup
|
|
// semantics and proved brittle for a validated 28 GiB artifact on
|
|
// Windows. Move(overwrite:true) changes only the directory entry:
|
|
// readers observe the complete old file or the complete new file.
|
|
File.Move(temporaryPath, fullDestination, overwrite: true);
|
|
|
|
return result;
|
|
}
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
if (File.Exists(temporaryPath))
|
|
File.Delete(temporaryPath);
|
|
}
|
|
catch
|
|
{
|
|
// Preserve the original exception. An adjacent .tmp is
|
|
// recognizable and never mistaken for a published pak.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exact adjacent staging-name contract shared, by documentation and
|
|
/// conformance tests, with Launcher.Core. Keeping this tiny contract in
|
|
/// each BCL-facing assembly avoids an otherwise inverted project edge.
|
|
/// </summary>
|
|
internal static string CreateStagingPath(string destinationPath, Guid transactionId)
|
|
{
|
|
string fullDestination = Path.GetFullPath(destinationPath);
|
|
string directory = Path.GetDirectoryName(fullDestination)
|
|
?? throw new InvalidOperationException(
|
|
"destination has no parent directory");
|
|
return Path.Combine(
|
|
directory,
|
|
$".{Path.GetFileName(fullDestination)}{StagingMarker}"
|
|
+ $"{transactionId:N}.tmp");
|
|
}
|
|
}
|