mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-20 11:56:23 +00:00
149 lines
4.9 KiB
Go
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)))
|
|
}
|