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{}