F2: Execute had no status guard, so a redelivered or stale message reran a finished run. Runs that are neither queued nor running are logged and acked without work; "running" still executes so a worker that crashed mid-run recovers. F4: failRun, finishNodeFailed and MarkRunFailed wrote the terminal status on the caller's context, which is exactly the context that is already cancelled when the run failed because of a shutdown or a sibling node — the write was refused and the run stayed stuck in "running". They now write on a context.WithoutCancel copy with a 10s timeout; the consumer's best-effort MarkRunFailed does the same. F5: peak memory scaled with the host's core count. LP_MAX_WORKERS (default 8) is now the total window-worker budget of a run and LP_MAX_NODES (default 2) the node fan-out; each node gets max(1, workers/nodes) through the new landscape.ComputeWithWorkers. Compute and service.ComputeFunc keep their signatures: Compute delegates with MaxWorkersPerNode, and a legacy ComputeFunc handed to NewLandscapeService is wrapped. F6: the stage loop used a plain errgroup, so a failing node let its siblings run to completion. It now uses errgroup.WithContext and passes the group context into runNode, cancelling siblings mid-compute. F7: wrap every float32 product in an explicit conversion so the compiler cannot fuse it into an FMA, keeping the kernels bit-identical to numpy's per-op rounding. F8: README's builder package list now matches the Dockerfile (adds g++), and the Landscape section documents the memory model and the two new knobs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1521 lines
45 KiB
Go
1521 lines
45 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gis/internal/domain"
|
|
"gis/internal/landscape"
|
|
"gis/internal/raster"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// GetByCode resolves a category by its code so stubCategoryRepo also satisfies
|
|
// the categoryReader dependency the landscape service needs.
|
|
func (r *stubCategoryRepo) GetByCode(_ context.Context, code string) (domain.Category, error) {
|
|
for _, c := range r.store {
|
|
if c.Code == code {
|
|
return c, nil
|
|
}
|
|
}
|
|
return domain.Category{}, domain.ErrNotFound
|
|
}
|
|
|
|
// lockedDatasetRepo serialises the dataset-repository calls the landscape
|
|
// service makes from concurrently executing nodes. stubDatasetRepo is a plain
|
|
// map, so without this the -race detector would flag the fan-out.
|
|
type lockedDatasetRepo struct {
|
|
*stubDatasetRepo
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func newLockedDatasetRepo() *lockedDatasetRepo {
|
|
return &lockedDatasetRepo{stubDatasetRepo: newStubDatasetRepo()}
|
|
}
|
|
|
|
func (r *lockedDatasetRepo) Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.stubDatasetRepo.Create(ctx, d)
|
|
}
|
|
|
|
func (r *lockedDatasetRepo) GetByID(ctx context.Context, id uuid.UUID) (domain.Dataset, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.stubDatasetRepo.GetByID(ctx, id)
|
|
}
|
|
|
|
func (r *lockedDatasetRepo) LatestInGroup(ctx context.Context, groupID uuid.UUID, status *string, fileType *domain.FileType) (domain.Dataset, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.stubDatasetRepo.LatestInGroup(ctx, groupID, status, fileType)
|
|
}
|
|
|
|
// stubLPRepo is an in-memory LandscapeRepository for tests.
|
|
// lpStatusWrite records one SetRunStatus call and whether the context it was
|
|
// handed was still live.
|
|
type lpStatusWrite struct {
|
|
status domain.LPRunStatus
|
|
ctxErr error
|
|
}
|
|
|
|
// lpNodeWrite records one SetNodeFinished call the same way.
|
|
type lpNodeWrite struct {
|
|
code string
|
|
status domain.LPNodeStatus
|
|
ctxErr error
|
|
}
|
|
|
|
type stubLPRepo struct {
|
|
mu sync.Mutex
|
|
runs map[uuid.UUID]*domain.LPRun
|
|
order []uuid.UUID
|
|
reuseLookups []string
|
|
clock int64
|
|
statusWrites []lpStatusWrite
|
|
nodeWrites []lpNodeWrite
|
|
}
|
|
|
|
// statusesWritten returns the run statuses written so far, in order.
|
|
func (r *stubLPRepo) statusesWritten() []domain.LPRunStatus {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]domain.LPRunStatus, 0, len(r.statusWrites))
|
|
for _, w := range r.statusWrites {
|
|
out = append(out, w.status)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// statusWrite returns the last write recording the given status.
|
|
func (r *stubLPRepo) statusWrite(status domain.LPRunStatus) (lpStatusWrite, bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for i := len(r.statusWrites) - 1; i >= 0; i-- {
|
|
if r.statusWrites[i].status == status {
|
|
return r.statusWrites[i], true
|
|
}
|
|
}
|
|
return lpStatusWrite{}, false
|
|
}
|
|
|
|
// nodeWrite returns the last SetNodeFinished write recording the given status.
|
|
func (r *stubLPRepo) nodeWrite(status domain.LPNodeStatus) (lpNodeWrite, bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for i := len(r.nodeWrites) - 1; i >= 0; i-- {
|
|
if r.nodeWrites[i].status == status {
|
|
return r.nodeWrites[i], true
|
|
}
|
|
}
|
|
return lpNodeWrite{}, false
|
|
}
|
|
|
|
func newStubLPRepo() *stubLPRepo {
|
|
return &stubLPRepo{runs: map[uuid.UUID]*domain.LPRun{}}
|
|
}
|
|
|
|
// next returns a strictly increasing timestamp so "most recent" ordering is
|
|
// deterministic regardless of wall-clock resolution.
|
|
func (r *stubLPRepo) next() time.Time {
|
|
r.clock++
|
|
return time.Unix(0, r.clock)
|
|
}
|
|
|
|
func copyRefs(in []domain.LPDatasetRef) []domain.LPDatasetRef {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
return append([]domain.LPDatasetRef(nil), in...)
|
|
}
|
|
|
|
func copyRun(run domain.LPRun) domain.LPRun {
|
|
out := run
|
|
out.Overrides = map[string]uuid.UUID{}
|
|
for k, v := range run.Overrides {
|
|
out.Overrides[k] = v
|
|
}
|
|
out.Nodes = make([]domain.LPRunNode, len(run.Nodes))
|
|
for i, n := range run.Nodes {
|
|
n.Inputs = copyRefs(n.Inputs)
|
|
n.Outputs = copyRefs(n.Outputs)
|
|
out.Nodes[i] = n
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (r *stubLPRepo) CreateRun(_ context.Context, run domain.LPRun) (domain.LPRun, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
run.ID = uuid.New()
|
|
run.CreatedAt = r.next()
|
|
if run.Overrides == nil {
|
|
run.Overrides = map[string]uuid.UUID{}
|
|
}
|
|
nodes := make([]domain.LPRunNode, 0, len(run.Nodes))
|
|
for _, n := range run.Nodes {
|
|
nodes = append(nodes, domain.LPRunNode{
|
|
ID: uuid.New(),
|
|
RunID: run.ID,
|
|
NodeCode: n.NodeCode,
|
|
Stage: n.Stage,
|
|
Status: domain.LPNodePending,
|
|
Inputs: []domain.LPDatasetRef{},
|
|
})
|
|
}
|
|
run.Nodes = nodes
|
|
stored := copyRun(run)
|
|
r.runs[run.ID] = &stored
|
|
r.order = append(r.order, run.ID)
|
|
return copyRun(stored), nil
|
|
}
|
|
|
|
func (r *stubLPRepo) GetRun(_ context.Context, id uuid.UUID) (domain.LPRun, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
run, ok := r.runs[id]
|
|
if !ok {
|
|
return domain.LPRun{}, domain.ErrNotFound
|
|
}
|
|
out := copyRun(*run)
|
|
sort.Slice(out.Nodes, func(i, j int) bool { return out.Nodes[i].NodeCode < out.Nodes[j].NodeCode })
|
|
return out, nil
|
|
}
|
|
|
|
func (r *stubLPRepo) ListRuns(_ context.Context, limit, offset int) ([]domain.LPRun, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := []domain.LPRun{}
|
|
// Newest first.
|
|
for i := len(r.order) - 1; i >= 0; i-- {
|
|
run := copyRun(*r.runs[r.order[i]])
|
|
run.Nodes = nil
|
|
out = append(out, run)
|
|
}
|
|
if offset > len(out) {
|
|
offset = len(out)
|
|
}
|
|
out = out[offset:]
|
|
if limit >= 0 && limit < len(out) {
|
|
out = out[:limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *stubLPRepo) CountRuns(_ context.Context) (int, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return len(r.runs), nil
|
|
}
|
|
|
|
func (r *stubLPRepo) SetRunStatus(ctx context.Context, id uuid.UUID, status domain.LPRunStatus, errMsg *string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.statusWrites = append(r.statusWrites, lpStatusWrite{status: status, ctxErr: ctx.Err()})
|
|
run, ok := r.runs[id]
|
|
if !ok {
|
|
return domain.ErrNotFound
|
|
}
|
|
run.Status = status
|
|
run.Error = errMsg
|
|
switch status {
|
|
case domain.LPRunRunning:
|
|
t := r.next()
|
|
run.StartedAt = &t
|
|
case domain.LPRunSucceeded, domain.LPRunFailed:
|
|
t := r.next()
|
|
run.FinishedAt = &t
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *stubLPRepo) node(runID uuid.UUID, code string) (*domain.LPRunNode, error) {
|
|
run, ok := r.runs[runID]
|
|
if !ok {
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
for i := range run.Nodes {
|
|
if run.Nodes[i].NodeCode == code {
|
|
return &run.Nodes[i], nil
|
|
}
|
|
}
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
|
|
func (r *stubLPRepo) SetNodeStarted(_ context.Context, runID uuid.UUID, code, fingerprint string, inputs []domain.LPDatasetRef) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
n, err := r.node(runID, code)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n.Status = domain.LPNodeRunning
|
|
n.Fingerprint = fingerprint
|
|
n.Inputs = copyRefs(inputs)
|
|
n.Error = nil
|
|
t := r.next()
|
|
n.StartedAt = &t
|
|
return nil
|
|
}
|
|
|
|
func (r *stubLPRepo) SetNodeFinished(ctx context.Context, runID uuid.UUID, code string, status domain.LPNodeStatus, outputs []domain.LPDatasetRef, errMsg *string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.nodeWrites = append(r.nodeWrites, lpNodeWrite{code: code, status: status, ctxErr: ctx.Err()})
|
|
n, err := r.node(runID, code)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n.Status = status
|
|
n.Outputs = copyRefs(outputs)
|
|
n.Error = errMsg
|
|
t := r.next()
|
|
n.FinishedAt = &t
|
|
return nil
|
|
}
|
|
|
|
func (r *stubLPRepo) FindReusableOutputs(_ context.Context, code, fingerprint string, excludeRun uuid.UUID) ([]domain.LPDatasetRef, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.reuseLookups = append(r.reuseLookups, code)
|
|
|
|
var best *domain.LPRunNode
|
|
for _, run := range r.runs {
|
|
if run.ID == excludeRun {
|
|
continue
|
|
}
|
|
for i := range run.Nodes {
|
|
n := &run.Nodes[i]
|
|
if n.NodeCode != code || n.Fingerprint != fingerprint {
|
|
continue
|
|
}
|
|
if n.Status != domain.LPNodeSucceeded || n.Outputs == nil {
|
|
continue
|
|
}
|
|
if best == nil || (n.FinishedAt != nil && best.FinishedAt != nil && n.FinishedAt.After(*best.FinishedAt)) {
|
|
best = n
|
|
}
|
|
}
|
|
}
|
|
if best == nil {
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
return copyRefs(best.Outputs), nil
|
|
}
|
|
|
|
// lookups returns the node codes FindReusableOutputs was called for.
|
|
func (r *stubLPRepo) lookups() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]string(nil), r.reuseLookups...)
|
|
}
|
|
|
|
// stubPublisher implements datasetPublisher: it records the calls and inserts a
|
|
// ready raster dataset (as the COG conversion eventually would) so downstream
|
|
// nodes can resolve it.
|
|
type stubPublisher struct {
|
|
mu sync.Mutex
|
|
repo *lockedDatasetRepo
|
|
created []CreateRasterInput
|
|
uploaded []UploadInput
|
|
err error
|
|
}
|
|
|
|
func (p *stubPublisher) CreateRasterFromFile(ctx context.Context, in CreateRasterInput) (domain.Dataset, error) {
|
|
p.mu.Lock()
|
|
if p.err != nil {
|
|
p.mu.Unlock()
|
|
return domain.Dataset{}, p.err
|
|
}
|
|
p.created = append(p.created, in)
|
|
p.mu.Unlock()
|
|
|
|
// The compute step must have written the file the input points at.
|
|
if _, err := os.Stat(in.Path); err != nil {
|
|
return domain.Dataset{}, err
|
|
}
|
|
key := "landscape/" + uuid.NewString() + "/" + in.Filename
|
|
return p.repo.Create(ctx, domain.Dataset{
|
|
CategoryID: in.CategoryID,
|
|
DatasetGroupID: in.DatasetGroupID,
|
|
Name: in.Name,
|
|
Filename: in.Filename,
|
|
StorageKey: key,
|
|
FileType: domain.FileTypeRaster,
|
|
ContentType: "image/tiff",
|
|
Status: domain.DatasetStatusReady,
|
|
Meta: in.Meta,
|
|
Automated: in.Automated,
|
|
StartDate: in.StartDate,
|
|
EndDate: in.EndDate,
|
|
CreatedAt: time.Now(),
|
|
})
|
|
}
|
|
|
|
func (p *stubPublisher) Upload(ctx context.Context, in UploadInput) (domain.Dataset, error) {
|
|
p.mu.Lock()
|
|
if p.err != nil {
|
|
p.mu.Unlock()
|
|
return domain.Dataset{}, p.err
|
|
}
|
|
p.uploaded = append(p.uploaded, in)
|
|
p.mu.Unlock()
|
|
return p.repo.Create(ctx, domain.Dataset{
|
|
CategoryID: in.CategoryID,
|
|
DatasetGroupID: in.DatasetGroupID,
|
|
Name: in.Name,
|
|
Filename: in.Filename,
|
|
StorageKey: "landscape/" + uuid.NewString() + "/" + in.Filename,
|
|
FileType: in.FileType,
|
|
ContentType: in.ContentType,
|
|
Status: domain.DatasetStatusProcessing,
|
|
StartDate: in.StartDate,
|
|
EndDate: in.EndDate,
|
|
CreatedAt: time.Now(),
|
|
})
|
|
}
|
|
|
|
// stubLPJobs records enqueued landscape runs.
|
|
type stubLPJobs struct {
|
|
mu sync.Mutex
|
|
enqueued []uuid.UUID
|
|
err error
|
|
}
|
|
|
|
func (j *stubLPJobs) EnqueueRun(_ context.Context, runID uuid.UUID) error {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
if j.err != nil {
|
|
return j.err
|
|
}
|
|
j.enqueued = append(j.enqueued, runID)
|
|
return nil
|
|
}
|
|
|
|
// stubComputeCall records one invocation of the compute stub.
|
|
type stubComputeCall struct {
|
|
node string
|
|
// workers is the per-node worker cap the service passed, or -1 when the
|
|
// call came through the legacy worker-agnostic ComputeFunc.
|
|
workers int
|
|
inputs map[string]string
|
|
// bodies holds each input file's content at call time; seedDataset stores
|
|
// each object under its own storage key as its body, so a test can tell
|
|
// which dataset a node actually downloaded.
|
|
bodies map[string]string
|
|
params landscape.Params
|
|
}
|
|
|
|
// stubCompute stands in for landscape.Compute: it writes one empty file per
|
|
// declared output and records the inputs it was handed.
|
|
type stubCompute struct {
|
|
mu sync.Mutex
|
|
calls []stubComputeCall
|
|
errs map[string]error
|
|
// waitForCancel names nodes whose compute blocks until its context is
|
|
// cancelled (or the deadline below elapses), so a test can observe whether
|
|
// a sibling's failure cancelled them.
|
|
waitForCancel map[string]bool
|
|
sawCancel map[string]bool
|
|
}
|
|
|
|
func newStubCompute() *stubCompute {
|
|
return &stubCompute{errs: map[string]error{}, waitForCancel: map[string]bool{}, sawCancel: map[string]bool{}}
|
|
}
|
|
|
|
// fn is a legacy service.ComputeFunc: it sees no worker cap.
|
|
func (c *stubCompute) fn(ctx context.Context, rio raster.IO, node landscape.Node, inputs map[string]string, outDir string, p landscape.Params) (map[string]string, error) {
|
|
return c.fnWithWorkers(ctx, rio, node, inputs, outDir, p, -1)
|
|
}
|
|
|
|
// observedCancel reports whether the node's compute saw a cancelled context.
|
|
func (c *stubCompute) observedCancel(code string) bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.sawCancel[code]
|
|
}
|
|
|
|
func (c *stubCompute) fnWithWorkers(ctx context.Context, _ raster.IO, node landscape.Node, inputs map[string]string, outDir string, p landscape.Params, workers int) (map[string]string, error) {
|
|
c.mu.Lock()
|
|
wait := c.waitForCancel[node.Code]
|
|
c.mu.Unlock()
|
|
if wait {
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
c.mu.Lock()
|
|
c.sawCancel[node.Code] = ctx.Err() != nil
|
|
c.mu.Unlock()
|
|
}
|
|
// The real Compute honours cancellation, so the stub must too.
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
in := map[string]string{}
|
|
bodies := map[string]string{}
|
|
for k, v := range inputs {
|
|
in[k] = v
|
|
body, err := os.ReadFile(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bodies[k] = string(body)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.calls = append(c.calls, stubComputeCall{node: node.Code, workers: workers, inputs: in, bodies: bodies, params: p})
|
|
err := c.errs[node.Code]
|
|
c.mu.Unlock()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := map[string]string{}
|
|
for _, g := range node.Outputs {
|
|
path := filepath.Join(outDir, g+".tif")
|
|
if err := os.WriteFile(path, []byte("TIF"), 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
out[g] = path
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *stubCompute) count() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return len(c.calls)
|
|
}
|
|
|
|
func (c *stubCompute) nodeCodes() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
out := make([]string, 0, len(c.calls))
|
|
for _, call := range c.calls {
|
|
out = append(out, call.node)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func (c *stubCompute) callFor(code string) (stubComputeCall, bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for _, call := range c.calls {
|
|
if call.node == code {
|
|
return call, true
|
|
}
|
|
}
|
|
return stubComputeCall{}, false
|
|
}
|
|
|
|
func (c *stubCompute) reset() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.calls = nil
|
|
}
|
|
|
|
// lpFixture wires a LandscapeService over in-memory stubs.
|
|
type lpFixture struct {
|
|
svc *LandscapeService
|
|
runs *stubLPRepo
|
|
datasets *lockedDatasetRepo
|
|
groups *stubGroupRepo
|
|
cats *stubCategoryRepo
|
|
pub *stubPublisher
|
|
store *stubStore
|
|
jobs *stubLPJobs
|
|
compute *stubCompute
|
|
category domain.Category
|
|
}
|
|
|
|
func newLPFixture(t *testing.T) *lpFixture {
|
|
t.Helper()
|
|
return newLPFixtureWith(t, LandscapeOptions{})
|
|
}
|
|
|
|
// newLPFixtureWith builds the fixture with explicit parallelism options.
|
|
func newLPFixtureWith(t *testing.T, opts LandscapeOptions) *lpFixture {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
cats := newStubCategoryRepo()
|
|
cat, err := cats.Create(ctx, domain.Category{Code: landscape.CategoryCode, Name: "Landscape"})
|
|
if err != nil {
|
|
t.Fatalf("seed category: %v", err)
|
|
}
|
|
|
|
groups := newStubGroupRepo()
|
|
for _, s := range landscape.Slots {
|
|
if _, err := groups.Create(ctx, domain.DatasetGroup{Code: s.Code, Name: s.Title}); err != nil {
|
|
t.Fatalf("seed group %s: %v", s.Code, err)
|
|
}
|
|
}
|
|
for _, o := range landscape.Outputs {
|
|
if _, err := groups.Create(ctx, domain.DatasetGroup{Code: o.Code, Name: o.Title}); err != nil {
|
|
t.Fatalf("seed group %s: %v", o.Code, err)
|
|
}
|
|
}
|
|
|
|
datasets := newLockedDatasetRepo()
|
|
store := newStubStore()
|
|
pub := &stubPublisher{repo: datasets}
|
|
runs := newStubLPRepo()
|
|
jobs := &stubLPJobs{}
|
|
comp := newStubCompute()
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
svc := NewLandscapeService(runs, datasets, groups, cats, pub, store, jobs, nil, nil, log, opts)
|
|
// Substitute the workers-aware stub so tests can see the per-node cap the
|
|
// service computes; NewLandscapeService's legacy-ComputeFunc wrapping is
|
|
// covered separately.
|
|
svc.computeWithWorkers = comp.fnWithWorkers
|
|
return &lpFixture{
|
|
svc: svc, runs: runs, datasets: datasets, groups: groups, cats: cats,
|
|
pub: pub, store: store, jobs: jobs, compute: comp, category: cat,
|
|
}
|
|
}
|
|
|
|
// groupID resolves a seeded group's id by code.
|
|
func (f *lpFixture) groupID(t *testing.T, code string) uuid.UUID {
|
|
t.Helper()
|
|
g, err := f.groups.GetByCode(context.Background(), code)
|
|
if err != nil {
|
|
t.Fatalf("group %s: %v", code, err)
|
|
}
|
|
return g.ID
|
|
}
|
|
|
|
// seedDataset inserts a dataset into a group and stores its object bytes.
|
|
func (f *lpFixture) seedDataset(t *testing.T, code, status string, fileType domain.FileType, endDate string) domain.Dataset {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
gid := f.groupID(t, code)
|
|
key := "uploads/" + uuid.NewString() + "/" + code + ".tif"
|
|
end := endDate
|
|
start := "2020-01-01"
|
|
d, err := f.datasets.Create(ctx, domain.Dataset{
|
|
CategoryID: f.category.ID,
|
|
DatasetGroupID: &gid,
|
|
Name: code,
|
|
Filename: code + ".tif",
|
|
StorageKey: key,
|
|
FileType: fileType,
|
|
ContentType: "image/tiff",
|
|
Status: status,
|
|
StartDate: &start,
|
|
EndDate: &end,
|
|
CreatedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("seed dataset %s: %v", code, err)
|
|
}
|
|
if err := f.store.Put(ctx, key, bytes.NewReader([]byte(key)), int64(len(key)), "image/tiff"); err != nil {
|
|
t.Fatalf("seed object %s: %v", key, err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
// seedAllSlots fills every leaf slot with a ready raster.
|
|
func (f *lpFixture) seedAllSlots(t *testing.T, except ...string) {
|
|
t.Helper()
|
|
skip := map[string]bool{}
|
|
for _, c := range except {
|
|
skip[c] = true
|
|
}
|
|
for _, s := range landscape.Slots {
|
|
if skip[s.Code] {
|
|
continue
|
|
}
|
|
f.seedDataset(t, s.Code, domain.DatasetStatusReady, domain.FileTypeRaster, "2024-12-31")
|
|
}
|
|
}
|
|
|
|
// runOnce creates a run and executes it, returning the final run state.
|
|
func (f *lpFixture) runOnce(t *testing.T, in RunInput) domain.LPRun {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
run, err := f.svc.CreateRun(ctx, in)
|
|
if err != nil {
|
|
t.Fatalf("CreateRun: %v", err)
|
|
}
|
|
execErr := f.svc.Execute(ctx, run.ID)
|
|
got, err := f.svc.GetRun(ctx, run.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetRun: %v", err)
|
|
}
|
|
if execErr != nil && got.Status != domain.LPRunFailed {
|
|
t.Fatalf("Execute returned %v but run status is %s", execErr, got.Status)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// nodeStatuses maps node code -> status for a run.
|
|
func nodeStatuses(run domain.LPRun) map[string]domain.LPNodeStatus {
|
|
out := map[string]domain.LPNodeStatus{}
|
|
for _, n := range run.Nodes {
|
|
out[n.NodeCode] = n.Status
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nodeByCode finds a run node by its code.
|
|
func nodeByCode(t *testing.T, run domain.LPRun, code string) domain.LPRunNode {
|
|
t.Helper()
|
|
for _, n := range run.Nodes {
|
|
if n.NodeCode == code {
|
|
return n
|
|
}
|
|
}
|
|
t.Fatalf("run has no node %q", code)
|
|
return domain.LPRunNode{}
|
|
}
|
|
|
|
// refsByGroup indexes dataset refs by their group code.
|
|
func refsByGroup(refs []domain.LPDatasetRef) map[string]uuid.UUID {
|
|
out := map[string]uuid.UUID{}
|
|
for _, r := range refs {
|
|
out[r.Group] = r.DatasetID
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestLandscapeService_CreateRun_ValidatesParamsAndOverrides(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
stage := func(i int) *int { return &i }
|
|
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T, f *lpFixture) RunInput
|
|
}{
|
|
{
|
|
name: "tie priority is not a permutation",
|
|
setup: func(_ *testing.T, _ *lpFixture) RunInput {
|
|
return RunInput{Params: landscape.Params{TiePriority: []int{1, 2, 3}}}
|
|
},
|
|
},
|
|
{
|
|
name: "window size out of range",
|
|
setup: func(_ *testing.T, _ *lpFixture) RunInput {
|
|
return RunInput{Params: landscape.Params{WindowSize: 10}}
|
|
},
|
|
},
|
|
{
|
|
name: "from_stage out of range",
|
|
setup: func(_ *testing.T, _ *lpFixture) RunInput {
|
|
return RunInput{FromStage: stage(4)}
|
|
},
|
|
},
|
|
{
|
|
name: "override for unknown group",
|
|
setup: func(t *testing.T, f *lpFixture) RunInput {
|
|
d := f.seedDataset(t, landscape.SlotZLCR, domain.DatasetStatusReady, domain.FileTypeRaster, "2024-12-31")
|
|
return RunInput{Overrides: map[string]uuid.UUID{"lp-not-a-group": d.ID}}
|
|
},
|
|
},
|
|
{
|
|
name: "override dataset does not exist",
|
|
setup: func(_ *testing.T, _ *lpFixture) RunInput {
|
|
return RunInput{Overrides: map[string]uuid.UUID{landscape.SlotZLCR: uuid.New()}}
|
|
},
|
|
},
|
|
{
|
|
name: "override dataset is not ready",
|
|
setup: func(t *testing.T, f *lpFixture) RunInput {
|
|
d := f.seedDataset(t, landscape.SlotZLCR, domain.DatasetStatusProcessing, domain.FileTypeRaster, "2024-12-31")
|
|
return RunInput{Overrides: map[string]uuid.UUID{landscape.SlotZLCR: d.ID}}
|
|
},
|
|
},
|
|
{
|
|
name: "override dataset is not a raster",
|
|
setup: func(t *testing.T, f *lpFixture) RunInput {
|
|
d := f.seedDataset(t, landscape.SlotZLCR, domain.DatasetStatusReady, domain.FileTypeVector, "2024-12-31")
|
|
return RunInput{Overrides: map[string]uuid.UUID{landscape.SlotZLCR: d.ID}}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
in := tc.setup(t, f)
|
|
|
|
_, err := f.svc.CreateRun(ctx, in)
|
|
if !errors.Is(err, domain.ErrValidation) {
|
|
t.Fatalf("expected ErrValidation, got %v", err)
|
|
}
|
|
if len(f.jobs.enqueued) != 0 {
|
|
t.Fatalf("expected no job to be enqueued, got %d", len(f.jobs.enqueued))
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("valid input queues a run with pending nodes", func(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
override := f.seedDataset(t, landscape.SlotZLCR, domain.DatasetStatusReady, domain.FileTypeRaster, "2024-12-31")
|
|
|
|
run, err := f.svc.CreateRun(ctx, RunInput{
|
|
FromStage: stage(2),
|
|
Overrides: map[string]uuid.UUID{landscape.SlotZLCR: override.ID},
|
|
Params: landscape.Params{V1MinSE12: 2},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if run.Status != domain.LPRunQueued {
|
|
t.Fatalf("status = %s, want queued", run.Status)
|
|
}
|
|
if len(run.Nodes) != len(landscape.Nodes) {
|
|
t.Fatalf("nodes = %d, want %d", len(run.Nodes), len(landscape.Nodes))
|
|
}
|
|
for _, n := range run.Nodes {
|
|
if n.Status != domain.LPNodePending {
|
|
t.Fatalf("node %s status = %s, want pending", n.NodeCode, n.Status)
|
|
}
|
|
}
|
|
if run.FromStage == nil || *run.FromStage != 2 {
|
|
t.Fatalf("from_stage = %v, want 2", run.FromStage)
|
|
}
|
|
if got := run.Overrides[landscape.SlotZLCR]; got != override.ID {
|
|
t.Fatalf("override = %v, want %v", got, override.ID)
|
|
}
|
|
|
|
// Params are persisted with the TZ defaults applied, so kernels never
|
|
// see a short tie_priority.
|
|
var stored landscape.Params
|
|
if err := json.Unmarshal(run.Params, &stored); err != nil {
|
|
t.Fatalf("params are not valid JSON: %v", err)
|
|
}
|
|
want := landscape.DefaultParams()
|
|
want.V1MinSE12 = 2
|
|
if fmt.Sprint(stored) != fmt.Sprint(want) {
|
|
t.Fatalf("params = %+v, want %+v", stored, want)
|
|
}
|
|
|
|
if len(f.jobs.enqueued) != 1 || f.jobs.enqueued[0] != run.ID {
|
|
t.Fatalf("enqueued = %v, want [%v]", f.jobs.enqueued, run.ID)
|
|
}
|
|
})
|
|
|
|
t.Run("enqueue failure fails the run", func(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
f.jobs.err = errors.New("broker down")
|
|
|
|
_, err := f.svc.CreateRun(ctx, RunInput{})
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
runs, err := f.svc.ListRuns(ctx, 1, 10)
|
|
if err != nil {
|
|
t.Fatalf("ListRuns: %v", err)
|
|
}
|
|
if len(runs.Items) != 1 {
|
|
t.Fatalf("runs = %d, want 1", len(runs.Items))
|
|
}
|
|
if runs.Items[0].Status != domain.LPRunFailed {
|
|
t.Fatalf("status = %s, want failed", runs.Items[0].Status)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestLandscapeService_Execute_ResolvesInputsAndPublishesOutputs(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
f.seedAllSlots(t)
|
|
|
|
run := f.runOnce(t, RunInput{})
|
|
|
|
if run.Status != domain.LPRunSucceeded {
|
|
t.Fatalf("status = %s (error %v), want succeeded", run.Status, run.Error)
|
|
}
|
|
for _, n := range run.Nodes {
|
|
if n.Status != domain.LPNodeSucceeded {
|
|
t.Fatalf("node %s status = %s (error %v), want succeeded", n.NodeCode, n.Status, n.Error)
|
|
}
|
|
}
|
|
if got := f.compute.count(); got != len(landscape.Nodes) {
|
|
t.Fatalf("compute calls = %d, want %d", got, len(landscape.Nodes))
|
|
}
|
|
|
|
// Node 10 consumes what nodes 00 and 01 produced in this very run.
|
|
n00 := refsByGroup(nodeByCode(t, run, "00").Outputs)
|
|
n01 := refsByGroup(nodeByCode(t, run, "01").Outputs)
|
|
n10in := refsByGroup(nodeByCode(t, run, "10").Inputs)
|
|
if n10in[landscape.OutZIntegrated] != n00[landscape.OutZIntegrated] {
|
|
t.Fatalf("node 10 z input = %v, want %v", n10in[landscape.OutZIntegrated], n00[landscape.OutZIntegrated])
|
|
}
|
|
if n10in[landscape.OutUIntegrated] != n01[landscape.OutUIntegrated] {
|
|
t.Fatalf("node 10 u input = %v, want %v", n10in[landscape.OutUIntegrated], n01[landscape.OutUIntegrated])
|
|
}
|
|
call, ok := f.compute.callFor("10")
|
|
if !ok {
|
|
t.Fatal("compute was not called for node 10")
|
|
}
|
|
for _, g := range []string{landscape.OutZIntegrated, landscape.OutUIntegrated} {
|
|
if call.inputs[g] == "" {
|
|
t.Fatalf("node 10 compute input %q is empty (got %v)", g, call.inputs)
|
|
}
|
|
}
|
|
|
|
// Every output group got exactly one published dataset.
|
|
if len(f.pub.created) != len(landscape.Outputs) {
|
|
t.Fatalf("created datasets = %d, want %d", len(f.pub.created), len(landscape.Outputs))
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, in := range f.pub.created {
|
|
if !in.Automated {
|
|
t.Fatalf("dataset %q is not marked automated", in.Name)
|
|
}
|
|
if in.CategoryID != f.category.ID {
|
|
t.Fatalf("category = %v, want %v", in.CategoryID, f.category.ID)
|
|
}
|
|
if in.DatasetGroupID == nil {
|
|
t.Fatalf("dataset %q has no group", in.Name)
|
|
}
|
|
group := ""
|
|
for _, o := range landscape.Outputs {
|
|
if f.groupID(t, o.Code) == *in.DatasetGroupID {
|
|
group = o.Code
|
|
}
|
|
}
|
|
if group == "" {
|
|
t.Fatalf("dataset %q is filed under an unknown group %v", in.Name, *in.DatasetGroupID)
|
|
}
|
|
if in.Filename != group+".tif" {
|
|
t.Fatalf("filename = %q, want %q", in.Filename, group+".tif")
|
|
}
|
|
seen[group] = true
|
|
|
|
var meta map[string]any
|
|
if err := json.Unmarshal(in.Meta, &meta); err != nil {
|
|
t.Fatalf("meta of %q is not valid JSON: %v", in.Name, err)
|
|
}
|
|
if meta["lp_run_id"] != run.ID.String() {
|
|
t.Fatalf("meta lp_run_id = %v, want %v", meta["lp_run_id"], run.ID)
|
|
}
|
|
if _, ok := meta["lp_node"]; !ok {
|
|
t.Fatalf("meta of %q has no lp_node: %v", in.Name, meta)
|
|
}
|
|
if _, ok := meta["inputs"]; !ok {
|
|
t.Fatalf("meta of %q has no inputs: %v", in.Name, meta)
|
|
}
|
|
if in.EndDate == nil || *in.EndDate != "2024-12-31" {
|
|
t.Fatalf("end_date = %v, want 2024-12-31", in.EndDate)
|
|
}
|
|
}
|
|
if len(seen) != len(landscape.Outputs) {
|
|
t.Fatalf("groups covered = %d, want %d", len(seen), len(landscape.Outputs))
|
|
}
|
|
}
|
|
|
|
func TestLandscapeService_Execute_ReusesFingerprintMatches(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
f.seedAllSlots(t)
|
|
|
|
first := f.runOnce(t, RunInput{})
|
|
if first.Status != domain.LPRunSucceeded {
|
|
t.Fatalf("first run status = %s (%v)", first.Status, first.Error)
|
|
}
|
|
f.compute.reset()
|
|
|
|
second := f.runOnce(t, RunInput{})
|
|
if second.Status != domain.LPRunSucceeded {
|
|
t.Fatalf("second run status = %s (%v)", second.Status, second.Error)
|
|
}
|
|
if got := f.compute.count(); got != 0 {
|
|
t.Fatalf("compute calls on rerun = %d, want 0 (%v)", got, f.compute.nodeCodes())
|
|
}
|
|
if got := f.runs.lookups(); len(got) != 2*len(landscape.Nodes) {
|
|
t.Fatalf("reuse lookups = %d, want %d (once per node per run)", len(got), 2*len(landscape.Nodes))
|
|
}
|
|
for _, n := range second.Nodes {
|
|
if n.Status != domain.LPNodeSkipped {
|
|
t.Fatalf("node %s status = %s, want skipped", n.NodeCode, n.Status)
|
|
}
|
|
}
|
|
for _, n := range second.Nodes {
|
|
want := refsByGroup(nodeByCode(t, first, n.NodeCode).Outputs)
|
|
got := refsByGroup(n.Outputs)
|
|
if len(got) != len(want) {
|
|
t.Fatalf("node %s outputs = %v, want %v", n.NodeCode, got, want)
|
|
}
|
|
for g, id := range want {
|
|
if got[g] != id {
|
|
t.Fatalf("node %s output %s = %v, want %v", n.NodeCode, g, got[g], id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLandscapeService_Execute_RecomputesDownstreamOfChangedInput(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
f.seedAllSlots(t)
|
|
|
|
if first := f.runOnce(t, RunInput{}); first.Status != domain.LPRunSucceeded {
|
|
t.Fatalf("first run status = %s (%v)", first.Status, first.Error)
|
|
}
|
|
f.compute.reset()
|
|
|
|
// A fresher P-crop raster only affects the C conflicts and P max nodes and
|
|
// everything downstream of them.
|
|
f.seedDataset(t, landscape.SlotPCrop, domain.DatasetStatusReady, domain.FileTypeRaster, "2025-06-30")
|
|
|
|
second := f.runOnce(t, RunInput{})
|
|
if second.Status != domain.LPRunSucceeded {
|
|
t.Fatalf("second run status = %s (%v)", second.Status, second.Error)
|
|
}
|
|
|
|
statuses := nodeStatuses(second)
|
|
for _, code := range []string{"00", "01", "02", "03", "10"} {
|
|
if statuses[code] != domain.LPNodeSkipped {
|
|
t.Fatalf("node %s status = %s, want skipped", code, statuses[code])
|
|
}
|
|
}
|
|
for _, code := range []string{"20", "21", "30", "31"} {
|
|
if statuses[code] != domain.LPNodeSucceeded {
|
|
t.Fatalf("node %s status = %s, want succeeded", code, statuses[code])
|
|
}
|
|
}
|
|
want := []string{"20", "21", "30", "31"}
|
|
got := f.compute.nodeCodes()
|
|
if fmt.Sprint(got) != fmt.Sprint(want) {
|
|
t.Fatalf("recomputed nodes = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLandscapeService_Execute_FromStageForcesRecompute(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
f.seedAllSlots(t)
|
|
|
|
if first := f.runOnce(t, RunInput{}); first.Status != domain.LPRunSucceeded {
|
|
t.Fatalf("first run status = %s (%v)", first.Status, first.Error)
|
|
}
|
|
f.compute.reset()
|
|
|
|
from := 2
|
|
second := f.runOnce(t, RunInput{FromStage: &from})
|
|
if second.Status != domain.LPRunSucceeded {
|
|
t.Fatalf("second run status = %s (%v)", second.Status, second.Error)
|
|
}
|
|
|
|
statuses := nodeStatuses(second)
|
|
for _, code := range []string{"00", "01", "02", "03", "10"} {
|
|
if statuses[code] != domain.LPNodeSkipped {
|
|
t.Fatalf("node %s status = %s, want skipped", code, statuses[code])
|
|
}
|
|
}
|
|
for _, code := range []string{"20", "21", "30", "31"} {
|
|
if statuses[code] != domain.LPNodeSucceeded {
|
|
t.Fatalf("node %s status = %s, want succeeded", code, statuses[code])
|
|
}
|
|
}
|
|
if got, want := f.compute.nodeCodes(), []string{"20", "21", "30", "31"}; fmt.Sprint(got) != fmt.Sprint(want) {
|
|
t.Fatalf("recomputed nodes = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLandscapeService_Execute_MissingSlotFailsRun(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
f.seedAllSlots(t, landscape.SlotZWR)
|
|
|
|
run := f.runOnce(t, RunInput{})
|
|
|
|
if run.Status != domain.LPRunFailed {
|
|
t.Fatalf("status = %s, want failed", run.Status)
|
|
}
|
|
if run.Error == nil || !strings.Contains(*run.Error, landscape.SlotZWR) {
|
|
t.Fatalf("run error = %v, want it to name %q", run.Error, landscape.SlotZWR)
|
|
}
|
|
statuses := nodeStatuses(run)
|
|
for _, code := range []string{"00", "02"} {
|
|
if statuses[code] != domain.LPNodeFailed {
|
|
t.Fatalf("node %s status = %s, want failed", code, statuses[code])
|
|
}
|
|
n := nodeByCode(t, run, code)
|
|
if n.Error == nil || !strings.Contains(*n.Error, landscape.SlotZWR) {
|
|
t.Fatalf("node %s error = %v, want it to name %q", code, n.Error, landscape.SlotZWR)
|
|
}
|
|
}
|
|
// Stage 1 and beyond are never started.
|
|
for _, code := range []string{"10", "20", "21", "30", "31"} {
|
|
if statuses[code] != domain.LPNodePending {
|
|
t.Fatalf("node %s status = %s, want pending", code, statuses[code])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLandscapeService_UploadToSlot_UnknownSlot(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
|
|
_, err := f.svc.UploadToSlot(context.Background(), "lp-not-a-slot", SlotUploadInput{
|
|
Filename: "x.tif",
|
|
ContentType: "image/tiff",
|
|
Size: 3,
|
|
Reader: bytes.NewReader([]byte("TIF")),
|
|
})
|
|
if !errors.Is(err, domain.ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
|
}
|
|
if len(f.pub.uploaded) != 0 {
|
|
t.Fatalf("expected no upload, got %d", len(f.pub.uploaded))
|
|
}
|
|
}
|
|
|
|
func TestLandscapeService_UploadToSlot_KnownSlot(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
ctx := context.Background()
|
|
|
|
got, err := f.svc.UploadToSlot(ctx, landscape.SlotPSnow, SlotUploadInput{
|
|
Filename: "snow.tif",
|
|
ContentType: "image/tiff",
|
|
Size: 3,
|
|
Reader: bytes.NewReader([]byte("TIF")),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(f.pub.uploaded) != 1 {
|
|
t.Fatalf("uploads = %d, want 1", len(f.pub.uploaded))
|
|
}
|
|
in := f.pub.uploaded[0]
|
|
if in.FileType != domain.FileTypeRaster {
|
|
t.Fatalf("file_type = %s, want raster", in.FileType)
|
|
}
|
|
if in.CategoryID != f.category.ID {
|
|
t.Fatalf("category = %v, want %v", in.CategoryID, f.category.ID)
|
|
}
|
|
gid := f.groupID(t, landscape.SlotPSnow)
|
|
if in.DatasetGroupID == nil || *in.DatasetGroupID != gid {
|
|
t.Fatalf("group = %v, want %v", in.DatasetGroupID, gid)
|
|
}
|
|
if in.Name != "snow.tif" {
|
|
t.Fatalf("name = %q, want the filename as a default", in.Name)
|
|
}
|
|
if got.ID == uuid.Nil {
|
|
t.Fatal("returned dataset has no id")
|
|
}
|
|
}
|
|
|
|
func TestLandscapeService_Slots_ListsLatestPerSlot(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
// Any status counts for a slot, not just ready.
|
|
seeded := f.seedDataset(t, landscape.SlotUSoil, domain.DatasetStatusProcessing, domain.FileTypeRaster, "2024-12-31")
|
|
|
|
slots, err := f.svc.Slots(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(slots) != len(landscape.Slots) {
|
|
t.Fatalf("slots = %d, want %d", len(slots), len(landscape.Slots))
|
|
}
|
|
for _, s := range slots {
|
|
switch s.Code {
|
|
case landscape.SlotUSoil:
|
|
if s.Latest == nil || s.Latest.ID != seeded.ID {
|
|
t.Fatalf("latest for %s = %v, want %v", s.Code, s.Latest, seeded.ID)
|
|
}
|
|
if s.GroupID != f.groupID(t, landscape.SlotUSoil) {
|
|
t.Fatalf("group id = %v", s.GroupID)
|
|
}
|
|
default:
|
|
if s.Latest != nil {
|
|
t.Fatalf("latest for %s = %v, want nil", s.Code, s.Latest)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLandscapeService_Results_ListsLatestPerOutput(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
ctx := context.Background()
|
|
|
|
ready := f.seedDataset(t, landscape.OutE, domain.DatasetStatusReady, domain.FileTypeRaster, "2024-12-31")
|
|
// A not-yet-converted result must not be served as the latest result.
|
|
f.seedDataset(t, landscape.OutV1, domain.DatasetStatusProcessing, domain.FileTypeRaster, "2025-01-01")
|
|
|
|
eGroup := f.groupID(t, landscape.OutE)
|
|
if _, err := f.groups.SetSLD(ctx, eGroup, "groups/e/sld/e.sld", "e.sld", "application/xml"); err != nil {
|
|
t.Fatalf("SetSLD: %v", err)
|
|
}
|
|
|
|
results, err := f.svc.Results(ctx)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(results) != len(landscape.Outputs) {
|
|
t.Fatalf("results = %d, want %d", len(results), len(landscape.Outputs))
|
|
}
|
|
|
|
byGroup := map[string]ResultInfo{}
|
|
for _, r := range results {
|
|
byGroup[r.Group] = r
|
|
}
|
|
|
|
e := byGroup[landscape.OutE]
|
|
if e.Latest == nil || e.Latest.ID != ready.ID {
|
|
t.Fatalf("latest for %s = %v, want %v", landscape.OutE, e.Latest, ready.ID)
|
|
}
|
|
if e.Node != "10" {
|
|
t.Fatalf("node for %s = %q, want 10", landscape.OutE, e.Node)
|
|
}
|
|
if e.Title != "E Ecological Constraint" {
|
|
t.Fatalf("title = %q", e.Title)
|
|
}
|
|
wantCog := "/datasets/" + ready.ID.String() + ".cog"
|
|
if e.CogURL == nil || *e.CogURL != wantCog {
|
|
t.Fatalf("cog_url = %v, want %q", e.CogURL, wantCog)
|
|
}
|
|
wantSld := "/dataset-groups/" + eGroup.String() + "/sld"
|
|
if e.SldURL == nil || *e.SldURL != wantSld {
|
|
t.Fatalf("sld_url = %v, want %q", e.SldURL, wantSld)
|
|
}
|
|
|
|
v1 := byGroup[landscape.OutV1]
|
|
if v1.Latest != nil {
|
|
t.Fatalf("latest for %s = %v, want nil (not ready)", landscape.OutV1, v1.Latest)
|
|
}
|
|
if v1.CogURL != nil || v1.SldURL != nil {
|
|
t.Fatalf("urls for %s = %v/%v, want nil", landscape.OutV1, v1.CogURL, v1.SldURL)
|
|
}
|
|
|
|
zmax := byGroup[landscape.OutZMax]
|
|
if zmax.SldURL != nil {
|
|
t.Fatalf("sld_url for a group without an SLD = %v, want nil", zmax.SldURL)
|
|
}
|
|
}
|
|
|
|
func TestLandscapeService_MarkRunFailed(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
ctx := context.Background()
|
|
|
|
run, err := f.svc.CreateRun(ctx, RunInput{})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun: %v", err)
|
|
}
|
|
if err := f.svc.MarkRunFailed(ctx, run.ID, "worker died"); err != nil {
|
|
t.Fatalf("MarkRunFailed: %v", err)
|
|
}
|
|
got, err := f.svc.GetRun(ctx, run.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetRun: %v", err)
|
|
}
|
|
if got.Status != domain.LPRunFailed {
|
|
t.Fatalf("status = %s, want failed", got.Status)
|
|
}
|
|
if got.Error == nil || *got.Error != "worker died" {
|
|
t.Fatalf("error = %v, want %q", got.Error, "worker died")
|
|
}
|
|
}
|
|
|
|
func TestLandscapeService_Execute_NodeFailureFailsRunAndCleansUp(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
arrange func(f *lpFixture)
|
|
want string
|
|
}{
|
|
{
|
|
name: "compute fails",
|
|
arrange: func(f *lpFixture) { f.compute.errs["00"] = errors.New("grid mismatch") },
|
|
want: "grid mismatch",
|
|
},
|
|
{
|
|
name: "publishing the result fails",
|
|
arrange: func(f *lpFixture) { f.pub.err = errors.New("minio down") },
|
|
want: "minio down",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
f.seedAllSlots(t)
|
|
tc.arrange(f)
|
|
|
|
run := f.runOnce(t, RunInput{})
|
|
|
|
if run.Status != domain.LPRunFailed {
|
|
t.Fatalf("status = %s, want failed", run.Status)
|
|
}
|
|
if run.Error == nil || !strings.Contains(*run.Error, tc.want) {
|
|
t.Fatalf("run error = %v, want it to mention %q", run.Error, tc.want)
|
|
}
|
|
n00 := nodeByCode(t, run, "00")
|
|
if n00.Status != domain.LPNodeFailed {
|
|
t.Fatalf("node 00 status = %s, want failed", n00.Status)
|
|
}
|
|
if n00.Error == nil || !strings.Contains(*n00.Error, tc.want) {
|
|
t.Fatalf("node 00 error = %v, want it to mention %q", n00.Error, tc.want)
|
|
}
|
|
// Temp files are always cleaned up, even on failure.
|
|
for _, call := range f.compute.calls {
|
|
for _, path := range call.inputs {
|
|
if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
|
|
t.Fatalf("temp dir %q was not removed (stat err %v)", filepath.Dir(path), err)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLandscapeService_Execute_OverridePinsInputOverFresherDataset(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
f.seedAllSlots(t)
|
|
|
|
// Two ready rasters in the same slot group: the pinned one is the older of
|
|
// the two, so resolving by freshness alone would pick the other.
|
|
pinned := f.seedDataset(t, landscape.SlotZLCR, domain.DatasetStatusReady, domain.FileTypeRaster, "2023-01-31")
|
|
fresher := f.seedDataset(t, landscape.SlotZLCR, domain.DatasetStatusReady, domain.FileTypeRaster, "2026-01-31")
|
|
|
|
run := f.runOnce(t, RunInput{Overrides: map[string]uuid.UUID{landscape.SlotZLCR: pinned.ID}})
|
|
if run.Status != domain.LPRunSucceeded {
|
|
t.Fatalf("status = %s (%v), want succeeded", run.Status, run.Error)
|
|
}
|
|
|
|
// Both Z nodes consume the slot, and both must record the pinned dataset.
|
|
for _, code := range []string{"00", "02"} {
|
|
got := refsByGroup(nodeByCode(t, run, code).Inputs)[landscape.SlotZLCR]
|
|
if got != pinned.ID {
|
|
if got == fresher.ID {
|
|
t.Fatalf("node %s resolved the fresher dataset %v, want the pinned %v", code, got, pinned.ID)
|
|
}
|
|
t.Fatalf("node %s input for %s = %v, want %v", code, landscape.SlotZLCR, got, pinned.ID)
|
|
}
|
|
}
|
|
|
|
// The compute stub was handed the pinned object's bytes, not the fresher one's.
|
|
call, ok := f.compute.callFor("00")
|
|
if !ok {
|
|
t.Fatal("compute was not called for node 00")
|
|
}
|
|
path := call.inputs[landscape.SlotZLCR]
|
|
if path == "" {
|
|
t.Fatalf("node 00 got no path for %s (%v)", landscape.SlotZLCR, call.inputs)
|
|
}
|
|
if got := call.bodies[landscape.SlotZLCR]; got != pinned.StorageKey {
|
|
t.Fatalf("node 00 downloaded %q, want the pinned dataset's object %q", got, pinned.StorageKey)
|
|
}
|
|
|
|
// A group with no override still resolves the freshest ready dataset.
|
|
newest := f.seedDataset(t, landscape.SlotUVeg, domain.DatasetStatusReady, domain.FileTypeRaster, "2026-02-28")
|
|
second := f.runOnce(t, RunInput{Overrides: map[string]uuid.UUID{landscape.SlotZLCR: pinned.ID}})
|
|
if got := refsByGroup(nodeByCode(t, second, "01").Inputs)[landscape.SlotUVeg]; got != newest.ID {
|
|
t.Fatalf("node 01 input for %s = %v, want the freshest %v", landscape.SlotUVeg, got, newest.ID)
|
|
}
|
|
}
|
|
|
|
// F2: a redelivered message for a finished run must be acked without rerunning.
|
|
func TestLandscapeService_Execute_IgnoresTerminalRun(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
ctx := context.Background()
|
|
f.seedAllSlots(t)
|
|
|
|
run, err := f.svc.CreateRun(ctx, RunInput{})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun: %v", err)
|
|
}
|
|
if err := f.runs.SetRunStatus(ctx, run.ID, domain.LPRunSucceeded, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.runs.mu.Lock()
|
|
f.runs.statusWrites = nil
|
|
f.runs.mu.Unlock()
|
|
|
|
if err := f.svc.Execute(ctx, run.ID); err != nil {
|
|
t.Fatalf("Execute on a terminal run must ack, got %v", err)
|
|
}
|
|
if n := f.compute.count(); n != 0 {
|
|
t.Fatalf("compute called %d times for a terminal run", n)
|
|
}
|
|
if got := f.runs.statusesWritten(); len(got) != 0 {
|
|
t.Fatalf("no status write expected, got %v", got)
|
|
}
|
|
got, err := f.svc.GetRun(ctx, run.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Status != domain.LPRunSucceeded {
|
|
t.Fatalf("run status changed to %s", got.Status)
|
|
}
|
|
}
|
|
|
|
// F2: a "running" run is still executed, so a worker that crashed mid-run
|
|
// recovers on redelivery.
|
|
func TestLandscapeService_Execute_RerunsRunningRun(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
ctx := context.Background()
|
|
f.seedAllSlots(t)
|
|
|
|
run, err := f.svc.CreateRun(ctx, RunInput{})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun: %v", err)
|
|
}
|
|
if err := f.runs.SetRunStatus(ctx, run.ID, domain.LPRunRunning, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.svc.Execute(ctx, run.ID); err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if f.compute.count() == 0 {
|
|
t.Fatal("a running run must be executed on redelivery")
|
|
}
|
|
}
|
|
|
|
// F4: terminal status writes must land even when the caller's context is dead.
|
|
func TestLandscapeService_Execute_TerminalWritesSurviveCancelledContext(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
f.seedAllSlots(t)
|
|
|
|
run, err := f.svc.CreateRun(context.Background(), RunInput{})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := f.svc.Execute(ctx, run.ID); err == nil {
|
|
t.Fatal("expected Execute to fail on a cancelled context")
|
|
}
|
|
|
|
got, err := f.svc.GetRun(context.Background(), run.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Status != domain.LPRunFailed {
|
|
t.Fatalf("run status = %s, want failed", got.Status)
|
|
}
|
|
w, ok := f.runs.statusWrite(domain.LPRunFailed)
|
|
if !ok {
|
|
t.Fatal("no failed status write recorded")
|
|
}
|
|
if w.ctxErr != nil {
|
|
t.Fatalf("terminal run status write got a cancelled context: %v", w.ctxErr)
|
|
}
|
|
nw, ok := f.runs.nodeWrite(domain.LPNodeFailed)
|
|
if !ok {
|
|
t.Fatal("no failed node write recorded")
|
|
}
|
|
if nw.ctxErr != nil {
|
|
t.Fatalf("terminal node write got a cancelled context: %v", nw.ctxErr)
|
|
}
|
|
}
|
|
|
|
// F4: MarkRunFailed is the consumer's last resort and must not need a live ctx.
|
|
func TestLandscapeService_MarkRunFailed_IgnoresCancelledContext(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
run, err := f.svc.CreateRun(context.Background(), RunInput{})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun: %v", err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := f.svc.MarkRunFailed(ctx, run.ID, "boom"); err != nil {
|
|
t.Fatalf("MarkRunFailed: %v", err)
|
|
}
|
|
w, ok := f.runs.statusWrite(domain.LPRunFailed)
|
|
if !ok {
|
|
t.Fatal("no failed status write recorded")
|
|
}
|
|
if w.ctxErr != nil {
|
|
t.Fatalf("MarkRunFailed wrote on a cancelled context: %v", w.ctxErr)
|
|
}
|
|
}
|
|
|
|
// F5: the per-node worker cap handed to Compute is the run's budget split over
|
|
// the concurrently running nodes.
|
|
func TestLandscapeService_Execute_SplitsWorkerBudgetOverNodes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
opts LandscapeOptions
|
|
want int
|
|
}{
|
|
{"defaults", LandscapeOptions{}, DefaultMaxWorkers / DefaultMaxConcurrentNodes},
|
|
{"explicit", LandscapeOptions{MaxWorkers: 12, MaxConcurrentNodes: 3}, 4},
|
|
{"single node keeps the whole budget", LandscapeOptions{MaxWorkers: 6, MaxConcurrentNodes: 1}, 6},
|
|
{"never below one", LandscapeOptions{MaxWorkers: 1, MaxConcurrentNodes: 4}, 1},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
f := newLPFixtureWith(t, c.opts)
|
|
f.seedAllSlots(t)
|
|
got := f.runOnce(t, RunInput{})
|
|
if got.Status != domain.LPRunSucceeded {
|
|
t.Fatalf("run status = %s: %v", got.Status, got.Error)
|
|
}
|
|
f.compute.mu.Lock()
|
|
defer f.compute.mu.Unlock()
|
|
if len(f.compute.calls) == 0 {
|
|
t.Fatal("compute was never called")
|
|
}
|
|
for _, call := range f.compute.calls {
|
|
if call.workers != c.want {
|
|
t.Fatalf("node %s got workers=%d, want %d", call.node, call.workers, c.want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// F6: a failing node cancels its siblings instead of letting them run on.
|
|
func TestLandscapeService_Execute_FailingNodeCancelsSiblings(t *testing.T) {
|
|
// Four stage-0 nodes, all in flight at once; "00" fails immediately while
|
|
// "01" blocks waiting for its context.
|
|
f := newLPFixtureWith(t, LandscapeOptions{MaxWorkers: 8, MaxConcurrentNodes: 4})
|
|
f.seedAllSlots(t)
|
|
f.compute.errs["00"] = errors.New("boom")
|
|
f.compute.waitForCancel["01"] = true
|
|
|
|
got := f.runOnce(t, RunInput{})
|
|
if got.Status != domain.LPRunFailed {
|
|
t.Fatalf("run status = %s, want failed", got.Status)
|
|
}
|
|
if !f.compute.observedCancel("01") {
|
|
t.Fatal("sibling node 01 did not observe the cancellation of its context")
|
|
}
|
|
for _, n := range got.Nodes {
|
|
if n.NodeCode == "01" && n.Status == domain.LPNodeSucceeded {
|
|
t.Fatal("cancelled sibling must not be recorded as succeeded")
|
|
}
|
|
}
|
|
}
|
|
|
|
// F5: a legacy ComputeFunc still wires up through NewLandscapeService.
|
|
func TestNewLandscapeService_WrapsLegacyComputeFunc(t *testing.T) {
|
|
f := newLPFixture(t)
|
|
f.seedAllSlots(t)
|
|
// Rebuild the service the production-adjacent way: a legacy ComputeFunc,
|
|
// no direct field substitution.
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
svc := NewLandscapeService(f.runs, f.datasets, f.groups, f.cats, f.pub, f.store, f.jobs, nil, f.compute.fn, log, LandscapeOptions{})
|
|
|
|
run, err := svc.CreateRun(context.Background(), RunInput{})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun: %v", err)
|
|
}
|
|
if err := svc.Execute(context.Background(), run.ID); err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
call, ok := f.compute.callFor("00")
|
|
if !ok {
|
|
t.Fatal("legacy ComputeFunc was never called")
|
|
}
|
|
if call.workers != -1 {
|
|
t.Fatalf("legacy ComputeFunc should not see a worker cap, got %d", call.workers)
|
|
}
|
|
}
|