df58bc25a3
---ci--- project: orca phase: 3 milestone: v0.3 status: complete requirements: covered: [REQ-022, REQ-030, REQ-032] partial: [] ---/ci--- v0.3 milestone merged to main. Includes all v0.2 work (P08-P10) that was previously on the milestone branch but not yet merged to main, plus the v0.3 completion work (iter.Seq streaming + doctor network/db). v0.2 phases included: P08 (mTLS), P09 (scheduling), P10 (security scan). v0.3 phases: P0 (pre-execution), P1 (iter.Seq streaming), P2 (doctor), P3 (final review+ship). Total: 40 requirements, all complete. No new go.mod dependencies. Full test suite passes under -race. gofmt + go vet clean.
118 lines
3.7 KiB
Go
118 lines
3.7 KiB
Go
// 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
|