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  }