479 lines
15 KiB
Go
479 lines
15 KiB
Go
// 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(), ports.StopReasonCancelled); 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)
|
|
}
|
|
}
|