Files
ajandClaude Opus 5.5 54ad7ee84d feat(server): nest Auto Nest jobs on the server's worker
Large nests tie up the workstation that runs them. In Database mode, Auto Nest
can now queue the job on the nest server, where a worker container solves one
job at a time under its own CPU and memory limits. The job has no time limit,
so a throttled worker finds the same nest a fast one does, only later.

- Server: /api/nest-jobs queue (schema v15). The worker holds a job on a
  heartbeat lease. When the lease lapses, the job goes back to the queue,
  or fails after three attempts. Only one job runs at a time. Packages and
  results are opaque to the HTTP server, which stays off Engine and IO.
- Worker: OpenNest.Server.Worker in the same image (OPENNEST_ROLE=worker),
  reaching the server over HTTP only. It records each engine answer with
  RecordingNestingEngine and gives its job back on shutdown.
- Desktop: "Nest on the server" in the Auto Nest dialog, for built-in
  engines only. The client replays the recorded answers with
  RemoteNestSubmission, so validation, the review and Keep run locally
  against the client's own drawings, exactly as after a local run. A
  drawing edited after sending refuses the result. If the server cannot
  take the job, the user may nest locally instead.
- compose.server.yaml adds the throttled worker service. The Dockerfile,
  .dockerignore, CI paths and the deploy script now include the worker's
  projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:18:09 -05:00

185 lines
8.2 KiB
C#

using System.Net;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using OpenNest.Api;
using OpenNest.Data;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Remote;
using OpenNest.Geometry;
using OpenNest.Server.Worker;
namespace OpenNest.Server.Tests;
/// <summary>
/// Server nesting over HTTP: the desktop's client submits a real job package, the worker
/// solves it through the in-memory server, and the desktop replays the result against its own
/// drawings. Queue rules themselves are pinned by <see cref="NestJobQueueTests"/>.
/// </summary>
public class NestJobApiTests
{
private static OpenNest.CNC.Program Rectangle(double width, double length)
{
var program = new OpenNest.CNC.Program();
program.MoveTo(0, 0);
program.LineTo(width, 0);
program.LineTo(width, length);
program.LineTo(0, length);
program.LineTo(0, 0);
return program;
}
private static NestPipelineRequest Request(string engine = "Rectangles")
{
var template = new Plate(48, 96) { EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25), PartSpacing = 0.25 };
return new NestPipelineRequest(engine,
new[]
{
new NestItem { Drawing = new Drawing("bracket", Rectangle(10, 20)), Quantity = 9 },
new NestItem { Drawing = new Drawing("gusset", Rectangle(6.125, 6.125)), Quantity = 14, Priority = 2 },
},
NestStockBuilder.FromTemplate(template, null),
new NestJobOptions(maxPlates: 5));
}
private static (RemoteNestJobs Jobs, NestJobWorker Worker) Connect(ServerFactory factory)
{
var http = factory.CreateClient();
var jobs = new RemoteNestJobs(http, http.BaseAddress!.ToString());
return (jobs, new NestJobWorker(jobs, NullLogger.Instance));
}
private static NestJobSubmission Describe(NestPipelineRequest request) =>
new() { Title = "N26-0042", EngineName = request.EngineName, SubmittedBy = "aj@shop-pc" };
[Fact]
public async Task TheWorkerSolvesASubmittedJobAndTheDesktopKeepsTheSameNest()
{
using var factory = new ServerFactory();
var (jobs, worker) = Connect(factory);
var request = Request();
var submission = new RemoteNestSubmission(request);
var job = await jobs.SubmitAsync(Describe(request), NestJobPackage.Write(request));
Assert.Equal(NestJobState.Queued, job.State);
Assert.Equal("N26-0042", job.Title);
Assert.True(await worker.RunOnceAsync(CancellationToken.None));
Assert.False(await worker.RunOnceAsync(CancellationToken.None));
var done = (await jobs.GetAsync(job.Id))!;
Assert.Equal(NestJobState.Succeeded, done.State);
var result = submission.Complete(NestSolveTranscript.FromJson(await jobs.GetResultAsync(job.Id)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Empty(result.Unplaced);
var parts = result.Plates.SelectMany(p => Enumerable.Repeat(p.Parts, p.Quantity)).SelectMany(p => p).ToList();
Assert.Equal(9, parts.Count(p => ReferenceEquals(p.BaseDrawing, request.Items[0].Drawing)));
Assert.Equal(14, parts.Count(p => ReferenceEquals(p.BaseDrawing, request.Items[1].Drawing)));
// The same nest a local run proposes.
var local = NestPipeline.Run(request);
Assert.Equal(local.Plates.SelectMany(p => p.Parts).Select(p => (p.BaseDrawing.Name, p.Location.X, p.Location.Y)),
result.Plates.SelectMany(p => p.Parts).Select(p => (p.BaseDrawing.Name, p.Location.X, p.Location.Y)));
}
[Fact]
public void APackageReadsBackAsTheSameRequest()
{
var request = Request("Default");
var shared = request.Items[0].Drawing;
request = request with { Items = request.Items.Append(new NestItem { Drawing = shared, Quantity = 0 }).ToArray() };
var back = NestJobPackage.Read(NestJobPackage.Write(request, Units.Millimeters));
Assert.Equal("Default", back.EngineName);
Assert.Equal(request.Items.Select(i => (i.Drawing.Name, i.Quantity, i.Priority)), back.Items.Select(i => (i.Drawing.Name, i.Quantity, i.Priority)));
Assert.Same(back.Items[0].Drawing, back.Items[2].Drawing);
Assert.Equal(request.Stock.Select(s => s.Id), back.Stock.Select(s => s.Id));
Assert.Equal(5, back.Options!.MaxPlates);
Assert.Throws<FormatException>(() => NestJobPackage.Read(Encoding.UTF8.GetBytes("not a zip")));
}
[Fact]
public async Task APlugInEngineFailsTheJobWithAReason()
{
using var factory = new ServerFactory();
var (jobs, worker) = Connect(factory);
var request = Request() with { EngineName = "MyPlugIn" };
var job = await jobs.SubmitAsync(Describe(request), NestJobPackage.Write(request));
await worker.RunOnceAsync(CancellationToken.None);
var failed = (await jobs.GetAsync(job.Id))!;
Assert.Equal(NestJobState.Failed, failed.State);
Assert.Contains("MyPlugIn", failed.Error);
await Assert.ThrowsAsync<IOException>(() => jobs.GetResultAsync(job.Id));
}
[Fact]
public async Task AnUnreadablePackageFailsTheJob()
{
using var factory = new ServerFactory();
var (jobs, worker) = Connect(factory);
var job = await jobs.SubmitAsync(new NestJobSubmission { EngineName = "Default" }, Encoding.UTF8.GetBytes("junk"));
await worker.RunOnceAsync(CancellationToken.None);
var failed = (await jobs.GetAsync(job.Id))!;
Assert.Equal(NestJobState.Failed, failed.State);
Assert.Contains("could not be read", failed.Error);
}
[Fact]
public async Task CancellingOverHttpStopsAQueuedJobAndFlagsARunningOne()
{
using var factory = new ServerFactory();
var (jobs, _) = Connect(factory);
var request = Request();
var running = await jobs.SubmitAsync(Describe(request), NestJobPackage.Write(request));
var queued = await jobs.SubmitAsync(Describe(request), NestJobPackage.Write(request));
Assert.Equal(1, queued.QueuePosition);
var claimed = await jobs.ClaimAsync("w1");
Assert.Equal(running.Id, claimed!.Id);
Assert.Null(await jobs.ClaimAsync("w2"));
Assert.Equal(NestJobState.Cancelled, (await jobs.CancelAsync(queued.Id))!.State);
Assert.True((await jobs.CancelAsync(running.Id))!.CancelRequested);
Assert.True((await jobs.HeartbeatAsync(running.Id, "w1"))!.CancelRequested);
Assert.Null(await jobs.HeartbeatAsync(running.Id, "w2"));
Assert.True(await jobs.ConfirmCancelledAsync(running.Id, "w1"));
Assert.Equal(NestJobState.Cancelled, (await jobs.GetAsync(running.Id))!.State);
Assert.False(await jobs.CompleteAsync(running.Id, "w1", "{}"));
}
[Fact]
public async Task UnknownJobsAndBadRequestsAreRefused()
{
using var factory = new ServerFactory();
var http = factory.CreateClient();
var (jobs, _) = Connect(factory);
Assert.Null(await jobs.GetAsync(Guid.NewGuid()));
Assert.Null(await jobs.CancelAsync(Guid.NewGuid()));
Assert.Null(await jobs.ClaimAsync("w1"));
Assert.Equal(HttpStatusCode.NotFound, (await http.GetAsync($"api/nest-jobs/{Guid.NewGuid()}/package")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,
(await http.PostAsync("api/nest-jobs", new StringContent("{}", Encoding.UTF8, "application/json"))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,
(await http.PostAsync("api/nest-jobs/claim", new StringContent("{}", Encoding.UTF8, "application/json"))).StatusCode);
using var noEngine = new MultipartFormDataContent
{
{ new StringContent("{\"title\":\"x\"}", Encoding.UTF8, "application/json"), "metadata" },
{ new ByteArrayContent(new byte[] { 1 }), "file", "job.zip" },
};
Assert.Equal(HttpStatusCode.BadRequest, (await http.PostAsync("api/nest-jobs", noEngine)).StatusCode);
var request = Request();
var job = await jobs.SubmitAsync(Describe(request), NestJobPackage.Write(request));
var early = await http.GetAsync($"api/nest-jobs/{job.Id}/result");
Assert.Equal(HttpStatusCode.Conflict, early.StatusCode);
}
}