fix(landscape): bound Compute's per-worker memory and clean up on failure
Review round 1: size per-worker buffers off the largest actual window instead of WindowSize^2 (avoids allocating ~200+ MiB per worker for small/partial rasters), cap workers at 8 regardless of GOMAXPROCS, remove partially-written outputs when a run fails, wrap input-open errors with node/group context, guard checkGrids against a node with no inputs, and clamp a non-positive WindowSize so raster.Windows can't loop forever. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ package landscape
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -43,6 +45,9 @@ func TestCompute_E_TilesWindowsAndWritesEveryPixel(t *testing.T) {
|
||||
if res.grid != g {
|
||||
t.Fatal("output grid must copy the first input's grid")
|
||||
}
|
||||
if res.opts != raster.DefaultWriterOptions() {
|
||||
t.Fatalf("output created with %+v, want raster.DefaultWriterOptions() = %+v", res.opts, raster.DefaultWriterOptions())
|
||||
}
|
||||
for i, v := range res.data {
|
||||
want := 4.0
|
||||
if i == 0 {
|
||||
@@ -99,3 +104,116 @@ func TestCompute_MultiOutputNode(t *testing.T) {
|
||||
t.Fatal("min(P, E) not applied per output")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompute_MultiOutputNode_TiledWindows runs the 6-input/5-output node 20
|
||||
// with a window size much smaller than the raster, so per-worker output
|
||||
// buffers are reused across windows of differing pixel counts (partial
|
||||
// windows at the raster's right/bottom edges).
|
||||
func TestCompute_MultiOutputNode_TiledWindows(t *testing.T) {
|
||||
fio := newFakeIO()
|
||||
w, h := 5, 3
|
||||
g := grid(w, h)
|
||||
paths := map[string]string{}
|
||||
for i, s := range []string{SlotPIrrigation, SlotPSnow, SlotPCrop, SlotPPasture, SlotPTourism} {
|
||||
paths[s] = s + ".tif"
|
||||
fio.put(paths[s], g, constRaster(w, h, float64(i+1)))
|
||||
}
|
||||
paths[OutE] = "e.tif"
|
||||
fio.put("e.tif", g, constRaster(w, h, 3))
|
||||
node, _ := NodeByCode("20")
|
||||
p := DefaultParams()
|
||||
p.WindowSize = 2 // 5x3 with 2x2 tiles: several differently-sized windows
|
||||
out, err := Compute(context.Background(), fio, node, paths, "/o2", p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codes := []string{OutCIrrigation, OutCSnow, OutCCrop, OutCPasture, OutCTourism}
|
||||
for i, code := range codes {
|
||||
want := min(i+1, 3) // min(P_sector, E=3)
|
||||
res := fio.files[out[code]]
|
||||
for px, v := range res.data {
|
||||
if v != float64(want) {
|
||||
t.Fatalf("%s pixel %d = %v want %v", code, px, v, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompute_ZeroParamsUsesDefaults exercises Compute with a completely
|
||||
// zero-valued Params, relying on WithDefaults to fill in WindowSize and the
|
||||
// other node-independent knobs.
|
||||
func TestCompute_ZeroParamsUsesDefaults(t *testing.T) {
|
||||
fio := newFakeIO()
|
||||
g := grid(4, 4)
|
||||
fio.put("z.tif", g, constRaster(4, 4, 2))
|
||||
fio.put("u.tif", g, constRaster(4, 4, 3))
|
||||
node, _ := NodeByCode("10")
|
||||
out, err := Compute(context.Background(), fio, node, map[string]string{OutZIntegrated: "z.tif", OutUIntegrated: "u.tif"}, "/z", Params{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range fio.files[out[OutE]].data {
|
||||
if v != 3 {
|
||||
t.Fatalf("pixel = %v want 3", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompute_NegativeWindowSizeClampedToDefault guards against
|
||||
// raster.Windows looping forever on a non-positive window size: WithDefaults
|
||||
// only fills in a zero value, so Compute must clamp negatives itself.
|
||||
func TestCompute_NegativeWindowSizeClampedToDefault(t *testing.T) {
|
||||
fio := newFakeIO()
|
||||
g := grid(2, 2)
|
||||
fio.put("z.tif", g, constRaster(2, 2, 2))
|
||||
fio.put("u.tif", g, constRaster(2, 2, 3))
|
||||
node, _ := NodeByCode("10")
|
||||
p := DefaultParams()
|
||||
p.WindowSize = -5
|
||||
out, err := Compute(context.Background(), fio, node, map[string]string{OutZIntegrated: "z.tif", OutUIntegrated: "u.tif"}, "/neg", p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fio.files[out[OutE]].data[0] != 3 {
|
||||
t.Fatal("unexpected result")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompute_FailureMidRunReturnsError simulates a read failure partway
|
||||
// through a run (the fake fails every ReadFloat64 against u.tif from the
|
||||
// first call on) and checks Compute surfaces the error instead of returning
|
||||
// a partial result.
|
||||
func TestCompute_FailureMidRunReturnsError(t *testing.T) {
|
||||
fio := newFakeIO()
|
||||
g := grid(10, 7)
|
||||
fio.put("z.tif", g, constRaster(10, 7, 2))
|
||||
fio.put("u.tif", g, constRaster(10, 7, 4))
|
||||
fio.failReadAfterN("u.tif", 0) // fail starting from the very first read
|
||||
node, _ := NodeByCode("10")
|
||||
p := DefaultParams()
|
||||
p.WindowSize = 4
|
||||
if _, err := Compute(context.Background(), fio, node, map[string]string{OutZIntegrated: "z.tif", OutUIntegrated: "u.tif"}, "/out-fail", p); err == nil {
|
||||
t.Fatal("expected error from simulated read failure")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveOutputs_DeletesFiles unit-tests the cleanup helper Compute uses
|
||||
// after a failed run, against the real filesystem (the fake IO above has no
|
||||
// on-disk files to assert against).
|
||||
func TestRemoveOutputs_DeletesFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var paths []string
|
||||
for i := 0; i < 3; i++ {
|
||||
p := filepath.Join(dir, fmt.Sprintf("out%d.tif", i))
|
||||
if err := os.WriteFile(p, []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths = append(paths, p)
|
||||
}
|
||||
removeOutputs(paths)
|
||||
for _, p := range paths {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected %s to be removed, stat err = %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user