c10779873b
P05 — Scheduler moves from daemon-side to CLI-side (R-001) with runtime-awareness. Scheduler (internal/scheduler/scheduler.go, REQ-083): - Pure Schedule(nodes, req) -> []Placement. Job=1 best-fit, Service=count replicas (anti-affinity default, colocation permitted), DaemonSet=1 per matching node. Score(node, req) = (FreeCPU*1000 + FreeMem); fits checks runtime compat (wasm->wasmtime, pve-vm/ct->proxmox), constraints (CEL AND), capacity. Affinity scoring (target + weight, anti-affinity for spreading). CEL evaluator (internal/scheduler/cel.go): - Hand-rolled recursive-descent (no CEL dep in go.mod). Subset: node.* attrs, literals, ==/!=/>=/<=/></>, in/not in, and/or/not, parens. Anything outside subset returns error (no silent wrong answer). Schedule treats eval errors as non-fit (node skipped). 23 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent. 89.5% coverage on internal/scheduler. ---ci--- project: orca phase: P05 milestone: v0.9 status: execute ---/ci---
473 lines
16 KiB
Go
473 lines
16 KiB
Go
// Package scheduler implements the v0.9 CLI-side scheduler (REQ-083,
|
|
// P05). Unlike the v0.8 daemon-side best-fit scheduler
|
|
// (internal/engine/scheduler.go), this scheduler runs entirely in the
|
|
// `orca` CLI process (R-001) and is pure: it takes a list of candidate
|
|
// nodes plus a workload request and returns placement decisions
|
|
// without performing any I/O.
|
|
//
|
|
// The scheduler is runtime-aware: a workload that declares
|
|
// `runtime.one_of: wasm` is only placed on nodes that expose
|
|
// `wasmtime` in their Runtimes list; a `pve-vm` workload is only
|
|
// placed on `proxmox` nodes. It is also constraint- and
|
|
// affinity-aware via the CEL-subset evaluator in cel.go.
|
|
//
|
|
// Workload kinds are handled differently per the PRD:
|
|
//
|
|
// - Job: one-shot, returns exactly one placement (best-fit
|
|
// bin-packing).
|
|
// - Service: count replicas spread across distinct nodes
|
|
// (anti-affinity by default); if fewer distinct nodes than count,
|
|
// colocation is permitted but distinct nodes are preferred.
|
|
// - DaemonSet: one placement per node that fits the constraints.
|
|
package scheduler
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
|
)
|
|
|
|
// NodeInfo is the scheduler's projection of a peer node: total and
|
|
// free capacity, the runtimes the node advertises, its tags, and its
|
|
// kind (linux/proxmox). The CLI populates this from the
|
|
// cluster/peers/ inventory plus the per-node capacity reports
|
|
// collected over SSH; the scheduler itself never reads either.
|
|
type NodeInfo struct {
|
|
Hostname string
|
|
Runtimes []string
|
|
Tags []string
|
|
CPU int64
|
|
Memory int64
|
|
FreeCPU int64
|
|
FreeMem int64
|
|
Kind string
|
|
}
|
|
|
|
// WorkloadRequest bundles a parsed WorkloadSpec with the namespace
|
|
// the workload is being scheduled into. The namespace is carried
|
|
// through to placement so the resulting AllocID can be namespaced,
|
|
// but the scheduler itself does not inspect it for fitting decisions.
|
|
type WorkloadRequest struct {
|
|
Spec *jobspec.WorkloadSpec
|
|
Namespace string
|
|
}
|
|
|
|
// Placement is a single scheduling decision: which node, which
|
|
// allocation id, and the bin-packing score that won the node the
|
|
// placement. AllocID is `ns/spec.Name-<idx>` so a multi-replica
|
|
// Service produces distinct ids per replica.
|
|
type Placement struct {
|
|
Node string
|
|
AllocID string
|
|
Score int64
|
|
}
|
|
|
|
// Schedule is the main entry point. For Job (kind=Job) it returns one
|
|
// placement on the best-fit node. For Service it returns `Count`
|
|
// placements spread across distinct nodes where possible (anti-
|
|
// affinity), permitting colocation when Count > nodes. For DaemonSet
|
|
// it returns one placement per node that fits. Any kind-agnostic
|
|
// validation error (no spec, unknown kind, no fitting node) is
|
|
// returned as an error rather than an empty slice so callers can
|
|
// distinguish "nothing fits" from "scheduled zero replicas".
|
|
func Schedule(nodes []NodeInfo, req WorkloadRequest) ([]Placement, error) {
|
|
if req.Spec == nil {
|
|
return nil, fmt.Errorf("scheduler: nil WorkloadSpec")
|
|
}
|
|
if len(nodes) == 0 {
|
|
return nil, fmt.Errorf("scheduler: no candidate nodes")
|
|
}
|
|
switch req.Spec.Kind {
|
|
case "Job":
|
|
return scheduleJob(nodes, req)
|
|
case "Service":
|
|
return scheduleService(nodes, req)
|
|
case "DaemonSet":
|
|
return scheduleDaemonSet(nodes, req)
|
|
default:
|
|
return nil, fmt.Errorf("scheduler: unknown kind %q", req.Spec.Kind)
|
|
}
|
|
}
|
|
|
|
// Score evaluates a single node against a workload. fits is true iff
|
|
// the node (a) advertises a runtime compatible with the workload's
|
|
// `runtime.one_of`, (b) satisfies every CEL constraint in
|
|
// `spec.Constraints`, and (c) has enough free CPU+memory for the
|
|
// workload's requested resources. When fits is true, score is the
|
|
// bin-packing score (more free capacity = higher score, so the node
|
|
// most likely to absorb the workload without starving its
|
|
// neighbours wins). When fits is false, score is 0.
|
|
func Score(node NodeInfo, req WorkloadRequest) (score int64, fits bool) {
|
|
// (a) runtime compatibility. A workload with no Runtime block or
|
|
// an empty OneOf is treated as runtime-agnostic (always fits on
|
|
// the runtime axis); this matches the v0.8 behaviour where a
|
|
// missing runtime meant "process".
|
|
runtimeOK := true
|
|
if req.Spec != nil && req.Spec.Runtime != nil && req.Spec.Runtime.OneOf != "" {
|
|
runtimeOK = hasRuntime(node, req.Spec.Runtime.OneOf)
|
|
}
|
|
if !runtimeOK {
|
|
return 0, false
|
|
}
|
|
// (b) constraints. Evaluation errors are treated as non-fit so a
|
|
// malformed constraint does not crash Schedule; the caller still
|
|
// sees the node filtered out.
|
|
if req.Spec != nil {
|
|
ok, err := EvaluateAll(req.Spec.Constraints, node)
|
|
if err != nil || !ok {
|
|
return 0, false
|
|
}
|
|
}
|
|
// (c) capacity. A workload with no Resources block is treated as
|
|
// zero-sized for fitting purposes (it always fits the capacity
|
|
// axis); real workloads declare cpu/memory.
|
|
needCPU, needMem := workloadResources(req)
|
|
if node.FreeCPU < needCPU || node.FreeMem < needMem {
|
|
return 0, false
|
|
}
|
|
// bin-packing score: most free capacity wins. CPU is weighted
|
|
// 1000x memory so a 1-core difference outweighs a 1-MiB
|
|
// difference, mirroring the v0.8 Score weighting that biased
|
|
// toward CPU (the more common binding constraint).
|
|
score = (node.FreeCPU-needCPU)*1000 + (node.FreeMem - needMem)
|
|
if score < 0 {
|
|
score = 0
|
|
}
|
|
return score, true
|
|
}
|
|
|
|
// hasRuntime reports whether node advertises the requested runtime.
|
|
// The match is case-insensitive and tolerant of aliases: `wasm` and
|
|
// `wasmtime` are treated as the same runtime, and `pve-vm`/`pve-ct`
|
|
// only match nodes whose Kind is "proxmox".
|
|
func hasRuntime(node NodeInfo, oneOf string) bool {
|
|
want := normalizeRuntime(oneOf)
|
|
// pve-* runtimes require a proxmox-kind node regardless of the
|
|
// node's Runtimes list (a proxmox node doesn't list "pve-vm" in
|
|
// Runtimes; it IS the runtime).
|
|
switch want {
|
|
case "pve-vm", "pve-ct", "proxmox":
|
|
return normalizeKind(node.Kind) == "proxmox"
|
|
}
|
|
for _, r := range node.Runtimes {
|
|
if normalizeRuntime(r) == want {
|
|
return true
|
|
}
|
|
// alias: wasmtime nodes advertise "wasmtime"; workloads ask
|
|
// for "wasm".
|
|
if want == "wasm" && normalizeRuntime(r) == "wasmtime" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// normalizeRuntime lowercases and trims a runtime name for matching.
|
|
func normalizeRuntime(s string) string {
|
|
s = toLowerASCII(s)
|
|
switch s {
|
|
case "wasmtime":
|
|
return "wasm"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// normalizeKind lowercases and trims a node Kind for matching.
|
|
func normalizeKind(s string) string { return toLowerASCII(s) }
|
|
|
|
// toLowerASCII lowercases ASCII letters without bringing in strings
|
|
// (avoid an alloc-heavy stdlib call in the hot path).
|
|
func toLowerASCII(s string) string {
|
|
b := []byte(s)
|
|
for i, c := range b {
|
|
if c >= 'A' && c <= 'Z' {
|
|
b[i] = c + 32
|
|
}
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// workloadResources returns the (cpu, memory) the workload requests,
|
|
// read from the WorkloadSpec's Resources block if present. The
|
|
// v0.9-P05 WorkloadSpec does not yet carry a Resources field (it
|
|
// lands in P0c, REQ-074); until then this returns (0, 0) so the
|
|
// capacity check is a no-op and runtime/constraints do the real
|
|
// filtering. The signature is here so the scheduler logic does not
|
|
// need to change when Resources lands.
|
|
func workloadResources(req WorkloadRequest) (int64, int64) {
|
|
_ = req
|
|
return 0, 0
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Kind-specific scheduling
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// scheduleJob places a single Job on the best-fit node.
|
|
func scheduleJob(nodes []NodeInfo, req WorkloadRequest) ([]Placement, error) {
|
|
type cand struct {
|
|
node NodeInfo
|
|
score int64
|
|
}
|
|
var cands []cand
|
|
for _, n := range nodes {
|
|
s, ok := Score(n, req)
|
|
if !ok {
|
|
continue
|
|
}
|
|
cands = append(cands, cand{node: n, score: s})
|
|
}
|
|
// CEL-based affinity rules apply to single-shot Jobs too: a Job
|
|
// with `affinity: [{target: "\"ssd\" in node.tags", weight: 100}]`
|
|
// should land on the tagged node even without prior placements.
|
|
// Name-based affinity (no prior placements to check) contributes
|
|
// zero for a standalone Job, so it is harmless to call here.
|
|
for i := range cands {
|
|
cands[i].score += affinityScore(cands[i].node, req, nil)
|
|
}
|
|
if len(cands) == 0 {
|
|
return nil, fmt.Errorf("scheduler: no node fits workload %q", req.Spec.Name)
|
|
}
|
|
sort.SliceStable(cands, func(i, j int) bool {
|
|
if cands[i].score != cands[j].score {
|
|
return cands[i].score > cands[j].score
|
|
}
|
|
return cands[i].node.Hostname < cands[j].node.Hostname
|
|
})
|
|
w := cands[0]
|
|
return []Placement{{
|
|
Node: w.node.Hostname,
|
|
AllocID: allocID(req, 0),
|
|
Score: w.score,
|
|
}}, nil
|
|
}
|
|
|
|
// scheduleService places `Count` replicas with implicit anti-affinity:
|
|
// prefer distinct nodes, but permit colocation when Count exceeds the
|
|
// number of fitting nodes. Each replica gets a distinct AllocID.
|
|
func scheduleService(nodes []NodeInfo, req WorkloadRequest) ([]Placement, error) {
|
|
count := req.Spec.Count
|
|
if count <= 0 {
|
|
count = 1
|
|
}
|
|
// Pre-filter fitting nodes once; the loop below re-scores them
|
|
// after each placement so the capacity accounting reflects the
|
|
// replicas already placed.
|
|
fitting := filterFitting(nodes, req)
|
|
if len(fitting) == 0 {
|
|
return nil, fmt.Errorf("scheduler: no node fits service %q", req.Spec.Name)
|
|
}
|
|
var placements []Placement
|
|
placed := map[string]int{} // hostname -> count placed there
|
|
// First pass: spread across distinct nodes.
|
|
for i := 0; i < count; i++ {
|
|
best, score, ok := pickServiceNode(fitting, req, placements, placed)
|
|
if !ok {
|
|
break
|
|
}
|
|
placements = append(placements, Placement{
|
|
Node: best.Hostname,
|
|
AllocID: allocID(req, i),
|
|
Score: score,
|
|
})
|
|
placed[best.Hostname]++
|
|
// Reflect the consumed capacity in the candidate snapshot so
|
|
// subsequent picks see updated free capacity.
|
|
needCPU, needMem := workloadResources(req)
|
|
for j := range fitting {
|
|
if fitting[j].Hostname == best.Hostname {
|
|
fitting[j].FreeCPU -= needCPU
|
|
fitting[j].FreeMem -= needMem
|
|
}
|
|
}
|
|
}
|
|
if len(placements) < count {
|
|
return nil, fmt.Errorf("scheduler: only placed %d/%d replicas for service %q",
|
|
len(placements), count, req.Spec.Name)
|
|
}
|
|
return placements, nil
|
|
}
|
|
|
|
// pickServiceNode selects the best node for the next replica. The
|
|
// selection prefers nodes with zero prior placements of this service
|
|
// (anti-affinity) and applies affinity scoring on top of the
|
|
// bin-packing score.
|
|
func pickServiceNode(fitting []NodeInfo, req WorkloadRequest, placements []Placement, placed map[string]int) (NodeInfo, int64, bool) {
|
|
type scored struct {
|
|
node NodeInfo
|
|
score int64
|
|
}
|
|
var cands []scored
|
|
for _, n := range fitting {
|
|
s, ok := Score(n, req)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Implicit anti-affinity: a node with N prior replicas of this
|
|
// service incurs a penalty of N * (1 << 62) so distinct nodes
|
|
// are preferred, but colocation is permitted (with a
|
|
// per-replica penalty) when no distinct node remains. This
|
|
// produces a balanced spread (e.g. 5 replicas on 3 nodes →
|
|
// 2/2/1) rather than stacking everything on the first node.
|
|
if placed[n.Hostname] > 0 {
|
|
s -= int64(placed[n.Hostname]) * (1 << 62)
|
|
}
|
|
// Affinity rules from the spec add/subtract their weight.
|
|
s += affinityScore(n, req, placements)
|
|
cands = append(cands, scored{node: n, score: s})
|
|
}
|
|
if len(cands) == 0 {
|
|
return NodeInfo{}, 0, false
|
|
}
|
|
sort.SliceStable(cands, func(i, j int) bool {
|
|
if cands[i].score != cands[j].score {
|
|
return cands[i].score > cands[j].score
|
|
}
|
|
return cands[i].node.Hostname < cands[j].node.Hostname
|
|
})
|
|
w := cands[0]
|
|
return w.node, w.score, true
|
|
}
|
|
|
|
// scheduleDaemonSet places one replica per node that fits the
|
|
// constraints. The PRD's DaemonSet placement mode (every-node /
|
|
// matching / mandatory) lives on the WorkloadSpec.Schedule block; the
|
|
// scheduler honours it indirectly by filtering on Constraints: a
|
|
// `matching` DaemonSet carries constraints that select the matching
|
|
// nodes, an `every-node` DaemonSet carries none, and a `mandatory`
|
|
// one is enforced elsewhere (the scheduler still just returns
|
|
// placements for every fitting node).
|
|
func scheduleDaemonSet(nodes []NodeInfo, req WorkloadRequest) ([]Placement, error) {
|
|
var placements []Placement
|
|
for _, n := range nodes {
|
|
s, ok := Score(n, req)
|
|
if !ok {
|
|
continue
|
|
}
|
|
placements = append(placements, Placement{
|
|
Node: n.Hostname,
|
|
AllocID: allocID(req, len(placements)),
|
|
Score: s,
|
|
})
|
|
}
|
|
if len(placements) == 0 {
|
|
return nil, fmt.Errorf("scheduler: no node fits daemonset %q", req.Spec.Name)
|
|
}
|
|
return placements, nil
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Affinity scoring
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// affinityScore returns the weighted affinity contribution for a
|
|
// node given the placements already made. For each AffinityRule the
|
|
// Target is a CEL expression; if it evaluates true against the node,
|
|
// the rule's Weight is added (positive = co-locate, negative =
|
|
// anti-affinity). An affinity target that fails to evaluate is
|
|
// ignored rather than failing the schedule: operators use affinity as
|
|
// a hint, not a hard gate.
|
|
//
|
|
// The PRD also mentions affinity rules like `{target: "redis", weight:
|
|
// 50}` where Target is a workload *name* rather than a CEL expression.
|
|
// We support both: if Target parses as a CEL expression it is
|
|
// evaluated against the node; otherwise it is treated as a workload
|
|
// name and we check whether any already-placed alloc for that name
|
|
// exists on the node. The placement-already-here check is done by the
|
|
// caller via placements; this function checks the node's own
|
|
// attributes only.
|
|
func affinityScore(node NodeInfo, req WorkloadRequest, placements []Placement) int64 {
|
|
if req.Spec == nil {
|
|
return 0
|
|
}
|
|
var total int64
|
|
for _, rule := range req.Spec.Affinity {
|
|
// Try CEL evaluation first; if the target is a bare workload
|
|
// name (no operator) the CEL parser will fail and we fall
|
|
// back to name-based placement counting.
|
|
ok, err := EvaluateConstraint(rule.Target, node)
|
|
if err == nil {
|
|
if ok {
|
|
total += int64(rule.Weight)
|
|
}
|
|
continue
|
|
}
|
|
// Fallback: target is a workload name; count existing
|
|
// placements for that workload on this node and apply the
|
|
// weight once per co-located replica.
|
|
for _, p := range placements {
|
|
if p.Node == node.Hostname && isAllocFor(p.AllocID, rule.Target) {
|
|
total += int64(rule.Weight)
|
|
}
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
// isAllocFor reports whether an AllocID encodes a placement for the
|
|
// named workload. AllocIDs are `ns/name-idx`, so we look for the
|
|
// workload name as the segment after the first slash and before the
|
|
// trailing `-idx`.
|
|
func isAllocFor(allocID, workloadName string) bool {
|
|
// strip namespace prefix
|
|
rest := allocID
|
|
if i := indexByte(rest, '/'); i >= 0 {
|
|
rest = rest[i+1:]
|
|
}
|
|
// strip trailing -idx
|
|
if i := lastIndexByte(rest, '-'); i >= 0 {
|
|
rest = rest[:i]
|
|
}
|
|
return rest == workloadName
|
|
}
|
|
|
|
// indexByte returns the index of the first occurrence of b in s, or
|
|
// -1. Avoids importing strings just for one helper.
|
|
func indexByte(s string, b byte) int {
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == b {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// lastIndexByte returns the index of the last occurrence of b in s, or
|
|
// -1.
|
|
func lastIndexByte(s string, b byte) int {
|
|
for i := len(s) - 1; i >= 0; i-- {
|
|
if s[i] == b {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Helpers
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// filterFitting returns a copy of the nodes that pass Score for the
|
|
// request, preserving order. Capacity is not yet decremented; the
|
|
// caller adjusts FreeCPU/FreeMem as it places replicas.
|
|
func filterFitting(nodes []NodeInfo, req WorkloadRequest) []NodeInfo {
|
|
var out []NodeInfo
|
|
for _, n := range nodes {
|
|
if _, ok := Score(n, req); ok {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// allocID renders a stable, namespaced allocation id for a placement.
|
|
// Format: `ns/spec.Name-<idx>`.
|
|
func allocID(req WorkloadRequest, idx int) string {
|
|
ns := req.Namespace
|
|
if ns == "" {
|
|
ns = "default"
|
|
}
|
|
return fmt.Sprintf("%s/%s-%d", ns, req.Spec.Name, idx)
|
|
}
|