Files
member-console/internal/workflows/schedule_dormancy.go
T
cgalo5758 30b8250cf5 Pause integration schedules when disabled
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.
2026-07-23 01:38:45 -05:00

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)
}