| package taskqueue |
| |
| import ( |
| "context" |
| "fmt" |
| "log" |
| "net/http" |
| "strings" |
| "sync" |
| "time" |
| |
| "google.golang.org/appengine" |
| "google.golang.org/appengine/datastore" |
| "google.golang.org/appengine/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")) |
| } |