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

347 lines
14 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"];
internal bool HasPendingJobs => Directory.Exists(jobsDirectory)
&& ReadRecoverableJobs().Any(item =>
item.Terminal is null
|| item.Terminal.Value.GetProperty("status").GetString() == "succeeded"
&& !item.UploadComplete);
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);
}