| // Copyright 2011 Google Inc. All rights reserved. | |
| // Use of this source code is governed by the Apache 2.0 | |
| // license that can be found in the LICENSE file. | |
| /* | |
| Package taskqueue provides a client for App Engine's taskqueue service. | |
| Using this service, applications may perform work outside a user's request. | |
| A Task may be constructed manually; alternatively, since the most common | |
| taskqueue operation is to add a single POST task, NewPOSTTask makes it easy. | |
| t := taskqueue.NewPOSTTask("/worker", url.Values{ | |
| "key": {key}, | |
| }) | |
| taskqueue.Add(c, t, "") // add t to the default queue | |
| */ | |
| package taskqueue // import "google.golang.org/appengine/v2/taskqueue" | |
| import ( | |
| "context" | |
| "errors" | |
| "fmt" | |
| "net/http" | |
| "net/url" | |
| "strconv" | |
| "time" | |
| "github.com/golang/protobuf/proto" | |
| "google.golang.org/appengine/v2" | |
| "google.golang.org/appengine/v2/internal" | |
| dspb "google.golang.org/appengine/v2/internal/datastore" | |
| pb "google.golang.org/appengine/v2/internal/taskqueue" | |
| ) | |
| var ( | |
| // ErrTaskAlreadyAdded is the error returned by Add and AddMulti when a task has already been added with a particular name. | |
| ErrTaskAlreadyAdded = errors.New("taskqueue: task has already been added") | |
| ) | |
| // RetryOptions let you control whether to retry a task and the backoff intervals between tries. | |
| type RetryOptions struct { | |
| // Number of tries/leases after which the task fails permanently and is deleted. | |
| // If AgeLimit is also set, both limits must be exceeded for the task to fail permanently. | |
| RetryLimit int32 | |
| // Maximum time allowed since the task's first try before the task fails permanently and is deleted (only for push tasks). | |
| // If RetryLimit is also set, both limits must be exceeded for the task to fail permanently. | |
| AgeLimit time.Duration | |
| // Minimum time between successive tries (only for push tasks). | |
| MinBackoff time.Duration | |
| // Maximum time between successive tries (only for push tasks). | |
| MaxBackoff time.Duration | |
| // Maximum number of times to double the interval between successive tries before the intervals increase linearly (only for push tasks). | |
| MaxDoublings int32 | |
| // If MaxDoublings is zero, set ApplyZeroMaxDoublings to true to override the default non-zero value. | |
| // Otherwise a zero MaxDoublings is ignored and the default is used. | |
| ApplyZeroMaxDoublings bool | |
| } | |
| // toRetryParameters converts RetryOptions to pb.TaskQueueRetryParameters. | |
| func (opt *RetryOptions) toRetryParameters() *pb.TaskQueueRetryParameters { | |
| params := &pb.TaskQueueRetryParameters{} | |
| if opt.RetryLimit > 0 { | |
| params.RetryLimit = proto.Int32(opt.RetryLimit) | |
| } | |
| if opt.AgeLimit > 0 { | |
| params.AgeLimitSec = proto.Int64(int64(opt.AgeLimit.Seconds())) | |
| } | |
| if opt.MinBackoff > 0 { | |
| params.MinBackoffSec = proto.Float64(opt.MinBackoff.Seconds()) | |
| } | |
| if opt.MaxBackoff > 0 { | |
| params.MaxBackoffSec = proto.Float64(opt.MaxBackoff.Seconds()) | |
| } | |
| if opt.MaxDoublings > 0 || (opt.MaxDoublings == 0 && opt.ApplyZeroMaxDoublings) { | |
| params.MaxDoublings = proto.Int32(opt.MaxDoublings) | |
| } | |
| return params | |
| } | |
| // A Task represents a task to be executed. | |
| type Task struct { | |
| // Path is the worker URL for the task. | |
| // If unset, it will default to /_ah/queue/<queue_name>. | |
| Path string | |
| // Payload is the data for the task. | |
| // This will be delivered as the HTTP request body. | |
| // It is only used when Method is POST, PUT or PULL. | |
| // url.Values' Encode method may be used to generate this for POST requests. | |
| Payload []byte | |
| // Additional HTTP headers to pass at the task's execution time. | |
| // To schedule the task to be run with an alternate app version | |
| // or backend, set the "Host" header. | |
| Header http.Header | |
| // Method is the HTTP method for the task ("GET", "POST", etc.), | |
| // or "PULL" if this is task is destined for a pull-based queue. | |
| // If empty, this defaults to "POST". | |
| Method string | |
| // A name for the task. | |
| // If empty, a name will be chosen. | |
| Name string | |
| // Delay specifies the duration the task queue service must wait | |
| // before executing the task. | |
| // Either Delay or ETA may be set, but not both. | |
| Delay time.Duration | |
| // ETA specifies the earliest time a task may be executed (push queues) | |
| // or leased (pull queues). | |
| // Either Delay or ETA may be set, but not both. | |
| ETA time.Time | |
| // The number of times the task has been dispatched or leased. | |
| RetryCount int32 | |
| // Tag for the task. Only used when Method is PULL. | |
| Tag string | |
| // Retry options for this task. May be nil. | |
| RetryOptions *RetryOptions | |
| } | |
| func (t *Task) method() string { | |
| if t.Method == "" { | |
| return "POST" | |
| } | |
| return t.Method | |
| } | |
| // NewPOSTTask creates a Task that will POST to a path with the given form data. | |
| func NewPOSTTask(path string, params url.Values) *Task { | |
| h := make(http.Header) | |
| h.Set("Content-Type", "application/x-www-form-urlencoded") | |
| return &Task{ | |
| Path: path, | |
| Payload: []byte(params.Encode()), | |
| Header: h, | |
| Method: "POST", | |
| } | |
| } | |
| // RequestHeaders are the special HTTP request headers available to push task | |
| // HTTP request handlers. These headers are set internally by App Engine. | |
| // See https://cloud.google.com/appengine/docs/standard/go/taskqueue/push/creating-handlers#reading_request_headers | |
| // for a description of the fields. | |
| type RequestHeaders struct { | |
| QueueName string | |
| TaskName string | |
| TaskRetryCount int64 | |
| TaskExecutionCount int64 | |
| TaskETA time.Time | |
| TaskPreviousResponse int | |
| TaskRetryReason string | |
| FailFast bool | |
| } | |
| // ParseRequestHeaders parses the special HTTP request headers available to push | |
| // task request handlers. This function silently ignores values of the wrong | |
| // format. | |
| func ParseRequestHeaders(h http.Header) *RequestHeaders { | |
| ret := &RequestHeaders{ | |
| QueueName: h.Get("X-AppEngine-QueueName"), | |
| TaskName: h.Get("X-AppEngine-TaskName"), | |
| } | |
| ret.TaskRetryCount, _ = strconv.ParseInt(h.Get("X-AppEngine-TaskRetryCount"), 10, 64) | |
| ret.TaskExecutionCount, _ = strconv.ParseInt(h.Get("X-AppEngine-TaskExecutionCount"), 10, 64) | |
| etaSecs, _ := strconv.ParseInt(h.Get("X-AppEngine-TaskETA"), 10, 64) | |
| if etaSecs != 0 { | |
| ret.TaskETA = time.Unix(etaSecs, 0) | |
| } | |
| ret.TaskPreviousResponse, _ = strconv.Atoi(h.Get("X-AppEngine-TaskPreviousResponse")) | |
| ret.TaskRetryReason = h.Get("X-AppEngine-TaskRetryReason") | |
| if h.Get("X-AppEngine-FailFast") != "" { | |
| ret.FailFast = true | |
| } | |
| return ret | |
| } | |
| var ( | |
| currentNamespace = http.CanonicalHeaderKey("X-AppEngine-Current-Namespace") | |
| defaultNamespace = http.CanonicalHeaderKey("X-AppEngine-Default-Namespace") | |
| ) | |
| func getDefaultNamespace(ctx context.Context) string { | |
| return internal.IncomingHeaders(ctx).Get(defaultNamespace) | |
| } | |
| func newAddReq(c context.Context, task *Task, queueName string) (*pb.TaskQueueAddRequest, error) { | |
| if queueName == "" { | |
| queueName = "default" | |
| } | |
| path := task.Path | |
| if path == "" { | |
| path = "/_ah/queue/" + queueName | |
| } | |
| eta := task.ETA | |
| if eta.IsZero() { | |
| eta = time.Now().Add(task.Delay) | |
| } else if task.Delay != 0 { | |
| panic("taskqueue: both Delay and ETA are set") | |
| } | |
| req := &pb.TaskQueueAddRequest{ | |
| QueueName: []byte(queueName), | |
| TaskName: []byte(task.Name), | |
| EtaUsec: proto.Int64(eta.UnixNano() / 1e3), | |
| } | |
| method := task.method() | |
| if method == "PULL" { | |
| // Pull-based task | |
| req.Body = task.Payload | |
| req.Mode = pb.TaskQueueMode_PULL.Enum() | |
| if task.Tag != "" { | |
| req.Tag = []byte(task.Tag) | |
| } | |
| } else { | |
| // HTTP-based task | |
| if v, ok := pb.TaskQueueAddRequest_RequestMethod_value[method]; ok { | |
| req.Method = pb.TaskQueueAddRequest_RequestMethod(v).Enum() | |
| } else { | |
| return nil, fmt.Errorf("taskqueue: bad method %q", method) | |
| } | |
| req.Url = []byte(path) | |
| for k, vs := range task.Header { | |
| for _, v := range vs { | |
| req.Header = append(req.Header, &pb.TaskQueueAddRequest_Header{ | |
| Key: []byte(k), | |
| Value: []byte(v), | |
| }) | |
| } | |
| } | |
| if method == "POST" || method == "PUT" { | |
| req.Body = task.Payload | |
| } | |
| // Namespace headers. | |
| if _, ok := task.Header[currentNamespace]; !ok { | |
| // Fetch the current namespace of this request. | |
| ns := internal.NamespaceFromContext(c) | |
| req.Header = append(req.Header, &pb.TaskQueueAddRequest_Header{ | |
| Key: []byte(currentNamespace), | |
| Value: []byte(ns), | |
| }) | |
| } | |
| if _, ok := task.Header[defaultNamespace]; !ok { | |
| // Fetch the X-AppEngine-Default-Namespace header of this request. | |
| if ns := getDefaultNamespace(c); ns != "" { | |
| req.Header = append(req.Header, &pb.TaskQueueAddRequest_Header{ | |
| Key: []byte(defaultNamespace), | |
| Value: []byte(ns), | |
| }) | |
| } | |
| } | |
| } | |
| if task.RetryOptions != nil { | |
| req.RetryParameters = task.RetryOptions.toRetryParameters() | |
| } | |
| return req, nil | |
| } | |
| var alreadyAddedErrors = map[pb.TaskQueueServiceError_ErrorCode]bool{ | |
| pb.TaskQueueServiceError_TASK_ALREADY_EXISTS: true, | |
| pb.TaskQueueServiceError_TOMBSTONED_TASK: true, | |
| } | |
| // Add adds the task to a named queue. | |
| // An empty queue name means that the default queue will be used. | |
| // Add returns an equivalent Task with defaults filled in, including setting | |
| // the task's Name field to the chosen name if the original was empty. | |
| func Add(c context.Context, task *Task, queueName string) (*Task, error) { | |
| if useCloudTasks() && task.Method != "PULL" { | |
| return addInCloudTasks(c, task, queueName) | |
| } | |
| req, err := newAddReq(c, task, queueName) | |
| if err != nil { | |
| return nil, err | |
| } | |
| res := &pb.TaskQueueAddResponse{} | |
| if err := internal.Call(c, "taskqueue", "Add", req, res); err != nil { | |
| apiErr, ok := err.(*internal.APIError) | |
| if ok && alreadyAddedErrors[pb.TaskQueueServiceError_ErrorCode(apiErr.Code)] { | |
| return nil, ErrTaskAlreadyAdded | |
| } | |
| return nil, err | |
| } | |
| resultTask := *task | |
| resultTask.Method = task.method() | |
| if task.Name == "" { | |
| resultTask.Name = string(res.ChosenTaskName) | |
| } | |
| return &resultTask, nil | |
| } | |
| // AddMulti adds multiple tasks to a named queue. | |
| // An empty queue name means that the default queue will be used. | |
| // AddMulti returns a slice of equivalent tasks with defaults filled in, including setting | |
| // each task's Name field to the chosen name if the original was empty. | |
| // If a given task is badly formed or could not be added, an appengine.MultiError is returned. | |
| func AddMulti(c context.Context, tasks []*Task, queueName string) ([]*Task, error) { | |
| if useCloudTasks() { | |
| if len(tasks) > 0 && tasks[0].Method != "PULL" { | |
| return addMultiInCloudTasks(c, tasks, queueName) | |
| } | |
| } | |
| req := &pb.TaskQueueBulkAddRequest{ | |
| AddRequest: make([]*pb.TaskQueueAddRequest, len(tasks)), | |
| } | |
| me, any := make(appengine.MultiError, len(tasks)), false | |
| for i, t := range tasks { | |
| req.AddRequest[i], me[i] = newAddReq(c, t, queueName) | |
| any = any || me[i] != nil | |
| } | |
| if any { | |
| return nil, me | |
| } | |
| res := &pb.TaskQueueBulkAddResponse{} | |
| if err := internal.Call(c, "taskqueue", "BulkAdd", req, res); err != nil { | |
| return nil, err | |
| } | |
| if len(res.Taskresult) != len(tasks) { | |
| return nil, errors.New("taskqueue: server error") | |
| } | |
| tasksOut := make([]*Task, len(tasks)) | |
| for i, tr := range res.Taskresult { | |
| tasksOut[i] = new(Task) | |
| *tasksOut[i] = *tasks[i] | |
| tasksOut[i].Method = tasksOut[i].method() | |
| if tasksOut[i].Name == "" { | |
| tasksOut[i].Name = string(tr.ChosenTaskName) | |
| } | |
| if *tr.Result != pb.TaskQueueServiceError_OK { | |
| if alreadyAddedErrors[*tr.Result] { | |
| me[i] = ErrTaskAlreadyAdded | |
| } else { | |
| me[i] = &internal.APIError{ | |
| Service: "taskqueue", | |
| Code: int32(*tr.Result), | |
| } | |
| } | |
| any = true | |
| } | |
| } | |
| if any { | |
| return tasksOut, me | |
| } | |
| return tasksOut, nil | |
| } | |
| // Delete deletes a task from a named queue. | |
| func Delete(c context.Context, task *Task, queueName string) error { | |
| err := DeleteMulti(c, []*Task{task}, queueName) | |
| if me, ok := err.(appengine.MultiError); ok { | |
| return me[0] | |
| } | |
| return err | |
| } | |
| // DeleteMulti deletes multiple tasks from a named queue. | |
| // If a given task could not be deleted, an appengine.MultiError is returned. | |
| // Each task is deleted independently; one may fail to delete while the others | |
| // are successfully deleted. | |
| func DeleteMulti(c context.Context, tasks []*Task, queueName string) error { | |
| if useCloudTasks() { | |
| if len(tasks) > 0 && tasks[0].Method != "PULL" { | |
| return deleteMultiInCloudTasks(c, tasks, queueName) | |
| } | |
| } | |
| taskNames := make([][]byte, len(tasks)) | |
| for i, t := range tasks { | |
| taskNames[i] = []byte(t.Name) | |
| } | |
| if queueName == "" { | |
| queueName = "default" | |
| } | |
| req := &pb.TaskQueueDeleteRequest{ | |
| QueueName: []byte(queueName), | |
| TaskName: taskNames, | |
| } | |
| res := &pb.TaskQueueDeleteResponse{} | |
| if err := internal.Call(c, "taskqueue", "Delete", req, res); err != nil { | |
| return err | |
| } | |
| if a, b := len(req.TaskName), len(res.Result); a != b { | |
| return fmt.Errorf("taskqueue: internal error: requested deletion of %d tasks, got %d results", a, b) | |
| } | |
| me, any := make(appengine.MultiError, len(res.Result)), false | |
| for i, ec := range res.Result { | |
| if ec != pb.TaskQueueServiceError_OK { | |
| me[i] = &internal.APIError{ | |
| Service: "taskqueue", | |
| Code: int32(ec), | |
| } | |
| any = true | |
| } | |
| } | |
| if any { | |
| return me | |
| } | |
| return nil | |
| } | |
| func lease(c context.Context, maxTasks int, queueName string, leaseTime int, groupByTag bool, tag []byte) ([]*Task, error) { | |
| if queueName == "" { | |
| queueName = "default" | |
| } | |
| req := &pb.TaskQueueQueryAndOwnTasksRequest{ | |
| QueueName: []byte(queueName), | |
| LeaseSeconds: proto.Float64(float64(leaseTime)), | |
| MaxTasks: proto.Int64(int64(maxTasks)), | |
| GroupByTag: proto.Bool(groupByTag), | |
| Tag: tag, | |
| } | |
| res := &pb.TaskQueueQueryAndOwnTasksResponse{} | |
| if err := internal.Call(c, "taskqueue", "QueryAndOwnTasks", req, res); err != nil { | |
| return nil, err | |
| } | |
| tasks := make([]*Task, len(res.Task)) | |
| for i, t := range res.Task { | |
| tasks[i] = &Task{ | |
| Payload: t.Body, | |
| Name: string(t.TaskName), | |
| Method: "PULL", | |
| ETA: time.Unix(0, *t.EtaUsec*1e3), | |
| RetryCount: *t.RetryCount, | |
| Tag: string(t.Tag), | |
| } | |
| } | |
| return tasks, nil | |
| } | |
| // Lease leases tasks from a queue. | |
| // leaseTime is in seconds. | |
| // The number of tasks fetched will be at most maxTasks. | |
| func Lease(c context.Context, maxTasks int, queueName string, leaseTime int) ([]*Task, error) { | |
| return lease(c, maxTasks, queueName, leaseTime, false, nil) | |
| } | |
| // LeaseByTag leases tasks from a queue, grouped by tag. | |
| // If tag is empty, then the returned tasks are grouped by the tag of the task with earliest ETA. | |
| // leaseTime is in seconds. | |
| // The number of tasks fetched will be at most maxTasks. | |
| func LeaseByTag(c context.Context, maxTasks int, queueName string, leaseTime int, tag string) ([]*Task, error) { | |
| return lease(c, maxTasks, queueName, leaseTime, true, []byte(tag)) | |
| } | |
| // Purge removes all tasks from a queue. | |
| func Purge(c context.Context, queueName string) error { | |
| if queueName == "" { | |
| queueName = "default" | |
| } | |
| req := &pb.TaskQueuePurgeQueueRequest{ | |
| QueueName: []byte(queueName), | |
| } | |
| res := &pb.TaskQueuePurgeQueueResponse{} | |
| return internal.Call(c, "taskqueue", "PurgeQueue", req, res) | |
| } | |
| // ModifyLease modifies the lease of a task. | |
| // Used to request more processing time, or to abandon processing. | |
| // leaseTime is in seconds and must not be negative. | |
| func ModifyLease(c context.Context, task *Task, queueName string, leaseTime int) error { | |
| if queueName == "" { | |
| queueName = "default" | |
| } | |
| req := &pb.TaskQueueModifyTaskLeaseRequest{ | |
| QueueName: []byte(queueName), | |
| TaskName: []byte(task.Name), | |
| EtaUsec: proto.Int64(task.ETA.UnixNano() / 1e3), // Used to verify ownership. | |
| LeaseSeconds: proto.Float64(float64(leaseTime)), | |
| } | |
| res := &pb.TaskQueueModifyTaskLeaseResponse{} | |
| if err := internal.Call(c, "taskqueue", "ModifyTaskLease", req, res); err != nil { | |
| return err | |
| } | |
| task.ETA = time.Unix(0, *res.UpdatedEtaUsec*1e3) | |
| return nil | |
| } | |
| // QueueStatistics represents statistics about a single task queue. | |
| type QueueStatistics struct { | |
| Tasks int // may be an approximation | |
| OldestETA time.Time // zero if there are no pending tasks | |
| Executed1Minute int // tasks executed in the last minute | |
| InFlight int // tasks executing now | |
| EnforcedRate float64 // requests per second | |
| } | |
| // QueueStats retrieves statistics about queues. | |
| func QueueStats(c context.Context, queueNames []string) ([]QueueStatistics, error) { | |
| if useCloudTasks() { | |
| return queueStatsInCloudTasks(c, queueNames) | |
| } | |
| req := &pb.TaskQueueFetchQueueStatsRequest{ | |
| QueueName: make([][]byte, len(queueNames)), | |
| } | |
| for i, q := range queueNames { | |
| if q == "" { | |
| q = "default" | |
| } | |
| req.QueueName[i] = []byte(q) | |
| } | |
| res := &pb.TaskQueueFetchQueueStatsResponse{} | |
| if err := internal.Call(c, "taskqueue", "FetchQueueStats", req, res); err != nil { | |
| return nil, err | |
| } | |
| qs := make([]QueueStatistics, len(res.Queuestats)) | |
| for i, qsg := range res.Queuestats { | |
| qs[i] = QueueStatistics{ | |
| Tasks: int(*qsg.NumTasks), | |
| } | |
| if eta := *qsg.OldestEtaUsec; eta > -1 { | |
| qs[i].OldestETA = time.Unix(0, eta*1e3) | |
| } | |
| if si := qsg.ScannerInfo; si != nil { | |
| qs[i].Executed1Minute = int(*si.ExecutedLastMinute) | |
| qs[i].InFlight = int(si.GetRequestsInFlight()) | |
| qs[i].EnforcedRate = si.GetEnforcedRate() | |
| } | |
| } | |
| return qs, nil | |
| } | |
| func setTransaction(x *pb.TaskQueueAddRequest, t *dspb.Transaction) { | |
| x.Transaction = t | |
| } | |
| func init() { | |
| internal.RegisterErrorCodeMap("taskqueue", pb.TaskQueueServiceError_ErrorCode_name) | |
| // Datastore error codes are shifted by DATASTORE_ERROR when presented through taskqueue. | |
| dsCode := int32(pb.TaskQueueServiceError_DATASTORE_ERROR) + int32(dspb.Error_TIMEOUT) | |
| internal.RegisterTimeoutErrorCode("taskqueue", dsCode) | |
| // Transaction registration. | |
| internal.RegisterTransactionSetter(setTransaction) | |
| internal.RegisterTransactionSetter(func(x *pb.TaskQueueBulkAddRequest, t *dspb.Transaction) { | |
| for _, req := range x.AddRequest { | |
| setTransaction(req, t) | |
| } | |
| }) | |
| } |