zcbot/windows-node/Zcbot.WindowsNode/JobOutputUploader.cs

270 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Zcbot.WindowsNode;
internal sealed class JobOutputUploader(NodeConfig config)
{
private const long MaxDiagnosticLogBytes = 1024 * 1024;
private static readonly object DiagnosticLogLock = new();
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private static readonly TimeSpan[] SharingViolationBackoff =
[
TimeSpan.FromMilliseconds(500),
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
];
internal async Task UploadAsync(RecoverableJob job, bool recovery)
{
var jobDirectory = Path.Combine(
NodePaths.ForCurrentMachine().JobsDirectory, job.JobId.ToString("D"));
var completionPath = Path.Combine(jobDirectory, "upload-complete.json");
if (File.Exists(completionPath)) return;
var terminalPath = Path.Combine(jobDirectory, "terminal.json");
using var terminal = JsonDocument.Parse(await File.ReadAllBytesAsync(terminalPath));
if (terminal.RootElement.GetProperty("status").GetString() != "succeeded") return;
var manifest = terminal.RootElement.GetProperty("artifact_manifest").Clone();
using var client = new HttpClient { BaseAddress = config.ServerUrl };
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", config.NodeToken);
client.DefaultRequestHeaders.Add("X-Node-Id", config.NodeId.ToString());
client.DefaultRequestHeaders.Add("X-Lease-Id", job.LeaseId.ToString());
client.DefaultRequestHeaders.Add("X-Request-Digest", job.RequestDigest);
if (recovery)
{
var replay = await TryCompleteAsync(
client, jobDirectory, job, manifest, allowConflict: true);
if (replay is not null)
{
await WriteCompletionWithRetryAsync(job, completionPath, replay);
Log(jobDirectory, "OK",
$"Output upload reconciled job={job.JobId:D} source=cloud");
return;
}
}
foreach (var artifact in manifest.EnumerateArray())
{
var localId = artifact.GetProperty("artifact_id").GetString()!;
var filename = artifact.GetProperty("filename").GetString()!;
var expectedSize = artifact.GetProperty("size_bytes").GetInt64();
var expectedDigest = artifact.GetProperty("sha256").GetString()!;
var path = Path.Combine(jobDirectory, "output", filename);
var info = new FileInfo(path);
if (!info.Exists || info.Length != expectedSize)
{
throw new InvalidDataException($"Output artifact is missing or changed: {localId}.");
}
Log(jobDirectory, "INFO",
$"Output upload phase=verify job={job.JobId:D} "
+ $"artifact={localId} file={filename}");
await using (var verify = await OpenOutputWithRetryAsync(
jobDirectory, job, localId, filename, path, "verify"))
{
var digest = Convert.ToHexString(
await SHA256.HashDataAsync(verify)).ToLowerInvariant();
if (digest != expectedDigest)
{
throw new InvalidDataException($"Output artifact digest changed: {localId}.");
}
}
Log(jobDirectory, "INFO",
$"Output upload phase=transfer job={job.JobId:D} "
+ $"artifact={localId} file={filename}");
await using var stream = await OpenOutputWithRetryAsync(
jobDirectory, job, localId, filename, path, "transfer");
using var content = new StreamContent(stream);
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
content.Headers.ContentLength = expectedSize;
content.Headers.Add("X-Content-SHA256", expectedDigest);
content.Headers.Add("X-Content-Length", expectedSize.ToString());
using var response = await client.PutAsync(
$"/v1/software-jobs/{job.JobId:D}/outputs/{Uri.EscapeDataString(localId)}",
content);
response.EnsureSuccessStatusCode();
}
var responseBody = await TryCompleteAsync(
client, jobDirectory, job, manifest, allowConflict: false)
?? throw new InvalidDataException("Output completion response is unavailable.");
await WriteCompletionWithRetryAsync(job, completionPath, responseBody);
Log(jobDirectory, "OK", $"Output upload completed job={job.JobId:D}");
}
internal void RecordDeferred(RecoverableJob job, Exception exception)
{
var jobDirectory = Path.Combine(
NodePaths.ForCurrentMachine().JobsDirectory, job.JobId.ToString("D"));
Log(jobDirectory, "WARN",
$"Output upload deferred job={job.JobId:D} "
+ $"exception={exception.GetType().Name} "
+ $"hresult=0x{exception.HResult:X8} detail={exception.Message}",
error: true);
}
private static async Task<byte[]?> TryCompleteAsync(
HttpClient client,
string jobDirectory,
RecoverableJob job,
JsonElement manifest,
bool allowConflict)
{
Log(jobDirectory, "INFO",
$"Output upload phase=complete job={job.JobId:D} "
+ $"mode={(allowConflict ? "reconcile" : "publish")}");
using var completeContent = new StringContent(
JsonSerializer.Serialize(new { artifact_manifest = manifest }),
Encoding.UTF8,
"application/json");
using var completeResponse = await client.PostAsync(
$"/v1/software-jobs/{job.JobId:D}/outputs/complete", completeContent);
if (allowConflict && completeResponse.StatusCode == HttpStatusCode.Conflict)
{
Log(jobDirectory, "INFO",
$"Output upload reconciliation pending job={job.JobId:D} "
+ "reason=cloud_not_complete");
return null;
}
completeResponse.EnsureSuccessStatusCode();
return await completeResponse.Content.ReadAsByteArrayAsync();
}
private static async Task<FileStream> OpenOutputWithRetryAsync(
string jobDirectory,
RecoverableJob job,
string artifactId,
string filename,
string path,
string phase)
{
for (var attempt = 0; ; attempt++)
{
try
{
return new FileStream(
path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
}
catch (IOException exception) when (
IsSharingViolation(exception) && attempt < SharingViolationBackoff.Length)
{
var delay = SharingViolationBackoff[attempt];
Log(jobDirectory, "WARN",
$"Output file busy job={job.JobId:D} artifact={artifactId} "
+ $"file={filename} phase={phase} attempt={attempt + 1} "
+ $"retry_ms={(int)delay.TotalMilliseconds} "
+ $"hresult=0x{exception.HResult:X8}", error: true);
await Task.Delay(delay);
}
catch (IOException exception) when (IsSharingViolation(exception))
{
Log(jobDirectory, "WARN",
$"Output file busy job={job.JobId:D} artifact={artifactId} "
+ $"file={filename} phase={phase} retries=exhausted "
+ $"hresult=0x{exception.HResult:X8}", error: true);
throw new IOException(
$"输出文件 {filename} 在{PhaseLabel(phase)}阶段持续被占用"
+ $"artifact={artifactId}, HRESULT=0x{exception.HResult:X8})。",
exception);
}
}
}
private static async Task WriteCompletionWithRetryAsync(
RecoverableJob job,
string completionPath,
byte[] responseBody)
{
for (var attempt = 0; ; attempt++)
{
try
{
AtomicWrite(completionPath, responseBody);
return;
}
catch (IOException exception) when (
IsSharingViolation(exception) && attempt < SharingViolationBackoff.Length)
{
var delay = SharingViolationBackoff[attempt];
Log(Path.GetDirectoryName(completionPath)!, "WARN",
$"Completion marker busy job={job.JobId:D} "
+ $"attempt={attempt + 1} retry_ms={(int)delay.TotalMilliseconds} "
+ $"hresult=0x{exception.HResult:X8}", error: true);
await Task.Delay(delay);
}
}
}
private static bool IsSharingViolation(IOException exception) =>
(exception.HResult & 0xFFFF) is 32 or 33;
private static string PhaseLabel(string phase) => phase == "verify" ? "校验" : "上传";
private static void Log(
string jobDirectory,
string level,
string message,
bool error = false)
{
var consoleMessage = $"[{level}] {message}";
if (error)
{
Console.Error.WriteLine(consoleMessage);
}
else
{
Console.WriteLine(consoleMessage);
}
try
{
lock (DiagnosticLogLock)
{
var logsDirectory = Path.Combine(jobDirectory, "logs");
Directory.CreateDirectory(logsDirectory);
var path = Path.Combine(logsDirectory, "node-output-upload.log");
if (File.Exists(path) && new FileInfo(path).Length >= MaxDiagnosticLogBytes)
{
File.Move(path, path + ".1", overwrite: true);
}
File.AppendAllText(
path,
$"{DateTimeOffset.UtcNow:O} [{level}] {message}{Environment.NewLine}",
new UTF8Encoding(false));
}
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException)
{
}
}
private static void AtomicWrite(string path, byte[] responseBody)
{
using var response = JsonDocument.Parse(responseBody);
var content = JsonSerializer.SerializeToUtf8Bytes(new
{
completed_at = DateTimeOffset.UtcNow,
response = response.RootElement,
}, JsonOptions);
var temporary = path + ".tmp-" + Guid.NewGuid().ToString("N");
try
{
using var stream = new FileStream(
temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None,
4096, FileOptions.WriteThrough);
stream.Write(content);
stream.Flush(flushToDisk: true);
File.Move(temporary, path, overwrite: false);
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
}