internal/relui: add delete schedules button
Add a button to permanently delete a workflow schedule.
For golang/go#54476
Change-Id: I14d5ba66e7ce609dfea97af9ecd2aa6173d03524
Reviewed-on: https://go-review.googlesource.com/c/build/+/432405
Run-TryBot: Jenny Rakoczy <jenny@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
Auto-Submit: Jenny Rakoczy <jenny@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/internal/relui/db/workflows.sql.go b/internal/relui/db/workflows.sql.go
index 93033b2..bc6c26a 100644
--- a/internal/relui/db/workflows.sql.go
+++ b/internal/relui/db/workflows.sql.go
@@ -47,6 +47,33 @@
return i, err
}
+const clearWorkflowSchedule = `-- name: ClearWorkflowSchedule :many
+UPDATE workflows
+SET schedule_id = NULL
+WHERE schedule_id = $1::int
+RETURNING id
+`
+
+func (q *Queries) ClearWorkflowSchedule(ctx context.Context, dollar_1 int32) ([]uuid.UUID, error) {
+ rows, err := q.db.Query(ctx, clearWorkflowSchedule, dollar_1)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []uuid.UUID
+ for rows.Next() {
+ var id uuid.UUID
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ items = append(items, id)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const createSchedule = `-- name: CreateSchedule :one
INSERT INTO schedules (workflow_name, workflow_params, spec, once, interval_minutes, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
diff --git a/internal/relui/queries/workflows.sql b/internal/relui/queries/workflows.sql
index 320734b..0c97f8d 100644
--- a/internal/relui/queries/workflows.sql
+++ b/internal/relui/queries/workflows.sql
@@ -193,6 +193,12 @@
WHERE id = $1
RETURNING *;
+-- name: ClearWorkflowSchedule :many
+UPDATE workflows
+SET schedule_id = NULL
+WHERE schedule_id = $1::int
+RETURNING id;
+
-- name: SchedulesLastRun :many
WITH last_scheduled_run AS (
SELECT DISTINCT ON (schedule_id) schedule_id, id, created_at, workflows.error, finished
diff --git a/internal/relui/schedule.go b/internal/relui/schedule.go
index cf114c9..3dc449f 100644
--- a/internal/relui/schedule.go
+++ b/internal/relui/schedule.go
@@ -8,6 +8,7 @@
"context"
"database/sql"
"encoding/json"
+ "errors"
"fmt"
"log"
"strings"
@@ -223,6 +224,33 @@
return ret
}
+var ErrScheduleNotFound = errors.New("schedule not found")
+
+// Delete removes a schedule from the scheduler, preventing subsequent
+// runs, and deletes the schedule from the database.
+//
+// Jobs in progress are not interrupted, but will be prevented from
+// starting again.
+func (s *Scheduler) Delete(ctx context.Context, id int) error {
+ entries := s.Entries()
+ i := slices.IndexFunc(entries, func(e ScheduleEntry) bool { return int(e.WorkflowJob().Schedule.ID) == id })
+ if i == -1 {
+ return ErrScheduleNotFound
+ }
+ entry := entries[i]
+ s.cron.Remove(entry.ID)
+ return s.db.BeginFunc(ctx, func(tx pgx.Tx) error {
+ q := db.New(tx)
+ if _, err := q.ClearWorkflowSchedule(ctx, int32(id)); err != nil {
+ return err
+ }
+ if _, err := q.DeleteSchedule(ctx, int32(id)); err != nil {
+ return err
+ }
+ return nil
+ })
+}
+
type ScheduleEntry struct {
cron.Entry
LastRun db.SchedulesLastRunRow
diff --git a/internal/relui/schedule_test.go b/internal/relui/schedule_test.go
index 6afc697..e6695ad 100644
--- a/internal/relui/schedule_test.go
+++ b/internal/relui/schedule_test.go
@@ -267,3 +267,81 @@
})
}
}
+
+func TestScheduleDelete(t *testing.T) {
+ now := time.Now()
+ cases := []struct {
+ desc string
+ sched Schedule
+ workflowName string
+ params map[string]any
+ wantErr bool
+ wantEntries []ScheduleEntry
+ want []db.Schedule
+ wantWorkflows []db.Workflow
+ }{
+ {
+ desc: "success",
+ sched: Schedule{Once: now.AddDate(1, 0, 0), Type: ScheduleOnce},
+ workflowName: "echo",
+ params: map[string]any{"greeting": "hello", "farewell": "bye"},
+ wantEntries: []ScheduleEntry{},
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.desc, func(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ p := testDB(ctx, t)
+ q := db.New(p)
+ s := NewScheduler(p, NewWorker(NewDefinitionHolder(), p, &PGListener{p}))
+ row, err := s.Create(ctx, c.sched, c.workflowName, c.params)
+ if err != nil {
+ t.Fatalf("s.Create(_, %v, %q, %v) = %v, %v, wanted no error", c.sched, c.workflowName, c.params, row, err)
+ }
+ // simulate a single run
+ wfid, err := s.w.StartWorkflow(ctx, c.workflowName, c.params, int(row.ID))
+ if err != nil {
+ t.Fatalf("s.w.StartWorkflow(_, %q, %v, %d) = %q, %v, wanted no error", c.workflowName, c.params, row.ID, wfid.String(), err)
+ }
+
+ err = s.Delete(ctx, int(row.ID))
+ if (err != nil) != c.wantErr {
+ t.Fatalf("s.Delete(%d) = %v, wantErr: %t", row.ID, err, c.wantErr)
+ }
+
+ entries := s.Entries()
+ diffOpts := []cmp.Option{
+ cmpopts.EquateApproxTime(time.Minute),
+ cmpopts.IgnoreFields(db.Schedule{}, "ID"),
+ cmpopts.IgnoreUnexported(RunOnce{}, WorkflowSchedule{}, time.Location{}),
+ cmpopts.IgnoreFields(ScheduleEntry{}, "ID", "LastRun.ID", "WrappedJob"),
+ }
+ if c.sched.Type == ScheduleCron {
+ diffOpts = append(diffOpts, cmpopts.IgnoreFields(ScheduleEntry{}, "Next"))
+ }
+ if diff := cmp.Diff(c.wantEntries, entries, diffOpts...); diff != "" {
+ t.Errorf("s.Entries() mismatch (-want +got):\n%s", diff)
+ }
+ got, err := q.Schedules(ctx)
+ if err != nil {
+ t.Fatalf("q.Schedules() = %v, %v, wanted no error", got, err)
+ }
+ if diff := cmp.Diff(c.want, got, diffOpts...); diff != "" {
+ t.Errorf("q.Schedules() mismatch (-want +got):\n%s", diff)
+ }
+ wfs, err := q.Workflows(ctx)
+ if err != nil {
+ t.Fatalf("q.Workflows() = %v, %v, wanted no error", wfs, err)
+ }
+ if len(wfs) != 1 {
+ t.Errorf("len(q.Workflows()) = %d, wanted %d", len(wfs), 1)
+ }
+ for _, w := range wfs {
+ if w.ScheduleID.Int32 == row.ID {
+ t.Errorf("w.ScheduleID = %d, wanted != %d", w.ScheduleID.Int32, row.ID)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/relui/static/styles.css b/internal/relui/static/styles.css
index f3ee6c9..f06d05b 100644
--- a/internal/relui/static/styles.css
+++ b/internal/relui/static/styles.css
@@ -484,6 +484,9 @@
text-overflow: ellipsis;
white-space: nowrap;
}
+.WorkflowList-itemActions {
+ width: 12.8125rem;
+}
.WorkflowList-itemStateHeader,
.WorkflowList-itemState {
width: 2.5rem;
diff --git a/internal/relui/templates/home.html b/internal/relui/templates/home.html
index 9ce514c..799960e 100644
--- a/internal/relui/templates/home.html
+++ b/internal/relui/templates/home.html
@@ -28,6 +28,7 @@
<th class="WorkflowList-itemHeaderCol WorkflowList-itemName">Name</th>
<th class="WorkflowList-itemHeaderCol WorkflowList-itemCreated">Next Run</th>
<th class="WorkflowList-itemHeaderCol WorkflowList-itemUpdated">Last Run</th>
+ <th class="WorkflowList-itemHeaderCol WorkflowList-itemActions">Actions</th>
</tr>
</thead>
<tbody>
@@ -78,6 +79,18 @@
{{$schedule.Prev.UTC.Format "Mon, 02 Jan 2006 15:04:05 MST"}}
{{end}}
</td>
+ <td class="WorkflowList-itemAction">
+ <div class="WorkflowList-deleteSchedule">
+ <form action="{{baseLink (printf "/schedules/%d/delete" $schedule.WorkflowJob.Schedule.ID)}}" method="post">
+ <input type="hidden" name="schedule.id" value="{{$schedule.WorkflowJob.Schedule.ID}}" />
+ <input class="Button Button--small"
+ name="schedule.delete"
+ type="submit"
+ value="Delete"
+ onclick="return this.form.reportValidity() && confirm('This will cancel and permanently delete the schedule.\n\nReady to proceed?')" />
+ </form>
+ </div>
+ </td>
</tr>
{{else}}
<tr>
diff --git a/internal/relui/web.go b/internal/relui/web.go
index d556efa..fcc9db1 100644
--- a/internal/relui/web.go
+++ b/internal/relui/web.go
@@ -18,6 +18,7 @@
"net/url"
"path"
"reflect"
+ "strconv"
"strings"
"time"
@@ -90,6 +91,7 @@
s.m.POST("/workflows/:id/stop", s.stopWorkflowHandler)
s.m.POST("/workflows/:id/tasks/:name/retry", s.retryTaskHandler)
s.m.POST("/workflows/:id/tasks/:name/approve", s.approveTaskHandler)
+ s.m.POST("/schedules/:id/delete", s.deleteScheduleHandler)
s.m.Handler(http.MethodGet, "/metrics", ms)
s.m.Handler(http.MethodGet, "/new_workflow", http.HandlerFunc(s.newWorkflowHandler))
s.m.Handler(http.MethodPost, "/workflows", http.HandlerFunc(s.createWorkflowHandler))
@@ -465,6 +467,26 @@
http.Redirect(w, r, s.BaseLink("/"), http.StatusSeeOther)
}
+func (s *Server) deleteScheduleHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
+ id, err := strconv.Atoi(params.ByName("id"))
+ if err != nil {
+ log.Printf("deleteScheduleHandler(_, _, %v) strconv.Atoi(%q) = %d, %v", params, params.ByName("id"), id, err)
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+ err = s.scheduler.Delete(r.Context(), id)
+ if err == ErrScheduleNotFound {
+ http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
+ return
+ } else if err != nil {
+ log.Printf("deleteScheduleHandler(_, _, %v) s.scheduler.Delete(_, %d) = %v", params, id, err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ http.Redirect(w, r, s.BaseLink("/"), http.StatusSeeOther)
+}
+
// resultDetail contains unmarshalled results from a workflow task, or
// workflow output. Only one field is expected to be populated.
//