// Package userlifecycle implements the cascade worker that reacts to // `user.lifecycle.permanent_blocked` and `user.lifecycle.deleted` events // from the User Service stream. The worker registers itself as a handler // on a ports.UserLifecycleConsumer (typically the Redis adapter) and // settles every Lobby artefact tied to the affected user: // // 1. Race Name Directory: release every registered, reservation, and // pending_registration binding via RND.ReleaseAllByUser. // 2. Memberships: every active membership transitions to `blocked`. For // each affected private game, publish a `lobby.membership.blocked` // intent to the owner. // 3. Applications: every `submitted` application transitions to // `rejected`. // 4. Invites: every `created` invite where the user is invitee or // inviter transitions to `revoked`. // 5. Owned games: every owner-side game (status != cancelled/finished) // transitions to `cancelled` via the `external_block` trigger. For // in-flight games (`starting`, `running`, `paused`), publish a // stop-job to Runtime Manager before the status transition. // // All store mutations are CAS-protected; replays detect the post-state // via *.ErrConflict / *.ErrInvalidTransition and short-circuit without // raising errors. Any non-conflict error returns to the consumer so the // stream offset is held and the next iteration retries. package userlifecycle import ( "context" "errors" "fmt" "log/slog" "time" "galaxy/lobby/internal/domain/application" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/invite" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/logging" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/telemetry" "galaxy/notificationintent" ) // inflightGameStatuses lists the statuses for which a stop-job to // Runtime Manager must be published before the cascade transitions the // game to `cancelled`. var inflightGameStatuses = map[game.Status]struct{}{ game.StatusStarting: {}, game.StatusRunning: {}, game.StatusPaused: {}, } // Dependencies groups the collaborators consumed by Worker. type Dependencies struct { // Directory exposes the Race Name Directory cascade entry point. Directory ports.RaceNameDirectory // Memberships persists the active → blocked transition for every // membership held by the affected user. Memberships ports.MembershipStore // Applications persists the submitted → rejected transition for every // application authored by the affected user. Applications ports.ApplicationStore // Invites persists the created → revoked transition for every invite // the affected user is invitee or inviter on. Invites ports.InviteStore // Games owns the cascade-cancel transition for games owned by the // affected user. Games ports.GameStore // RuntimeManager publishes stop-jobs for in-flight cancelled games. RuntimeManager ports.RuntimeManager // Intents publishes `lobby.membership.blocked` notifications to // private-game owners whose roster lost the affected member. Intents ports.IntentPublisher // Clock supplies the wall-clock used for status transition // timestamps. Defaults to time.Now when nil. Clock func() time.Time // Logger receives structured worker-level events. Defaults to // slog.Default when nil. Logger *slog.Logger // Telemetry records the // `lobby.user_lifecycle.cascade_releases`, // `lobby.membership.changes`, and `lobby.game.transitions` // counters per processed event. Optional; nil disables metric // emission. Telemetry *telemetry.Runtime } // Worker executes the cascade triggered by one user-lifecycle event. type Worker struct { directory ports.RaceNameDirectory memberships ports.MembershipStore applications ports.ApplicationStore invites ports.InviteStore games ports.GameStore runtimeManager ports.RuntimeManager intents ports.IntentPublisher clock func() time.Time logger *slog.Logger telemetry *telemetry.Runtime } // NewWorker constructs one Worker from deps. func NewWorker(deps Dependencies) (*Worker, error) { switch { case deps.Directory == nil: return nil, errors.New("new user lifecycle worker: nil race name directory") case deps.Memberships == nil: return nil, errors.New("new user lifecycle worker: nil membership store") case deps.Applications == nil: return nil, errors.New("new user lifecycle worker: nil application store") case deps.Invites == nil: return nil, errors.New("new user lifecycle worker: nil invite store") case deps.Games == nil: return nil, errors.New("new user lifecycle worker: nil game store") case deps.RuntimeManager == nil: return nil, errors.New("new user lifecycle worker: nil runtime manager") case deps.Intents == nil: return nil, errors.New("new user lifecycle worker: nil intent publisher") } clock := deps.Clock if clock == nil { clock = time.Now } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Worker{ directory: deps.Directory, memberships: deps.Memberships, applications: deps.Applications, invites: deps.Invites, games: deps.Games, runtimeManager: deps.RuntimeManager, intents: deps.Intents, clock: clock, logger: logger.With("worker", "lobby.userlifecycle"), telemetry: deps.Telemetry, }, nil } // Handle processes one decoded lifecycle event and runs the full // cascade. The function returns nil when every per-entity step either // succeeded or was absorbed as an idempotent replay; non-conflict errors // abort processing and bubble up so the consumer can retry the entry. func (worker *Worker) Handle(ctx context.Context, event ports.UserLifecycleEvent) error { if worker == nil { return errors.New("user lifecycle handle: nil worker") } if ctx == nil { return errors.New("user lifecycle handle: nil context") } if err := event.Validate(); err != nil { // Decode-level guard so an obviously malformed event is rejected // at the boundary rather than wandering through the cascade. worker.logger.WarnContext(ctx, "drop invalid user lifecycle event", "stream_entry_id", event.EntryID, "err", err.Error(), ) return nil } reason := reasonForEvent(event.EventType) now := worker.clock().UTC() startArgs := []any{ "stream_entry_id", event.EntryID, "lifecycle_event", string(event.EventType), "user_id", event.UserID, } startArgs = append(startArgs, logging.ContextAttrs(ctx)...) worker.logger.InfoContext(ctx, "user lifecycle cascade starting", startArgs...) worker.telemetry.RecordUserLifecycleCascadeRelease(ctx, string(event.EventType)) if err := worker.directory.ReleaseAllByUser(ctx, event.UserID); err != nil { return fmt.Errorf("user lifecycle handle: release race names: %w", err) } memberCount, err := worker.cascadeMemberships(ctx, event, reason, now) if err != nil { return err } applicationCount, err := worker.cascadeApplications(ctx, event.UserID, now) if err != nil { return err } inviteCount, err := worker.cascadeInvites(ctx, event.UserID, now) if err != nil { return err } gameCount, err := worker.cascadeOwnedGames(ctx, event.UserID, now) if err != nil { return err } completedArgs := []any{ "stream_entry_id", event.EntryID, "lifecycle_event", string(event.EventType), "user_id", event.UserID, "memberships_blocked", memberCount, "applications_rejected", applicationCount, "invites_revoked", inviteCount, "games_cancelled", gameCount, } completedArgs = append(completedArgs, logging.ContextAttrs(ctx)...) worker.logger.InfoContext(ctx, "user lifecycle cascade completed", completedArgs...) return nil } func (worker *Worker) cascadeMemberships( ctx context.Context, event ports.UserLifecycleEvent, reason string, now time.Time, ) (int, error) { records, err := worker.memberships.GetByUser(ctx, event.UserID) if err != nil { return 0, fmt.Errorf("user lifecycle handle: load memberships: %w", err) } blocked := 0 for _, record := range records { if record.Status != membership.StatusActive { continue } updateErr := worker.memberships.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{ MembershipID: record.MembershipID, ExpectedFrom: membership.StatusActive, To: membership.StatusBlocked, At: now, }) switch { case updateErr == nil: blocked++ worker.telemetry.RecordMembershipChange(ctx, "external_block") worker.publishMembershipBlocked(ctx, event, record, reason, now) case errors.Is(updateErr, membership.ErrConflict), errors.Is(updateErr, membership.ErrInvalidTransition), errors.Is(updateErr, membership.ErrNotFound): worker.logger.InfoContext(ctx, "membership cascade absorbed", "membership_id", record.MembershipID.String(), "user_id", record.UserID, "err", updateErr.Error(), ) default: return blocked, fmt.Errorf("user lifecycle handle: block membership %s: %w", record.MembershipID, updateErr) } } return blocked, nil } func (worker *Worker) publishMembershipBlocked( ctx context.Context, event ports.UserLifecycleEvent, record membership.Membership, reason string, now time.Time, ) { gameRecord, err := worker.games.Get(ctx, record.GameID) if err != nil { worker.logger.WarnContext(ctx, "load game for membership.blocked intent", "membership_id", record.MembershipID.String(), "game_id", record.GameID.String(), "err", err.Error(), ) return } // Intent target is the private-game owner. Public games and self-owned // memberships do not produce a notification. if gameRecord.GameType != game.GameTypePrivate { return } if gameRecord.OwnerUserID == "" || gameRecord.OwnerUserID == record.UserID { return } intent, err := notificationintent.NewLobbyMembershipBlockedIntent( notificationintent.Metadata{ IdempotencyKey: "lobby.membership.blocked:" + record.MembershipID.String() + ":" + event.EntryID, OccurredAt: now, TraceID: event.TraceID, }, gameRecord.OwnerUserID, notificationintent.LobbyMembershipBlockedPayload{ GameID: gameRecord.GameID.String(), GameName: gameRecord.GameName, MembershipUserID: record.UserID, MembershipUserName: record.RaceName, Reason: reason, }, ) if err != nil { worker.logger.WarnContext(ctx, "build membership.blocked intent", "membership_id", record.MembershipID.String(), "err", err.Error(), ) return } if _, err := worker.intents.Publish(ctx, intent); err != nil { worker.logger.WarnContext(ctx, "publish membership.blocked intent", "membership_id", record.MembershipID.String(), "owner_user_id", gameRecord.OwnerUserID, "err", err.Error(), ) } } func (worker *Worker) cascadeApplications( ctx context.Context, userID string, now time.Time, ) (int, error) { records, err := worker.applications.GetByUser(ctx, userID) if err != nil { return 0, fmt.Errorf("user lifecycle handle: load applications: %w", err) } rejected := 0 for _, record := range records { if record.Status != application.StatusSubmitted { continue } updateErr := worker.applications.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{ ApplicationID: record.ApplicationID, ExpectedFrom: application.StatusSubmitted, To: application.StatusRejected, At: now, }) switch { case updateErr == nil: rejected++ case errors.Is(updateErr, application.ErrConflict), errors.Is(updateErr, application.ErrInvalidTransition), errors.Is(updateErr, application.ErrNotFound): worker.logger.InfoContext(ctx, "application cascade absorbed", "application_id", record.ApplicationID.String(), "err", updateErr.Error(), ) default: return rejected, fmt.Errorf("user lifecycle handle: reject application %s: %w", record.ApplicationID, updateErr) } } return rejected, nil } func (worker *Worker) cascadeInvites( ctx context.Context, userID string, now time.Time, ) (int, error) { addressed, err := worker.invites.GetByUser(ctx, userID) if err != nil { return 0, fmt.Errorf("user lifecycle handle: load invitee invites: %w", err) } owned, err := worker.invites.GetByInviter(ctx, userID) if err != nil { return 0, fmt.Errorf("user lifecycle handle: load inviter invites: %w", err) } visited := make(map[common.InviteID]struct{}, len(addressed)+len(owned)) revoked := 0 for _, record := range append(append([]invite.Invite(nil), addressed...), owned...) { if _, seen := visited[record.InviteID]; seen { continue } visited[record.InviteID] = struct{}{} if record.Status != invite.StatusCreated { continue } updateErr := worker.invites.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: record.InviteID, ExpectedFrom: invite.StatusCreated, To: invite.StatusRevoked, At: now, }) switch { case updateErr == nil: revoked++ case errors.Is(updateErr, invite.ErrConflict), errors.Is(updateErr, invite.ErrInvalidTransition), errors.Is(updateErr, invite.ErrNotFound): worker.logger.InfoContext(ctx, "invite cascade absorbed", "invite_id", record.InviteID.String(), "err", updateErr.Error(), ) default: return revoked, fmt.Errorf("user lifecycle handle: revoke invite %s: %w", record.InviteID, updateErr) } } return revoked, nil } func (worker *Worker) cascadeOwnedGames( ctx context.Context, userID string, now time.Time, ) (int, error) { records, err := worker.games.GetByOwner(ctx, userID) if err != nil { return 0, fmt.Errorf("user lifecycle handle: load owned games: %w", err) } cancelled := 0 for _, record := range records { if record.Status.IsTerminal() { continue } if _, inflight := inflightGameStatuses[record.Status]; inflight { if err := worker.runtimeManager.PublishStopJob(ctx, record.GameID.String()); err != nil { return cancelled, fmt.Errorf("user lifecycle handle: publish stop job for %s: %w", record.GameID, err) } } updateErr := worker.games.UpdateStatus(ctx, ports.UpdateStatusInput{ GameID: record.GameID, ExpectedFrom: record.Status, To: game.StatusCancelled, Trigger: game.TriggerExternalBlock, At: now, }) switch { case updateErr == nil: cancelled++ worker.telemetry.RecordGameTransition(ctx, string(record.Status), string(game.StatusCancelled), string(game.TriggerExternalBlock), ) case errors.Is(updateErr, game.ErrConflict), errors.Is(updateErr, game.ErrInvalidTransition), errors.Is(updateErr, game.ErrNotFound): worker.logger.InfoContext(ctx, "game cascade absorbed", "game_id", record.GameID.String(), "current_status", string(record.Status), "err", updateErr.Error(), ) default: return cancelled, fmt.Errorf("user lifecycle handle: cancel game %s: %w", record.GameID, updateErr) } } return cancelled, nil } func reasonForEvent(eventType ports.UserLifecycleEventType) string { switch eventType { case ports.UserLifecycleEventTypePermanentBlocked: return "permanent_blocked" case ports.UserLifecycleEventTypeDeleted: return "deleted" default: return string(eventType) } }