Taskqueue migration preview (#410)
* v2: add Cloud Tasks transparent routing to v2/taskqueue for dual v1 and v2 module support
* taskqueue: implement QueueStats via Cloud Tasks v2beta3 GetQueue with stats read mask
diff --git a/taskqueue/cloudtask.go b/taskqueue/cloudtask.go
index 10e67d7..f7a8a54 100644
--- a/taskqueue/cloudtask.go
+++ b/taskqueue/cloudtask.go
@@ -17,8 +17,10 @@
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/fieldmaskpb"
"google.golang.org/protobuf/types/known/timestamppb"
+
cloudtasks "cloud.google.com/go/cloudtasks/apiv2beta3"
taskspb "cloud.google.com/go/cloudtasks/apiv2beta3/cloudtaskspb"
)
@@ -584,3 +586,42 @@
}
return fmt.Errorf("cloud tasks operation failed (%d): %s", code, msg)
}
+
+func queueStatsInCloudTasks(ctx context.Context, queueNames []string) ([]QueueStatistics, error) {
+ client, err := cloudtasks.NewClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create cloudtasks client: %v", err)
+ }
+ defer client.Close()
+
+ qs := make([]QueueStatistics, len(queueNames))
+ for i, q := range queueNames {
+ fullQueueName, err := getQueuePath(ctx, q)
+ if err != nil {
+ return nil, err
+ }
+ req := &taskspb.GetQueueRequest{
+ Name: fullQueueName,
+ ReadMask: &fieldmaskpb.FieldMask{
+ Paths: []string{"stats"},
+ },
+ }
+ queue, err := client.GetQueue(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ if queue != nil && queue.Stats != nil {
+ qs[i] = QueueStatistics{
+ Tasks: int(queue.Stats.TasksCount),
+ Executed1Minute: int(queue.Stats.ExecutedLastMinuteCount),
+ InFlight: int(queue.Stats.ConcurrentDispatchesCount),
+ EnforcedRate: queue.Stats.EffectiveExecutionRate,
+ }
+ if queue.Stats.OldestEstimatedArrivalTime != nil {
+ qs[i].OldestETA = queue.Stats.OldestEstimatedArrivalTime.AsTime()
+ }
+ }
+ }
+ return qs, nil
+}
+
diff --git a/taskqueue/taskqueue.go b/taskqueue/taskqueue.go
index ff09e1e..4843ac6 100644
--- a/taskqueue/taskqueue.go
+++ b/taskqueue/taskqueue.go
@@ -503,6 +503,9 @@
// 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)),
}
diff --git a/v2/internal/transaction.go b/v2/internal/transaction.go
index 7198bac..56a9ff3 100644
--- a/v2/internal/transaction.go
+++ b/v2/internal/transaction.go
@@ -17,6 +17,23 @@
pb "google.golang.org/appengine/v2/internal/datastore"
)
+// Transaction hooks for taskqueue outbox pattern
+var (
+ PostCommitHook func(ctx context.Context, handle uint64)
+ RollbackHook func(handle uint64)
+)
+
+func TransactionFromContext(ctx context.Context) *pb.Transaction {
+ if t := transactionFromContext(ctx); t != nil {
+ return &t.transaction
+ }
+ return nil
+}
+
+func TransactionlessContext(ctx context.Context) context.Context {
+ return withTransaction(ctx, nil)
+}
+
var transactionSetters = make(map[reflect.Type]reflect.Value)
// RegisterTransactionSetter registers a function that sets transaction information
@@ -89,6 +106,9 @@
// Ignore the error return value, since we are already returning a non-nil
// error (or we're panicking).
Call(c, "datastore_v3", "Rollback", &t.transaction, &basepb.VoidProto{})
+ if RollbackHook != nil {
+ RollbackHook(t.transaction.GetHandle())
+ }
}()
if err := f(withTransaction(c, t)); err != nil {
return &t.transaction, err
@@ -111,5 +131,12 @@
return &t.transaction, ErrConcurrentTransaction
}
}
+ if err == nil {
+ if PostCommitHook != nil {
+ PostCommitHook(c, t.transaction.GetHandle())
+ }
+ } else if RollbackHook != nil {
+ RollbackHook(t.transaction.GetHandle())
+ }
return &t.transaction, err
}
diff --git a/v2/taskqueue/cloudtask.go b/v2/taskqueue/cloudtask.go
new file mode 100644
index 0000000..8661894
--- /dev/null
+++ b/v2/taskqueue/cloudtask.go
@@ -0,0 +1,627 @@
+package taskqueue
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "google.golang.org/appengine/v2"
+ "google.golang.org/appengine/v2/datastore"
+ "google.golang.org/appengine/v2/internal"
+ pb "google.golang.org/appengine/v2/internal/taskqueue"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/durationpb"
+ "google.golang.org/protobuf/types/known/fieldmaskpb"
+ "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)
+}
+
+func queueStatsInCloudTasks(ctx context.Context, queueNames []string) ([]QueueStatistics, error) {
+ client, err := cloudtasks.NewClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create cloudtasks client: %v", err)
+ }
+ defer client.Close()
+
+ qs := make([]QueueStatistics, len(queueNames))
+ for i, q := range queueNames {
+ fullQueueName, err := getQueuePath(ctx, q)
+ if err != nil {
+ return nil, err
+ }
+ req := &taskspb.GetQueueRequest{
+ Name: fullQueueName,
+ ReadMask: &fieldmaskpb.FieldMask{
+ Paths: []string{"stats"},
+ },
+ }
+ queue, err := client.GetQueue(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ if queue != nil && queue.Stats != nil {
+ qs[i] = QueueStatistics{
+ Tasks: int(queue.Stats.TasksCount),
+ Executed1Minute: int(queue.Stats.ExecutedLastMinuteCount),
+ InFlight: int(queue.Stats.ConcurrentDispatchesCount),
+ EnforcedRate: queue.Stats.EffectiveExecutionRate,
+ }
+ if queue.Stats.OldestEstimatedArrivalTime != nil {
+ qs[i].OldestETA = queue.Stats.OldestEstimatedArrivalTime.AsTime()
+ }
+ }
+ }
+ return qs, nil
+}
+
diff --git a/v2/taskqueue/cloudtask_transactional.go b/v2/taskqueue/cloudtask_transactional.go
new file mode 100644
index 0000000..9745f77
--- /dev/null
+++ b/v2/taskqueue/cloudtask_transactional.go
@@ -0,0 +1,242 @@
+package taskqueue
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "google.golang.org/appengine/v2"
+ "google.golang.org/appengine/v2/datastore"
+ "google.golang.org/appengine/v2/internal"
+ "google.golang.org/protobuf/proto"
+
+ taskspb "cloud.google.com/go/cloudtasks/apiv2beta3/cloudtaskspb"
+)
+
+const (
+ statusPending = "PENDING"
+ statusProcessing = "PROCESSING"
+ statusFailed = "FAILED"
+ statusDone = "DONE"
+ statusAlreadyExists = "ALREADY_EXISTS"
+
+ lockDuration = 60 * time.Second
+ fastPathGracePeriod = 60 * time.Second
+ maxSweeperRetries = 5
+ maxLastErrorLength = 500
+)
+
+type PendingCloudTask struct {
+ QueueName string `datastore:"queue_name"`
+ CloudTaskName string `datastore:"cloud_task_name"`
+ CloudTaskPayload string `datastore:"cloud_task_payload,noindex"`
+ Created time.Time `datastore:"created"`
+ Status string `datastore:"status"`
+ LockExpires time.Time `datastore:"lock_expires"`
+ RetryCount int64 `datastore:"retry_count"`
+ LastError string `datastore:"last_error,noindex"`
+ HandledBySweeper bool `datastore:"handled_by_sweeper"`
+ SdkLang string `datastore:"sdk_lang"`
+}
+
+var (
+ pendingTasksMu sync.Mutex
+ pendingTasks = make(map[uint64][]string) // transaction handle -> list of urlsafe keys
+)
+
+func init() {
+ internal.PostCommitHook = func(ctx context.Context, handle uint64) {
+ go dispatchPendingTasks(ctx, handle)
+ }
+ internal.RollbackHook = func(handle uint64) {
+ cleanupPendingTasks(handle)
+ }
+ http.HandleFunc("/_ah/cloudtask/sweep", handleSweep)
+}
+
+func cleanupPendingTasks(handle uint64) {
+ pendingTasksMu.Lock()
+ delete(pendingTasks, handle)
+ pendingTasksMu.Unlock()
+}
+
+type noCancelContext struct {
+ context.Context
+}
+
+func (c *noCancelContext) Deadline() (deadline time.Time, ok bool) {
+ return time.Time{}, false
+}
+
+func (c *noCancelContext) Done() <-chan struct{} {
+ return nil
+}
+
+func (c *noCancelContext) Err() error {
+ return nil
+}
+
+func logErrorf(ctx context.Context, format string, v ...interface{}) {
+ log.Printf("ERROR: "+format, v...)
+}
+
+func dispatchPendingTasks(ctx context.Context, handle uint64) {
+ pendingTasksMu.Lock()
+ urlsafeKeys, ok := pendingTasks[handle]
+ if ok {
+ delete(pendingTasks, handle)
+ }
+ pendingTasksMu.Unlock()
+
+ if !ok || len(urlsafeKeys) == 0 {
+ return
+ }
+
+ noCancelCtx := &noCancelContext{Context: internal.TransactionlessContext(ctx)}
+
+ for _, urlsafeKey := range urlsafeKeys {
+ key, err := datastore.DecodeKey(urlsafeKey)
+ if err != nil {
+ logErrorf(ctx, "Failed to decode pending task key: %v", err)
+ continue
+ }
+
+ var taskEntity PendingCloudTask
+ err = datastore.Get(noCancelCtx, key, &taskEntity)
+ if err != nil {
+ logErrorf(ctx, "Failed to get pending task from Datastore: %v", err)
+ continue
+ }
+
+ now := time.Now()
+ taskEntity.Status = statusProcessing
+ taskEntity.LockExpires = now.Add(lockDuration)
+ taskEntity.HandledBySweeper = false
+ if _, err := datastore.Put(noCancelCtx, key, &taskEntity); err != nil {
+ logErrorf(ctx, "Failed to acquire lock in fast-path for task %s: %v", taskEntity.CloudTaskName, err)
+ continue
+ }
+
+ var taskObj taskspb.Task
+ if err := proto.Unmarshal([]byte(taskEntity.CloudTaskPayload), &taskObj); err != nil {
+ logErrorf(ctx, "Failed to unmarshal pending task proto: %v", err)
+ continue
+ }
+ _, err = sendTask(noCancelCtx, taskEntity.QueueName, taskEntity.CloudTaskName, &taskObj)
+ if err != nil {
+ if err == ErrTaskAlreadyAdded {
+ datastore.Delete(noCancelCtx, key)
+ continue
+ }
+ logErrorf(ctx, "Failed to dispatch task %s to queue %s: %v", taskEntity.CloudTaskName, taskEntity.QueueName, err)
+ taskEntity.RetryCount++
+ taskEntity.LastError = err.Error()
+ if len(taskEntity.LastError) > maxLastErrorLength {
+ taskEntity.LastError = taskEntity.LastError[:maxLastErrorLength]
+ }
+ taskEntity.Status = statusPending
+ datastore.Put(noCancelCtx, key, &taskEntity)
+ continue
+ }
+
+ err = datastore.Delete(noCancelCtx, key)
+ if err != nil {
+ logErrorf(ctx, "Failed to delete pending task %s from Datastore: %v", taskEntity.CloudTaskName, err)
+ }
+ }
+}
+
+func sweep(ctx context.Context) error {
+ query := datastore.NewQuery("_AE_PendingCloudTask")
+ var tasks []PendingCloudTask
+ keys, err := query.GetAll(ctx, &tasks)
+ if err != nil {
+ return fmt.Errorf("failed to query _AE_PendingCloudTask: %v", err)
+ }
+
+ now := time.Now()
+ count := 0
+ for i, key := range keys {
+ task := tasks[i]
+ if task.Status == statusDone || task.Status == statusAlreadyExists {
+ continue
+ }
+ if task.Status == statusProcessing {
+ if !task.LockExpires.IsZero() && now.Before(task.LockExpires) {
+ continue // Still actively processing and lock valid
+ } else if task.LockExpires.IsZero() {
+ continue // Assume lock valid if just started
+ }
+ } else if task.Status == statusPending || task.Status == "" {
+ if !task.Created.IsZero() && now.Sub(task.Created) < fastPathGracePeriod {
+ continue // Give fast-path grace period to dispatch post-commit
+ }
+ } else if task.Status == statusFailed && task.RetryCount >= maxSweeperRetries {
+ continue // Exceeded max sweeper retries
+ }
+
+ // Acquire lock
+ task.Status = statusProcessing
+ task.LockExpires = now.Add(lockDuration)
+ task.HandledBySweeper = true
+ if _, err := datastore.Put(ctx, key, &task); err != nil {
+ logErrorf(ctx, "Sweeper failed to acquire lock for task %s: %v", task.CloudTaskName, err)
+ continue
+ }
+
+ var taskObj taskspb.Task
+ if err := proto.Unmarshal([]byte(task.CloudTaskPayload), &taskObj); err != nil {
+ logErrorf(ctx, "Sweeper failed to unmarshal pending task proto: %v", err)
+ continue
+ }
+ _, err := sendTask(ctx, task.QueueName, task.CloudTaskName, &taskObj)
+ if err != nil && err != ErrTaskAlreadyAdded {
+ logErrorf(ctx, "Sweeper failed to dispatch task %s: %v", task.CloudTaskName, err)
+ task.RetryCount++
+ task.LastError = err.Error()
+ if len(task.LastError) > maxLastErrorLength {
+ task.LastError = task.LastError[:maxLastErrorLength]
+ }
+ if task.RetryCount >= maxSweeperRetries {
+ task.Status = statusFailed
+ task.LockExpires = time.Time{}
+ } else {
+ task.Status = statusPending
+ task.LockExpires = time.Time{}
+ }
+ if _, putErr := datastore.Put(ctx, key, &task); putErr != nil {
+ logErrorf(ctx, "Sweeper failed to record error state for task %s: %v", task.CloudTaskName, putErr)
+ }
+ continue
+ }
+
+ if err := datastore.Delete(ctx, key); err != nil {
+ logErrorf(ctx, "Sweeper failed to delete entity %s: %v", task.CloudTaskName, err)
+ }
+ count++
+ }
+
+ log.Printf("Cloud Tasks sweeper processed %d tasks.", count)
+ return nil
+}
+
+func handleSweep(w http.ResponseWriter, r *http.Request) {
+ isCron := strings.EqualFold(r.Header.Get("X-AppEngine-Cron"), "true") || strings.EqualFold(r.Header.Get("X-Appengine-Cron"), "true")
+ if !isCron && !appengine.IsDevAppServer() {
+ http.Error(w, "Access denied: endpoint only accessible via App Engine Cron.", http.StatusForbidden)
+ return
+ }
+ ctx := appengine.NewContext(r)
+ if err := sweep(ctx); err != nil {
+ logErrorf(ctx, "Sweeper failed: %v", err)
+ http.Error(w, fmt.Sprintf("Sweeper failed: %v", err), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("Sweeper completed successfully.\n"))
+}
diff --git a/v2/taskqueue/taskqueue.go b/v2/taskqueue/taskqueue.go
index 8bbd85a..dcf2c3e 100644
--- a/v2/taskqueue/taskqueue.go
+++ b/v2/taskqueue/taskqueue.go
@@ -283,6 +283,9 @@
// 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
@@ -309,6 +312,11 @@
// 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)),
}
@@ -367,6 +375,11 @@
// 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)
@@ -490,6 +503,9 @@
// 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)),
}