github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/job/broker.go (about)

     1  package job
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"sort"
     9  	"time"
    10  
    11  	"github.com/cozy/cozy-stack/model/permission"
    12  	"github.com/cozy/cozy-stack/pkg/consts"
    13  	"github.com/cozy/cozy-stack/pkg/couchdb"
    14  	"github.com/cozy/cozy-stack/pkg/couchdb/mango"
    15  	"github.com/cozy/cozy-stack/pkg/logger"
    16  	"github.com/cozy/cozy-stack/pkg/prefixer"
    17  	"github.com/cozy/cozy-stack/pkg/realtime"
    18  )
    19  
    20  const (
    21  	// Queued state
    22  	Queued State = "queued"
    23  	// Running state
    24  	Running State = "running"
    25  	// Done state
    26  	Done State = "done"
    27  	// Errored state
    28  	Errored State = "errored"
    29  )
    30  
    31  // defaultMaxLimits defines the maximum limit of how much jobs will be returned
    32  // for each job state
    33  var defaultMaxLimits map[State]int = map[State]int{
    34  	Queued:  50,
    35  	Running: 50,
    36  	Done:    50,
    37  	Errored: 50,
    38  }
    39  
    40  type (
    41  	// Broker interface is used to represent a job broker associated to a
    42  	// particular domain. A broker can be used to create jobs that are pushed in
    43  	// the job system.
    44  	//
    45  	// This interface is matched by several implementations:
    46  	// - [BrokerMock] a mock implementation used for the tests.
    47  	Broker interface {
    48  		StartWorkers(workersList WorkersList) error
    49  		ShutdownWorkers(ctx context.Context) error
    50  
    51  		// PushJob will push try to push a new job from the specified job request.
    52  		// This method is asynchronous.
    53  		PushJob(db prefixer.Prefixer, request *JobRequest) (*Job, error)
    54  
    55  		// WorkerQueueLen returns the total element in the queue of the specified
    56  		// worker type.
    57  		WorkerQueueLen(workerType string) (int, error)
    58  		// WorkerIsReserved returns true if the given worker type is reserved
    59  		// (ie clients should not push jobs to it, only the stack).
    60  		WorkerIsReserved(workerType string) (bool, error)
    61  		// WorkersTypes returns the list of registered workers types.
    62  		WorkersTypes() []string
    63  	}
    64  
    65  	// State represent the state of a job.
    66  	State string
    67  
    68  	// Message is a json encoded job message.
    69  	Message json.RawMessage
    70  
    71  	// Event is a json encoded value of a realtime.Event.
    72  	Event json.RawMessage
    73  
    74  	// Payload is a json encode value of a webhook payload.
    75  	Payload json.RawMessage
    76  
    77  	// Job contains all the metadata informations of a Job. It can be
    78  	// marshalled in JSON.
    79  	Job struct {
    80  		JobID       string      `json:"_id,omitempty"`
    81  		JobRev      string      `json:"_rev,omitempty"`
    82  		Cluster     int         `json:"couch_cluster,omitempty"`
    83  		Domain      string      `json:"domain"`
    84  		Prefix      string      `json:"prefix,omitempty"`
    85  		WorkerType  string      `json:"worker"`
    86  		TriggerID   string      `json:"trigger_id,omitempty"`
    87  		Message     Message     `json:"message"`
    88  		Event       Event       `json:"event"`
    89  		Payload     Payload     `json:"payload,omitempty"`
    90  		Manual      bool        `json:"manual_execution,omitempty"`
    91  		Debounced   bool        `json:"debounced,omitempty"`
    92  		Options     *JobOptions `json:"options,omitempty"`
    93  		State       State       `json:"state"`
    94  		QueuedAt    time.Time   `json:"queued_at"`
    95  		StartedAt   time.Time   `json:"started_at"`
    96  		FinishedAt  time.Time   `json:"finished_at"`
    97  		Error       string      `json:"error,omitempty"`
    98  		ForwardLogs bool        `json:"forward_logs,omitempty"`
    99  	}
   100  
   101  	// JobRequest struct is used to represent a new job request.
   102  	JobRequest struct {
   103  		WorkerType  string
   104  		TriggerID   string
   105  		Trigger     Trigger
   106  		Message     Message
   107  		Event       Event
   108  		Payload     Payload
   109  		Manual      bool
   110  		Debounced   bool
   111  		ForwardLogs bool
   112  		Options     *JobOptions
   113  	}
   114  
   115  	// JobOptions struct contains the execution properties of the jobs.
   116  	JobOptions struct {
   117  		MaxExecCount int           `json:"max_exec_count"`
   118  		Timeout      time.Duration `json:"timeout"`
   119  	}
   120  )
   121  
   122  var joblog = logger.WithNamespace("jobs")
   123  
   124  // DBCluster implements the prefixer.Prefixer interface.
   125  func (j *Job) DBCluster() int {
   126  	return j.Cluster
   127  }
   128  
   129  // DBPrefix implements the prefixer.Prefixer interface.
   130  func (j *Job) DBPrefix() string {
   131  	if j.Prefix != "" {
   132  		return j.Prefix
   133  	}
   134  	return j.Domain
   135  }
   136  
   137  // DomainName implements the prefixer.Prefixer interface.
   138  func (j *Job) DomainName() string {
   139  	return j.Domain
   140  }
   141  
   142  // ID implements the couchdb.Doc interface
   143  func (j *Job) ID() string { return j.JobID }
   144  
   145  // Rev implements the couchdb.Doc interface
   146  func (j *Job) Rev() string { return j.JobRev }
   147  
   148  // Clone implements the couchdb.Doc interface
   149  func (j *Job) Clone() couchdb.Doc {
   150  	cloned := *j
   151  	if j.Options != nil {
   152  		tmp := *j.Options
   153  		cloned.Options = &tmp
   154  	}
   155  	if j.Message != nil {
   156  		tmp := j.Message
   157  		j.Message = make([]byte, len(tmp))
   158  		copy(j.Message[:], tmp)
   159  	}
   160  	if j.Event != nil {
   161  		tmp := j.Event
   162  		j.Event = make([]byte, len(tmp))
   163  		copy(j.Event[:], tmp)
   164  	}
   165  	if j.Payload != nil {
   166  		tmp := j.Payload
   167  		j.Payload = make([]byte, len(tmp))
   168  		copy(j.Payload[:], tmp)
   169  	}
   170  	return &cloned
   171  }
   172  
   173  // DocType implements the couchdb.Doc interface
   174  func (j *Job) DocType() string { return consts.Jobs }
   175  
   176  // SetID implements the couchdb.Doc interface
   177  func (j *Job) SetID(id string) { j.JobID = id }
   178  
   179  // SetRev implements the couchdb.Doc interface
   180  func (j *Job) SetRev(rev string) { j.JobRev = rev }
   181  
   182  // Fetch implements the permission.Fetcher interface
   183  func (j *Job) Fetch(field string) []string {
   184  	switch field {
   185  	case "worker":
   186  		return []string{j.WorkerType}
   187  	case "state":
   188  		return []string{fmt.Sprintf("%v", j.State)}
   189  	}
   190  	return nil
   191  }
   192  
   193  // ID implements the permission.Getter interface
   194  func (jr *JobRequest) ID() string { return "" }
   195  
   196  // DocType implements the permission.Getter interface
   197  func (jr *JobRequest) DocType() string { return consts.Jobs }
   198  
   199  // Fetch implements the permission.Fetcher interface
   200  func (jr *JobRequest) Fetch(field string) []string {
   201  	switch field {
   202  	case "worker":
   203  		return []string{jr.WorkerType}
   204  	default:
   205  		return nil
   206  	}
   207  }
   208  
   209  // Logger returns a logger associated with the job domain
   210  func (j *Job) Logger() *logger.Entry {
   211  	return logger.WithDomain(j.Domain).WithNamespace("jobs")
   212  }
   213  
   214  // AckConsumed sets the job infos state to Running an sends the new job infos
   215  // on the channel.
   216  func (j *Job) AckConsumed() error {
   217  	j.Logger().Debugf("ack_consume %s", j.ID())
   218  	j.StartedAt = time.Now()
   219  	j.State = Running
   220  	return j.Update()
   221  }
   222  
   223  // Ack sets the job infos state to Done an sends the new job infos on the
   224  // channel.
   225  func (j *Job) Ack() error {
   226  	j.Logger().Debugf("ack %s", j.ID())
   227  	j.FinishedAt = time.Now()
   228  	j.State = Done
   229  	j.Event = nil
   230  	j.Payload = nil
   231  	return j.Update()
   232  }
   233  
   234  // Nack sets the job infos state to Errored, set the specified error has the
   235  // error field and sends the new job infos on the channel.
   236  func (j *Job) Nack(errorMessage string) error {
   237  	j.Logger().Debugf("nack %s", j.ID())
   238  	j.FinishedAt = time.Now()
   239  	j.State = Errored
   240  	j.Error = errorMessage
   241  	j.Event = nil
   242  	j.Payload = nil
   243  	return j.Update()
   244  }
   245  
   246  // Update updates the job in couchdb
   247  func (j *Job) Update() error {
   248  	err := couchdb.UpdateDoc(j, j)
   249  	// XXX When a job for an import runs, the database for io.cozy.jobs is
   250  	// deleted, and we need to recreate the job, not just update it.
   251  	if couchdb.IsNotFoundError(err) {
   252  		j.SetID("")
   253  		j.SetRev("")
   254  		return j.Create()
   255  	}
   256  	return err
   257  }
   258  
   259  // Create creates the job in couchdb
   260  func (j *Job) Create() error {
   261  	return couchdb.CreateDoc(j, j)
   262  }
   263  
   264  // WaitUntilDone will wait until the job is done. It will return an error if
   265  // the job has failed. And there is a timeout (10 minutes).
   266  func (j *Job) WaitUntilDone(db prefixer.Prefixer) error {
   267  	sub := realtime.GetHub().Subscriber(db)
   268  	defer sub.Close()
   269  	sub.Watch(j.DocType(), j.ID())
   270  	timeout := time.After(10 * time.Minute)
   271  	for {
   272  		select {
   273  		case e := <-sub.Channel:
   274  			state := Queued
   275  			if doc, ok := e.Doc.(*couchdb.JSONDoc); ok {
   276  				stateStr, _ := doc.M["state"].(string)
   277  				state = State(stateStr)
   278  			} else if doc, ok := e.Doc.(*realtime.JSONDoc); ok {
   279  				stateStr, _ := doc.M["state"].(string)
   280  				state = State(stateStr)
   281  			} else if doc, ok := e.Doc.(*Job); ok {
   282  				state = doc.State
   283  			}
   284  			switch state {
   285  			case Done:
   286  				return nil
   287  			case Errored:
   288  				return errors.New("The konnector failed on account deletion")
   289  			}
   290  		case <-timeout:
   291  			return nil
   292  		}
   293  	}
   294  }
   295  
   296  // UnmarshalJSON implements json.Unmarshaler on Message. It should be retro-
   297  // compatible with the old Message representation { Data, Type }.
   298  func (m *Message) UnmarshalJSON(data []byte) error {
   299  	// For retro-compatibility purposes
   300  	var mm struct {
   301  		Data []byte `json:"Data"`
   302  		Type string `json:"Type"`
   303  	}
   304  	if err := json.Unmarshal(data, &mm); err == nil && mm.Type == "json" {
   305  		var v json.RawMessage
   306  		if err = json.Unmarshal(mm.Data, &v); err != nil {
   307  			return err
   308  		}
   309  		*m = Message(v)
   310  		return nil
   311  	}
   312  	var v json.RawMessage
   313  	if err := json.Unmarshal(data, &v); err != nil {
   314  		return err
   315  	}
   316  	*m = Message(v)
   317  	return nil
   318  }
   319  
   320  // MarshalJSON implements json.Marshaler on Message.
   321  func (m Message) MarshalJSON() ([]byte, error) {
   322  	v := json.RawMessage(m)
   323  	return json.Marshal(v)
   324  }
   325  
   326  // NewJob creates a new Job instance from a job request.
   327  func NewJob(db prefixer.Prefixer, req *JobRequest) *Job {
   328  	return &Job{
   329  		Cluster:     db.DBCluster(),
   330  		Domain:      db.DomainName(),
   331  		Prefix:      db.DBPrefix(),
   332  		WorkerType:  req.WorkerType,
   333  		TriggerID:   req.TriggerID,
   334  		Manual:      req.Manual,
   335  		Message:     req.Message,
   336  		Debounced:   req.Debounced,
   337  		Event:       req.Event,
   338  		Payload:     req.Payload,
   339  		Options:     req.Options,
   340  		ForwardLogs: req.ForwardLogs,
   341  		State:       Queued,
   342  		QueuedAt:    time.Now(),
   343  	}
   344  }
   345  
   346  // Get returns the informations about a job.
   347  func Get(db prefixer.Prefixer, jobID string) (*Job, error) {
   348  	var job Job
   349  	if err := couchdb.GetDoc(db, consts.Jobs, jobID, &job); err != nil {
   350  		if couchdb.IsNotFoundError(err) {
   351  			return nil, ErrNotFoundJob
   352  		}
   353  		return nil, err
   354  	}
   355  	return &job, nil
   356  }
   357  
   358  // GetQueuedJobs returns the list of jobs which states is "queued" or "running"
   359  func GetQueuedJobs(db prefixer.Prefixer, workerType string) ([]*Job, error) {
   360  	var results []*Job
   361  	req := &couchdb.FindRequest{
   362  		UseIndex: "by-worker-and-state",
   363  		Selector: mango.And(
   364  			mango.Equal("worker", workerType),
   365  			mango.Exists("state"), // XXX it is needed by couchdb to use the index
   366  			mango.Or(
   367  				mango.Equal("state", Queued),
   368  				mango.Equal("state", Running),
   369  			),
   370  		),
   371  		Limit: 200,
   372  	}
   373  	err := couchdb.FindDocs(db, consts.Jobs, req, &results)
   374  	if err != nil {
   375  		return nil, err
   376  	}
   377  	return results, nil
   378  }
   379  
   380  // GetAllJobs returns the list of all the jobs on the given instance.
   381  func GetAllJobs(db prefixer.Prefixer) ([]*Job, error) {
   382  	var startkey string
   383  	var lastJob *Job
   384  
   385  	finalJobs := []*Job{}
   386  	remainingJobs := true
   387  
   388  	for remainingJobs {
   389  		jobs := []*Job{}
   390  		req := &couchdb.AllDocsRequest{
   391  			Limit:    10001,
   392  			StartKey: startkey,
   393  		}
   394  
   395  		err := couchdb.GetAllDocs(db, consts.Jobs, req, &jobs)
   396  		if err != nil {
   397  			return nil, err
   398  		}
   399  
   400  		if len(jobs) == 0 {
   401  			return finalJobs, nil
   402  		}
   403  
   404  		lastJob, jobs = jobs[len(jobs)-1], jobs[:len(jobs)-1]
   405  
   406  		// Startkey for the next request
   407  		startkey = lastJob.JobID
   408  
   409  		// Appending to the final jobs
   410  		finalJobs = append(finalJobs, jobs...)
   411  
   412  		// Only the startkey is present: we are in the last lap of the loop
   413  		// We have to append the startkey as the last element
   414  		if len(jobs) == 0 {
   415  			remainingJobs = false
   416  			finalJobs = append(finalJobs, lastJob)
   417  		}
   418  	}
   419  
   420  	return finalJobs, nil
   421  }
   422  
   423  // FilterJobsBeforeDate returns alls jobs queued before the specified date
   424  func FilterJobsBeforeDate(jobs []*Job, date time.Time) []*Job {
   425  	b := []*Job{}
   426  
   427  	for _, x := range jobs {
   428  		if x.QueuedAt.Before(date) {
   429  			b = append(b, x)
   430  		}
   431  	}
   432  
   433  	return b
   434  }
   435  
   436  // FilterByWorkerAndState filters a job slice by its workerType and State
   437  func FilterByWorkerAndState(jobs []*Job, workerType string, state State, limit int) []*Job {
   438  	returned := []*Job{}
   439  	for _, j := range jobs {
   440  		if j.WorkerType == workerType && j.State == state {
   441  			returned = append(returned, j)
   442  			if len(returned) == limit {
   443  				return returned
   444  			}
   445  		}
   446  	}
   447  
   448  	return returned
   449  }
   450  
   451  // GetLastsJobs returns the N lasts job of each state for an instance/worker
   452  // type pair
   453  func GetLastsJobs(jobs []*Job, workerType string) ([]*Job, error) {
   454  	var result []*Job
   455  
   456  	// Ordering by QueuedAt before filtering jobs
   457  	sort.Slice(jobs, func(i, j int) bool { return jobs[i].QueuedAt.Before(jobs[j].QueuedAt) })
   458  
   459  	for _, state := range []State{Queued, Running, Done, Errored} {
   460  		limit := defaultMaxLimits[state]
   461  
   462  		filtered := FilterByWorkerAndState(jobs, workerType, state, limit)
   463  		result = append(result, filtered...)
   464  	}
   465  
   466  	return result, nil
   467  }
   468  
   469  // NewMessage returns a json encoded data
   470  func NewMessage(data interface{}) (Message, error) {
   471  	b, err := json.Marshal(data)
   472  	if err != nil {
   473  		return nil, err
   474  	}
   475  	return Message(b), nil
   476  }
   477  
   478  // NewEvent return a json encoded realtime.Event
   479  func NewEvent(data *realtime.Event) (Event, error) {
   480  	b, err := json.Marshal(data)
   481  	if err != nil {
   482  		return nil, err
   483  	}
   484  	return Event(b), nil
   485  }
   486  
   487  // Unmarshal can be used to unmarshal the encoded message value in the
   488  // specified interface's type.
   489  func (m Message) Unmarshal(msg interface{}) error {
   490  	if m == nil {
   491  		return ErrMessageNil
   492  	}
   493  	if err := json.Unmarshal(m, &msg); err != nil {
   494  		return ErrMessageUnmarshal
   495  	}
   496  	return nil
   497  }
   498  
   499  // Unmarshal can be used to unmarshal the encoded message value in the
   500  // specified interface's type.
   501  func (e Event) Unmarshal(evt interface{}) error {
   502  	if e == nil {
   503  		return ErrMessageNil
   504  	}
   505  	if err := json.Unmarshal(e, &evt); err != nil {
   506  		return ErrMessageUnmarshal
   507  	}
   508  	return nil
   509  }
   510  
   511  // Unmarshal can be used to unmarshal the encoded message value in the
   512  // specified interface's type.
   513  func (p Payload) Unmarshal(evt interface{}) error {
   514  	if p == nil {
   515  		return ErrMessageNil
   516  	}
   517  	if err := json.Unmarshal(p, &evt); err != nil {
   518  		return ErrMessageUnmarshal
   519  	}
   520  	return nil
   521  }
   522  
   523  var (
   524  	_ permission.Fetcher = (*JobRequest)(nil)
   525  	_ permission.Fetcher = (*Job)(nil)
   526  )