github.com/mattermost/mattermost-plugin-api@v0.1.4/cluster/job_once_test.go (about) 1 package cluster 2 3 import ( 4 "encoding/json" 5 "sync" 6 "sync/atomic" 7 "testing" 8 "time" 9 10 "github.com/mattermost/mattermost-server/v6/model" 11 "github.com/pkg/errors" 12 "github.com/stretchr/testify/assert" 13 "github.com/stretchr/testify/require" 14 ) 15 16 func TestScheduleOnceParallel(t *testing.T) { 17 makeKey := model.NewId 18 19 // there is only one callback by design, so all tests need to add their key 20 // and callback handling code here. 21 jobKey1 := makeKey() 22 count1 := new(int32) 23 jobKey2 := makeKey() 24 count2 := new(int32) 25 jobKey3 := makeKey() 26 jobKey4 := makeKey() 27 count4 := new(int32) 28 jobKey5 := makeKey() 29 count5 := new(int32) 30 31 manyJobs := make(map[string]*int32) 32 for i := 0; i < 100; i++ { 33 manyJobs[makeKey()] = new(int32) 34 } 35 36 callback := func(key string, _ any) { 37 switch key { 38 case jobKey1: 39 atomic.AddInt32(count1, 1) 40 case jobKey2: 41 atomic.AddInt32(count2, 1) 42 case jobKey3: 43 return // do nothing, like an error occurred in the plugin 44 case jobKey4: 45 atomic.AddInt32(count4, 1) 46 case jobKey5: 47 atomic.AddInt32(count5, 1) 48 default: 49 count, ok := manyJobs[key] 50 if ok { 51 atomic.AddInt32(count, 1) 52 return 53 } 54 } 55 } 56 57 mockPluginAPI := newMockPluginAPI(t) 58 getVal := func(key string) []byte { 59 data, _ := mockPluginAPI.KVGet(key) 60 return data 61 } 62 63 s := GetJobOnceScheduler(mockPluginAPI) 64 65 // should error if we try to start without callback 66 err := s.Start() 67 require.Error(t, err) 68 69 err = s.SetCallback(callback) 70 require.NoError(t, err) 71 err = s.Start() 72 require.NoError(t, err) 73 74 jobs, err := s.ListScheduledJobs() 75 require.NoError(t, err) 76 require.Empty(t, jobs) 77 78 t.Run("one scheduled job", func(t *testing.T) { 79 t.Parallel() 80 81 job, err2 := s.ScheduleOnce(jobKey1, time.Now().Add(100*time.Millisecond), nil) 82 require.NoError(t, err2) 83 require.NotNil(t, job) 84 assert.NotEmpty(t, getVal(oncePrefix+jobKey1)) 85 86 time.Sleep(200*time.Millisecond + scheduleOnceJitter) 87 88 assert.Empty(t, getVal(oncePrefix+jobKey1)) 89 s.activeJobs.mu.RLock() 90 assert.Empty(t, s.activeJobs.jobs[jobKey1]) 91 s.activeJobs.mu.RUnlock() 92 93 // It's okay to cancel jobs extra times, even if they're completed. 94 job.Cancel() 95 job.Cancel() 96 job.Cancel() 97 job.Cancel() 98 99 // Should have been called once 100 assert.Equal(t, int32(1), atomic.LoadInt32(count1)) 101 }) 102 103 t.Run("one job, stopped before firing", func(t *testing.T) { 104 t.Parallel() 105 106 job, err2 := s.ScheduleOnce(jobKey2, time.Now().Add(100*time.Millisecond), nil) 107 require.NoError(t, err2) 108 require.NotNil(t, job) 109 assert.NotEmpty(t, getVal(oncePrefix+jobKey2)) 110 111 job.Cancel() 112 assert.Empty(t, getVal(oncePrefix+jobKey2)) 113 s.activeJobs.mu.RLock() 114 assert.Empty(t, s.activeJobs.jobs[jobKey2]) 115 s.activeJobs.mu.RUnlock() 116 117 time.Sleep(2 * (waitAfterFail + scheduleOnceJitter)) 118 119 // Should not have been called 120 assert.Equal(t, int32(0), atomic.LoadInt32(count2)) 121 122 // It's okay to cancel jobs extra times, even if they're completed. 123 job.Cancel() 124 job.Cancel() 125 job.Cancel() 126 job.Cancel() 127 }) 128 129 t.Run("failed at the plugin, job removed from db", func(t *testing.T) { 130 t.Parallel() 131 132 job, err2 := s.ScheduleOnce(jobKey3, time.Now().Add(100*time.Millisecond), nil) 133 require.NoError(t, err2) 134 require.NotNil(t, job) 135 assert.NotEmpty(t, getVal(oncePrefix+jobKey3)) 136 137 time.Sleep(200*time.Millisecond + scheduleOnceJitter) 138 assert.Empty(t, getVal(oncePrefix+jobKey3)) 139 s.activeJobs.mu.RLock() 140 assert.Empty(t, s.activeJobs.jobs[jobKey3]) 141 s.activeJobs.mu.RUnlock() 142 }) 143 144 t.Run("cancel and restart a job with the same key", func(t *testing.T) { 145 t.Parallel() 146 147 job, err2 := s.ScheduleOnce(jobKey4, time.Now().Add(100*time.Millisecond), nil) 148 require.NoError(t, err2) 149 require.NotNil(t, job) 150 assert.NotEmpty(t, getVal(oncePrefix+jobKey4)) 151 152 job.Cancel() 153 assert.Empty(t, getVal(oncePrefix+jobKey4)) 154 s.activeJobs.mu.RLock() 155 assert.Empty(t, s.activeJobs.jobs[jobKey4]) 156 s.activeJobs.mu.RUnlock() 157 158 job, err2 = s.ScheduleOnce(jobKey4, time.Now().Add(100*time.Millisecond), nil) 159 require.NoError(t, err2) 160 require.NotNil(t, job) 161 assert.NotEmpty(t, getVal(oncePrefix+jobKey4)) 162 163 time.Sleep(200*time.Millisecond + scheduleOnceJitter) 164 assert.Equal(t, int32(1), atomic.LoadInt32(count4)) 165 assert.Empty(t, getVal(oncePrefix+jobKey4)) 166 s.activeJobs.mu.RLock() 167 assert.Empty(t, s.activeJobs.jobs[jobKey4]) 168 s.activeJobs.mu.RUnlock() 169 }) 170 171 t.Run("many scheduled jobs", func(t *testing.T) { 172 t.Parallel() 173 174 for k := range manyJobs { 175 job, err2 := s.ScheduleOnce(k, time.Now().Add(100*time.Millisecond), nil) 176 require.NoError(t, err2) 177 require.NotNil(t, job) 178 assert.NotEmpty(t, getVal(oncePrefix+k)) 179 } 180 181 time.Sleep(200*time.Millisecond + scheduleOnceJitter) 182 183 for k, v := range manyJobs { 184 assert.Empty(t, getVal(oncePrefix+k)) 185 s.activeJobs.mu.RLock() 186 assert.Empty(t, s.activeJobs.jobs[k]) 187 s.activeJobs.mu.RUnlock() 188 assert.Equal(t, int32(1), *v) 189 } 190 }) 191 192 t.Run("cancel a job by key name", func(t *testing.T) { 193 t.Parallel() 194 195 job, err2 := s.ScheduleOnce(jobKey5, time.Now().Add(100*time.Millisecond), nil) 196 require.NoError(t, err2) 197 require.NotNil(t, job) 198 assert.NotEmpty(t, getVal(oncePrefix+jobKey5)) 199 s.activeJobs.mu.RLock() 200 assert.NotEmpty(t, s.activeJobs.jobs[jobKey5]) 201 s.activeJobs.mu.RUnlock() 202 203 s.Cancel(jobKey5) 204 205 assert.Empty(t, getVal(oncePrefix+jobKey5)) 206 s.activeJobs.mu.RLock() 207 assert.Empty(t, s.activeJobs.jobs[jobKey5]) 208 s.activeJobs.mu.RUnlock() 209 210 // cancel it again doesn't do anything: 211 s.Cancel(jobKey5) 212 213 time.Sleep(150*time.Millisecond + scheduleOnceJitter) 214 assert.Equal(t, int32(0), atomic.LoadInt32(count5)) 215 }) 216 217 t.Run("starting the scheduler again will return an error", func(t *testing.T) { 218 t.Parallel() 219 220 newScheduler := GetJobOnceScheduler(mockPluginAPI) 221 err = newScheduler.Start() 222 require.Error(t, err) 223 }) 224 } 225 226 func TestScheduleOnceSequential(t *testing.T) { 227 makeKey := model.NewId 228 229 // get the existing scheduler 230 s := GetJobOnceScheduler(newMockPluginAPI(t)) 231 getVal := func(key string) []byte { 232 data, _ := s.pluginAPI.KVGet(key) 233 return data 234 } 235 setMetadata := func(key string, metadata JobOnceMetadata) error { 236 data, err := json.Marshal(metadata) 237 if err != nil { 238 return err 239 } 240 ok, appErr := s.pluginAPI.KVSetWithOptions(oncePrefix+key, data, model.PluginKVSetOptions{}) 241 if !ok { 242 return errors.New("KVSetWithOptions failed") 243 } 244 if appErr != nil { 245 return normalizeAppErr(appErr) 246 } 247 return nil 248 } 249 250 resetScheduler := func() { 251 s.activeJobs.mu.Lock() 252 defer s.activeJobs.mu.Unlock() 253 s.activeJobs.jobs = make(map[string]*JobOnce) 254 s.storedCallback.mu.Lock() 255 defer s.storedCallback.mu.Unlock() 256 s.storedCallback.callback = nil 257 s.startedMu.Lock() 258 defer s.startedMu.Unlock() 259 s.started = false 260 s.pluginAPI.(*mockPluginAPI).clear() 261 } 262 263 t.Run("starting the scheduler without a callback will return an error", func(t *testing.T) { 264 resetScheduler() 265 266 err := s.Start() 267 require.Error(t, err) 268 }) 269 270 t.Run("trying to schedule a job without starting will return an error", func(t *testing.T) { 271 resetScheduler() 272 273 callback := func(key string, _ any) {} 274 err := s.SetCallback(callback) 275 require.NoError(t, err) 276 277 _, err = s.ScheduleOnce("will fail", time.Now(), nil) 278 require.Error(t, err) 279 }) 280 281 t.Run("adding two callback works, only second one is called", func(t *testing.T) { 282 resetScheduler() 283 284 newCount2 := new(int32) 285 newCount3 := new(int32) 286 287 callback2 := func(key string, _ any) { 288 atomic.AddInt32(newCount2, 1) 289 } 290 callback3 := func(key string, _ any) { 291 atomic.AddInt32(newCount3, 1) 292 } 293 294 err := s.SetCallback(callback2) 295 require.NoError(t, err) 296 err = s.SetCallback(callback3) 297 require.NoError(t, err) 298 err = s.Start() 299 require.NoError(t, err) 300 301 _, err = s.ScheduleOnce("anything", time.Now().Add(50*time.Millisecond), nil) 302 require.NoError(t, err) 303 time.Sleep(70*time.Millisecond + scheduleOnceJitter) 304 assert.Equal(t, int32(0), atomic.LoadInt32(newCount2)) 305 assert.Equal(t, int32(1), atomic.LoadInt32(newCount3)) 306 }) 307 308 t.Run("test paging keys from the db by inserting 3 pages of jobs and starting scheduler", func(t *testing.T) { 309 resetScheduler() 310 311 numPagingJobs := keysPerPage*3 + 2 312 testPagingJobs := make(map[string]*int32) 313 for i := 0; i < numPagingJobs; i++ { 314 testPagingJobs[makeKey()] = new(int32) 315 } 316 317 callback := func(key string, _ any) { 318 count, ok := testPagingJobs[key] 319 if ok { 320 atomic.AddInt32(count, 1) 321 return 322 } 323 } 324 325 // add the test paging jobs before starting scheduler 326 for k := range testPagingJobs { 327 assert.Empty(t, getVal(oncePrefix+k)) 328 job, err := newJobOnce(s.pluginAPI, k, time.Now().Add(100*time.Millisecond), s.storedCallback, s.activeJobs, nil) 329 require.NoError(t, err) 330 err = job.saveMetadata() 331 require.NoError(t, err) 332 assert.NotEmpty(t, getVal(oncePrefix+k)) 333 } 334 335 jobs, err := s.ListScheduledJobs() 336 require.NoError(t, err) 337 assert.Equal(t, len(testPagingJobs), len(jobs)) 338 339 err = s.SetCallback(callback) 340 require.NoError(t, err) 341 342 // reschedule from the db: 343 err = s.scheduleNewJobsFromDB() 344 require.NoError(t, err) 345 346 // wait for the testPagingJobs created in the setup to finish 347 time.Sleep(300 * time.Millisecond) 348 349 numInDB := 0 350 numActive := 0 351 numCountsAtZero := 0 352 for k, v := range testPagingJobs { 353 if getVal(oncePrefix+k) != nil { 354 numInDB++ 355 } 356 s.activeJobs.mu.RLock() 357 if s.activeJobs.jobs[k] != nil { 358 numActive++ 359 } 360 s.activeJobs.mu.RUnlock() 361 if atomic.LoadInt32(v) == int32(0) { 362 numCountsAtZero++ 363 } 364 } 365 366 assert.Equal(t, 0, numInDB) 367 assert.Equal(t, 0, numActive) 368 assert.Equal(t, 0, numCountsAtZero) 369 }) 370 371 t.Run("failed at the db", func(t *testing.T) { 372 resetScheduler() 373 374 jobKey1 := makeKey() 375 count1 := new(int32) 376 377 callback := func(key string, _ any) { 378 if key == jobKey1 { 379 atomic.AddInt32(count1, 1) 380 } 381 } 382 383 err := s.SetCallback(callback) 384 require.NoError(t, err) 385 err = s.Start() 386 require.NoError(t, err) 387 388 jobs, err := s.ListScheduledJobs() 389 require.NoError(t, err) 390 require.Empty(t, jobs) 391 392 job, err := s.ScheduleOnce(jobKey1, time.Now().Add(100*time.Millisecond), nil) 393 require.NoError(t, err) 394 require.NotNil(t, job) 395 assert.NotEmpty(t, getVal(oncePrefix+jobKey1)) 396 assert.NotEmpty(t, s.activeJobs.jobs[jobKey1]) 397 s.pluginAPI.(*mockPluginAPI).setFailingWithPrefix(oncePrefix) 398 399 // wait until the metadata has failed to read 400 time.Sleep((maxNumFails + 1) * (waitAfterFail + scheduleOnceJitter)) 401 assert.Equal(t, int32(0), atomic.LoadInt32(count1)) 402 assert.Nil(t, getVal(oncePrefix+jobKey1)) 403 404 assert.Empty(t, s.activeJobs.jobs[jobKey1]) 405 assert.Empty(t, getVal(oncePrefix+jobKey1)) 406 assert.Equal(t, int32(0), atomic.LoadInt32(count1)) 407 408 s.pluginAPI.(*mockPluginAPI).setFailingWithPrefix("") 409 }) 410 411 t.Run("simulate starting the plugin with 3 pending jobs in the db", func(t *testing.T) { 412 resetScheduler() 413 414 jobKeys := make(map[string]*int32) 415 for i := 0; i < 3; i++ { 416 jobKeys[makeKey()] = new(int32) 417 } 418 419 callback := func(key string, _ any) { 420 count, ok := jobKeys[key] 421 if ok { 422 atomic.AddInt32(count, 1) 423 } 424 } 425 err := s.SetCallback(callback) 426 require.NoError(t, err) 427 err = s.Start() 428 require.NoError(t, err) 429 430 for k := range jobKeys { 431 job, err3 := newJobOnce(s.pluginAPI, k, time.Now().Add(100*time.Millisecond), s.storedCallback, s.activeJobs, nil) 432 require.NoError(t, err3) 433 err3 = job.saveMetadata() 434 require.NoError(t, err3) 435 assert.NotEmpty(t, getVal(oncePrefix+k)) 436 } 437 438 // double checking they're in the db: 439 jobs, err := s.ListScheduledJobs() 440 require.NoError(t, err) 441 require.Len(t, jobs, 3) 442 443 // simulate starting the plugin 444 require.NoError(t, err) 445 err = s.scheduleNewJobsFromDB() 446 require.NoError(t, err) 447 448 time.Sleep(120*time.Millisecond + scheduleOnceJitter) 449 450 for k, v := range jobKeys { 451 assert.Empty(t, getVal(oncePrefix+k)) 452 assert.Empty(t, s.activeJobs.jobs[k]) 453 assert.Equal(t, int32(1), *v) 454 } 455 jobs, err = s.ListScheduledJobs() 456 require.NoError(t, err) 457 require.Empty(t, jobs) 458 }) 459 460 t.Run("starting a job and polling before it's finished results in only one job running", func(t *testing.T) { 461 resetScheduler() 462 463 jobKey := makeKey() 464 count := new(int32) 465 466 callback := func(key string, _ any) { 467 if key == jobKey { 468 atomic.AddInt32(count, 1) 469 } 470 } 471 472 err := s.SetCallback(callback) 473 require.NoError(t, err) 474 err = s.Start() 475 require.NoError(t, err) 476 477 jobs, err := s.ListScheduledJobs() 478 require.NoError(t, err) 479 require.Empty(t, jobs) 480 481 job, err := s.ScheduleOnce(jobKey, time.Now().Add(100*time.Millisecond), nil) 482 require.NoError(t, err) 483 require.NotNil(t, job) 484 assert.NotEmpty(t, getVal(oncePrefix+jobKey)) 485 s.activeJobs.mu.Lock() 486 assert.NotEmpty(t, s.activeJobs.jobs[jobKey]) 487 assert.Len(t, s.activeJobs.jobs, 1) 488 s.activeJobs.mu.Unlock() 489 490 // simulate what the polling function will do for a long running job: 491 err = s.scheduleNewJobsFromDB() 492 require.NoError(t, err) 493 err = s.scheduleNewJobsFromDB() 494 require.NoError(t, err) 495 err = s.scheduleNewJobsFromDB() 496 require.NoError(t, err) 497 assert.NotEmpty(t, getVal(oncePrefix+jobKey)) 498 s.activeJobs.mu.Lock() 499 assert.NotEmpty(t, s.activeJobs.jobs[jobKey]) 500 assert.Len(t, s.activeJobs.jobs, 1) 501 s.activeJobs.mu.Unlock() 502 503 // now wait for it to complete 504 time.Sleep(120*time.Millisecond + scheduleOnceJitter) 505 assert.Equal(t, int32(1), atomic.LoadInt32(count)) 506 assert.Empty(t, getVal(oncePrefix+jobKey)) 507 s.activeJobs.mu.Lock() 508 assert.Empty(t, s.activeJobs.jobs) 509 s.activeJobs.mu.Unlock() 510 }) 511 512 t.Run("starting the same job again while it's still active will fail", func(t *testing.T) { 513 resetScheduler() 514 515 jobKey := makeKey() 516 count := new(int32) 517 518 callback := func(key string, _ any) { 519 if key == jobKey { 520 atomic.AddInt32(count, 1) 521 } 522 } 523 524 err := s.SetCallback(callback) 525 require.NoError(t, err) 526 err = s.Start() 527 require.NoError(t, err) 528 529 jobs, err := s.ListScheduledJobs() 530 require.NoError(t, err) 531 require.Empty(t, jobs) 532 533 job, err := s.ScheduleOnce(jobKey, time.Now().Add(100*time.Millisecond), nil) 534 require.NoError(t, err) 535 require.NotNil(t, job) 536 assert.NotEmpty(t, getVal(oncePrefix+jobKey)) 537 assert.NotEmpty(t, s.activeJobs.jobs[jobKey]) 538 assert.Len(t, s.activeJobs.jobs, 1) 539 540 // a plugin tries to start the same jobKey again: 541 job, err = s.ScheduleOnce(jobKey, time.Now().Add(10000*time.Millisecond), nil) 542 require.Error(t, err) 543 require.Nil(t, job) 544 545 // now wait for first job to complete 546 time.Sleep(120*time.Millisecond + scheduleOnceJitter) 547 assert.Equal(t, int32(1), atomic.LoadInt32(count)) 548 assert.Empty(t, getVal(oncePrefix+jobKey)) 549 assert.Empty(t, s.activeJobs.jobs) 550 }) 551 552 t.Run("simulate HA: canceling and setting a job with a different time--old one shouldn't fire", func(t *testing.T) { 553 resetScheduler() 554 555 key := makeKey() 556 jobKeys := make(map[string]*int32) 557 jobKeys[key] = new(int32) 558 559 // control is like the "control group" in an experiment. It will be overwritten, 560 // but with the same runAt. It should fire. 561 control := makeKey() 562 jobKeys[control] = new(int32) 563 564 callback := func(key string, _ any) { 565 count, ok := jobKeys[key] 566 if ok { 567 atomic.AddInt32(count, 1) 568 } 569 } 570 err := s.SetCallback(callback) 571 require.NoError(t, err) 572 err = s.Start() 573 require.NoError(t, err) 574 575 originalRunAt := time.Now().Add(100 * time.Millisecond) 576 newRunAt := time.Now().Add(101 * time.Millisecond) 577 578 // store original 579 job, err := newJobOnce(s.pluginAPI, key, originalRunAt, s.storedCallback, s.activeJobs, nil) 580 require.NoError(t, err) 581 err = job.saveMetadata() 582 require.NoError(t, err) 583 assert.NotEmpty(t, getVal(oncePrefix+key)) 584 585 // store oringal control 586 job2, err := newJobOnce(s.pluginAPI, control, originalRunAt, s.storedCallback, s.activeJobs, nil) 587 require.NoError(t, err) 588 err = job2.saveMetadata() 589 require.NoError(t, err) 590 assert.NotEmpty(t, getVal(oncePrefix+control)) 591 592 // double checking originals are in the db: 593 jobs, err := s.ListScheduledJobs() 594 require.NoError(t, err) 595 require.Len(t, jobs, 2) 596 require.True(t, originalRunAt.Equal(jobs[0].RunAt)) 597 require.True(t, originalRunAt.Equal(jobs[1].RunAt)) 598 599 // simulate starting the plugin 600 require.NoError(t, err) 601 err = s.scheduleNewJobsFromDB() 602 require.NoError(t, err) 603 604 // Now "cancel" the original and make a new job with the same key but a different time. 605 // However, because we have only one list of synced jobs, we can't make two jobs with the 606 // same key. So we'll simulate this by changing the job metadata in the db. When the original 607 // job fires, it should see that the runAt is different, and it will think it has been canceled. 608 err = setMetadata(key, JobOnceMetadata{ 609 Key: key, 610 RunAt: newRunAt, 611 }) 612 require.NoError(t, err) 613 614 // overwrite the control with the same runAt. It should fire. 615 err = setMetadata(control, JobOnceMetadata{ 616 Key: control, 617 RunAt: originalRunAt, 618 }) 619 require.NoError(t, err) 620 621 time.Sleep(120*time.Millisecond + scheduleOnceJitter) 622 623 // original job didn't fire the callback: 624 assert.Empty(t, getVal(oncePrefix+key)) 625 assert.Empty(t, s.activeJobs.jobs[key]) 626 assert.Equal(t, int32(0), *jobKeys[key]) 627 628 // control job did fire the callback: 629 assert.Empty(t, getVal(oncePrefix+control)) 630 assert.Empty(t, s.activeJobs.jobs[control]) 631 assert.Equal(t, int32(1), *jobKeys[control]) 632 633 jobs, err = s.ListScheduledJobs() 634 require.NoError(t, err) 635 require.Empty(t, jobs) 636 }) 637 } 638 639 func TestScheduleOnceProps(t *testing.T) { 640 t.Run("confirm props are returned", func(t *testing.T) { 641 s := GetJobOnceScheduler(newMockPluginAPI(t)) 642 643 jobKey := model.NewId() 644 jobProps := struct { 645 Foo string 646 }{ 647 Foo: "some foo", 648 } 649 650 var mut sync.Mutex 651 var called bool 652 callback := func(key string, props any) { 653 require.Equal(t, jobKey, key) 654 require.Equal(t, jobProps, props) 655 mut.Lock() 656 defer mut.Unlock() 657 called = true 658 } 659 660 err := s.SetCallback(callback) 661 require.NoError(t, err) 662 if !s.started { 663 err = s.Start() 664 require.NoError(t, err) 665 } 666 667 _, err = s.ScheduleOnce(jobKey, time.Now().Add(100*time.Millisecond), jobProps) 668 require.NoError(t, err) 669 670 // Check if callback was called 671 require.Eventually(t, func() bool { mut.Lock(); defer mut.Unlock(); return called }, time.Second, 50*time.Millisecond) 672 }) 673 674 t.Run("props to large", func(t *testing.T) { 675 s := GetJobOnceScheduler(newMockPluginAPI(t)) 676 677 props := make([]byte, propsLimit) 678 for i := 0; i < propsLimit; i++ { 679 props[i] = 'a' 680 } 681 682 _, err := s.ScheduleOnce(model.NewId(), time.Now().Add(100*time.Millisecond), props) 683 require.Error(t, err) 684 }) 685 }