| package taskqueue |
| |
| import ( |
| "context" |
| "fmt" |
| "io" |
| "net/http" |
| "os" |
| "regexp" |
| "strconv" |
| "strings" |
| "time" |
| |
| "google.golang.org/appengine" |
| "google.golang.org/appengine/datastore" |
| "google.golang.org/appengine/internal" |
| pb "google.golang.org/appengine/internal/taskqueue" |
| "google.golang.org/protobuf/proto" |
| "google.golang.org/protobuf/types/known/durationpb" |
| "google.golang.org/protobuf/types/known/timestamppb" |
| |
| cloudtasks "cloud.google.com/go/cloudtasks/apiv2beta3" |
| taskspb "cloud.google.com/go/cloudtasks/apiv2beta3/cloudtaskspb" |
| ) |
| |
| const ( |
| maxTaskPayloadBytes = 100 * 1024 // 100 KB max payload size for Cloud Tasks |
| maxTransactionalTasks = 5 // Maximum tasks allowed in a single Datastore transaction |
| batchCreateChunkSize = 100 // Maximum tasks per BatchCreateTasks request |
| batchDeleteChunkSize = 1000 // Maximum tasks per BatchDeleteTasks request |
| |
| grpcNotFound = 5 |
| grpcAlreadyExists = 6 |
| httpNotFound = 404 |
| httpAlreadyExists = 409 |
| ) |
| |
| var ( |
| taskNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) |
| ErrTooManyTasksInTransaction = &internal.APIError{ |
| Service: "taskqueue", |
| Detail: "too many tasks in transaction", |
| Code: int32(pb.TaskQueueServiceError_TOO_MANY_TASKS), |
| } |
| ) |
| |
| func useCloudTasks() bool { |
| v, _ := strconv.ParseBool(os.Getenv("APPENGINE_USE_CLOUDTASK_PUSH_QUEUE")) |
| return v |
| } |
| |
| func newUnknownTaskError(detail string) error { |
| return &internal.APIError{ |
| Service: "taskqueue", |
| Detail: detail, |
| Code: int32(pb.TaskQueueServiceError_UNKNOWN_TASK), |
| } |
| } |
| |
| func isAlreadyExistsError(err error) bool { |
| if err == nil { |
| return false |
| } |
| msg := err.Error() |
| return strings.Contains(msg, "AlreadyExists") || strings.Contains(msg, "already exists") || strings.Contains(msg, "409") || strings.Contains(msg, "Policy checks are unavailable") |
| } |
| |
| func isUnimplementedError(err error) bool { |
| if err == nil { |
| return false |
| } |
| msg := err.Error() |
| return strings.Contains(msg, "Unimplemented") || strings.Contains(msg, "unknown method") || strings.Contains(msg, "404") |
| } |
| |
| func getQueuePath(ctx context.Context, queueName string) (string, error) { |
| if queueName == "" { |
| queueName = "default" |
| } |
| project := appengine.AppID(ctx) |
| if idx := strings.Index(project, "~"); idx != -1 { |
| project = project[idx+1:] |
| } |
| region, err := getRegion(ctx) |
| if err != nil { |
| return "", fmt.Errorf("failed to get region: %v", err) |
| } |
| return fmt.Sprintf("projects/%s/locations/%s/queues/%s", project, region, queueName), nil |
| } |
| |
| func getRegion(ctx context.Context) (string, error) { |
| req, err := http.NewRequest("GET", "http://metadata.google.internal/computeMetadata/v1/instance/region", nil) |
| if err != nil { |
| return "", err |
| } |
| req.Header.Set("Metadata-Flavor", "Google") |
| resp, err := http.DefaultClient.Do(req.WithContext(ctx)) |
| if err != nil { |
| return "", err |
| } |
| defer resp.Body.Close() |
| if resp.StatusCode != http.StatusOK { |
| return "", fmt.Errorf("metadata server returned status %d", resp.StatusCode) |
| } |
| body, err := io.ReadAll(resp.Body) |
| if err != nil { |
| return "", err |
| } |
| parts := strings.Split(strings.TrimSpace(string(body)), "/") |
| if len(parts) == 0 { |
| return "", fmt.Errorf("invalid region format: %s", string(body)) |
| } |
| return parts[len(parts)-1], nil |
| } |
| |
| func sendTask(ctx context.Context, queueName string, taskName string, taskObj *taskspb.Task) (string, error) { |
| parent, err := getQueuePath(ctx, queueName) |
| if err != nil { |
| return "", err |
| } |
| |
| client, err := cloudtasks.NewClient(ctx) |
| if err != nil { |
| return "", fmt.Errorf("failed to create cloudtasks client: %v", err) |
| } |
| defer client.Close() |
| |
| req := &taskspb.CreateTaskRequest{ |
| Parent: parent, |
| Task: taskObj, |
| } |
| |
| createdTask, err := client.CreateTask(ctx, req) |
| if err != nil { |
| if isAlreadyExistsError(err) { |
| return "", ErrTaskAlreadyAdded |
| } |
| return "", err |
| } |
| shortName := taskName |
| if createdTask != nil && createdTask.Name != "" { |
| if idx := strings.LastIndex(createdTask.Name, "/"); idx != -1 { |
| shortName = createdTask.Name[idx+1:] |
| } else { |
| shortName = createdTask.Name |
| } |
| } |
| return shortName, nil |
| } |
| |
| func extractServiceFromHost(ctx context.Context, host string) string { |
| if host == "" { |
| if s := os.Getenv("GAE_SERVICE"); s != "" { |
| return s |
| } |
| return "default" |
| } |
| |
| if idx := strings.Index(host, ":"); idx != -1 { |
| host = host[:idx] |
| } |
| |
| project := appengine.AppID(ctx) |
| if idx := strings.Index(project, "~"); idx != -1 { |
| project = project[idx+1:] |
| } |
| |
| pIdx := strings.Index(host, project) |
| if pIdx == -1 { |
| defaultHost := appengine.DefaultVersionHostname(ctx) |
| if host == defaultHost { |
| return "default" |
| } |
| return host |
| } |
| |
| domainSuffix := host[pIdx:] |
| if host == domainSuffix { |
| return "default" |
| } |
| |
| suffixes := []string{ |
| "." + domainSuffix, |
| "-dot-" + domainSuffix, |
| } |
| stripped := host |
| for _, suffix := range suffixes { |
| if strings.HasSuffix(stripped, suffix) { |
| stripped = stripped[:len(stripped)-len(suffix)] |
| break |
| } |
| } |
| |
| if stripped == host { |
| return host |
| } |
| |
| stripped = strings.ReplaceAll(stripped, "-dot-", ".") |
| parts := strings.Split(stripped, ".") |
| if len(parts) > 0 { |
| return parts[len(parts)-1] |
| } |
| return "default" |
| } |
| |
| func buildCloudTaskProto(ctx context.Context, queueName string, task *Task) (*taskspb.Task, string, error) { |
| if task.Name != "" { |
| if !taskNameRegex.MatchString(task.Name) { |
| return nil, "", fmt.Errorf("taskqueue: invalid task name %q", task.Name) |
| } |
| } |
| |
| if len(task.Payload) > maxTaskPayloadBytes { |
| return nil, "", fmt.Errorf("taskqueue: task too large (%d bytes)", len(task.Payload)) |
| } |
| |
| queuePath, err := getQueuePath(ctx, queueName) |
| if err != nil { |
| return nil, "", err |
| } |
| |
| taskName := task.Name |
| var fullTaskName string |
| if taskName != "" { |
| fullTaskName = fmt.Sprintf("%s/tasks/%s", queuePath, taskName) |
| } |
| |
| path := task.Path |
| if path == "" { |
| path = "/_ah/queue/" + queueName |
| } |
| |
| headers := make(map[string]string) |
| for k, vs := range task.Header { |
| if len(vs) > 0 { |
| headers[k] = vs[0] |
| } |
| } |
| |
| if _, ok := headers["Content-Type"]; !ok { |
| headers["Content-Type"] = "application/octet-stream" |
| } |
| if _, ok := headers["X-AppEngine-QueueName"]; !ok { |
| headers["X-AppEngine-QueueName"] = queueName |
| } |
| if taskName != "" { |
| if _, ok := headers["X-AppEngine-TaskName"]; !ok { |
| headers["X-AppEngine-TaskName"] = taskName |
| } |
| } |
| |
| targetService := extractServiceFromHost(ctx, headers["Host"]) |
| var routing *taskspb.AppEngineRouting |
| if targetService != "" { |
| routing = &taskspb.AppEngineRouting{ |
| Service: targetService, |
| } |
| } |
| delete(headers, "Host") |
| ae := &taskspb.AppEngineHttpRequest{ |
| RelativeUri: path, |
| Headers: headers, |
| Body: task.Payload, |
| AppEngineRouting: routing, |
| } |
| if code, ok := taskspb.HttpMethod_value[task.method()]; ok { |
| ae.HttpMethod = taskspb.HttpMethod(code) |
| } |
| |
| taskObj := &taskspb.Task{ |
| Name: fullTaskName, |
| PayloadType: &taskspb.Task_AppEngineHttpRequest{ |
| AppEngineHttpRequest: ae, |
| }, |
| } |
| |
| if !task.ETA.IsZero() { |
| taskObj.ScheduleTime = timestamppb.New(task.ETA) |
| } else if task.Delay > 0 { |
| taskObj.ScheduleTime = timestamppb.New(time.Now().Add(task.Delay)) |
| } |
| |
| if task.RetryOptions != nil { |
| rc := &taskspb.RetryConfig{} |
| hasRC := false |
| if task.RetryOptions.RetryLimit > 0 { |
| rc.MaxAttempts = task.RetryOptions.RetryLimit |
| hasRC = true |
| } |
| if task.RetryOptions.AgeLimit > 0 { |
| rc.MaxRetryDuration = durationpb.New(task.RetryOptions.AgeLimit) |
| hasRC = true |
| } |
| if task.RetryOptions.MinBackoff > 0 { |
| rc.MinBackoff = durationpb.New(task.RetryOptions.MinBackoff) |
| hasRC = true |
| } |
| if task.RetryOptions.MaxBackoff > 0 { |
| rc.MaxBackoff = durationpb.New(task.RetryOptions.MaxBackoff) |
| hasRC = true |
| } |
| if task.RetryOptions.MaxDoublings > 0 || (task.RetryOptions.MaxDoublings == 0 && task.RetryOptions.ApplyZeroMaxDoublings) { |
| rc.MaxDoublings = task.RetryOptions.MaxDoublings |
| hasRC = true |
| } |
| if hasRC { |
| taskObj.RetryConfig = rc |
| } |
| } |
| |
| return taskObj, taskName, nil |
| } |
| |
| func addInCloudTasks(ctx context.Context, task *Task, queueName string) (*Task, error) { |
| if queueName == "" { |
| queueName = "default" |
| } |
| |
| taskObj, taskName, err := buildCloudTaskProto(ctx, queueName, task) |
| if err != nil { |
| return nil, err |
| } |
| |
| // In App Engine Datastore, external HTTP/gRPC Cloud Tasks RPCs cannot participate |
| // in Datastore 2PC transactions. If we are running inside an active Datastore transaction, |
| // we stage the task as a _AE_PendingCloudTask entity in Datastore under the transaction. |
| // When the transaction commits, PostCommitHook dispatches the staged task to Cloud Tasks. |
| if t := internal.TransactionFromContext(ctx); t != nil { |
| handle := t.GetHandle() |
| pendingTasksMu.Lock() |
| if len(pendingTasks[handle]) >= maxTransactionalTasks { |
| pendingTasksMu.Unlock() |
| return nil, ErrTooManyTasksInTransaction |
| } |
| pendingTasksMu.Unlock() |
| |
| protoBytes, err := proto.Marshal(taskObj) |
| if err != nil { |
| return nil, fmt.Errorf("failed to marshal proto for transactional task: %v", err) |
| } |
| key := datastore.NewIncompleteKey(ctx, "_AE_PendingCloudTask", nil) |
| pendingTask := &PendingCloudTask{ |
| QueueName: queueName, |
| CloudTaskName: taskName, |
| CloudTaskPayload: string(protoBytes), |
| Created: time.Now(), |
| Status: "PENDING", |
| RetryCount: 0, |
| LastError: "", |
| HandledBySweeper: false, |
| SdkLang: "GO", |
| } |
| key, err = datastore.Put(ctx, key, pendingTask) |
| if err != nil { |
| return nil, fmt.Errorf("failed to save transactional task to Datastore: %v", err) |
| } |
| |
| pendingTasksMu.Lock() |
| pendingTasks[handle] = append(pendingTasks[handle], key.Encode()) |
| pendingTasksMu.Unlock() |
| |
| resultTask := *task |
| resultTask.Name = taskName |
| resultTask.Method = task.method() |
| return &resultTask, nil |
| } |
| |
| assignedName, err := sendTask(ctx, queueName, taskName, taskObj) |
| if err != nil { |
| return nil, err |
| } |
| |
| resultTask := *task |
| resultTask.Name = assignedName |
| resultTask.Method = task.method() |
| return &resultTask, nil |
| } |
| |
| func addMultiInCloudTasks(ctx context.Context, tasks []*Task, queueName string) ([]*Task, error) { |
| // If AddMulti is called inside a Datastore transaction, each task in the batch |
| // is transactionally staged in Datastore via addInCloudTasks so that all tasks |
| // commit atomically with the Datastore transaction. |
| if t := internal.TransactionFromContext(ctx); t != nil { |
| handle := t.GetHandle() |
| pendingTasksMu.Lock() |
| if len(pendingTasks[handle])+len(tasks) > maxTransactionalTasks { |
| pendingTasksMu.Unlock() |
| return nil, ErrTooManyTasksInTransaction |
| } |
| pendingTasksMu.Unlock() |
| |
| me, any := make(appengine.MultiError, len(tasks)), false |
| results := make([]*Task, len(tasks)) |
| for i, task := range tasks { |
| res, err := addInCloudTasks(ctx, task, queueName) |
| if err != nil { |
| me[i] = err |
| any = true |
| } else { |
| results[i] = res |
| } |
| } |
| if any { |
| return results, me |
| } |
| return results, nil |
| } |
| |
| fullQueueName, err := getQueuePath(ctx, queueName) |
| if err != nil { |
| return nil, err |
| } |
| |
| me, any := make(appengine.MultiError, len(tasks)), false |
| results := make([]*Task, len(tasks)) |
| |
| client, err := cloudtasks.NewClient(ctx) |
| if err != nil { |
| return nil, fmt.Errorf("failed to create cloudtasks client: %v", err) |
| } |
| defer client.Close() |
| |
| chunkSize := batchCreateChunkSize |
| for chunkStart := 0; chunkStart < len(tasks); chunkStart += chunkSize { |
| chunkEnd := chunkStart + chunkSize |
| if chunkEnd > len(tasks) { |
| chunkEnd = len(tasks) |
| } |
| chunkTasks := tasks[chunkStart:chunkEnd] |
| |
| createReqs := make([]*taskspb.CreateTaskRequest, 0, len(chunkTasks)) |
| for i, t := range chunkTasks { |
| taskObj, taskName, err := buildCloudTaskProto(ctx, queueName, t) |
| if err != nil { |
| me[chunkStart+i] = err |
| any = true |
| continue |
| } |
| results[chunkStart+i] = new(Task) |
| *results[chunkStart+i] = *t |
| results[chunkStart+i].Name = taskName |
| results[chunkStart+i].Method = t.method() |
| |
| createReqs = append(createReqs, &taskspb.CreateTaskRequest{ |
| Parent: fullQueueName, |
| Task: taskObj, |
| }) |
| } |
| if len(createReqs) == 0 { |
| continue |
| } |
| |
| batchReq := &taskspb.BatchCreateTasksRequest{ |
| Parent: fullQueueName, |
| Requests: createReqs, |
| } |
| |
| op, err := client.BatchCreateTasks(ctx, batchReq) |
| if err != nil { |
| if isUnimplementedError(err) { |
| for i, t := range chunkTasks { |
| if me[chunkStart+i] != nil { |
| continue |
| } |
| res, err := addInCloudTasks(ctx, t, queueName) |
| if err != nil { |
| me[chunkStart+i] = err |
| any = true |
| } else { |
| results[chunkStart+i] = res |
| } |
| } |
| } else { |
| for i := range chunkTasks { |
| if me[chunkStart+i] == nil { |
| me[chunkStart+i] = err |
| any = true |
| } |
| } |
| } |
| } else if op != nil { |
| meta, _ := op.Metadata() |
| resp, _ := op.Wait(ctx) |
| for i := range chunkTasks { |
| if meta != nil && meta.FailedRequests != nil { |
| if st, failed := meta.FailedRequests[int32(i)]; failed && st != nil && st.Code != 0 { |
| me[chunkStart+i] = mapOperationErrorCode(int(st.Code), st.Message, false) |
| any = true |
| continue |
| } |
| } |
| if resp != nil && i < len(resp.Tasks) && resp.Tasks[i] != nil { |
| createdTask := resp.Tasks[i] |
| if createdTask.Name != "" && results[chunkStart+i] != nil { |
| if idx := strings.LastIndex(createdTask.Name, "/"); idx != -1 { |
| results[chunkStart+i].Name = createdTask.Name[idx+1:] |
| } else { |
| results[chunkStart+i].Name = createdTask.Name |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| if any { |
| return results, me |
| } |
| return results, nil |
| } |
| |
| func deleteMultiInCloudTasks(ctx context.Context, tasks []*Task, queueName string) error { |
| fullQueueName, err := getQueuePath(ctx, queueName) |
| if err != nil { |
| return err |
| } |
| |
| client, err := cloudtasks.NewClient(ctx) |
| if err != nil { |
| return fmt.Errorf("failed to create cloudtasks client: %v", err) |
| } |
| defer client.Close() |
| |
| me, any := make(appengine.MultiError, len(tasks)), false |
| |
| chunkSize := batchDeleteChunkSize |
| for chunkStart := 0; chunkStart < len(tasks); chunkStart += chunkSize { |
| chunkEnd := chunkStart + chunkSize |
| if chunkEnd > len(tasks) { |
| chunkEnd = len(tasks) |
| } |
| chunkTasks := tasks[chunkStart:chunkEnd] |
| |
| names := make([]string, len(chunkTasks)) |
| for i, t := range chunkTasks { |
| names[i] = fmt.Sprintf("%s/tasks/%s", fullQueueName, t.Name) |
| } |
| |
| batchReq := &taskspb.BatchDeleteTasksRequest{ |
| Parent: fullQueueName, |
| Names: names, |
| } |
| |
| op, err := client.BatchDeleteTasks(ctx, batchReq) |
| if err != nil { |
| for i := range chunkTasks { |
| me[chunkStart+i] = err |
| any = true |
| } |
| } else if op != nil { |
| meta, _ := op.Metadata() |
| for i := range chunkTasks { |
| if meta != nil && meta.FailedRequests != nil { |
| if st, failed := meta.FailedRequests[int32(i)]; failed && st != nil && st.Code != 0 { |
| me[chunkStart+i] = mapOperationErrorCode(int(st.Code), st.Message, true) |
| any = true |
| } |
| } |
| } |
| } |
| } |
| |
| if any { |
| return me |
| } |
| return nil |
| } |
| |
| |
| |
| func mapOperationErrorCode(code int, msg string, isDelete bool) error { |
| lowerMsg := strings.ToLower(msg) |
| isNotFound := code == grpcNotFound || code == httpNotFound || strings.Contains(lowerMsg, "not found") || strings.Contains(lowerMsg, "unknown") |
| isAlreadyExists := code == grpcAlreadyExists || code == httpAlreadyExists || strings.Contains(lowerMsg, "already exists") |
| |
| if isDelete && isNotFound { |
| return newUnknownTaskError(msg) |
| } |
| if isAlreadyExists || (isNotFound && strings.Contains(lowerMsg, "requested entity was not found")) { |
| return ErrTaskAlreadyAdded |
| } |
| if isNotFound { |
| return newUnknownTaskError(msg) |
| } |
| return fmt.Errorf("cloud tasks operation failed (%d): %s", code, msg) |
| } |