167 lines
6.4 KiB
C#
167 lines
6.4 KiB
C#
using System.Text.Json;
|
|
|
|
namespace Zcbot.WindowsNode;
|
|
|
|
internal sealed class WorkspaceStore(NodePaths paths)
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
|
|
|
internal string CurrentDirectory(RecoverableJob job) =>
|
|
Path.Combine(paths.WorkspacesDirectory, Binding(job).WorkspaceId.ToString("D"), "current");
|
|
|
|
internal void Prepare(RecoverableJob job, string capability)
|
|
{
|
|
var root = WorkspaceRoot(job);
|
|
var current = Path.Combine(root, "current");
|
|
var rollback = Path.Combine(root, "rollback");
|
|
Directory.CreateDirectory(root);
|
|
var binding = Binding(job);
|
|
if (binding.Mode == "continue")
|
|
{
|
|
var metadata = ReadMetadata(root);
|
|
if (metadata.Capability != capability
|
|
|| metadata.HeadJobId != binding.SourceJobId)
|
|
{
|
|
throw new InvalidDataException("Workspace head does not match the requested source job.");
|
|
}
|
|
if (!Directory.Exists(current))
|
|
{
|
|
if (Directory.Exists(rollback)) Directory.Move(rollback, current);
|
|
else throw new InvalidDataException("Workspace current state is missing.");
|
|
}
|
|
if (Directory.Exists(rollback)) Directory.Delete(rollback, recursive: true);
|
|
Directory.Move(current, rollback);
|
|
}
|
|
else if (File.Exists(Path.Combine(root, "workspace.json")) || Directory.Exists(current))
|
|
{
|
|
throw new InvalidDataException("A new workspace already contains state.");
|
|
}
|
|
|
|
var jobDirectory = Path.Combine(paths.JobsDirectory, job.JobId.ToString("D"));
|
|
AtomicJson(Path.Combine(jobDirectory, "workspace.json"), new
|
|
{
|
|
workspace_id = binding.WorkspaceId,
|
|
source_job_id = binding.SourceJobId,
|
|
mode = binding.Mode,
|
|
local_base_path = binding.Mode == "continue" ? rollback : null,
|
|
});
|
|
}
|
|
|
|
internal long Promote(RecoverableJob job, string capability, string stateFilename)
|
|
{
|
|
var root = WorkspaceRoot(job);
|
|
var current = Path.Combine(root, "current");
|
|
var output = Path.Combine(paths.JobsDirectory, job.JobId.ToString("D"), "output");
|
|
var statePath = Path.Combine(output, stateFilename);
|
|
if (!File.Exists(statePath))
|
|
{
|
|
throw new InvalidDataException("Adapter did not create the workspace state output.");
|
|
}
|
|
if (Directory.Exists(current))
|
|
{
|
|
throw new InvalidDataException("Workspace current destination already exists.");
|
|
}
|
|
Directory.Move(output, current);
|
|
return CommitCurrent(job, capability);
|
|
}
|
|
|
|
internal long EnsureSucceeded(
|
|
RecoverableJob job, string capability, string stateFilename)
|
|
{
|
|
var current = CurrentDirectory(job);
|
|
if (File.Exists(Path.Combine(current, stateFilename)))
|
|
{
|
|
return CommitCurrent(job, capability);
|
|
}
|
|
var output = Path.Combine(paths.JobsDirectory, job.JobId.ToString("D"), "output");
|
|
if (File.Exists(Path.Combine(output, stateFilename)))
|
|
{
|
|
return Promote(job, capability, stateFilename);
|
|
}
|
|
Restore(job);
|
|
throw new InvalidDataException("Successful workspace output cannot be recovered.");
|
|
}
|
|
|
|
private long CommitCurrent(RecoverableJob job, string capability)
|
|
{
|
|
var root = WorkspaceRoot(job);
|
|
var sizeBytes = Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
|
|
.Where(item => !Path.GetFileName(item).Equals(
|
|
"workspace.json", StringComparison.OrdinalIgnoreCase))
|
|
.Sum(item => new FileInfo(item).Length);
|
|
AtomicJson(Path.Combine(root, "workspace.json"), new
|
|
{
|
|
workspace_id = Binding(job).WorkspaceId,
|
|
capability,
|
|
head_job_id = job.JobId,
|
|
size_bytes = sizeBytes,
|
|
updated_at = DateTimeOffset.UtcNow,
|
|
});
|
|
AtomicJson(
|
|
Path.Combine(paths.JobsDirectory, job.JobId.ToString("D"), "workspace-result.json"),
|
|
new { size_bytes = sizeBytes });
|
|
return sizeBytes;
|
|
}
|
|
|
|
internal void Restore(RecoverableJob job)
|
|
{
|
|
if (Binding(job).Mode != "continue") return;
|
|
var root = WorkspaceRoot(job);
|
|
var current = Path.Combine(root, "current");
|
|
var rollback = Path.Combine(root, "rollback");
|
|
if (!Directory.Exists(current) && Directory.Exists(rollback))
|
|
{
|
|
Directory.Move(rollback, current);
|
|
}
|
|
}
|
|
|
|
private string WorkspaceRoot(RecoverableJob job) =>
|
|
Path.Combine(paths.WorkspacesDirectory, Binding(job).WorkspaceId.ToString("D"));
|
|
|
|
private static WorkspaceBinding Binding(RecoverableJob job) =>
|
|
job.Workspace
|
|
?? throw new InvalidDataException("Job has no persistent workspace binding.");
|
|
|
|
private static WorkspaceMetadata ReadMetadata(string root)
|
|
{
|
|
var path = Path.Combine(root, "workspace.json");
|
|
using var document = JsonDocument.Parse(File.ReadAllBytes(path));
|
|
var value = document.RootElement;
|
|
if (!value.TryGetProperty("capability", out var capability)
|
|
|| !value.TryGetProperty("head_job_id", out var head)
|
|
|| !Guid.TryParse(head.GetString(), out var headJobId))
|
|
{
|
|
throw new InvalidDataException("Workspace metadata is invalid.");
|
|
}
|
|
return new WorkspaceMetadata(capability.GetString() ?? "", headJobId);
|
|
}
|
|
|
|
private static void AtomicJson(string path, object value)
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
|
var temporary = path + ".tmp-" + Guid.NewGuid().ToString("N");
|
|
try
|
|
{
|
|
using (var stream = new FileStream(
|
|
temporary,
|
|
FileMode.CreateNew,
|
|
FileAccess.Write,
|
|
FileShare.None,
|
|
4096,
|
|
FileOptions.WriteThrough))
|
|
{
|
|
var content = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions);
|
|
stream.Write(content);
|
|
stream.Flush(flushToDisk: true);
|
|
}
|
|
File.Move(temporary, path, overwrite: true);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(temporary)) File.Delete(temporary);
|
|
}
|
|
}
|
|
|
|
private sealed record WorkspaceMetadata(string Capability, Guid HeadJobId);
|
|
}
|