github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/job/redis_scheduler_test.go (about) 1 package job_test 2 3 import ( 4 "context" 5 "sync" 6 "testing" 7 "time" 8 9 "github.com/cozy/cozy-stack/model/job" 10 "github.com/cozy/cozy-stack/model/vfs" 11 "github.com/cozy/cozy-stack/pkg/config/config" 12 "github.com/cozy/cozy-stack/pkg/consts" 13 "github.com/cozy/cozy-stack/pkg/couchdb" 14 "github.com/cozy/cozy-stack/pkg/prefixer" 15 "github.com/cozy/cozy-stack/pkg/realtime" 16 "github.com/cozy/cozy-stack/pkg/utils" 17 "github.com/cozy/cozy-stack/tests/testutils" 18 "github.com/redis/go-redis/v9" 19 "github.com/stretchr/testify/assert" 20 "github.com/stretchr/testify/require" 21 ) 22 23 type testDoc struct { 24 id string 25 rev string 26 doctype string 27 } 28 29 // fakeFilePather is used to force a cached value for the fullpath of a FileDoc 30 type fakeFilePather struct { 31 Fullpath string 32 } 33 34 func TestRedisScheduler(t *testing.T) { 35 const redisURL = "redis://localhost:6379/15" 36 37 if testing.Short() { 38 t.Skip("an instance is required for this test: test skipped due to the use of --short flag") 39 } 40 41 testutils.NeedCouchdb(t) 42 setup := testutils.NewSetup(t, t.Name()) 43 testInstance := setup.GetTestInstance() 44 45 config.UseTestFile(t) 46 opts, _ := redis.ParseURL(redisURL) 47 client := redis.NewClient(opts) 48 t.Cleanup(func() { 49 _ = client.Del(context.Background(), job.TriggersKey, job.SchedKey) 50 }) 51 52 t.Run("RedisSchedulerWithTimeTriggers", func(t *testing.T) { 53 var wAt sync.WaitGroup 54 var wIn sync.WaitGroup 55 bro := job.NewMemBroker() 56 assert.NoError(t, bro.StartWorkers(job.WorkersList{ 57 { 58 WorkerType: "worker", 59 Concurrency: 1, 60 MaxExecCount: 1, 61 Timeout: 1 * time.Millisecond, 62 WorkerFunc: func(ctx *job.TaskContext) error { 63 var msg string 64 if err := ctx.UnmarshalMessage(&msg); err != nil { 65 return err 66 } 67 switch msg { 68 case "@at": 69 wAt.Done() 70 case "@in": 71 wIn.Done() 72 } 73 return nil 74 }, 75 }, 76 })) 77 78 msg1, _ := job.NewMessage("@at") 79 msg2, _ := job.NewMessage("@in") 80 81 wAt.Add(1) // 1 time in @at 82 wIn.Add(1) // 1 time in @in 83 84 at := job.TriggerInfos{ 85 Type: "@at", 86 Arguments: time.Now().Add(2 * time.Second).Format(time.RFC3339), 87 WorkerType: "worker", 88 } 89 in := job.TriggerInfos{ 90 Type: "@in", 91 Arguments: "1s", 92 WorkerType: "worker", 93 } 94 95 sch := job.NewRedisScheduler(client) 96 defer func() { 97 assert.NoError(t, sch.ShutdownScheduler(context.Background())) 98 }() 99 assert.NoError(t, sch.StartScheduler(bro)) 100 time.Sleep(50 * time.Millisecond) 101 102 // Clear the existing triggers before testing with our triggers 103 ts, err := sch.GetAllTriggers(testInstance) 104 assert.NoError(t, err) 105 for _, trigger := range ts { 106 err = sch.DeleteTrigger(testInstance, trigger.ID()) 107 assert.NoError(t, err) 108 } 109 110 tat, err := job.NewTrigger(testInstance, at, msg1) 111 assert.NoError(t, err) 112 err = sch.AddTrigger(tat) 113 assert.NoError(t, err) 114 atID := tat.Infos().TID 115 116 tin, err := job.NewTrigger(testInstance, in, msg2) 117 assert.NoError(t, err) 118 err = sch.AddTrigger(tin) 119 assert.NoError(t, err) 120 inID := tin.Infos().TID 121 122 ts, err = sch.GetAllTriggers(testInstance) 123 assert.NoError(t, err) 124 assert.Len(t, ts, 2) 125 126 for _, trigger := range ts { 127 switch trigger.Infos().TID { 128 case atID: 129 assert.True(t, tat.Infos().Metadata.CreatedAt.Equal(trigger.Infos().Metadata.CreatedAt)) 130 assert.True(t, tat.Infos().Metadata.UpdatedAt.Equal(trigger.Infos().Metadata.UpdatedAt)) 131 132 tat.Infos().Metadata = nil 133 trigger.Infos().Metadata = nil 134 assert.Equal(t, tat.Infos(), trigger.Infos()) 135 case inID: 136 assert.True(t, tin.Infos().Metadata.CreatedAt.Equal(trigger.Infos().Metadata.CreatedAt)) 137 assert.True(t, tin.Infos().Metadata.UpdatedAt.Equal(trigger.Infos().Metadata.UpdatedAt)) 138 139 tin.Infos().Metadata = nil 140 trigger.Infos().Metadata = nil 141 assert.Equal(t, tin.Infos(), trigger.Infos()) 142 default: 143 // Just ignore the @event trigger for generating thumbnails 144 infos := trigger.Infos() 145 if infos.Type != "@event" || infos.WorkerType != "thumbnail" { 146 t.Fatalf("unknown trigger ID %s", trigger.Infos().TID) 147 } 148 } 149 } 150 151 done := make(chan bool) 152 go func() { 153 wAt.Wait() 154 done <- true 155 }() 156 157 go func() { 158 wIn.Wait() 159 done <- true 160 }() 161 162 for i := 0; i < 2; i++ { 163 select { 164 case <-done: 165 case <-time.After(2 * time.Second): 166 t.Fatalf("Timeout") 167 } 168 } 169 170 time.Sleep(50 * time.Millisecond) 171 172 _, err = sch.GetTrigger(testInstance, atID) 173 assert.Error(t, err) 174 assert.Equal(t, job.ErrNotFoundTrigger, err) 175 176 _, err = sch.GetTrigger(testInstance, inID) 177 assert.Error(t, err) 178 assert.Equal(t, job.ErrNotFoundTrigger, err) 179 }) 180 181 t.Run("RedisSchedulerWithCronTriggers", func(t *testing.T) { 182 err := client.Del(context.Background(), job.TriggersKey, job.SchedKey).Err() 183 assert.NoError(t, err) 184 185 bro := newMockBroker() 186 sch := job.NewRedisScheduler(client) 187 defer func() { 188 assert.NoError(t, sch.ShutdownScheduler(context.Background())) 189 }() 190 assert.NoError(t, sch.StartScheduler(bro)) 191 192 msg, _ := job.NewMessage("@cron") 193 194 infos := job.TriggerInfos{ 195 Type: "@cron", 196 Arguments: "*/3 * * * * *", 197 WorkerType: "incr", 198 } 199 trigger, err := job.NewTrigger(testInstance, infos, msg) 200 assert.NoError(t, err) 201 err = sch.AddTrigger(trigger) 202 assert.NoError(t, err) 203 204 now := time.Now().UTC().Unix() 205 for i := int64(0); i < 15; i++ { 206 err = sch.PollScheduler(now + i + 4) 207 assert.NoError(t, err) 208 } 209 count, _ := bro.WorkerQueueLen("incr") 210 assert.Equal(t, 6, count) 211 }) 212 213 t.Run("RedisPollFromSchedKey", func(t *testing.T) { 214 err := client.Del(context.Background(), job.TriggersKey, job.SchedKey).Err() 215 assert.NoError(t, err) 216 217 bro := newMockBroker() 218 sch := job.NewRedisScheduler(client) 219 defer func() { 220 assert.NoError(t, sch.ShutdownScheduler(context.Background())) 221 }() 222 assert.NoError(t, sch.StartScheduler(bro)) 223 224 now := time.Now() 225 msg, _ := job.NewMessage("@at") 226 227 at := job.TriggerInfos{ 228 Type: "@at", 229 Arguments: now.Format(time.RFC3339), 230 WorkerType: "incr", 231 } 232 233 tat, err := job.NewTrigger(testInstance, at, msg) 234 assert.NoError(t, err) 235 236 err = couchdb.CreateDoc(testInstance, tat.Infos()) 237 assert.NoError(t, err) 238 239 ts := now.UTC().Unix() 240 key := testInstance.DBPrefix() + "/" + tat.ID() 241 err = client.ZAdd(context.Background(), job.SchedKey, redis.Z{ 242 Score: float64(ts + 1), 243 Member: key, 244 }).Err() 245 assert.NoError(t, err) 246 247 err = sch.PollScheduler(ts + 2) 248 assert.NoError(t, err) 249 <-time.After(1 * time.Millisecond) 250 count, _ := bro.WorkerQueueLen("incr") 251 assert.Equal(t, 0, count) 252 253 err = sch.PollScheduler(ts + 13) 254 assert.NoError(t, err) 255 <-time.After(1 * time.Millisecond) 256 count, _ = bro.WorkerQueueLen("incr") 257 assert.Equal(t, 1, count) 258 }) 259 260 t.Run("RedisTriggerEvent", func(t *testing.T) { 261 err := client.Del(context.Background(), job.TriggersKey, job.SchedKey).Err() 262 assert.NoError(t, err) 263 264 bro := newMockBroker() 265 sch := job.NewRedisScheduler(client) 266 defer func() { 267 assert.NoError(t, sch.ShutdownScheduler(context.Background())) 268 }() 269 assert.NoError(t, sch.StartScheduler(bro)) 270 271 evTrigger := job.TriggerInfos{ 272 Type: "@event", 273 Arguments: "io.cozy.event.test:CREATED", 274 WorkerType: "incr", 275 } 276 277 tri, err := job.NewTrigger(testInstance, evTrigger, nil) 278 assert.NoError(t, err) 279 assert.NoError(t, sch.AddTrigger(tri)) 280 281 realtime.GetHub().Publish(testInstance, realtime.EventCreate, 282 &testDoc{id: "foo", doctype: "io.cozy.event.test"}, nil) 283 284 time.Sleep(1 * time.Second) 285 286 count, _ := bro.WorkerQueueLen("incr") 287 require.Equal(t, 1, count) 288 289 var evt struct { 290 Domain string `json:"domain"` 291 Prefix string `json:"prefix"` 292 Verb string `json:"verb"` 293 } 294 var data string 295 err = bro.jobs[0].Event.Unmarshal(&evt) 296 assert.NoError(t, err) 297 err = bro.jobs[0].Message.Unmarshal(&data) 298 assert.NoError(t, err) 299 300 assert.Equal(t, evt.Domain, testInstance.Domain) 301 assert.Equal(t, evt.Verb, "CREATED") 302 303 realtime.GetHub().Publish(testInstance, realtime.EventUpdate, 304 &testDoc{id: "foo", doctype: "io.cozy.event.test"}, nil) 305 306 realtime.GetHub().Publish(testInstance, realtime.EventCreate, 307 &testDoc{id: "foo", doctype: "io.cozy.event.test.bad"}, nil) 308 309 time.Sleep(10 * time.Millisecond) 310 311 count, _ = bro.WorkerQueueLen("incr") 312 assert.Equal(t, 1, count) 313 }) 314 315 t.Run("RedisTriggerEventForDirectories", func(t *testing.T) { 316 err := client.Del(context.Background(), job.TriggersKey, job.SchedKey).Err() 317 assert.NoError(t, err) 318 319 bro := newMockBroker() 320 sch := job.NewRedisScheduler(client) 321 defer func() { 322 assert.NoError(t, sch.ShutdownScheduler(context.Background())) 323 }() 324 assert.NoError(t, sch.StartScheduler(bro)) 325 326 dir := &vfs.DirDoc{ 327 Type: "directory", 328 DocName: "foo", 329 DirID: consts.RootDirID, 330 CreatedAt: time.Now(), 331 UpdatedAt: time.Now(), 332 Fullpath: "/foo", 333 } 334 err = testInstance.VFS().CreateDirDoc(dir) 335 assert.NoError(t, err) 336 337 evTrigger := job.TriggerInfos{ 338 Type: "@event", 339 Arguments: "io.cozy.files:CREATED:" + dir.DocID, 340 WorkerType: "incr", 341 } 342 tri, err := job.NewTrigger(testInstance, evTrigger, nil) 343 assert.NoError(t, err) 344 assert.NoError(t, sch.AddTrigger(tri)) 345 346 time.Sleep(1 * time.Second) 347 count, _ := bro.WorkerQueueLen("incr") 348 require.Equal(t, 0, count) 349 350 barID := utils.RandomString(10) 351 realtime.GetHub().Publish(testInstance, realtime.EventCreate, 352 &vfs.DirDoc{ 353 Type: "directory", 354 DocID: barID, 355 DocRev: "1-" + utils.RandomString(10), 356 DocName: "bar", 357 DirID: dir.DocID, 358 CreatedAt: time.Now(), 359 UpdatedAt: time.Now(), 360 Fullpath: "/foo/bar", 361 }, nil) 362 363 time.Sleep(100 * time.Millisecond) 364 count, _ = bro.WorkerQueueLen("incr") 365 assert.Equal(t, 1, count) 366 367 bazID := utils.RandomString(10) 368 baz := &vfs.FileDoc{ 369 Type: "file", 370 DocID: bazID, 371 DocRev: "1-" + utils.RandomString(10), 372 DocName: "baz", 373 DirID: barID, 374 CreatedAt: time.Now(), 375 UpdatedAt: time.Now(), 376 ByteSize: 42, 377 Mime: "application/json", 378 Class: "application", 379 Trashed: false, 380 } 381 ffp := fakeFilePather{"/foo/bar/baz"} 382 p, err := baz.Path(ffp) 383 assert.NoError(t, err) 384 assert.Equal(t, "/foo/bar/baz", p) 385 386 realtime.GetHub().Publish(testInstance, realtime.EventCreate, baz, nil) 387 388 time.Sleep(100 * time.Millisecond) 389 count, _ = bro.WorkerQueueLen("incr") 390 assert.Equal(t, 2, count) 391 392 // Simulate that /foo/bar/baz is moved to /quux 393 quux := &vfs.FileDoc{ 394 Type: "file", 395 DocID: bazID, 396 DocRev: "2-" + utils.RandomString(10), 397 DocName: "quux", 398 DirID: consts.RootDirID, 399 CreatedAt: baz.CreatedAt, 400 UpdatedAt: time.Now(), 401 ByteSize: 42, 402 Mime: "application/json", 403 Class: "application", 404 Trashed: false, 405 } 406 ffp = fakeFilePather{"/quux"} 407 p, err = quux.Path(ffp) 408 assert.NoError(t, err) 409 assert.Equal(t, "/quux", p) 410 411 realtime.GetHub().Publish(testInstance, realtime.EventCreate, quux, baz) 412 413 time.Sleep(100 * time.Millisecond) 414 count, _ = bro.WorkerQueueLen("incr") 415 assert.Equal(t, 3, count) 416 }) 417 418 t.Run("RedisSchedulerWithDebounce", func(t *testing.T) { 419 err := client.Del(context.Background(), job.TriggersKey, job.SchedKey).Err() 420 assert.NoError(t, err) 421 422 bro := newMockBroker() 423 sch := job.NewRedisScheduler(client) 424 defer func() { 425 assert.NoError(t, sch.ShutdownScheduler(context.Background())) 426 }() 427 assert.NoError(t, sch.StartScheduler(bro)) 428 429 evTrigger := job.TriggerInfos{ 430 Type: "@event", 431 Arguments: "io.cozy.debounce.test:CREATED io.cozy.debounce.more:CREATED", 432 WorkerType: "incr", 433 Debounce: "4s", 434 } 435 tri, err := job.NewTrigger(testInstance, evTrigger, nil) 436 assert.NoError(t, err) 437 assert.NoError(t, sch.AddTrigger(tri)) 438 439 doc := testDoc{ 440 id: "foo", 441 doctype: "io.cozy.debounce.test", 442 } 443 444 doc2 := testDoc{ 445 id: "foo", 446 doctype: "io.cozy.debounce.more", 447 } 448 449 for i := 0; i < 10; i++ { 450 time.Sleep(600 * time.Millisecond) 451 realtime.GetHub().Publish(testInstance, realtime.EventCreate, &doc, nil) 452 } 453 454 time.Sleep(5000 * time.Millisecond) 455 count, _ := bro.WorkerQueueLen("incr") 456 assert.Equal(t, 2, count) 457 458 realtime.GetHub().Publish(testInstance, realtime.EventCreate, &doc, nil) 459 realtime.GetHub().Publish(testInstance, realtime.EventCreate, &doc2, nil) 460 time.Sleep(5000 * time.Millisecond) 461 count, _ = bro.WorkerQueueLen("incr") 462 assert.Equal(t, 3, count) 463 }) 464 } 465 466 func (t *testDoc) ID() string { return t.id } 467 func (t *testDoc) Rev() string { return t.rev } 468 func (t *testDoc) DocType() string { return t.doctype } 469 470 type mockBroker struct { 471 l *sync.Mutex 472 jobs []*job.JobRequest 473 } 474 475 func newMockBroker() *mockBroker { 476 return &mockBroker{ 477 l: new(sync.Mutex), 478 jobs: []*job.JobRequest{}, 479 } 480 } 481 482 func (b *mockBroker) StartWorkers(workersList job.WorkersList) error { 483 return nil 484 } 485 486 func (b *mockBroker) ShutdownWorkers(ctx context.Context) error { 487 return nil 488 } 489 490 func (b *mockBroker) PushJob(db prefixer.Prefixer, request *job.JobRequest) (*job.Job, error) { 491 b.l.Lock() 492 493 b.jobs = append(b.jobs, request) 494 495 b.l.Unlock() 496 return nil, nil 497 } 498 499 func (b *mockBroker) WorkerQueueLen(workerType string) (int, error) { 500 count := 0 501 b.l.Lock() 502 503 for _, job := range b.jobs { 504 if job.WorkerType == workerType { 505 count++ 506 } 507 } 508 509 b.l.Unlock() 510 511 return count, nil 512 } 513 514 func (b *mockBroker) WorkerIsReserved(workerType string) (bool, error) { 515 return false, nil 516 } 517 518 func (b *mockBroker) WorkersTypes() []string { 519 return []string{} 520 } 521 522 func (d fakeFilePather) FilePath(doc *vfs.FileDoc) (string, error) { 523 return d.Fullpath, nil 524 }