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  }