Files

149 lines
4.9 KiB
Go

package scimsync
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/italypaleale/francis/actor"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// The ScimSync singleton actor decides when the recurring and debounced SCIM synchronizations run
// ActorType is the actor type for the SCIM sync actor
const ActorType = "ScimSync"
const (
// alarmRecurringSync runs the cluster-wide hourly synchronization
alarmRecurringSync = "recurring-sync"
// alarmScheduledSync runs the cluster-wide synchronization requested after a local change
alarmScheduledSync = "scheduled-sync"
// methodScheduleSync moves the debounced synchronization five minutes past the latest change
methodScheduleSync = "schedule-sync"
// recurringSyncInterval is how often the full synchronization runs, as the ISO8601 duration the alarm repetition expects
recurringSyncInterval = "PT1H"
// initialSyncDelay gives the application a moment to finish starting before the first synchronization
initialSyncDelay = 5 * time.Second
// scheduledSyncDelay debounces changes so several related writes produce one synchronization
scheduledSyncDelay = 5 * time.Minute
// alarmTimeout bounds the alarm operations performed by the actor
alarmTimeout = 10 * time.Second
)
type syncer interface {
SyncAll(ctx context.Context) error
}
// syncActor is the cluster-wide singleton that triggers SCIM synchronization
type syncActor struct {
log *slog.Logger
syncer syncer
scheduleDisabled bool
client actor.Client[struct{}]
}
// NewActor returns the factory that allocates the SCIM sync actor
func NewActor(service *Service, scheduleDisabled bool) actor.Factory {
return newActor(service, scheduleDisabled)
}
func newActor(syncer syncer, scheduleDisabled bool) actor.Factory {
return func(actorID string, actorService *actor.Service) actor.Actor {
return &syncActor{
log: slog.With(
slog.String("scope", "actor"),
slog.String("actorType", ActorType),
),
syncer: syncer,
scheduleDisabled: scheduleDisabled,
client: actor.NewActorClient[struct{}](ActorType, actorID, actorService),
}
}
}
// Bootstrap implements actor.ActorBootstrapper
// The host drives it on every startup, routed to the single owning host, so it must stay idempotent
func (a *syncActor) Bootstrap(parentCtx context.Context, _ actor.Envelope) error {
ctx, cancel := context.WithTimeout(parentCtx, alarmTimeout)
defer cancel()
// The test environment drives synchronization explicitly and must not inherit alarms from a previous run
if a.scheduleDisabled {
for _, name := range []string{alarmRecurringSync, alarmScheduledSync} {
err := a.client.DeleteAlarm(ctx, name)
if err != nil && !errors.Is(err, actor.ErrAlarmNotFound) {
return fmt.Errorf("error deleting the SCIM sync alarm %q: %w", name, err)
}
}
return nil
}
// Replacing the alarm restores a missing schedule and applies interval changes after an upgrade
err := a.client.SetAlarm(ctx, alarmRecurringSync, actor.AlarmProperties{
DueTime: time.Now().Add(initialSyncDelay),
Interval: recurringSyncInterval,
})
if err != nil {
return fmt.Errorf("error setting the recurring SCIM sync alarm: %w", err)
}
a.log.DebugContext(parentCtx, "Registered the recurring SCIM sync alarm", slog.String("interval", recurringSyncInterval))
return nil
}
// Invoke implements actor.ActorInvoke
func (a *syncActor) Invoke(ctx context.Context, method string, _ actor.Envelope) (any, error) {
if method != methodScheduleSync {
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
if a.scheduleDisabled {
return nil, nil
}
// Setting the same alarm replaces its due time, which debounces changes across every replica
err := a.client.SetAlarm(ctx, alarmScheduledSync, actor.AlarmProperties{
DueTime: time.Now().Add(scheduledSyncDelay),
})
if err != nil {
return nil, fmt.Errorf("error setting the scheduled SCIM sync alarm: %w", err)
}
return nil, nil
}
// Alarm implements actor.ActorAlarm
func (a *syncActor) Alarm(ctx context.Context, name string, _ actor.Envelope) error {
if name != alarmRecurringSync && name != alarmScheduledSync {
return fmt.Errorf("unsupported alarm '%s' for the %s actor", name, ActorType)
}
a.sync(ctx)
// A failed recurring sync must not surface as an error because exhausted alarm retries would remove the recurring schedule
// The sync method shows a log in case of failure
return nil
}
// sync runs one full SCIM synchronization and records its outcome
func (a *syncActor) sync(ctx context.Context) {
a.log.InfoContext(ctx, "Starting the SCIM sync")
start := time.Now()
err := a.syncer.SyncAll(ctx)
if err != nil {
a.log.ErrorContext(ctx, "SCIM sync failed, will try again on the next run", slog.Duration("duration", time.Since(start)), slog.Any("error", err))
return
}
a.log.InfoContext(ctx, "SCIM sync completed", slog.Duration("duration", time.Since(start)))
}