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 )