Resume only app-paused schedules so operator pauses survive restarts. Apply dormancy handling to Discourse and FedWiki. Remove the unused pool-scoped grant route, hide internal transition errors, and update specs and tests to use the canonical grant endpoint.
101 lines
3.9 KiB
Go
101 lines
3.9 KiB
Go
package workflows
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"go.temporal.io/api/serviceerror"
|
|
"go.temporal.io/sdk/client"
|
|
)
|
|
|
|
// DormantPauseNote marks a schedule pause as made by an unconfigured boot.
|
|
// ResumeDormantSchedule resumes ONLY pauses carrying this exact note, so an
|
|
// operator's deliberate pause (any other note, including one the system
|
|
// overwrote) survives restarts. Changing this string strands schedules
|
|
// paused under the old wording — treat it as frozen.
|
|
const DormantPauseNote = "paused by member-console: integration not configured or disabled this boot; a configured boot resumes it automatically"
|
|
|
|
const dormantResumeNote = "resumed by member-console: integration configured"
|
|
|
|
// PauseLeftoverSchedule pauses scheduleID if it exists and is running.
|
|
// Integrations call this when they boot unconfigured/disabled: Temporal
|
|
// schedules are durable, so a schedule ensured by an earlier configured
|
|
// boot would otherwise keep firing workflows into a worker whose provider
|
|
// client has no configuration, failing every attempt at every interval,
|
|
// indefinitely. An absent schedule is a no-op; an already-paused schedule
|
|
// is left untouched whether the pause was ours or an operator's.
|
|
func PauseLeftoverSchedule(ctx context.Context, c client.Client, scheduleID string, logger *slog.Logger) error {
|
|
handle := c.ScheduleClient().GetHandle(ctx, scheduleID)
|
|
desc, err := handle.Describe(ctx)
|
|
if err != nil {
|
|
if isScheduleAbsent(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("describe schedule %s: %w", scheduleID, err)
|
|
}
|
|
if !shouldPauseLeftover(desc.Schedule.State) {
|
|
return nil
|
|
}
|
|
if err := handle.Pause(ctx, client.SchedulePauseOptions{Note: DormantPauseNote}); err != nil {
|
|
return fmt.Errorf("pause schedule %s: %w", scheduleID, err)
|
|
}
|
|
logger.Info("paused leftover schedule of unconfigured integration",
|
|
slog.String("scheduleID", scheduleID))
|
|
return nil
|
|
}
|
|
|
|
// ResumeDormantSchedule unpauses scheduleID only when its pause note is
|
|
// DormantPauseNote — a pause made by PauseLeftoverSchedule on an earlier
|
|
// unconfigured boot. Integrations call this after ensuring their schedule
|
|
// on a configured boot. A pause carrying any other note is treated as
|
|
// operator intent and left in place (logged, so a configured-but-silent
|
|
// integration is diagnosable).
|
|
func ResumeDormantSchedule(ctx context.Context, c client.Client, scheduleID string, logger *slog.Logger) error {
|
|
handle := c.ScheduleClient().GetHandle(ctx, scheduleID)
|
|
desc, err := handle.Describe(ctx)
|
|
if err != nil {
|
|
if isScheduleAbsent(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("describe schedule %s: %w", scheduleID, err)
|
|
}
|
|
state := desc.Schedule.State
|
|
if state == nil || !state.Paused {
|
|
return nil
|
|
}
|
|
if !shouldResumeDormant(state) {
|
|
logger.Info("schedule is paused with a non-dormancy note; leaving it paused",
|
|
slog.String("scheduleID", scheduleID),
|
|
slog.String("note", state.Note))
|
|
return nil
|
|
}
|
|
if err := handle.Unpause(ctx, client.ScheduleUnpauseOptions{Note: dormantResumeNote}); err != nil {
|
|
return fmt.Errorf("unpause schedule %s: %w", scheduleID, err)
|
|
}
|
|
logger.Info("resumed schedule paused by an earlier unconfigured boot",
|
|
slog.String("scheduleID", scheduleID))
|
|
return nil
|
|
}
|
|
|
|
// shouldPauseLeftover and shouldResumeDormant hold the decision logic as
|
|
// pure functions: the Schedule API has no testsuite fake (same constraint
|
|
// as the fedwiki ScheduleManager tests), so these are the reachable,
|
|
// meaningful units.
|
|
func shouldPauseLeftover(state *client.ScheduleState) bool {
|
|
return state == nil || !state.Paused
|
|
}
|
|
|
|
func shouldResumeDormant(state *client.ScheduleState) bool {
|
|
return state != nil && state.Paused && state.Note == DormantPauseNote
|
|
}
|
|
|
|
// isScheduleAbsent mirrors the NotFound-only classification the schedule
|
|
// managers use: only Temporal's *serviceerror.NotFound means "no schedule";
|
|
// anything else is surfaced rather than swallowed.
|
|
func isScheduleAbsent(err error) bool {
|
|
var nf *serviceerror.NotFound
|
|
return errors.As(err, &nf)
|
|
}
|