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

     1  package job
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"time"
     7  
     8  	"github.com/cozy/cozy-stack/model/permission"
     9  	"github.com/cozy/cozy-stack/pkg/consts"
    10  	"github.com/cozy/cozy-stack/pkg/couchdb"
    11  	"github.com/cozy/cozy-stack/pkg/couchdb/mango"
    12  	"github.com/cozy/cozy-stack/pkg/metadata"
    13  	"github.com/cozy/cozy-stack/pkg/prefixer"
    14  	"github.com/cozy/cozy-stack/pkg/realtime"
    15  )
    16  
    17  // DocTypeVersionTrigger represents the doctype version. Each time this document
    18  // structure is modified, update this value
    19  const DocTypeVersionTrigger = "1"
    20  
    21  // The different modes for combining job requests when debounced.
    22  const (
    23  	keepOriginalRequest = "original"
    24  	suppressPayload     = "recreate"
    25  	appendPayload       = "append"
    26  )
    27  
    28  type (
    29  	// Trigger interface is used to represent a trigger.
    30  	Trigger interface {
    31  		prefixer.Prefixer
    32  		permission.Fetcher
    33  		Type() string
    34  		Infos() *TriggerInfos
    35  		// Schedule should return a channel on which the trigger can send job
    36  		// requests when it decides to.
    37  		Schedule() <-chan *JobRequest
    38  		// Unschedule should be used to clean the trigger states and should close
    39  		// the returns jobs channel.
    40  		Unschedule()
    41  		CombineRequest() string
    42  	}
    43  
    44  	// Scheduler interface is used to represent a scheduler that is responsible
    45  	// to listen respond to triggers jobs requests and send them to the broker.
    46  	Scheduler interface {
    47  		StartScheduler(broker Broker) error
    48  		ShutdownScheduler(ctx context.Context) error
    49  		PollScheduler(now int64) error
    50  		AddTrigger(trigger Trigger) error
    51  		GetTrigger(db prefixer.Prefixer, id string) (Trigger, error)
    52  		UpdateMessage(db prefixer.Prefixer, trigger Trigger, message json.RawMessage) error
    53  		UpdateCron(db prefixer.Prefixer, trigger Trigger, arguments string) error
    54  		DeleteTrigger(db prefixer.Prefixer, id string) error
    55  		GetAllTriggers(db prefixer.Prefixer) ([]Trigger, error)
    56  		HasTrigger(db prefixer.Prefixer, infos TriggerInfos) bool
    57  		CleanRedis() error
    58  		RebuildRedis(db prefixer.Prefixer) error
    59  	}
    60  
    61  	// TriggerInfos is a struct containing all the options of a trigger.
    62  	TriggerInfos struct {
    63  		TID          string                 `json:"_id,omitempty"`
    64  		TRev         string                 `json:"_rev,omitempty"`
    65  		Cluster      int                    `json:"couch_cluster,omitempty"`
    66  		Domain       string                 `json:"domain"`
    67  		Prefix       string                 `json:"prefix,omitempty"`
    68  		Type         string                 `json:"type"`
    69  		WorkerType   string                 `json:"worker"`
    70  		Arguments    string                 `json:"arguments"`
    71  		Debounce     string                 `json:"debounce"`
    72  		Options      *JobOptions            `json:"options"`
    73  		Message      Message                `json:"message"`
    74  		CurrentState *TriggerState          `json:"current_state,omitempty"`
    75  		Metadata     *metadata.CozyMetadata `json:"cozyMetadata,omitempty"`
    76  	}
    77  
    78  	// TriggerState represent the current state of the trigger
    79  	TriggerState struct {
    80  		TID                 string     `json:"trigger_id"`
    81  		Status              State      `json:"status"`
    82  		LastSuccess         *time.Time `json:"last_success,omitempty"`
    83  		LastSuccessfulJobID string     `json:"last_successful_job_id,omitempty"`
    84  		LastExecution       *time.Time `json:"last_execution,omitempty"`
    85  		LastExecutedJobID   string     `json:"last_executed_job_id,omitempty"`
    86  		LastFailure         *time.Time `json:"last_failure,omitempty"`
    87  		LastFailedJobID     string     `json:"last_failed_job_id,omitempty"`
    88  		LastError           string     `json:"last_error,omitempty"`
    89  		LastManualExecution *time.Time `json:"last_manual_execution,omitempty"`
    90  		LastManualJobID     string     `json:"last_manual_job_id,omitempty"`
    91  	}
    92  )
    93  
    94  // DBCluster implements the prefixer.Prefixer interface.
    95  func (t *TriggerInfos) DBCluster() int {
    96  	return t.Cluster
    97  }
    98  
    99  // DBPrefix implements the prefixer.Prefixer interface.
   100  func (t *TriggerInfos) DBPrefix() string {
   101  	if t.Prefix != "" {
   102  		return t.Prefix
   103  	}
   104  	return t.Domain
   105  }
   106  
   107  // DomainName implements the prefixer.Prefixer interface.
   108  func (t *TriggerInfos) DomainName() string {
   109  	return t.Domain
   110  }
   111  
   112  func (t *TriggerInfos) IsKonnectorTrigger() bool {
   113  	return t.WorkerType == "konnector" || t.WorkerType == "client"
   114  }
   115  
   116  // NewTrigger creates the trigger associates with the specified trigger
   117  // options.
   118  func NewTrigger(db prefixer.Prefixer, infos TriggerInfos, data interface{}) (Trigger, error) {
   119  	var msg Message
   120  	var err error
   121  	if data != nil {
   122  		msg, err = NewMessage(data)
   123  		if err != nil {
   124  			return nil, err
   125  		}
   126  		infos.Message = msg
   127  	}
   128  	infos.Cluster = db.DBCluster()
   129  	infos.Prefix = db.DBPrefix()
   130  	infos.Domain = db.DomainName()
   131  
   132  	// Adding metadata
   133  	md := metadata.New()
   134  	md.DocTypeVersion = DocTypeVersionTrigger
   135  	if infos.Metadata == nil {
   136  		infos.Metadata = md
   137  	} else {
   138  		infos.Metadata.EnsureCreatedFields(md)
   139  	}
   140  
   141  	return fromTriggerInfos(&infos)
   142  }
   143  
   144  func fromTriggerInfos(infos *TriggerInfos) (Trigger, error) {
   145  	switch infos.Type {
   146  	case "@at":
   147  		return NewAtTrigger(infos)
   148  	case "@in":
   149  		return NewInTrigger(infos)
   150  	case "@hourly":
   151  		return NewHourlyTrigger(infos)
   152  	case "@daily":
   153  		return NewDailyTrigger(infos)
   154  	case "@weekly":
   155  		return NewWeeklyTrigger(infos)
   156  	case "@monthly":
   157  		return NewMonthlyTrigger(infos)
   158  	case "@cron":
   159  		return NewCronTrigger(infos)
   160  	case "@every":
   161  		return NewEveryTrigger(infos)
   162  	case "@event":
   163  		return NewEventTrigger(infos)
   164  	case "@webhook":
   165  		return NewWebhookTrigger(infos)
   166  	case "@client":
   167  		return NewClientTrigger(infos)
   168  	default:
   169  		return nil, ErrUnknownTrigger
   170  	}
   171  }
   172  
   173  // ID implements the couchdb.Doc interface
   174  func (t *TriggerInfos) ID() string { return t.TID }
   175  
   176  // Rev implements the couchdb.Doc interface
   177  func (t *TriggerInfos) Rev() string { return t.TRev }
   178  
   179  // DocType implements the couchdb.Doc interface
   180  func (t *TriggerInfos) DocType() string { return consts.Triggers }
   181  
   182  // Clone implements the couchdb.Doc interface
   183  func (t *TriggerInfos) Clone() couchdb.Doc {
   184  	cloned := *t
   185  	if t.Options != nil {
   186  		tmp := *t.Options
   187  		cloned.Options = &tmp
   188  	}
   189  
   190  	if t.Message != nil {
   191  		cloned.Message = make([]byte, len(t.Message))
   192  		copy(cloned.Message, t.Message)
   193  	}
   194  
   195  	if t.CurrentState != nil {
   196  		tmp := *t.CurrentState
   197  		cloned.CurrentState = &tmp
   198  	}
   199  
   200  	if t.Metadata != nil {
   201  		cloned.Metadata = t.Metadata.Clone()
   202  	}
   203  
   204  	return &cloned
   205  }
   206  
   207  // JobRequest returns a job request associated with the scheduler informations.
   208  func (t *TriggerInfos) JobRequest() *JobRequest {
   209  	trigger, _ := fromTriggerInfos(t)
   210  	return &JobRequest{
   211  		WorkerType: t.WorkerType,
   212  		TriggerID:  t.ID(),
   213  		Trigger:    trigger,
   214  		Message:    t.Message,
   215  		Options:    t.Options,
   216  	}
   217  }
   218  
   219  // JobRequestWithEvent returns a job request associated with the scheduler
   220  // informations associated to the specified realtime event.
   221  func (t *TriggerInfos) JobRequestWithEvent(event *realtime.Event) (*JobRequest, error) {
   222  	evt, err := NewEvent(event)
   223  	if err != nil {
   224  		return nil, err
   225  	}
   226  	req := t.JobRequest()
   227  	req.Event = evt
   228  	return req, nil
   229  }
   230  
   231  // SetID implements the couchdb.Doc interface
   232  func (t *TriggerInfos) SetID(id string) { t.TID = id }
   233  
   234  // SetRev implements the couchdb.Doc interface
   235  func (t *TriggerInfos) SetRev(rev string) { t.TRev = rev }
   236  
   237  // Fetch implements the permission.Fetcher interface
   238  func (t *TriggerInfos) Fetch(field string) []string {
   239  	switch field {
   240  	case "worker":
   241  		return []string{t.WorkerType}
   242  	default:
   243  		return nil
   244  	}
   245  }
   246  
   247  func createTrigger(t Trigger) error {
   248  	infos := t.Infos()
   249  	if infos.TID != "" {
   250  		return couchdb.CreateNamedDoc(t, infos)
   251  	}
   252  	return couchdb.CreateDoc(t, infos)
   253  }
   254  
   255  // GetJobs returns the jobs launched by the given trigger.
   256  func GetJobs(db prefixer.Prefixer, triggerID string, limit int) ([]*Job, error) {
   257  	if limit <= 0 || limit > 50 {
   258  		limit = 50
   259  	}
   260  	var jobs []*Job
   261  	req := &couchdb.FindRequest{
   262  		UseIndex: "by-trigger-id",
   263  		Selector: mango.Equal("trigger_id", triggerID),
   264  		Sort: mango.SortBy{
   265  			{Field: "trigger_id", Direction: mango.Desc},
   266  			{Field: "queued_at", Direction: mango.Desc},
   267  		},
   268  		Limit: limit,
   269  	}
   270  	err := couchdb.FindDocs(db, consts.Jobs, req, &jobs)
   271  	if err != nil {
   272  		return nil, err
   273  	}
   274  	return jobs, nil
   275  }
   276  
   277  // GetTriggerState returns the state of the trigger, calculated from the last
   278  // launched jobs.
   279  func GetTriggerState(db prefixer.Prefixer, triggerID string) (*TriggerState, error) {
   280  	js, err := GetJobs(db, triggerID, 0)
   281  	if err != nil {
   282  		return nil, err
   283  	}
   284  
   285  	var state TriggerState
   286  
   287  	state.Status = Done
   288  	state.TID = triggerID
   289  
   290  	// jobs are ordered from the oldest to most recent job
   291  	for i := len(js) - 1; i >= 0; i-- {
   292  		j := js[i]
   293  		startedAt := &j.StartedAt
   294  
   295  		state.Status = j.State
   296  		state.LastExecution = startedAt
   297  		state.LastExecutedJobID = j.ID()
   298  
   299  		if j.Manual {
   300  			state.LastManualExecution = startedAt
   301  			state.LastManualJobID = j.ID()
   302  		}
   303  
   304  		switch j.State {
   305  		case Errored:
   306  			state.LastFailure = startedAt
   307  			state.LastFailedJobID = j.ID()
   308  			state.LastError = j.Error
   309  		case Done:
   310  			state.LastSuccess = startedAt
   311  			state.LastSuccessfulJobID = j.ID()
   312  		default:
   313  			// skip any job that is not done or errored
   314  			continue
   315  		}
   316  	}
   317  
   318  	return &state, nil
   319  }
   320  
   321  var _ couchdb.Doc = &TriggerInfos{}
   322  var _ permission.Fetcher = &TriggerInfos{}