346 lines
15 KiB
C#
346 lines
15 KiB
C#
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);
|
||
}
|