using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
namespace OpenNest.Data;
///
/// Server nesting over /api/nest-jobs: desktops submit, poll, read results and cancel;
/// the worker claims, heartbeats and reports. Packages and results are opaque bytes here
/// (OpenNest.Api.NestJobPackage, NestSolveTranscript). A server without nest jobs
/// raises ; other failures surface as .
///
public sealed class RemoteNestJobs : NestServerClient
{
private const string Feature = "server nesting";
public RemoteNestJobs(string baseUrl)
: base(new HttpClient(), baseUrl, ownsClient: true)
{
}
/// For tests and hosts that pool HttpClient instances.
public RemoteNestJobs(HttpClient httpClient, string baseUrl)
: base(httpClient, baseUrl, ownsClient: false)
{
}
public async Task SubmitAsync(NestJobSubmission submission, byte[] package, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(submission);
ArgumentNullException.ThrowIfNull(package);
if (package.Length == 0 || package.Length > NestJobRules.MaxPackageBytes)
throw new ArgumentException("The nest job package is empty or too large.", nameof(package));
using var content = new MultipartFormDataContent();
content.Add(new StringContent(JsonSerializer.Serialize(submission, NestServerHttp.JsonOptions), Encoding.UTF8, "application/json"), "metadata");
var file = new ByteArrayContent(package);
file.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
content.Add(file, "file", "job.zip");
using var response = await _httpClient.PostAsync(Url("api/nest-jobs"), content, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new ServerTooOldException(Feature);
await NestServerHttp.EnsureSuccessAsync(response, "submit the nest", cancellationToken);
return await ReadRecordAsync(response, cancellationToken);
}
/// The job, or null when the server does not hold it (removed after ).
public async Task GetAsync(Guid id, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url($"api/nest-jobs/{id}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await NestServerHttp.EnsureSuccessAsync(response, "read the nest job", cancellationToken);
var record = await ReadRecordAsync(response, cancellationToken);
return record.Id == id ? record : throw new IOException("Nest server returned a different nest job.");
}
///
/// Jobs newest first (at most ), optionally one submitter's
/// (user matches that user on any workstation) and only active, finished or
/// unreviewed ones. With the of an earlier answer, null means the
/// list has not changed. A server without job lists raises .
///
public async Task ListAsync(NestJobListFilter filter = NestJobListFilter.All, string? submittedBy = null,
int limit = 200, string? etag = null, CancellationToken cancellationToken = default)
{
var query = $"api/nest-jobs?filter={filter.ToString().ToLowerInvariant()}&limit={limit.ToString(CultureInfo.InvariantCulture)}";
if (!string.IsNullOrWhiteSpace(submittedBy))
query += "&submittedBy=" + Uri.EscapeDataString(submittedBy);
using var request = new HttpRequestMessage(HttpMethod.Get, Url(query));
if (!string.IsNullOrEmpty(etag) && EntityTagHeaderValue.TryParse(etag, out var tag))
request.Headers.IfNoneMatch.Add(tag);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotModified)
return null;
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed)
throw new ServerTooOldException("server nest job lists");
await NestServerHttp.EnsureSuccessAsync(response, "list the nest jobs", cancellationToken);
var jobs = await response.Content.ReadFromJsonAsync>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty job list.");
return new NestJobList(jobs, response.Headers.ETag?.ToString());
}
///
/// Records that the result of (as last read) is kept. Only one Keep or
/// Discard ever succeeds: a job kept, discarded or changed meanwhile raises
/// with the job as it is. Null when the server no longer has it.
///
public Task KeepAsync(NestJobRecord job, string reviewedBy, CancellationToken cancellationToken = default) =>
ReviewAsync(job, "keep", reviewedBy, cancellationToken);
/// Records that the result is discarded, as records a Keep.
public Task DiscardAsync(NestJobRecord job, string reviewedBy, CancellationToken cancellationToken = default) =>
ReviewAsync(job, "discard", reviewedBy, cancellationToken);
private async Task ReviewAsync(NestJobRecord job, string action, string reviewedBy, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(job);
using var request = new HttpRequestMessage(HttpMethod.Post, Url($"api/nest-jobs/{job.Id}/{action}"))
{
Content = JsonContent.Create(new NestJobReviewRequest { ReviewedBy = reviewedBy ?? "" }, options: NestServerHttp.JsonOptions),
};
NestServerHttp.AddIfMatch(request, checked((int)job.Revision));
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return await GetAsync(job.Id, cancellationToken) is null ? null : throw new ServerTooOldException("keeping server nests once");
if (response.StatusCode == HttpStatusCode.Conflict)
throw new NestJobConflictException(await ReadRecordAsync(response, cancellationToken));
await NestServerHttp.EnsureSuccessAsync(response, $"{action} the nest result", cancellationToken);
return await ReadRecordAsync(response, cancellationToken);
}
///
/// Removes a finished job and its result. False when the server no longer has it; a job
/// still active, or changed since was read, raises .
///
public async Task DeleteAsync(NestJobRecord job, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
using var request = new HttpRequestMessage(HttpMethod.Delete, Url($"api/nest-jobs/{job.Id}"));
NestServerHttp.AddIfMatch(request, checked((int)job.Revision));
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return false;
if (response.StatusCode == HttpStatusCode.Conflict)
throw new NestJobConflictException(await ReadRecordAsync(response, cancellationToken));
await NestServerHttp.EnsureSuccessAsync(response, "remove the nest job", cancellationToken);
return true;
}
/// A succeeded job's result (a NestSolveTranscript as JSON).
public async Task GetResultAsync(Guid id, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url($"api/nest-jobs/{id}/result"), cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, "read the nest result", cancellationToken);
return await response.Content.ReadAsStringAsync(cancellationToken);
}
/// The header naming the revision GET .../plan returned.
public const string PlanRevisionHeader = "X-Plan-Revision";
/// The best plan so far and the revision it was stored at, or null when the job has none.
public async Task<(string Plan, long Revision)?> GetPlanAsync(Guid id, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url($"api/nest-jobs/{id}/plan"), cancellationToken);
if (response.StatusCode is HttpStatusCode.NoContent or HttpStatusCode.NotFound)
return null;
await NestServerHttp.EnsureSuccessAsync(response, "read the nest job's plan", cancellationToken);
var revision = response.Headers.TryGetValues(PlanRevisionHeader, out var values)
&& long.TryParse(values.FirstOrDefault(), NumberStyles.None, CultureInfo.InvariantCulture, out var value) ? value : 0;
return (await response.Content.ReadAsStringAsync(cancellationToken), revision);
}
///
/// The job's event stream: an event, then each plan and
/// job change after (zero: the current job at once), ending
/// after the job event that is final, or with . A stream
/// that ends without either, or stays silent for , dropped:
/// reconnect with the last id seen. A server without the stream raises .
///
public async IAsyncEnumerable WatchAsync(Guid id, long lastEventId = 0, TimeSpan? idleTimeout = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, Url($"api/nest-jobs/{id}/events"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
if (lastEventId > 0)
request.Headers.TryAddWithoutValidation("Last-Event-ID", lastEventId.ToString(CultureInfo.InvariantCulture));
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
if (await GetAsync(id, cancellationToken) is null)
{
yield return new NestJobEvent { Kind = NestJobEventKind.Gone };
yield break;
}
throw new ServerTooOldException("server nesting progress");
}
await NestServerHttp.EnsureSuccessAsync(response, "watch the nest job", cancellationToken);
yield return new NestJobEvent { Kind = NestJobEventKind.Open };
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream, Encoding.UTF8);
var idle = idleTimeout ?? NestJobRules.StreamIdleTimeout;
var type = "";
var eventId = 0L;
var data = new StringBuilder();
while (true)
{
string? line;
using (var silence = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{
silence.CancelAfter(idle);
try { line = await reader.ReadLineAsync(silence.Token); }
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new IOException("The nest server stopped sending job updates.");
}
}
if (line is null)
yield break;
if (line.Length > 0)
{
if (line[0] == ':')
continue;
var colon = line.IndexOf(':');
var field = colon < 0 ? line : line[..colon];
var value = colon < 0 ? "" : line[(colon + 1)..];
if (value.StartsWith(' '))
value = value[1..];
switch (field)
{
case "event": type = value; break;
case "id": long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out eventId); break;
case "data":
if (data.Length > 0)
data.Append('\n');
data.Append(value);
break;
}
continue;
}
// A blank line dispatches the event.
var text = data.ToString();
var kind = type;
data.Clear();
type = "";
switch (kind)
{
case "job":
var job = JsonSerializer.Deserialize(text, NestServerHttp.JsonOptions)
?? throw new IOException("Nest server sent an empty nest job.");
yield return new NestJobEvent { Kind = NestJobEventKind.Job, Id = eventId, Job = job };
break;
case "plan":
yield return new NestJobEvent { Kind = NestJobEventKind.Plan, Id = eventId, Plan = text };
break;
case "gone":
yield return new NestJobEvent { Kind = NestJobEventKind.Gone, Id = eventId };
yield break;
}
}
}
/// Stops the job: a queued job is cancelled at once, a running one at its worker's next heartbeat.
public async Task CancelAsync(Guid id, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.PostAsync(Url($"api/nest-jobs/{id}/cancel"), null, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await NestServerHttp.EnsureSuccessAsync(response, "stop the nest job", cancellationToken);
return await ReadRecordAsync(response, cancellationToken);
}
/// Takes the oldest queued job for , or null when there is
/// none or another job is still running.
public async Task ClaimAsync(string workerId, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.PostAsJsonAsync(Url("api/nest-jobs/claim"),
new NestJobWorkerMessage { WorkerId = workerId }, NestServerHttp.JsonOptions, cancellationToken);
if (response.StatusCode == HttpStatusCode.NoContent)
return null;
if (response.StatusCode == HttpStatusCode.NotFound)
throw new ServerTooOldException(Feature);
await NestServerHttp.EnsureSuccessAsync(response, "claim a nest job", cancellationToken);
return await ReadRecordAsync(response, cancellationToken);
}
public async Task GetPackageAsync(Guid id, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url($"api/nest-jobs/{id}/package"), cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, "read the nest job package", cancellationToken);
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
/// Keeps the job's lease. Null when the worker no longer holds the job.
public async Task HeartbeatAsync(Guid id, string workerId, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.PostAsJsonAsync(Url($"api/nest-jobs/{id}/heartbeat"),
new NestJobWorkerMessage { WorkerId = workerId }, NestServerHttp.JsonOptions, cancellationToken);
if (response.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.NotFound)
return null;
await NestServerHttp.EnsureSuccessAsync(response, "report on the nest job", cancellationToken);
return await response.Content.ReadFromJsonAsync(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty heartbeat.");
}
///
/// Stores the worker's latest progress report (and renews the lease). Null when the worker
/// no longer holds the job; otherwise whether someone asked to stop it.
///
public Task ReportProgressAsync(Guid id, string workerId, string progress, CancellationToken cancellationToken = default) =>
PutReportAsync($"api/nest-jobs/{id}/progress", workerId, progress, "report the nest job's progress", cancellationToken);
/// Stores the best plan so far, as does a progress report.
public Task ReportPlanAsync(Guid id, string workerId, string plan, CancellationToken cancellationToken = default) =>
PutReportAsync($"api/nest-jobs/{id}/plan", workerId, plan, "report the nest job's plan", cancellationToken);
private async Task PutReportAsync(string path, string workerId, string json, string action, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(json);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await _httpClient.PutAsync(Url($"{path}?workerId={Uri.EscapeDataString(workerId)}"), content, cancellationToken);
if (response.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.NotFound)
return null;
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
return await response.Content.ReadFromJsonAsync(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty answer.");
}
///
/// Stores the result, with its for job lists. False when the
/// worker no longer holds the job.
///
public async Task CompleteAsync(Guid id, string workerId, string result, NestJobSummary? summary = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
var path = $"api/nest-jobs/{id}/result?workerId={Uri.EscapeDataString(workerId)}";
if (summary is not null)
path += string.Create(CultureInfo.InvariantCulture,
$"&parts={summary.PartsPlaced}&unplaced={summary.PartsUnplaced}&sheets={summary.Sheets}&utilization={System.Math.Clamp(summary.Utilization, 0, 1):R}");
using var content = new StringContent(result, Encoding.UTF8, "application/json");
using var response = await _httpClient.PutAsync(Url(path), content, cancellationToken);
return await Held(response, "store the nest result", cancellationToken);
}
public async Task FailAsync(Guid id, string workerId, string error, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.PostAsJsonAsync(Url($"api/nest-jobs/{id}/fail"),
new NestJobWorkerMessage { WorkerId = workerId, Error = error }, NestServerHttp.JsonOptions, cancellationToken);
return await Held(response, "report the failed nest job", cancellationToken);
}
/// The worker stopped the job as asked.
public async Task ConfirmCancelledAsync(Guid id, string workerId, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.PostAsJsonAsync(Url($"api/nest-jobs/{id}/cancelled"),
new NestJobWorkerMessage { WorkerId = workerId }, NestServerHttp.JsonOptions, cancellationToken);
return await Held(response, "report the stopped nest job", cancellationToken);
}
/// Gives the job back to the queue without spending an attempt (the worker is shutting down).
public async Task ReleaseAsync(Guid id, string workerId, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.PostAsJsonAsync(Url($"api/nest-jobs/{id}/release"),
new NestJobWorkerMessage { WorkerId = workerId }, NestServerHttp.JsonOptions, cancellationToken);
return await Held(response, "give back the nest job", cancellationToken);
}
private static async Task Held(HttpResponseMessage response, string action, CancellationToken cancellationToken)
{
if (response.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.NotFound)
return false;
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
return true;
}
private static async Task ReadRecordAsync(HttpResponseMessage response, CancellationToken cancellationToken) =>
await response.Content.ReadFromJsonAsync(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty nest job.");
}
/// One answer of : the jobs and the tag to send next time.
public sealed record NestJobList(IReadOnlyList Jobs, string? ETag);
/// A Keep, Discard or Remove refused because the job was reviewed, removed or changed meanwhile.
public sealed class NestJobConflictException : IOException
{
public NestJobConflictException(NestJobRecord job)
: base(Describe(job))
{
Job = job;
}
/// The job as the server now holds it.
public NestJobRecord Job { get; }
private static string Describe(NestJobRecord job) => job.Review switch
{
NestJobReview.Kept => $"This result was already kept{By(job)}.",
NestJobReview.Discarded => $"This result was already discarded{By(job)}.",
_ when !job.IsFinished => "The job is still running on the nest server; stop it first.",
_ => "The job changed on the nest server; refresh and try again.",
};
private static string By(NestJobRecord job) =>
(string.IsNullOrWhiteSpace(job.ReviewedBy) ? "" : $" by {job.ReviewedBy}")
+ (job.ReviewedAt is { } at ? $" on {at.ToLocalTime():g}" : "");
}