internal/relui: make poll-wait for submit less spammy

To make logging less spammy and the poll-wait timeout
the same, we instrument a small amount of bookkeeping
that allows us to log events as they occur and a new
heartbeat every 30 minutes.

For golang/go#81171

Change-Id: I4b4027496a9170fa9beab341dff433423e60f84f
Reviewed-on: https://go-review.googlesource.com/c/build/+/845047
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Neal Patel <nealpatel@google.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/internal/relui/workflows.go b/internal/relui/workflows.go
index 23a18d4..7e3bc13 100644
--- a/internal/relui/workflows.go
+++ b/internal/relui/workflows.go
@@ -27,6 +27,7 @@
 	"sort"
 	"strings"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	"cloud.google.com/go/storage"
@@ -1052,50 +1053,89 @@
 // submits them, landing the security fixes on the internal release branches.
 // It returns a display map of internal release branch to submitted CL URLs.
 func (b *BuildReleaseTasks) submitCherryPicks(ctx *wf.TaskContext, cherryPicks []*gerrit.ChangeInfo) (map[string][]string, error) {
-	if _, err := task.AwaitCondition(ctx, time.Second*10, func() (string, bool, error) {
-		unsubmitted := len(cherryPicks)
-		var blocking []string
+	var (
+		awaiting []string
+		seen     = make(map[string]string, len(cherryPicks))
+	)
+	for _, cp := range cherryPicks {
+		if cp.Status != gerrit.ChangeStatusMerged {
+			seen[cp.ID] = "not submittable"
+			awaiting = append(awaiting, task.PrivateChangeURL(b.PrivateGerritProject, cp.ChangeNumber))
+		}
+	}
+	ctx.Printf("awaiting submission of CL(s): %s", strings.Join(awaiting, ", "))
+
+	var (
+		done      = make(chan struct{})
+		ch        = make(chan string)
+		remaining atomic.Int64
+	)
+	go func() {
+		defer close(done)
+		tick := time.NewTicker(30 * time.Minute)
+		defer tick.Stop()
+		for {
+			select {
+			case u, ok := <-ch:
+				if !ok {
+					return
+				}
+				ctx.Printf("%s", u)
+			case <-tick.C:
+				ctx.Printf("Waiting on %d changes...", remaining.Load())
+			}
+		}
+	}()
+	remaining.Store(int64(len(awaiting)))
+
+	observe := func(ci *gerrit.ChangeInfo, state string) {
+		if seen[ci.ID] == state {
+			return
+		}
+		seen[ci.ID] = state
+		ch <- fmt.Sprintf("CL %s: %s", task.PrivateChangeURL(b.PrivateGerritProject, ci.ChangeNumber), state)
+	}
+
+	_, err := task.AwaitCondition(ctx, time.Second*10, func() (string, bool, error) {
 		for i, cp := range cherryPicks {
 			if cp.Status == gerrit.ChangeStatusMerged {
-				unsubmitted--
 				continue
 			}
 			ci, err := b.PrivateGerritClient.GetChange(ctx, cp.ID, gerrit.QueryChangesOpt{Fields: []string{"SUBMITTABLE"}})
 			if err != nil {
 				return "", false, err
 			}
-			url := task.PrivateChangeURL(b.PrivateGerritProject, ci.ChangeNumber)
-			wip := ci.WorkInProgress
-			if ci.Submittable && wip {
+			if !ci.Submittable {
+				observe(ci, "not submittable")
+				continue
+			}
+			if ci.WorkInProgress {
 				var httpErr *gerrit.HTTPError
 				switch err := b.PrivateGerritClient.MarkReady(ctx, ci.ID); {
 				case err == nil:
-					wip = false
 				case errors.As(err, &httpErr) && httpErr.Res.StatusCode == http.StatusForbidden:
-					ctx.Printf("CL %s must be marked ready by its patch owner: %v", url, err)
+					observe(ci, fmt.Sprintf("must be marked ready by its patch owner: %v", err))
+					continue
 				default:
 					return "", false, err
 				}
 			}
-			if !ci.Submittable || wip {
-				blocking = append(blocking, url)
-				continue
-			}
 			submitted, err := b.PrivateGerritClient.SubmitChange(ctx, ci.ID)
 			if err != nil {
 				return "", false, err
 			}
 			cherryPicks[i] = &submitted
-			unsubmitted--
+			remaining.Add(-1)
+			observe(ci, "submitted")
 		}
-		if unsubmitted == 0 {
-			return "", true, nil
-		}
-		ctx.Printf("awaiting non-submittable CL(s): %s", strings.Join(blocking, ", "))
-		return "", false, nil
-	}); err != nil {
+		return "", remaining.Load() == 0, nil
+	})
+	close(ch)
+	<-done
+	if err != nil {
 		return nil, err
 	}
+
 	submitted := map[string][]string{}
 	for _, cp := range cherryPicks {
 		submitted[cp.Branch] = append(submitted[cp.Branch], task.PrivateChangeURL(b.PrivateGerritProject, cp.ChangeNumber))