| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731 |
- package jobs
- /*
- ArozOS Cluster Job Runtime (AJR) and Scheduler (AJS)
- An application submits a job; the cluster picks a node that satisfies the
- job's requirements and preferably already holds its input data; that node
- runs the job as the submitting user and publishes the result. Jobs are
- AGI scripts (JavaScript) because that is the only payload portable across
- nodes of different OS and CPU architecture.
- Every job is one replicated record (metadata.KindJob), so any node can
- answer status queries locally and a new leader can resume scheduling.
- The scheduler loop runs on the metadata leader only; execution happens
- anywhere.
- */
- import (
- "context"
- "encoding/json"
- "errors"
- "net/http"
- "runtime"
- "sort"
- "strings"
- "sync"
- "time"
- uuid "github.com/satori/go.uuid"
- "imuslab.com/arozos/mod/cluster/capability"
- "imuslab.com/arozos/mod/cluster/membership"
- "imuslab.com/arozos/mod/cluster/metadata"
- "imuslab.com/arozos/mod/cluster/scheduling"
- "imuslab.com/arozos/mod/info/logger"
- )
- // Job statuses.
- const (
- StatusQueued = "queued"
- StatusScheduled = "scheduled"
- StatusRunning = "running"
- StatusSucceeded = "succeeded"
- StatusFailed = "failed"
- StatusCancelled = "cancelled"
- )
- // Job kinds.
- const (
- KindRun = "run"
- KindMap = "map"
- KindReduce = "reduce"
- KindMapReduce = "mapreduce"
- )
- const maxLogLines = 200
- // Spec is what the submitter asks for.
- type Spec struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Owner string `json:"owner"`
- Script string `json:"script"` //full JS source, captured at submit time
- ScriptName string `json:"scriptName"` //for logs
- Args json.RawMessage `json:"args,omitempty"`
- Inputs []string `json:"inputs,omitempty"`
- Requirements capability.Requirements `json:"requirements"`
- //Nodes limits placement to these node ids; empty means any node. Map
- //and reduce children inherit it.
- Nodes []string `json:"nodes,omitempty"`
- Priority int `json:"priority"`
- TimeoutSec int `json:"timeoutSec"`
- MaxAttempts int `json:"maxAttempts"`
- Kind string `json:"kind"`
- ParentID string `json:"parentId,omitempty"`
- //Map/reduce only: the dataset glob and how many files one map task takes
- Dataset string `json:"dataset,omitempty"`
- PartitionMax int `json:"partitionMax,omitempty"`
- Created int64 `json:"created"`
- }
- // State is what happened to it.
- type State struct {
- Status string `json:"status"`
- Node string `json:"node,omitempty"`
- Attempts int `json:"attempts"`
- LeaseExpires int64 `json:"leaseExpires,omitempty"`
- Progress float64 `json:"progress"`
- Output json.RawMessage `json:"output,omitempty"`
- Log []string `json:"log,omitempty"`
- Error string `json:"error,omitempty"`
- Started int64 `json:"started,omitempty"`
- Finished int64 `json:"finished,omitempty"`
- Reason string `json:"reason,omitempty"` //why it is still queued
- Blocked []string `json:"blocked,omitempty"` //nodes that refused this job
- }
- // Record is the replicated job (spec + state in one document, so the two can
- // never disagree after a merge).
- type Record struct {
- Spec Spec `json:"spec"`
- State State `json:"state"`
- }
- // Done reports whether the job reached a terminal state.
- func (r *Record) Done() bool {
- switch r.State.Status {
- case StatusSucceeded, StatusFailed, StatusCancelled:
- return true
- }
- return false
- }
- // Option configures the job service.
- type Option struct {
- Membership *membership.Manager
- Metadata *metadata.Manager
- Executor Executor
- //Scheduler scores the candidate nodes; nil falls back to the defaults.
- Scheduler *scheduling.Manager
- // LocalityBytes reports how many bytes of the given cluster:/ paths have a
- // healthy copy on nodeID, and the total size of those paths.
- LocalityBytes func(paths []string, nodeID string) (onNode int64, total int64)
- ScheduleInterval time.Duration
- TaskLease time.Duration
- LeaseRenew time.Duration
- MaxParallel int
- RetentionHours int
- }
- // Executor runs one job's script on this node. It is implemented by the core
- // on top of the AGI gateway.
- type Executor interface {
- // UserExists reports whether the job's owner has an account on this node.
- UserExists(owner string) bool
- // Run executes source as owner and returns what the script produced.
- // The context is cancelled on timeout or cancellation.
- Run(ctx context.Context, rec Record, source string, hooks ExecHooks) (output json.RawMessage, err error)
- }
- // ExecHooks are the callbacks the script drives through the job object.
- type ExecHooks struct {
- Log func(line string)
- Progress func(p float64)
- Cancelled func() bool
- }
- // Manager is the job service of this node.
- type Manager struct {
- m *membership.Manager
- meta *metadata.Manager
- exec Executor
- sched *scheduling.Manager
- opt Option
- mu sync.Mutex
- running map[string]*runState
- starting map[string]bool //map/reduce parents currently being expanded
- accepted map[string]bool //jobs this node took on and has not finished
- //schedMu serialises scheduling passes. Every submission, completion and
- //refusal starts a pass, and two passes running at once would both see a
- //job as queued and hand it out twice.
- schedMu sync.Mutex
- sem chan struct{}
- stop chan struct{}
- wg sync.WaitGroup
- once sync.Once
- // OnFinished fires when a job reaches a terminal state on this node.
- OnFinished func(rec Record)
- }
- type runState struct {
- cancel context.CancelFunc
- logs []string
- prog float64
- mu sync.Mutex
- }
- var (
- ErrNotLeader = errors.New("this node is not the metadata leader")
- ErrNotFound = errors.New("job not found")
- ErrNoUser = errors.New("the job owner has no account on this node")
- ErrNoExecutor = errors.New("this node cannot execute jobs")
- )
- // New creates the job service and starts its loops.
- func New(opt Option) (*Manager, error) {
- if opt.Membership == nil || opt.Metadata == nil {
- return nil, errors.New("membership and metadata are required")
- }
- if opt.ScheduleInterval <= 0 {
- opt.ScheduleInterval = 3 * time.Second
- }
- if opt.TaskLease <= 0 {
- opt.TaskLease = 30 * time.Second
- }
- if opt.LeaseRenew <= 0 {
- opt.LeaseRenew = 10 * time.Second
- }
- if opt.MaxParallel <= 0 {
- opt.MaxParallel = runtime.NumCPU()
- }
- if opt.RetentionHours <= 0 {
- opt.RetentionHours = 72
- }
- if opt.Scheduler == nil {
- sc, err := scheduling.New(opt.Membership, opt.Metadata)
- if err != nil {
- return nil, err
- }
- opt.Scheduler = sc
- }
- j := &Manager{
- m: opt.Membership,
- meta: opt.Metadata,
- exec: opt.Executor,
- sched: opt.Scheduler,
- opt: opt,
- running: map[string]*runState{},
- starting: map[string]bool{},
- accepted: map[string]bool{},
- sem: make(chan struct{}, opt.MaxParallel),
- stop: make(chan struct{}),
- }
- j.registerACNHandlers()
- j.wg.Add(1)
- go j.loop()
- return j, nil
- }
- // Close stops the scheduler and cancels local jobs.
- func (j *Manager) Close() {
- j.once.Do(func() { close(j.stop) })
- j.mu.Lock()
- for _, rs := range j.running {
- rs.cancel()
- }
- j.mu.Unlock()
- j.wg.Wait()
- }
- func (j *Manager) loop() {
- defer j.wg.Done()
- ticker := time.NewTicker(j.opt.ScheduleInterval)
- defer ticker.Stop()
- gc := time.NewTicker(time.Hour)
- defer gc.Stop()
- for {
- select {
- case <-j.stop:
- return
- case <-ticker.C:
- if j.meta.IsLeader() {
- j.schedule()
- }
- case <-gc.C:
- if j.meta.IsLeader() {
- cutoff := time.Now().Add(-time.Duration(j.opt.RetentionHours) * time.Hour).Unix()
- j.meta.GCJobs(cutoff, map[string]bool{StatusQueued: true, StatusScheduled: true, StatusRunning: true})
- }
- }
- }
- }
- /*
- Record helpers
- */
- func (j *Manager) decode(mj *metadata.Job) (Record, bool) {
- var rec Record
- if mj == nil || json.Unmarshal(mj.Body, &rec) != nil {
- return rec, false
- }
- return rec, true
- }
- // Get returns one job record.
- func (j *Manager) Get(id string) (Record, bool) {
- mj, ok := j.meta.Job(id)
- if !ok || mj.Removed {
- return Record{}, false
- }
- return j.decode(mj)
- }
- // List returns jobs, newest first; owner "" lists every job.
- func (j *Manager) List(owner string) []Record {
- out := []Record{}
- for _, mj := range j.meta.Jobs() {
- if mj.Removed || (owner != "" && mj.Owner != owner) {
- continue
- }
- if rec, ok := j.decode(&mj); ok {
- out = append(out, rec)
- }
- }
- sort.Slice(out, func(a, b int) bool { return out[a].Spec.Created > out[b].Spec.Created })
- return out
- }
- // put replicates a record.
- func (j *Manager) put(rec Record) error {
- body, err := json.Marshal(rec)
- if err != nil {
- return err
- }
- return j.meta.Submit(metadata.KindJob, &metadata.Job{
- ID: rec.Spec.ID,
- Owner: rec.Spec.Owner,
- Node: rec.State.Node,
- Status: rec.State.Status,
- Created: rec.Spec.Created,
- Body: body,
- })
- }
- /*
- Submission
- */
- // Submit validates and queues a job. The caller supplies the script source.
- func (j *Manager) Submit(spec Spec) (Record, error) {
- if !j.m.InCluster() {
- return Record{}, metadata.ErrNotInCluster
- }
- if strings.TrimSpace(spec.Script) == "" {
- return Record{}, errors.New("job script is empty")
- }
- if spec.Owner == "" {
- return Record{}, errors.New("job owner is required")
- }
- if spec.ID == "" {
- spec.ID = uuid.NewV4().String()
- }
- if strings.TrimSpace(spec.Name) == "" {
- spec.Name = spec.ScriptName
- }
- if spec.ScriptName == "" {
- spec.ScriptName = "job.agi"
- }
- if spec.Kind == "" {
- spec.Kind = KindRun
- }
- if spec.Kind == KindMapReduce && strings.TrimSpace(spec.Dataset) == "" {
- return Record{}, errors.New("a map/reduce job needs a dataset pattern")
- }
- if spec.TimeoutSec <= 0 {
- spec.TimeoutSec = 3600
- }
- if spec.MaxAttempts <= 0 {
- spec.MaxAttempts = 3
- }
- spec.Created = time.Now().Unix()
- for i, in := range spec.Inputs {
- spec.Inputs[i] = metadata.NormalizePath(in)
- }
- rec := Record{Spec: spec, State: State{Status: StatusQueued}}
- if err := j.put(rec); err != nil {
- return Record{}, err
- }
- logger.PrintAndLog("Cluster", "Job queued: "+spec.Name+" ("+spec.ID+")", nil)
- //Schedule immediately when we are the leader; otherwise the leader picks
- //it up on its next pass.
- if j.meta.IsLeader() {
- go j.schedule()
- }
- return rec, nil
- }
- // Cancel marks a job cancelled; a running node notices within a second.
- func (j *Manager) Cancel(id string, requester string, isAdmin bool) error {
- rec, ok := j.Get(id)
- if !ok {
- return ErrNotFound
- }
- if !isAdmin && rec.Spec.Owner != requester {
- return errors.New("permission denied")
- }
- if rec.Done() {
- return nil
- }
- rec.State.Status = StatusCancelled
- rec.State.Finished = time.Now().Unix()
- rec.State.Error = "cancelled by " + requester
- if err := j.put(rec); err != nil {
- return err
- }
- j.mu.Lock()
- if rs, running := j.running[id]; running {
- rs.cancel()
- }
- j.mu.Unlock()
- return nil
- }
- // Wait blocks until the job finishes or the timeout expires.
- func (j *Manager) Wait(id string, timeout time.Duration) (Record, error) {
- deadline := time.Now().Add(timeout)
- for {
- rec, ok := j.Get(id)
- if !ok {
- return Record{}, ErrNotFound
- }
- if rec.Done() {
- return rec, nil
- }
- if time.Now().After(deadline) {
- return rec, errors.New("timed out waiting for the job to finish")
- }
- select {
- case <-j.stop:
- return rec, errors.New("shutting down")
- case <-time.After(time.Second):
- }
- }
- }
- /*
- Scheduler (leader)
- */
- func usableState(s membership.NodeState) bool {
- return s == membership.StateOnline || s == membership.StateDegraded
- }
- // schedule is one scheduling pass.
- func (j *Manager) schedule() {
- if !j.meta.IsLeader() {
- return
- }
- j.schedMu.Lock()
- defer j.schedMu.Unlock()
- now := time.Now()
- nodes := j.m.NodeViews()
- loadPerNode := map[string]int{}
- pending := []Record{}
- for _, mj := range j.meta.Jobs() {
- if mj.Removed {
- continue
- }
- rec, ok := j.decode(&mj)
- if !ok || rec.Done() {
- continue
- }
- if rec.Spec.Kind == KindMapReduce && rec.State.Status == StatusRunning {
- continue //handled below, it runs on no node
- }
- switch rec.State.Status {
- case StatusScheduled, StatusRunning:
- if rec.State.LeaseExpires > 0 && rec.State.LeaseExpires < now.Unix() {
- //The worker went silent: requeue or give up
- if rec.State.Attempts >= rec.Spec.MaxAttempts {
- rec.State.Status = StatusFailed
- rec.State.Error = "node lost while running the job"
- rec.State.Finished = now.Unix()
- j.put(rec)
- j.finishedEvent(rec)
- continue
- }
- rec.State.Status = StatusQueued
- rec.State.Node = ""
- rec.State.LeaseExpires = 0
- rec.State.Reason = "previous node stopped reporting"
- j.put(rec)
- pending = append(pending, rec)
- continue
- }
- loadPerNode[rec.State.Node]++
- case StatusQueued:
- if rec.Spec.Kind == KindMapReduce {
- j.startMapReduce(rec)
- continue
- }
- pending = append(pending, rec)
- }
- }
- //Move running map/reduce parents forward
- for _, mj := range j.meta.Jobs() {
- if mj.Removed || mj.Status != StatusRunning {
- continue
- }
- if rec, ok := j.decode(&mj); ok && rec.Spec.Kind == KindMapReduce {
- j.advanceMapReduce(rec)
- }
- }
- if len(pending) == 0 {
- return
- }
- sort.Slice(pending, func(a, b int) bool {
- if pending[a].Spec.Priority != pending[b].Spec.Priority {
- return pending[a].Spec.Priority > pending[b].Spec.Priority
- }
- return pending[a].Spec.Created < pending[b].Spec.Created
- })
- for _, rec := range pending {
- node, reason := j.pick(rec, nodes, loadPerNode)
- if node == "" {
- if rec.State.Reason != reason {
- rec.State.Reason = reason
- j.put(rec)
- }
- continue
- }
- rec.State.Status = StatusScheduled
- rec.State.Node = node
- rec.State.Attempts++
- rec.State.LeaseExpires = now.Add(j.opt.TaskLease).Unix()
- rec.State.Reason = ""
- if err := j.put(rec); err != nil {
- continue
- }
- loadPerNode[node]++
- go j.assign(rec)
- }
- }
- // locality reports how many of the input bytes already sit on a node. The
- // host may supply a richer implementation (which also expands directories);
- // otherwise the metadata records answer it directly.
- func (j *Manager) locality(paths []string, nodeID string) (int64, int64) {
- if j.opt.LocalityBytes != nil {
- return j.opt.LocalityBytes(paths, nodeID)
- }
- var onNode, total int64
- for _, p := range paths {
- rec, err := j.meta.Stat(p)
- if err != nil || rec.IsDir {
- continue
- }
- size := rec.Size
- if size == 0 {
- size = 1 //count the file even when it is empty
- }
- total += size
- for _, loc := range rec.HealthyLocations() {
- if loc.NodeID == nodeID {
- onNode += size
- break
- }
- }
- }
- return onNode, total
- }
- // pick scores the eligible nodes and returns the best one, or a reason why
- // nothing fits. It also returns the full ranking, which the explain endpoint
- // shows to administrators.
- func (j *Manager) pick(rec Record, nodes []membership.NodeView, load map[string]int) (string, string) {
- results := j.rank(rec, nodes, load)
- return scheduling.Best(results)
- }
- // rank scores every node for a job.
- func (j *Manager) rank(rec Record, nodes []membership.NodeView, load map[string]int) []scheduling.Result {
- blocked := map[string]bool{}
- for _, id := range rec.State.Blocked {
- blocked[id] = true
- }
- candidates := []scheduling.Candidate{}
- for _, n := range nodes {
- c := scheduling.Candidate{Node: n, Queue: load[n.ID], FreeDisk: -1, LatencyToData: -1, Diversity: -1}
- if len(rec.Spec.Inputs) > 0 {
- onNode, total := j.locality(rec.Spec.Inputs, n.ID)
- if total > 0 {
- c.Locality = float64(onNode) / float64(total)
- }
- }
- candidates = append(candidates, c)
- }
- return j.sched.Rank(candidates, func(c scheduling.Candidate) (bool, string) {
- if !usableState(c.Node.State) {
- return false, "node is " + strings.ToLower(string(c.Node.State))
- }
- if blocked[c.Node.ID] {
- return false, "node refused this job earlier"
- }
- if len(rec.Spec.Nodes) > 0 && !contains(rec.Spec.Nodes, c.Node.ID) {
- return false, "job is limited to other nodes"
- }
- if ok, why := c.Node.Capabilities.Satisfies(rec.Spec.Requirements); !ok {
- return false, why
- }
- return true, ""
- })
- }
- // Explain returns the ranking the scheduler would use for a job right now.
- func (j *Manager) Explain(jobID string) ([]scheduling.Result, error) {
- rec, ok := j.Get(jobID)
- if !ok {
- return nil, ErrNotFound
- }
- load := map[string]int{}
- for _, mj := range j.meta.Jobs() {
- if mj.Removed {
- continue
- }
- if r, ok := j.decode(&mj); ok && (r.State.Status == StatusRunning || r.State.Status == StatusScheduled) {
- load[r.State.Node]++
- }
- }
- return j.rank(rec, j.m.NodeViews(), load), nil
- }
- // assign tells the chosen node to run the job (or runs it here). A node that
- // refuses (no account for the owner, cannot execute) is remembered so the
- // scheduler does not offer it the same job again.
- func (j *Manager) assign(rec Record) {
- node := rec.State.Node
- var err error
- if node == j.m.NodeID() {
- err = j.Start(rec.Spec.ID)
- } else {
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
- err = j.m.Transport().DoJSON(ctx, node, http.MethodPost, pathRun, RunRequest{JobID: rec.Spec.ID}, nil)
- cancel()
- }
- if err == nil {
- return
- }
- //Could not hand it over: put it back in the queue right away
- latest, ok := j.Get(rec.Spec.ID)
- if !ok || latest.Done() || latest.State.Status == StatusRunning {
- return
- }
- latest.State.Status = StatusQueued
- latest.State.Node = ""
- latest.State.LeaseExpires = 0
- latest.State.Reason = "node " + j.m.NodeName(node) + " could not take the job: " + err.Error()
- //Only a real refusal keeps the node away from this job for good. A
- //hiccup (a tunnel still reconnecting after a restart, a record that has
- //not reached the node yet) is retried on the next pass; blocking on it
- //would strand a job that is pinned to that node.
- if refusalIsPermanent(err) && !contains(latest.State.Blocked, node) {
- latest.State.Blocked = append(latest.State.Blocked, node)
- }
- //A refusal is not a failed attempt by the job itself
- if latest.State.Attempts > 0 {
- latest.State.Attempts--
- }
- j.put(latest)
- if j.meta.IsLeader() {
- go j.schedule()
- }
- }
- // refusalIsPermanent reports whether a node's answer to "run this job" means
- // it will never be able to run it: the owner has no account there, or it
- // cannot execute jobs at all. The error may come straight from Start or, for
- // a remote node, as the text the node sent back.
- func refusalIsPermanent(err error) bool {
- if err == nil {
- return false
- }
- if errors.Is(err, ErrNoUser) || errors.Is(err, ErrNoExecutor) {
- return true
- }
- msg := err.Error()
- return strings.Contains(msg, ErrNoUser.Error()) || strings.Contains(msg, ErrNoExecutor.Error())
- }
- func contains(list []string, v string) bool {
- for _, x := range list {
- if x == v {
- return true
- }
- }
- return false
- }
- func (j *Manager) finishedEvent(rec Record) {
- if j.OnFinished != nil {
- j.OnFinished(rec)
- }
- }
- /*
- Status
- */
- // Status summarises the job system for the UI.
- type Status struct {
- InCluster bool `json:"inCluster"`
- IsLeader bool `json:"isLeader"`
- LeaderName string `json:"leaderName"`
- Queued int `json:"queued"`
- Scheduled int `json:"scheduled"`
- Running int `json:"running"`
- Succeeded int `json:"succeeded"`
- Failed int `json:"failed"`
- Cancelled int `json:"cancelled"`
- LocalSlots int `json:"localSlots"`
- LocalBusy int `json:"localBusy"`
- Jobs []Record `json:"jobs"`
- }
- // Status builds the picture, filtered to owner when non-empty.
- func (j *Manager) Status(owner string) Status {
- st := Status{InCluster: j.m.InCluster(), IsLeader: j.meta.IsLeader(), LocalSlots: j.opt.MaxParallel, Jobs: []Record{}}
- if l := j.meta.Leader(); l != "" {
- st.LeaderName = j.m.NodeName(l)
- }
- for _, rec := range j.List(owner) {
- switch rec.State.Status {
- case StatusQueued:
- st.Queued++
- case StatusScheduled:
- st.Scheduled++
- case StatusRunning:
- st.Running++
- case StatusSucceeded:
- st.Succeeded++
- case StatusFailed:
- st.Failed++
- case StatusCancelled:
- st.Cancelled++
- }
- if len(st.Jobs) < 100 {
- st.Jobs = append(st.Jobs, rec)
- }
- }
- j.mu.Lock()
- st.LocalBusy = len(j.running)
- j.mu.Unlock()
- return st
- }
|