go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/tq/dispatcher_test.go (about)

     1  // Copyright 2020 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package tq
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"net/http"
    21  	"net/http/httptest"
    22  	"strings"
    23  	"sync"
    24  	"testing"
    25  	"time"
    26  
    27  	taskspb "cloud.google.com/go/cloudtasks/apiv2/cloudtaskspb"
    28  	"cloud.google.com/go/pubsub/apiv1/pubsubpb"
    29  	"google.golang.org/grpc/codes"
    30  	"google.golang.org/grpc/status"
    31  	"google.golang.org/protobuf/proto"
    32  	"google.golang.org/protobuf/reflect/protoreflect"
    33  	"google.golang.org/protobuf/types/known/durationpb"
    34  	"google.golang.org/protobuf/types/known/emptypb"
    35  	"google.golang.org/protobuf/types/known/timestamppb"
    36  
    37  	"go.chromium.org/luci/common/clock"
    38  	"go.chromium.org/luci/common/clock/testclock"
    39  	"go.chromium.org/luci/common/errors"
    40  	"go.chromium.org/luci/common/retry/transient"
    41  	"go.chromium.org/luci/common/tsmon"
    42  	"go.chromium.org/luci/common/tsmon/distribution"
    43  	"go.chromium.org/luci/common/tsmon/store"
    44  	"go.chromium.org/luci/common/tsmon/target"
    45  	"go.chromium.org/luci/common/tsmon/types"
    46  
    47  	"go.chromium.org/luci/server/router"
    48  	"go.chromium.org/luci/server/tq/internal/metrics"
    49  	"go.chromium.org/luci/server/tq/internal/reminder"
    50  	"go.chromium.org/luci/server/tq/internal/testutil"
    51  	"go.chromium.org/luci/server/tq/tqtesting"
    52  
    53  	. "github.com/smartystreets/goconvey/convey"
    54  	. "go.chromium.org/luci/common/testing/assertions"
    55  )
    56  
    57  func TestAddTask(t *testing.T) {
    58  	t.Parallel()
    59  
    60  	Convey("With dispatcher", t, func() {
    61  		var now = time.Unix(1442540000, 0)
    62  
    63  		ctx, _ := testclock.UseTime(context.Background(), now)
    64  		submitter := &submitter{}
    65  		ctx = UseSubmitter(ctx, submitter)
    66  
    67  		d := Dispatcher{
    68  			CloudProject:      "proj",
    69  			CloudRegion:       "reg",
    70  			DefaultTargetHost: "example.com",
    71  			PushAs:            "push-as@example.com",
    72  		}
    73  
    74  		d.RegisterTaskClass(TaskClass{
    75  			ID:        "test-dur",
    76  			Prototype: &durationpb.Duration{}, // just some proto type
    77  			Kind:      NonTransactional,
    78  			Queue:     "queue-1",
    79  		})
    80  
    81  		task := &Task{
    82  			Payload: durationpb.New(10 * time.Second),
    83  			Title:   "hi",
    84  			Delay:   123 * time.Second,
    85  		}
    86  		expectedPayload := []byte(`{
    87  	"class": "test-dur",
    88  	"type": "google.protobuf.Duration",
    89  	"body": "10s"
    90  }`)
    91  
    92  		expectedScheduleTime := timestamppb.New(now.Add(123 * time.Second))
    93  		expectedHeaders := defaultHeaders()
    94  		expectedHeaders[ExpectedETAHeader] = fmt.Sprintf("%d.%06d", expectedScheduleTime.GetSeconds(), expectedScheduleTime.GetNanos()/1000)
    95  
    96  		Convey("Nameless HTTP task", func() {
    97  			So(d.AddTask(ctx, task), ShouldBeNil)
    98  
    99  			So(submitter.reqs, ShouldHaveLength, 1)
   100  			So(submitter.reqs[0].CreateTaskRequest, ShouldResembleProto, &taskspb.CreateTaskRequest{
   101  				Parent: "projects/proj/locations/reg/queues/queue-1",
   102  				Task: &taskspb.Task{
   103  					ScheduleTime: expectedScheduleTime,
   104  					MessageType: &taskspb.Task_HttpRequest{
   105  						HttpRequest: &taskspb.HttpRequest{
   106  							HttpMethod: taskspb.HttpMethod_POST,
   107  							Url:        "https://example.com/internal/tasks/t/test-dur/hi",
   108  							Headers:    expectedHeaders,
   109  							Body:       expectedPayload,
   110  							AuthorizationHeader: &taskspb.HttpRequest_OidcToken{
   111  								OidcToken: &taskspb.OidcToken{
   112  									ServiceAccountEmail: "push-as@example.com",
   113  								},
   114  							},
   115  						},
   116  					},
   117  				},
   118  			})
   119  		})
   120  
   121  		Convey("HTTP task with no delay", func() {
   122  			task.Delay = 0
   123  			So(d.AddTask(ctx, task), ShouldBeNil)
   124  
   125  			// See `var now = ...` above.
   126  			expectedHeaders[ExpectedETAHeader] = "1442540000.000000"
   127  
   128  			So(submitter.reqs, ShouldHaveLength, 1)
   129  			So(submitter.reqs[0].CreateTaskRequest, ShouldResembleProto, &taskspb.CreateTaskRequest{
   130  				Parent: "projects/proj/locations/reg/queues/queue-1",
   131  				Task: &taskspb.Task{
   132  					MessageType: &taskspb.Task_HttpRequest{
   133  						HttpRequest: &taskspb.HttpRequest{
   134  							HttpMethod: taskspb.HttpMethod_POST,
   135  							Url:        "https://example.com/internal/tasks/t/test-dur/hi",
   136  							Headers:    expectedHeaders,
   137  							Body:       expectedPayload,
   138  							AuthorizationHeader: &taskspb.HttpRequest_OidcToken{
   139  								OidcToken: &taskspb.OidcToken{
   140  									ServiceAccountEmail: "push-as@example.com",
   141  								},
   142  							},
   143  						},
   144  					},
   145  				},
   146  			})
   147  		})
   148  
   149  		Convey("Nameless GAE task", func() {
   150  			d.GAE = true
   151  			d.DefaultTargetHost = ""
   152  			So(d.AddTask(ctx, task), ShouldBeNil)
   153  
   154  			So(submitter.reqs, ShouldHaveLength, 1)
   155  			expectedScheduleTime := timestamppb.New(now.Add(123 * time.Second))
   156  
   157  			So(submitter.reqs[0].CreateTaskRequest, ShouldResembleProto, &taskspb.CreateTaskRequest{
   158  				Parent: "projects/proj/locations/reg/queues/queue-1",
   159  				Task: &taskspb.Task{
   160  					ScheduleTime: expectedScheduleTime,
   161  					MessageType: &taskspb.Task_AppEngineHttpRequest{
   162  						AppEngineHttpRequest: &taskspb.AppEngineHttpRequest{
   163  							HttpMethod:  taskspb.HttpMethod_POST,
   164  							RelativeUri: "/internal/tasks/t/test-dur/hi",
   165  							Headers:     expectedHeaders,
   166  							Body:        expectedPayload,
   167  						},
   168  					},
   169  				},
   170  			})
   171  		})
   172  
   173  		Convey("Named task", func() {
   174  			task.DeduplicationKey = "key"
   175  
   176  			So(d.AddTask(ctx, task), ShouldBeNil)
   177  
   178  			So(submitter.reqs, ShouldHaveLength, 1)
   179  			So(submitter.reqs[0].CreateTaskRequest.Task.Name, ShouldEqual,
   180  				"projects/proj/locations/reg/queues/queue-1/tasks/"+
   181  					"ca0a124846df4b453ae63e3ad7c63073b0d25941c6e63e5708fd590c016edcef")
   182  		})
   183  
   184  		Convey("Titleless task", func() {
   185  			task.Title = ""
   186  
   187  			So(d.AddTask(ctx, task), ShouldBeNil)
   188  
   189  			So(submitter.reqs, ShouldHaveLength, 1)
   190  			So(
   191  				submitter.reqs[0].CreateTaskRequest.Task.MessageType.(*taskspb.Task_HttpRequest).HttpRequest.Url,
   192  				ShouldEqual,
   193  				"https://example.com/internal/tasks/t/test-dur",
   194  			)
   195  		})
   196  
   197  		Convey("Transient err", func() {
   198  			submitter.err = func(title string) error {
   199  				return status.Errorf(codes.Internal, "boo, go away")
   200  			}
   201  			err := d.AddTask(ctx, task)
   202  			So(transient.Tag.In(err), ShouldBeTrue)
   203  		})
   204  
   205  		Convey("Fatal err", func() {
   206  			submitter.err = func(title string) error {
   207  				return status.Errorf(codes.PermissionDenied, "boo, go away")
   208  			}
   209  			err := d.AddTask(ctx, task)
   210  			So(err, ShouldNotBeNil)
   211  			So(transient.Tag.In(err), ShouldBeFalse)
   212  		})
   213  
   214  		Convey("Unknown payload type", func() {
   215  			err := d.AddTask(ctx, &Task{
   216  				Payload: &timestamppb.Timestamp{},
   217  			})
   218  			So(err, ShouldErrLike, "no task class matching type")
   219  			So(submitter.reqs, ShouldHaveLength, 0)
   220  		})
   221  
   222  		Convey("Bad task title: spaces", func() {
   223  			task.Title = "No spaces please"
   224  			err := d.AddTask(ctx, task)
   225  			So(err, ShouldErrLike, "bad task title")
   226  			So(err, ShouldErrLike, "must not contain spaces")
   227  			So(submitter.reqs, ShouldHaveLength, 0)
   228  		})
   229  
   230  		Convey("Bad task title: too long", func() {
   231  			task.Title = strings.Repeat("a", 2070)
   232  			err := d.AddTask(ctx, task)
   233  			So(err, ShouldErrLike, "bad task title")
   234  			So(err, ShouldErrLike, `too long; must not exceed 2083 characters when combined with "/internal/tasks/t/test-dur"`)
   235  			So(submitter.reqs, ShouldHaveLength, 0)
   236  		})
   237  
   238  		Convey("Custom task payload on GAE", func() {
   239  			d.GAE = true
   240  			d.DefaultTargetHost = ""
   241  			d.RegisterTaskClass(TaskClass{
   242  				ID:        "test-ts",
   243  				Prototype: &timestamppb.Timestamp{}, // just some proto type
   244  				Kind:      NonTransactional,
   245  				Queue:     "queue-1",
   246  				Custom: func(ctx context.Context, m proto.Message) (*CustomPayload, error) {
   247  					ts := m.(*timestamppb.Timestamp)
   248  					return &CustomPayload{
   249  						Method:      "GET",
   250  						Meta:        map[string]string{"k": "v"},
   251  						RelativeURI: "/zzz",
   252  						Body:        []byte(fmt.Sprintf("%d", ts.Seconds)),
   253  					}, nil
   254  				},
   255  			})
   256  
   257  			So(d.AddTask(ctx, &Task{
   258  				Payload: &timestamppb.Timestamp{Seconds: 123},
   259  				Delay:   444 * time.Second,
   260  			}), ShouldBeNil)
   261  
   262  			So(submitter.reqs, ShouldHaveLength, 1)
   263  			st := timestamppb.New(now.Add(444 * time.Second))
   264  			So(submitter.reqs[0].CreateTaskRequest, ShouldResembleProto, &taskspb.CreateTaskRequest{
   265  				Parent: "projects/proj/locations/reg/queues/queue-1",
   266  				Task: &taskspb.Task{
   267  					ScheduleTime: st,
   268  					MessageType: &taskspb.Task_AppEngineHttpRequest{
   269  						AppEngineHttpRequest: &taskspb.AppEngineHttpRequest{
   270  							HttpMethod:  taskspb.HttpMethod_GET,
   271  							RelativeUri: "/zzz",
   272  							Headers:     map[string]string{"k": "v", ExpectedETAHeader: fmt.Sprintf("%d.%06d", st.GetSeconds(), st.GetNanos()/1000)},
   273  							Body:        []byte("123"),
   274  						},
   275  					},
   276  				},
   277  			})
   278  		})
   279  	})
   280  }
   281  
   282  func TestPushHandler(t *testing.T) {
   283  	t.Parallel()
   284  
   285  	Convey("With dispatcher", t, func() {
   286  		var handlerErr error
   287  		var handlerCb func(context.Context)
   288  
   289  		d := Dispatcher{DisableAuth: true}
   290  		ref := d.RegisterTaskClass(TaskClass{
   291  			ID:        "test-1",
   292  			Prototype: &emptypb.Empty{},
   293  			Kind:      NonTransactional,
   294  			Queue:     "queue",
   295  			Handler: func(ctx context.Context, payload proto.Message) error {
   296  				if handlerCb != nil {
   297  					handlerCb(ctx)
   298  				}
   299  				return handlerErr
   300  			},
   301  		})
   302  
   303  		var now = time.Unix(1442540100, 0)
   304  		ctx, _ := testclock.UseTime(context.Background(), now)
   305  		ctx, _, _ = tsmon.WithFakes(ctx)
   306  		tsmon.GetState(ctx).SetStore(store.NewInMemory(&target.Task{}))
   307  
   308  		metric := func(m types.Metric, fieldVals ...any) any {
   309  			return tsmon.GetState(ctx).Store().Get(ctx, m, time.Time{}, fieldVals)
   310  		}
   311  
   312  		metricDist := func(m types.Metric, fieldVals ...any) (count int64, sum float64) {
   313  			val := metric(m, fieldVals...)
   314  			if val != nil {
   315  				So(val, ShouldHaveSameTypeAs, &distribution.Distribution{})
   316  				count = val.(*distribution.Distribution).Count()
   317  				sum = val.(*distribution.Distribution).Sum()
   318  			}
   319  			return
   320  		}
   321  
   322  		srv := router.New()
   323  		d.InstallTasksRoutes(srv, "/pfx")
   324  
   325  		call := func(body string, header http.Header) int {
   326  			req := httptest.NewRequest("POST", "/pfx/ignored/part", strings.NewReader(body)).WithContext(ctx)
   327  			req.Header = header
   328  			rec := httptest.NewRecorder()
   329  			srv.ServeHTTP(rec, req)
   330  			return rec.Result().StatusCode
   331  		}
   332  
   333  		Convey("Using class ID", func() {
   334  			Convey("Success", func() {
   335  				So(call(`{"class": "test-1", "body": {}}`, nil), ShouldEqual, 200)
   336  			})
   337  			Convey("Unknown", func() {
   338  				So(call(`{"class": "unknown", "body": {}}`, nil), ShouldEqual, 404)
   339  			})
   340  		})
   341  
   342  		Convey("Using type name", func() {
   343  			Convey("Success", func() {
   344  				So(call(`{"type": "google.protobuf.Empty", "body": {}}`, nil), ShouldEqual, 200)
   345  			})
   346  			Convey("Totally unknown", func() {
   347  				So(call(`{"type": "unknown", "body": {}}`, nil), ShouldEqual, 404)
   348  			})
   349  			Convey("Not a registered task", func() {
   350  				So(call(`{"type": "google.protobuf.Duration", "body": {}}`, nil), ShouldEqual, 404)
   351  			})
   352  		})
   353  
   354  		Convey("Not a JSON body", func() {
   355  			So(call(`blarg`, nil), ShouldEqual, 400)
   356  		})
   357  
   358  		Convey("Bad envelope", func() {
   359  			So(call(`{}`, nil), ShouldEqual, 400)
   360  		})
   361  
   362  		Convey("Missing message body", func() {
   363  			So(call(`{"class": "test-1"}`, nil), ShouldEqual, 400)
   364  		})
   365  
   366  		Convey("Bad message body", func() {
   367  			So(call(`{"class": "test-1", "body": "huh"}`, nil), ShouldEqual, 400)
   368  		})
   369  
   370  		Convey("Handler fatal error", func() {
   371  			handlerErr = errors.New("boo", Fatal)
   372  			So(call(`{"class": "test-1", "body": {}}`, nil), ShouldEqual, 202)
   373  		})
   374  
   375  		Convey("Handler ignore error", func() {
   376  			handlerErr = errors.New("boo", Ignore)
   377  			So(call(`{"class": "test-1", "body": {}}`, nil), ShouldEqual, 204)
   378  		})
   379  
   380  		Convey("Handler transient error", func() {
   381  			handlerErr = errors.New("boo", transient.Tag)
   382  			So(call(`{"class": "test-1", "body": {}}`, nil), ShouldEqual, 500)
   383  		})
   384  
   385  		Convey("Handler non-fatal error", func() {
   386  			handlerErr = errors.New("boo")
   387  			So(call(`{"class": "test-1", "body": {}}`, nil), ShouldEqual, 429)
   388  		})
   389  
   390  		Convey("No handler", func() {
   391  			ref.(*taskClassImpl).Handler = nil
   392  			So(call(`{"class": "test-1", "body": {}}`, nil), ShouldEqual, 404)
   393  		})
   394  
   395  		Convey("Metrics work", func() {
   396  			callWithHeaders := func(headers map[string]string) {
   397  				hdr := make(http.Header)
   398  				for k, v := range headers {
   399  					hdr.Set(k, v)
   400  				}
   401  				So(call(`{"type": "google.protobuf.Empty", "body": {}}`, hdr), ShouldEqual, 200)
   402  			}
   403  
   404  			Convey("No ETA header", func() {
   405  				const fakeDelayMS = 33
   406  
   407  				handlerCb = func(ctx context.Context) {
   408  					info := TaskExecutionInfo(ctx)
   409  					So(info.ExecutionCount, ShouldEqual, 500)
   410  					So(info.TaskID, ShouldEqual, "task-without-eta")
   411  					So(info.expectedETA, ShouldBeZeroValue)
   412  					So(info.submitterTraceContext, ShouldEqual, "zzz")
   413  					clock.Get(ctx).(testclock.TestClock).Add(fakeDelayMS * time.Millisecond)
   414  				}
   415  
   416  				callWithHeaders(map[string]string{
   417  					"X-CloudTasks-TaskExecutionCount": "500",
   418  					"X-CloudTasks-TaskName":           "task-without-eta",
   419  					TraceContextHeader:                "zzz",
   420  				})
   421  
   422  				So(metric(metrics.ServerHandledCount, "test-1", "OK", metrics.MaxRetryFieldValue), ShouldEqual, 1)
   423  
   424  				durCount, durSum := metricDist(metrics.ServerDurationMS, "test-1", "OK")
   425  				So(durCount, ShouldEqual, 1)
   426  				So(durSum, ShouldEqual, float64(fakeDelayMS))
   427  
   428  				latCount, _ := metricDist(metrics.ServerTaskLatency, "test-1", "OK", metrics.MaxRetryFieldValue)
   429  				So(latCount, ShouldEqual, 0)
   430  			})
   431  
   432  			Convey("With ETA header", func() {
   433  				var etaValue = time.Unix(1442540050, 1000)
   434  				const fakeDelayMS = 33
   435  
   436  				handlerCb = func(ctx context.Context) {
   437  					info := TaskExecutionInfo(ctx)
   438  					So(info.ExecutionCount, ShouldEqual, 5)
   439  					So(info.TaskID, ShouldEqual, "task-with-eta")
   440  					So(info.expectedETA.Equal(etaValue), ShouldBeTrue)
   441  					clock.Get(ctx).(testclock.TestClock).Add(fakeDelayMS * time.Millisecond)
   442  				}
   443  
   444  				callWithHeaders(map[string]string{
   445  					"X-CloudTasks-TaskExecutionCount": "5",
   446  					"X-CloudTasks-TaskName":           "task-with-eta",
   447  					ExpectedETAHeader:                 "1442540050.000001",
   448  				})
   449  
   450  				latCount, latSum := metricDist(metrics.ServerTaskLatency, "test-1", "OK", 5)
   451  				So(latCount, ShouldEqual, 1)
   452  				So(latSum, ShouldEqual, float64(now.Sub(etaValue).Milliseconds()+fakeDelayMS))
   453  			})
   454  
   455  			Convey("ServerRunning metric", func() {
   456  				handlerCb = func(ctx context.Context) {
   457  					d.ReportMetrics(ctx)
   458  				}
   459  				callWithHeaders(nil)
   460  
   461  				// Was reported while the handler was running.
   462  				So(metric(metrics.ServerRunning, "test-1"), ShouldEqual, 1)
   463  
   464  				// Should report 0 now, since the handler is not running anymore.
   465  				d.ReportMetrics(ctx)
   466  				So(metric(metrics.ServerRunning, "test-1"), ShouldEqual, 0)
   467  			})
   468  		})
   469  	})
   470  }
   471  
   472  func TestTransactionalEnqueue(t *testing.T) {
   473  	t.Parallel()
   474  
   475  	Convey("With mocks", t, func() {
   476  		var now = time.Unix(1442540000, 0)
   477  
   478  		submitter := &submitter{}
   479  		db := testutil.FakeDB{}
   480  		d := Dispatcher{
   481  			CloudProject:      "proj",
   482  			CloudRegion:       "reg",
   483  			DefaultTargetHost: "example.com",
   484  			PushAs:            "push-as@example.com",
   485  		}
   486  		d.RegisterTaskClass(TaskClass{
   487  			ID:        "test-dur",
   488  			Prototype: &durationpb.Duration{}, // just some proto type
   489  			Kind:      Transactional,
   490  			Queue:     "queue-1",
   491  		})
   492  
   493  		ctx, tc := testclock.UseTime(context.Background(), now)
   494  		ctx = UseSubmitter(ctx, submitter)
   495  		txn := db.Inject(ctx)
   496  
   497  		Convey("Happy path", func() {
   498  			task := &Task{
   499  				Payload: durationpb.New(5 * time.Second),
   500  				Delay:   10 * time.Second,
   501  			}
   502  			err := d.AddTask(txn, task)
   503  			So(err, ShouldBeNil)
   504  
   505  			// Created the reminder.
   506  			So(db.AllReminders(), ShouldHaveLength, 1)
   507  			rem := db.AllReminders()[0]
   508  
   509  			// But didn't submitted the task yet.
   510  			So(submitter.reqs, ShouldBeEmpty)
   511  
   512  			// The defer will submit the task and wipe the reminder.
   513  			db.ExecDefers(ctx)
   514  			So(db.AllReminders(), ShouldBeEmpty)
   515  			So(submitter.reqs, ShouldHaveLength, 1)
   516  			req := submitter.reqs[0]
   517  
   518  			// Make sure the reminder and the task look as expected.
   519  			So(rem.ID, ShouldHaveLength, reminderKeySpaceBytes*2)
   520  			So(rem.FreshUntil.Equal(now.Add(happyPathMaxDuration)), ShouldBeTrue)
   521  			So(req.TaskClass, ShouldEqual, "test-dur")
   522  			So(req.Created.Equal(now), ShouldBeTrue)
   523  			So(req.Raw, ShouldEqual, task.Payload) // the exact same pointer
   524  			So(req.CreateTaskRequest.Task.Name, ShouldEqual, "projects/proj/locations/reg/queues/queue-1/tasks/"+rem.ID)
   525  
   526  			// The task request inside the reminder's raw payload is correct.
   527  			remPayload, err := rem.DropPayload().Payload()
   528  			So(err, ShouldBeNil)
   529  			So(req.CreateTaskRequest, ShouldResembleProto, remPayload.CreateTaskRequest)
   530  		})
   531  
   532  		Convey("Fatal Submit error", func() {
   533  			submitter.err = func(string) error { return status.Errorf(codes.PermissionDenied, "boom") }
   534  
   535  			err := d.AddTask(txn, &Task{
   536  				Payload: durationpb.New(5 * time.Second),
   537  				Delay:   10 * time.Second,
   538  			})
   539  			So(err, ShouldBeNil)
   540  
   541  			So(db.AllReminders(), ShouldHaveLength, 1)
   542  			db.ExecDefers(ctx)
   543  			So(db.AllReminders(), ShouldBeEmpty)
   544  		})
   545  
   546  		Convey("Transient Submit error", func() {
   547  			submitter.err = func(string) error { return status.Errorf(codes.Internal, "boom") }
   548  
   549  			err := d.AddTask(txn, &Task{
   550  				Payload: durationpb.New(5 * time.Second),
   551  				Delay:   10 * time.Second,
   552  			})
   553  			So(err, ShouldBeNil)
   554  
   555  			So(db.AllReminders(), ShouldHaveLength, 1)
   556  			db.ExecDefers(ctx)
   557  			So(db.AllReminders(), ShouldHaveLength, 1)
   558  		})
   559  
   560  		Convey("Slow", func() {
   561  			err := d.AddTask(txn, &Task{
   562  				Payload: durationpb.New(5 * time.Second),
   563  				Delay:   10 * time.Second,
   564  			})
   565  			So(err, ShouldBeNil)
   566  
   567  			tc.Add(happyPathMaxDuration + 1*time.Second)
   568  
   569  			So(db.AllReminders(), ShouldHaveLength, 1)
   570  			db.ExecDefers(ctx)
   571  			So(db.AllReminders(), ShouldHaveLength, 1)
   572  			So(submitter.reqs, ShouldBeEmpty)
   573  		})
   574  	})
   575  }
   576  
   577  func TestTesting(t *testing.T) {
   578  	t.Parallel()
   579  
   580  	Convey("Works", t, func() {
   581  		var epoch = testclock.TestRecentTimeUTC
   582  
   583  		ctx, tc := testclock.UseTime(context.Background(), epoch)
   584  		tc.SetTimerCallback(func(d time.Duration, t clock.Timer) {
   585  			if testclock.HasTags(t, tqtesting.ClockTag) {
   586  				tc.Add(d)
   587  			}
   588  		})
   589  
   590  		disp := Dispatcher{}
   591  		ctx, sched := TestingContext(ctx, &disp)
   592  
   593  		var success tqtesting.TaskList
   594  		sched.TaskSucceeded = tqtesting.TasksCollector(&success)
   595  
   596  		m := sync.Mutex{}
   597  		etas := []time.Duration{}
   598  
   599  		disp.RegisterTaskClass(TaskClass{
   600  			ID:        "test-dur",
   601  			Prototype: &durationpb.Duration{}, // just some proto type
   602  			Kind:      NonTransactional,
   603  			Queue:     "queue-1",
   604  			Handler: func(ctx context.Context, msg proto.Message) error {
   605  				m.Lock()
   606  				etas = append(etas, clock.Now(ctx).Sub(epoch))
   607  				m.Unlock()
   608  				if clock.Now(ctx).Sub(epoch) < 3*time.Second {
   609  					disp.AddTask(ctx, &Task{
   610  						Payload: &durationpb.Duration{
   611  							Seconds: msg.(*durationpb.Duration).Seconds + 1,
   612  						},
   613  						Delay: time.Second,
   614  					})
   615  				}
   616  				return nil
   617  			},
   618  		})
   619  
   620  		So(disp.AddTask(ctx, &Task{Payload: &durationpb.Duration{Seconds: 1}}), ShouldBeNil)
   621  		sched.Run(ctx, tqtesting.StopWhenDrained())
   622  		So(etas, ShouldResemble, []time.Duration{
   623  			0, 1 * time.Second, 2 * time.Second, 3 * time.Second,
   624  		})
   625  
   626  		So(success, ShouldHaveLength, 4)
   627  		So(success.Payloads(), ShouldResembleProto, []protoreflect.ProtoMessage{
   628  			&durationpb.Duration{Seconds: 1},
   629  			&durationpb.Duration{Seconds: 2},
   630  			&durationpb.Duration{Seconds: 3},
   631  			&durationpb.Duration{Seconds: 4},
   632  		})
   633  	})
   634  }
   635  
   636  func TestPubSubEnqueue(t *testing.T) {
   637  	t.Parallel()
   638  
   639  	Convey("With dispatcher", t, func() {
   640  		var epoch = testclock.TestRecentTimeUTC
   641  
   642  		ctx, tc := testclock.UseTime(context.Background(), epoch)
   643  		db := testutil.FakeDB{}
   644  
   645  		disp := Dispatcher{Sweeper: NewInProcSweeper(InProcSweeperOptions{})}
   646  		ctx, sched := TestingContext(ctx, &disp)
   647  
   648  		disp.RegisterTaskClass(TaskClass{
   649  			ID:        "test-dur",
   650  			Prototype: &durationpb.Duration{}, // just some proto type
   651  			Kind:      Transactional,
   652  			Topic:     "topic-1",
   653  			Custom: func(_ context.Context, msg proto.Message) (*CustomPayload, error) {
   654  				return &CustomPayload{
   655  					Meta: map[string]string{"a": "b"},
   656  					Body: []byte(fmt.Sprintf("%d", msg.(*durationpb.Duration).Seconds)),
   657  				}, nil
   658  			},
   659  		})
   660  
   661  		So(disp.AddTask(db.Inject(ctx), &Task{Payload: &durationpb.Duration{Seconds: 1}}), ShouldBeNil)
   662  
   663  		Convey("Happy path", func() {
   664  			db.ExecDefers(ctx) // actually enqueue
   665  
   666  			So(sched.Tasks(), ShouldHaveLength, 1)
   667  
   668  			task := sched.Tasks()[0]
   669  			So(task.Payload, ShouldResembleProto, &durationpb.Duration{Seconds: 1})
   670  			So(task.Message, ShouldResembleProto, &pubsubpb.PubsubMessage{
   671  				Data: []byte("1"),
   672  				Attributes: map[string]string{
   673  					"a":                     "b",
   674  					"X-Luci-Tq-Reminder-Id": task.Message.Attributes["X-Luci-Tq-Reminder-Id"],
   675  				},
   676  			})
   677  		})
   678  
   679  		Convey("Unhappy path", func() {
   680  			// Not enqueued, but have a reminder.
   681  			So(sched.Tasks(), ShouldHaveLength, 0)
   682  			So(db.AllReminders(), ShouldHaveLength, 1)
   683  
   684  			// Make reminder sufficiently stale to be eligible for sweeping.
   685  			tc.Add(5 * time.Minute)
   686  
   687  			// Run the sweeper to enqueue from the reminder.
   688  			So(disp.Sweep(db.Inject(ctx)), ShouldBeNil)
   689  
   690  			// Have the task now!
   691  			So(sched.Tasks(), ShouldHaveLength, 1)
   692  
   693  			task := sched.Tasks()[0]
   694  			So(task.Payload, ShouldBeNil) // not available on non-happy path
   695  			So(task.Message, ShouldResembleProto, &pubsubpb.PubsubMessage{
   696  				Data: []byte("1"),
   697  				Attributes: map[string]string{
   698  					"a":                     "b",
   699  					"X-Luci-Tq-Reminder-Id": task.Message.Attributes["X-Luci-Tq-Reminder-Id"],
   700  				},
   701  			})
   702  		})
   703  	})
   704  }
   705  
   706  type submitter struct {
   707  	err  func(title string) error
   708  	m    sync.Mutex
   709  	reqs []*reminder.Payload
   710  }
   711  
   712  func (s *submitter) Submit(ctx context.Context, req *reminder.Payload) error {
   713  	s.m.Lock()
   714  	defer s.m.Unlock()
   715  	s.reqs = append(s.reqs, req)
   716  	if s.err == nil {
   717  		return nil
   718  	}
   719  	return s.err(title(req))
   720  }
   721  
   722  func title(req *reminder.Payload) string {
   723  	url := ""
   724  	switch mt := req.CreateTaskRequest.Task.MessageType.(type) {
   725  	case *taskspb.Task_HttpRequest:
   726  		url = mt.HttpRequest.Url
   727  	case *taskspb.Task_AppEngineHttpRequest:
   728  		url = mt.AppEngineHttpRequest.RelativeUri
   729  	}
   730  	idx := strings.LastIndex(url, "/")
   731  	return url[idx+1:]
   732  }