github.com/cozy/cozy-stack@v0.0.0-20240327093429-939e4a21320e/model/job/mem_scheduler.go (about)

     1  package job
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"os"
     9  	"strings"
    10  	"sync"
    11  	"time"
    12  
    13  	"github.com/cozy/cozy-stack/model/instance"
    14  	"github.com/cozy/cozy-stack/pkg/consts"
    15  	"github.com/cozy/cozy-stack/pkg/couchdb"
    16  	"github.com/cozy/cozy-stack/pkg/logger"
    17  	"github.com/cozy/cozy-stack/pkg/prefixer"
    18  )
    19  
    20  // memScheduler is a centralized scheduler of many triggers. It starts all of
    21  // them and schedules jobs accordingly.
    22  type memScheduler struct {
    23  	broker Broker
    24  
    25  	ts    map[string]Trigger
    26  	thumb *ThumbnailTrigger
    27  	share *ShareGroupTrigger
    28  	mu    sync.RWMutex
    29  	log   *logger.Entry
    30  }
    31  
    32  // NewMemScheduler creates a new in-memory scheduler that will load all
    33  // registered triggers and schedule their work.
    34  func NewMemScheduler() Scheduler {
    35  	return newMemScheduler()
    36  }
    37  
    38  func newMemScheduler() *memScheduler {
    39  	return &memScheduler{
    40  		ts:  make(map[string]Trigger),
    41  		log: logger.WithNamespace("mem-scheduler"),
    42  	}
    43  }
    44  
    45  // StartScheduler will start the scheduler by actually loading all triggers
    46  // from the scheduler's storage and associate for each of them a go routine in
    47  // which they wait for the trigger send job requests.
    48  func (s *memScheduler) StartScheduler(b Broker) error {
    49  	s.mu.Lock()
    50  	defer s.mu.Unlock()
    51  	s.broker = b
    52  
    53  	s.thumb = NewThumbnailTrigger(s.broker)
    54  	go s.thumb.Schedule()
    55  	s.share = NewShareGroupTrigger(s.broker)
    56  	go s.share.Schedule()
    57  
    58  	// XXX The memory scheduler loads the triggers from CouchDB when the stack
    59  	// is started. This can cause some stability issues when running system
    60  	// tests in parallel. To avoid that, an env variable
    61  	// COZY_SKIP_LOADING_TRIGGERS can be set to skip loading the triggers from
    62  	// CouchDB. It is correct for system tests, as instances are created and
    63  	// destroyed by the same process. But, it should not be used elsewhere.
    64  	for _, env := range os.Environ() {
    65  		if strings.HasPrefix(env, "COZY_SKIP_LOADING_TRIGGERS=") {
    66  			return nil
    67  		}
    68  	}
    69  
    70  	var ts []*TriggerInfos
    71  	err := couchdb.ForeachDocs(prefixer.GlobalPrefixer, consts.Instances, func(_ string, data json.RawMessage) error {
    72  		db := &instance.Instance{}
    73  		if err := json.Unmarshal(data, db); err != nil {
    74  			return err
    75  		}
    76  		err := couchdb.ForeachDocs(db, consts.Triggers, func(_ string, data json.RawMessage) error {
    77  			var t *TriggerInfos
    78  			if err := json.Unmarshal(data, &t); err != nil {
    79  				return err
    80  			}
    81  
    82  			// Remove the legacy @event trigger for thumbnail, it is now hardcoded
    83  			if t.WorkerType == "thumbnail" {
    84  				_ = couchdb.DeleteDoc(db, t)
    85  				return nil
    86  			}
    87  
    88  			ts = append(ts, t)
    89  			return nil
    90  		})
    91  		if err != nil && !couchdb.IsNoDatabaseError(err) {
    92  			return err
    93  		}
    94  		return nil
    95  	})
    96  	if err != nil && !couchdb.IsNoDatabaseError(err) {
    97  		return err
    98  	}
    99  
   100  	for _, infos := range ts {
   101  		t, err := fromTriggerInfos(infos)
   102  		if err != nil {
   103  			joblog.Errorf("Could not start trigger with ID %s: %s",
   104  				infos.ID(), err.Error())
   105  			continue
   106  		}
   107  		s.ts[t.DBPrefix()+"/"+infos.TID] = t
   108  		go s.schedule(t)
   109  	}
   110  
   111  	return nil
   112  }
   113  
   114  // ShutdownScheduler shuts down the scheduling of triggers
   115  func (s *memScheduler) ShutdownScheduler(ctx context.Context) error {
   116  	s.mu.Lock()
   117  	defer s.mu.Unlock()
   118  	fmt.Print("  shutting down in-memory scheduler...")
   119  	for _, t := range s.ts {
   120  		t.Unschedule()
   121  	}
   122  	s.thumb.Unschedule()
   123  	s.share.Unschedule()
   124  	fmt.Println("ok.")
   125  	return nil
   126  }
   127  
   128  // AddTrigger will add a new trigger to the scheduler. The trigger is persisted
   129  // in storage.
   130  func (s *memScheduler) AddTrigger(t Trigger) error {
   131  	s.mu.Lock()
   132  	defer s.mu.Unlock()
   133  	if err := createTrigger(t); err != nil {
   134  		return err
   135  	}
   136  	s.ts[t.DBPrefix()+"/"+t.Infos().TID] = t
   137  	go s.schedule(t)
   138  	return nil
   139  }
   140  
   141  // GetTrigger returns the trigger with the specified ID.
   142  func (s *memScheduler) GetTrigger(db prefixer.Prefixer, id string) (Trigger, error) {
   143  	s.mu.RLock()
   144  	defer s.mu.RUnlock()
   145  	t, ok := s.ts[db.DBPrefix()+"/"+id]
   146  	if !ok {
   147  		return nil, ErrNotFoundTrigger
   148  	}
   149  	return t, nil
   150  }
   151  
   152  // UpdateMessage changes the message for the given trigger.
   153  func (s *memScheduler) UpdateMessage(db prefixer.Prefixer, trigger Trigger, message json.RawMessage) error {
   154  	s.mu.RLock()
   155  	defer s.mu.RUnlock()
   156  	infos := trigger.Infos()
   157  	infos.Message = Message(message)
   158  	return couchdb.UpdateDoc(db, infos)
   159  }
   160  
   161  // UpdateCron will change the frequency of execution for the given trigger.
   162  func (s *memScheduler) UpdateCron(db prefixer.Prefixer, trigger Trigger, arguments string) error {
   163  	s.mu.RLock()
   164  	defer s.mu.RUnlock()
   165  	if trigger.Type() != "@cron" {
   166  		return ErrNotCronTrigger
   167  	}
   168  	infos := trigger.Infos()
   169  	infos.Arguments = arguments
   170  	updated, err := NewCronTrigger(infos)
   171  	if err != nil {
   172  		return err
   173  	}
   174  	trigger.Unschedule()
   175  	s.ts[updated.DBPrefix()+"/"+infos.TID] = updated
   176  	go s.schedule(updated)
   177  	return couchdb.UpdateDoc(db, infos)
   178  }
   179  
   180  // DeleteTrigger removes the trigger with the specified ID. The trigger is unscheduled
   181  // and remove from the storage.
   182  func (s *memScheduler) DeleteTrigger(db prefixer.Prefixer, id string) error {
   183  	s.mu.Lock()
   184  	defer s.mu.Unlock()
   185  	t, ok := s.ts[db.DBPrefix()+"/"+id]
   186  	if !ok {
   187  		return ErrNotFoundTrigger
   188  	}
   189  	delete(s.ts, db.DBPrefix()+"/"+id)
   190  	t.Unschedule()
   191  	return couchdb.DeleteDoc(db, t.Infos())
   192  }
   193  
   194  // GetAllTriggers returns all the running in-memory triggers.
   195  func (s *memScheduler) GetAllTriggers(db prefixer.Prefixer) ([]Trigger, error) {
   196  	s.mu.RLock()
   197  	defer s.mu.RUnlock()
   198  	prefix := db.DBPrefix() + "/"
   199  	v := make([]Trigger, 0)
   200  	for n, t := range s.ts {
   201  		if strings.HasPrefix(n, prefix) {
   202  			v = append(v, t)
   203  		}
   204  	}
   205  	return v, nil
   206  }
   207  
   208  // HasEventTrigger returns true if the given trigger already exists. Only the
   209  // type (@event, @cron...), worker, and arguments (if not empty) are looked at.
   210  func (s *memScheduler) HasTrigger(db prefixer.Prefixer, infos TriggerInfos) bool {
   211  	prefix := db.DBPrefix() + "/"
   212  	for n, t := range s.ts {
   213  		if !strings.HasPrefix(n, prefix) {
   214  			continue
   215  		}
   216  		i := t.Infos()
   217  		if infos.Type == i.Type && infos.WorkerType == i.WorkerType {
   218  			if infos.Arguments == "" || infos.Arguments == i.Arguments {
   219  				return true
   220  			}
   221  		}
   222  	}
   223  	return false
   224  }
   225  
   226  func (s *memScheduler) schedule(t Trigger) {
   227  	s.log.Debugf("trigger %s(%s): Starting trigger",
   228  		t.Type(), t.Infos().TID)
   229  	ch := t.Schedule()
   230  	if ch == nil {
   231  		return
   232  	}
   233  	var debounced <-chan time.Time
   234  	var combinedReq *JobRequest
   235  	var d time.Duration
   236  	infos := t.Infos()
   237  	if infos.Debounce != "" {
   238  		var err error
   239  		if d, err = time.ParseDuration(infos.Debounce); err != nil {
   240  			s.log.Errorf("trigger %s has an invalid debounce: %s",
   241  				infos.TID, infos.Debounce)
   242  		}
   243  	}
   244  	for {
   245  		select {
   246  		case req, ok := <-ch:
   247  			if !ok {
   248  				return
   249  			}
   250  			if d == 0 {
   251  				s.pushJob(t, req)
   252  			} else if debounced == nil {
   253  				debounced = time.After(d)
   254  				combinedReq = combineRequests(t, req, nil)
   255  			} else {
   256  				combinedReq = combineRequests(t, combinedReq, req)
   257  			}
   258  		case <-debounced:
   259  			s.pushJob(t, combinedReq)
   260  			debounced = nil
   261  			combinedReq = nil
   262  		}
   263  	}
   264  }
   265  
   266  func combineRequests(t Trigger, req1, req2 *JobRequest) *JobRequest {
   267  	switch t.CombineRequest() {
   268  	case appendPayload:
   269  		if req2 == nil {
   270  			req1.Payload = Payload(`{"payloads":[` + string(req1.Payload) + "]}")
   271  		} else {
   272  			was := string(req1.Payload)
   273  			cut := was[:len(was)-2] // Remove the final ]}
   274  			req1.Payload = Payload(cut + "," + string(req2.Payload) + "]}")
   275  		}
   276  		return req1
   277  	case suppressPayload:
   278  		return t.Infos().JobRequest()
   279  	default: // keepOriginalRequest
   280  		return req1
   281  	}
   282  }
   283  
   284  func (s *memScheduler) pushJob(t Trigger, req *JobRequest) {
   285  	s.mu.Lock()
   286  	defer s.mu.Unlock()
   287  
   288  	log := s.log.WithField("domain", t.DomainName())
   289  	log.Infof("trigger %s(%s): Pushing new job %s",
   290  		t.Type(), t.Infos().TID, req.WorkerType)
   291  	if _, err := s.broker.PushJob(t, req); err != nil {
   292  		log.Errorf("trigger %s(%s): Could not schedule a new job: %s",
   293  			t.Type(), t.Infos().TID, err.Error())
   294  	}
   295  }
   296  
   297  func (s *memScheduler) PollScheduler(now int64) error {
   298  	return errors.New("memScheduler cannot be polled")
   299  }
   300  
   301  // CleanRedis does nothing for the in memory scheduler. It's just
   302  // here to implement the Scheduler interface.
   303  func (s *memScheduler) CleanRedis() error {
   304  	return errors.New("memScheduler does not use redis")
   305  }
   306  
   307  // RebuildRedis does nothing for the in memory scheduler. It's just
   308  // here to implement the Scheduler interface.
   309  func (s *memScheduler) RebuildRedis(db prefixer.Prefixer) error {
   310  	return errors.New("memScheduler does not use redis")
   311  }
   312  
   313  var _ Scheduler = &memScheduler{}