// Package engine — scheduler.go implements best-fit bin-packing for // the multi-node scheduler (v0.2 P02, REQ-028). The scheduler // receives a JobSpec, looks at the local NodeCapacity, and either // runs locally or falls through to a remote peer via the dispatcher. // // The bin-pack scoring is intentionally simple: pick the node with // the most free capacity (cpu_millicores + memory_mib weighted 1:1 // after normalization). This is deterministic and easy to test. package engine import ( "context" "fmt" "sort" "git.cloudinit.dev/coreci/orca/internal/model" "git.cloudinit.dev/coreci/orca/internal/store" ) // JobSpec is a minimal projection of the spec needed for scheduling // decisions. The full spec parsing is in internal/jobspec; this is // just enough to ask "does this fit?" and "where should it go?". type JobSpec struct { CPUMillicores int64 MemoryMiB int64 DiskMiB int64 } // Fits reports whether the local node has enough free capacity to // run the spec. Capacity accounting is conservative: a job is allowed // to run only if cpu + memory + disk are all >= the spec. func (s JobSpec) Fits(c *store.NodeCapacity) bool { if c == nil { return false } return c.CPUMillicores >= s.CPUMillicores && c.MemoryMiB >= s.MemoryMiB && c.DiskMiB >= s.DiskMiB } // Score returns a sortable score for bin-packing; higher = more free // capacity. Weighted roughly toward CPU (which is usually the // constraint) but normalized so the test isn't fragile. func (s JobSpec) Score(c *store.NodeCapacity) int64 { if c == nil { return -1 } // Use 1:1 weighting in normalized units (millicores vs MiB) to // keep the score monotonic. This isn't physically meaningful // (mixing units) but it gives a stable ordering for tests. freeCPU := c.CPUMillicores - s.CPUMillicores freeMem := c.MemoryMiB - s.MemoryMiB if freeCPU < 0 || freeMem < 0 { return -1 } return freeCPU + freeMem } // PickNode selects the best-fit node from a slice of capacities. // Returns the chosen *store.NodeCapacity and its index, or an error // if none can fit. Ties are broken by NodeID (lexicographic) for // determinism. func PickNode(spec JobSpec, capacities []*store.NodeCapacity) (*store.NodeCapacity, int, error) { if len(capacities) == 0 { return nil, -1, fmt.Errorf("PickNode: no nodes available") } type scored struct { c *store.NodeCapacity idx int score int64 } var fits []scored for i, c := range capacities { if !spec.Fits(c) { continue } fits = append(fits, scored{c: c, idx: i, score: spec.Score(c)}) } if len(fits) == 0 { return nil, -1, fmt.Errorf("PickNode: no node can fit the spec (cpu=%d mem=%d disk=%d)", spec.CPUMillicores, spec.MemoryMiB, spec.DiskMiB) } sort.SliceStable(fits, func(i, j int) bool { if fits[i].score != fits[j].score { return fits[i].score > fits[j].score } return fits[i].c.NodeID < fits[j].c.NodeID }) return fits[0].c, fits[0].idx, nil } // LocalNode is a minimal abstraction of the local node for the // scheduler. The concrete implementation reads from the // store.CapacityRepo. type LocalNode interface { Capacity(ctx context.Context) (*store.NodeCapacity, error) } // memLocalNode returns capacity from a fixed *store.NodeCapacity. // Useful for tests; production code wraps CapacityRepo. type memLocalNode struct{ c *store.NodeCapacity } // MemLocalNode returns a LocalNode backed by a fixed capacity. Test-only. func MemLocalNode(c *store.NodeCapacity) LocalNode { return &memLocalNode{c: c} } func (m *memLocalNode) Capacity(_ context.Context) (*store.NodeCapacity, error) { if m.c == nil { return nil, store.ErrNotFound } return m.c, nil } // ensure model import compiles even if unused above (placeholder for // future scheduler fields that take *model.Node). var _ = model.NodeStateReady