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

346 lines
15 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.Text;
using System.Text.Json;
namespace Zcbot.WindowsNode;
internal sealed class JobInboxStore(string jobsDirectory)
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private static readonly HashSet<string> PlotTypes =
["line", "scatter", "line_scatter"];
private static readonly HashSet<string> OutputFormats = ["opju", "png", "svg", "pdf"];
// Origin 执行槽只由尚无终态的任务占用。成功但上传确认尚未落盘的任务会由
// 心跳恢复管线继续重传;上传不使用 Origin不能反向阻塞新的绘图任务。
internal bool HasPendingOriginJobs => Directory.Exists(jobsDirectory)
&& ReadRecoverableJobs().Any(item => item.Terminal is null);
internal IReadOnlyList<RecoverableJob> ReadRecoverableJobs()
{
if (!Directory.Exists(jobsDirectory))
{
return [];
}
var jobs = new List<RecoverableJob>();
foreach (var requestPath in Directory.EnumerateFiles(
jobsDirectory, "request.json", SearchOption.AllDirectories))
{
try
{
using var request = JsonDocument.Parse(File.ReadAllBytes(requestPath));
var root = request.RootElement;
if (!TryReadGuid(root, "job_id", out var jobId)
|| !TryReadGuid(root, "lease_id", out var leaseId)
|| !root.TryGetProperty("request_digest", out var digestValue)
|| digestValue.GetString() is not { Length: 64 } requestDigest)
{
continue;
}
var jobDirectory = Directory.GetParent(Directory.GetParent(requestPath)!.FullName)!.FullName;
jobs.Add(new RecoverableJob(
jobId,
leaseId,
requestDigest,
root.TryGetProperty("input_transfer", out var transfer)
? transfer.Clone() : null,
ReadTerminal(Path.Combine(jobDirectory, "terminal.json")),
File.Exists(Path.Combine(jobDirectory, "upload-complete.json"))));
}
catch (Exception exception) when (
exception is JsonException or IOException or UnauthorizedAccessException)
{
}
}
return jobs;
}
internal JobOfferResult Accept(JsonElement payload)
{
if (!TryReadGuid(payload, "job_id", out var jobId)
|| !TryReadGuid(payload, "lease_id", out var leaseId)
|| !payload.TryGetProperty("request_digest", out var digestValue)
|| digestValue.ValueKind != JsonValueKind.String
|| digestValue.GetString() is not { Length: 64 } requestDigest
|| !payload.TryGetProperty("capability", out var capabilityValue)
|| capabilityValue.GetString() != "origin.plot@v1"
|| !payload.TryGetProperty("request", out var request)
|| request.ValueKind != JsonValueKind.Object
|| !payload.TryGetProperty("input_transfer", out var inputTransfer)
|| !IsValidInputTransfer(inputTransfer))
{
return JobOfferResult.Reject("invalid_offer");
}
if (!IsValidRequest(request))
{
return JobOfferResult.Reject("unsupported_request");
}
var directory = Path.Combine(jobsDirectory, jobId.ToString("D"));
var requestDirectory = Path.Combine(directory, "request");
var requestPath = Path.Combine(requestDirectory, "request.json");
Directory.CreateDirectory(requestDirectory);
if (File.Exists(requestPath))
{
try
{
using var existing = JsonDocument.Parse(File.ReadAllBytes(requestPath));
var root = existing.RootElement;
var sameDigest = root.TryGetProperty("request_digest", out var existingDigest)
&& existingDigest.GetString() == requestDigest;
if (!sameDigest)
{
return JobOfferResult.Reject("job_digest_conflict");
}
if (!TryReadGuid(root, "lease_id", out var existingLease)
|| existingLease != leaseId)
{
var updated = JsonSerializer.SerializeToUtf8Bytes(new
{
job_id = jobId,
lease_id = leaseId,
request_digest = requestDigest,
capability = "origin.plot@v1",
accepted_at = DateTimeOffset.UtcNow,
request,
input_transfer = inputTransfer,
}, JsonOptions);
AtomicWrite(requestPath, updated, overwrite: true);
}
return JobOfferResult.Accept(jobId, leaseId, requestDigest);
}
catch (JsonException)
{
return JobOfferResult.Reject("local_job_record_invalid");
}
}
var record = JsonSerializer.SerializeToUtf8Bytes(new
{
job_id = jobId,
lease_id = leaseId,
request_digest = requestDigest,
capability = "origin.plot@v1",
accepted_at = DateTimeOffset.UtcNow,
request,
input_transfer = inputTransfer,
}, JsonOptions);
try
{
AtomicWrite(requestPath, record, overwrite: false);
return JobOfferResult.Accept(jobId, leaseId, requestDigest);
}
catch (IOException)
{
return JobOfferResult.Reject("local_job_persist_failed");
}
}
private static JsonElement? ReadTerminal(string path)
{
if (!File.Exists(path))
{
return null;
}
using var document = JsonDocument.Parse(File.ReadAllBytes(path));
return document.RootElement.Clone();
}
private static void AtomicWrite(string path, byte[] content, bool overwrite)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temporaryPath = path + ".tmp-" + Guid.NewGuid().ToString("N");
try
{
using (var stream = new FileStream(
temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 4096, FileOptions.WriteThrough))
{
stream.Write(content);
stream.Flush(flushToDisk: true);
}
File.Move(temporaryPath, path, overwrite);
}
finally
{
if (File.Exists(temporaryPath)) File.Delete(temporaryPath);
}
}
private static bool IsValidRequest(JsonElement request) =>
HasOnlyProperties(request, "schema_version", "input", "plot", "output")
&& request.TryGetProperty("schema_version", out var schemaVersion)
&& schemaVersion.TryGetInt32(out var version)
&& version == 1
&& request.TryGetProperty("input", out var input)
&& input.ValueKind == JsonValueKind.Object
&& HasOnlyProperties(input, "input_id", "sheet")
&& input.TryGetProperty("input_id", out var inputId)
&& inputId.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(inputId.GetString())
&& request.TryGetProperty("plot", out var plot)
&& plot.ValueKind == JsonValueKind.Object
&& HasOnlyProperties(
plot, "type", "x", "y", "template", "title", "x_axis", "y_axis", "legend", "error_bars")
&& plot.TryGetProperty("type", out var plotType)
&& plotType.ValueKind == JsonValueKind.String
&& PlotTypes.Contains(plotType.GetString() ?? "")
&& (!plot.TryGetProperty("title", out var title)
|| title.ValueKind == JsonValueKind.String && title.GetString()!.Length <= 500)
&& plot.TryGetProperty("x", out var x)
&& IsColumnName(x)
&& plot.TryGetProperty("y", out var y)
&& IsValidYColumns(y)
&& (!plot.TryGetProperty("template", out var template)
|| template.GetString() == "publication_double_column")
&& IsValidAxis(plot, "x_axis")
&& IsValidAxis(plot, "y_axis")
&& IsValidLegend(plot)
&& !plot.TryGetProperty("error_bars", out _)
&& request.TryGetProperty("output", out var output)
&& output.ValueKind == JsonValueKind.Object
&& HasOnlyProperties(output, "formats", "dpi", "capture_screenshots", "record_video")
&& output.TryGetProperty("formats", out var formats)
&& formats.ValueKind == JsonValueKind.Array
&& formats.GetArrayLength() > 0
&& IsValidFormats(formats)
&& (!output.TryGetProperty("dpi", out var dpi)
|| dpi.TryGetInt32(out var dpiValue) && dpiValue is >= 72 and <= 1200)
&& IsOptionalBoolean(output, "capture_screenshots")
&& IsOptionalBoolean(output, "record_video")
&& (!output.TryGetProperty("record_video", out var recordVideo)
|| recordVideo.ValueKind == JsonValueKind.False);
private static bool IsColumnName(JsonElement value) =>
value.ValueKind == JsonValueKind.String
&& value.GetString() is { Length: >= 1 and <= 128 };
private static bool IsValidFormats(JsonElement formats)
{
var values = formats.EnumerateArray().ToArray();
return values.All(item =>
item.ValueKind == JsonValueKind.String
&& OutputFormats.Contains(item.GetString() ?? ""))
&& values.Select(item => item.GetString()).Distinct(StringComparer.Ordinal).Count()
== values.Length;
}
private static bool IsValidYColumns(JsonElement value)
{
if (IsColumnName(value)) return true;
if (value.ValueKind != JsonValueKind.Array
|| value.GetArrayLength() is < 1 or > 16)
{
return false;
}
var names = value.EnumerateArray().Select(item => item.GetString()).ToArray();
return value.EnumerateArray().All(IsColumnName)
&& names.Distinct(StringComparer.Ordinal).Count() == names.Length;
}
private static bool IsValidAxis(JsonElement plot, string name)
{
if (!plot.TryGetProperty(name, out var axis)) return true;
return axis.ValueKind == JsonValueKind.Object
&& HasOnlyProperties(axis, "title", "unit", "scale")
&& (!axis.TryGetProperty("title", out var title) || title.ValueKind == JsonValueKind.String)
&& (!axis.TryGetProperty("unit", out var unit) || unit.ValueKind == JsonValueKind.String)
&& (!axis.TryGetProperty("scale", out var scale) || scale.GetString() == "linear");
}
private static bool IsValidLegend(JsonElement plot)
{
if (!plot.TryGetProperty("legend", out var legend)) return true;
return legend.ValueKind == JsonValueKind.Object
&& HasOnlyProperties(legend, "enabled", "position")
&& (!legend.TryGetProperty("enabled", out var enabled)
|| enabled.ValueKind == JsonValueKind.True)
&& (!legend.TryGetProperty("position", out var position)
|| position.GetString() == "top_right");
}
private static bool IsOptionalBoolean(JsonElement value, string name) =>
!value.TryGetProperty(name, out var property)
|| property.ValueKind is JsonValueKind.True or JsonValueKind.False;
private static bool IsValidInputTransfer(JsonElement transfer) =>
transfer.ValueKind == JsonValueKind.Object
&& HasOnlyProperties(transfer, "artifact_id", "filename", "size_bytes", "sha256", "download_path")
&& transfer.TryGetProperty("artifact_id", out var artifactId)
&& Guid.TryParse(artifactId.GetString(), out _)
&& transfer.TryGetProperty("filename", out var filename)
&& filename.ValueKind == JsonValueKind.String
&& Path.GetFileName(filename.GetString()) == filename.GetString()
&& transfer.TryGetProperty("size_bytes", out var size)
&& size.TryGetInt64(out var sizeBytes)
&& sizeBytes is >= 0 and <= 104_857_600
&& transfer.TryGetProperty("sha256", out var sha)
&& sha.GetString() is { Length: 64 }
&& transfer.TryGetProperty("download_path", out var downloadPath)
&& downloadPath.GetString()?.StartsWith("/v1/software-jobs/", StringComparison.Ordinal) == true
&& downloadPath.GetString()?.EndsWith("/input", StringComparison.Ordinal) == true;
internal string InputPath(RecoverableJob job)
{
if (job.InputTransfer is not JsonElement transfer
|| !IsValidInputTransfer(transfer))
{
throw new InvalidDataException("Stored input transfer is invalid.");
}
var filename = transfer.GetProperty("filename").GetString()!;
return Path.Combine(jobsDirectory, job.JobId.ToString("D"), "input", filename);
}
internal void WriteTerminal(
RecoverableJob job, string status, string code, string detail)
{
var path = Path.Combine(jobsDirectory, job.JobId.ToString("D"), "terminal.json");
if (File.Exists(path))
{
return;
}
var content = JsonSerializer.SerializeToUtf8Bytes(new
{
job_id = job.JobId,
lease_id = job.LeaseId,
request_digest = job.RequestDigest,
status,
error = new { code, detail },
artifact_manifest = Array.Empty<object>(),
terminal_at = DateTimeOffset.UtcNow,
}, JsonOptions);
AtomicWrite(path, content, overwrite: false);
}
private static bool HasOnlyProperties(JsonElement value, params string[] allowed)
{
var names = new HashSet<string>(allowed, StringComparer.Ordinal);
return value.EnumerateObject().All(item => names.Contains(item.Name));
}
private static bool TryReadGuid(JsonElement payload, string name, out Guid value)
{
value = Guid.Empty;
return payload.TryGetProperty(name, out var property)
&& property.ValueKind == JsonValueKind.String
&& Guid.TryParse(property.GetString(), out value);
}
}
internal sealed record RecoverableJob(
Guid JobId,
Guid LeaseId,
string RequestDigest,
JsonElement? InputTransfer,
JsonElement? Terminal,
bool UploadComplete);
internal sealed record JobOfferResult(
bool Accepted, Guid JobId, Guid LeaseId, string RequestDigest, string Reason)
{
internal static JobOfferResult Accept(Guid jobId, Guid leaseId, string requestDigest) =>
new(true, jobId, leaseId, requestDigest, "");
internal static JobOfferResult Reject(string reason) =>
new(false, Guid.Empty, Guid.Empty, "", reason);
}