package streamgate import ( "context" "errors" "fmt" "sync" ) // ErrBoundaryUncommitted is returned when an operation requires the // boundary to be in transport_uncommitted state but it is not. var ErrBoundaryUncommitted = errors.New("streamgate: commit boundary not in uncommitted state") // ErrBoundaryAlreadyTerminal is returned when a terminal has already been // committed. var ErrBoundaryAlreadyTerminal = errors.New("streamgate: commit boundary already terminal") // ErrBoundaryNoStagedStart is returned when a release is attempted without // a staged response start. var ErrBoundaryNoStagedStart = errors.New("streamgate: no staged response start") // ErrBoundaryDuplicateAttempt is returned when BeginAttempt is called for an // attempt already in progress. var ErrBoundaryDuplicateAttempt = errors.New("streamgate: duplicate attempt id") // ErrBoundaryInvalidAttempt is returned when the given attempt ID does not // match the current active attempt. var ErrBoundaryInvalidAttempt = errors.New("streamgate: attempt id mismatch") // ErrBoundaryNoCurrentAttempt is returned when no attempt has been begun. var ErrBoundaryNoCurrentAttempt = errors.New("streamgate: no current attempt") // ErrBoundaryReplaced is returned when an operation is attempted with an // old attempt ID after the boundary has been replaced. var ErrBoundaryReplaced = errors.New("streamgate: boundary replaced by newer attempt") // ErrBoundaryRecoveryNotPermitted is returned when the current state does // not allow the requested recovery strategy. var ErrBoundaryRecoveryNotPermitted = errors.New("streamgate: recovery strategy not permitted for current state") // ErrBoundarySerializerConflict is returned when another operation on the boundary // is currently in progress. var ErrBoundarySerializerConflict = errors.New("streamgate: boundary operation in progress") // ErrInvalidStateTransition is returned when a sink returns an invalid state transition. var ErrInvalidStateTransition = errors.New("streamgate: invalid commit state transition") // ErrBoundaryInvalidCallbackOp is returned when the callback operation does // not match the reservation's expected operation type. var ErrBoundaryInvalidCallbackOp = errors.New("streamgate: callback operation does not match reservation") // ReleaseProgress records the number of events successfully released through // the boundary during a ReleaseSafe call. It is immutable to callers. type ReleaseProgress struct { releasedEvents int } // ReleasedEvents returns the count of events that the downstream sink // accepted during this release. func (p ReleaseProgress) ReleasedEvents() int { return p.releasedEvents } type boundaryOpType int const ( boundaryOpNone boundaryOpType = iota boundaryOpRelease boundaryOpTerminal ) type reservation struct { op boundaryOpType attemptID string generation uint64 } // CommitBoundary owns the request-scope state machine for committing release // payloads to the downstream sink. It enforces staged response-start, // monotonic state transitions, partial progress tracking, and exactly-once // terminal semantics. type CommitBoundary struct { mu sync.Mutex sink ReleaseSink attemptID string stagedStart *ResponseStart state CommitState confirmed []ReleaseEvent generation uint64 activeReservation *reservation } // NewCommitBoundary creates a new CommitBoundary bound to the given sink. // The boundary starts in transport_uncommitted state. func NewCommitBoundary(sink ReleaseSink) (*CommitBoundary, error) { if sink == nil { return nil, errors.New("streamgate: commit boundary sink is required") } return &CommitBoundary{ sink: sink, state: CommitStateTransportUncommitted, }, nil } func (b *CommitBoundary) reserveLocked(op boundaryOpType, attemptID string) (*reservation, error) { if b.activeReservation != nil { return nil, ErrBoundarySerializerConflict } b.generation++ res := &reservation{ op: op, attemptID: attemptID, generation: b.generation, } b.activeReservation = res return res, nil } func (b *CommitBoundary) releaseReservationLocked(res *reservation) { if b.activeReservation == res { b.activeReservation = nil } } func validateStateTransition(current, returned CommitState) error { if returned != "" { if err := returned.Validate(); err != nil { return fmt.Errorf("%w: %v", ErrInvalidStateTransition, err) } } if current == returned || returned == "" { return nil } switch current { case CommitStateTransportUncommitted: return nil case CommitStateStreamOpen: if returned == CommitStateTerminalCommitted { return nil } return fmt.Errorf("%w: cannot transition from stream_open to %v", ErrInvalidStateTransition, returned) case CommitStateTerminalCommitted: return fmt.Errorf("%w: cannot transition from terminal_committed to %v", ErrInvalidStateTransition, returned) default: return fmt.Errorf("%w: invalid current state %v", ErrInvalidStateTransition, current) } } func isStateMoreAdvanced(a, b CommitState) bool { if a == b { return false } if a == CommitStateTerminalCommitted { return true } if a == CommitStateStreamOpen && b == CommitStateTransportUncommitted { return true } return false } // applySinkOutcomeLockedWithOpLocked validates the sink outcome against the // reservation operation type. It preserves monotonic state transitions // independent of sink error or role mismatch, requires role-specific success // states (stream_open for response-start and release, terminal_committed for // terminal), and short-circuits on callback error for response-start and // release operations. func (b *CommitBoundary) applySinkOutcomeLockedWithOpLocked( res *reservation, op boundaryOpType, returnedState CommitState, sinkErr error, ) error { if b.activeReservation != res || res.generation != b.generation { return ErrBoundarySerializerConflict } // 1. Validate attempt identity and callback operation match the reservation. // This is defense-in-depth: public entry points (ReleaseSafe, CommitTerminal) // check attemptID before reserving, but the callback path must also reject // a mismatch in case b.attemptID is changed between reserve and callback. identityErr := errors.Join( validateAttemptIdentity(res.attemptID, b.attemptID), validateReservationCallbackOperation(res.op, op), ) // 2. Validate state transition (independent of sink error, role, and identity). transitionErr := validateStateTransition(b.state, returnedState) // 3. Apply valid forward state regardless of sink error, role error, or identity error. // This ensures that a callback returning a more advanced state (e.g. // terminal_committed) is trusted even when the role does not match, // a sink error is present, or the callback operation mismatches the reservation. if returnedState != "" && transitionErr == nil && isStateMoreAdvanced(returnedState, b.state) { b.state = returnedState } // 4. Clear staged start when state advances out of transport_uncommitted. if b.state == CommitStateStreamOpen || b.state == CommitStateTerminalCommitted { b.stagedStart = nil } // 5. Validate role-specific expected state. var roleErr error if returnedState != "" { switch op { case boundaryOpTerminal: if returnedState != CommitStateTerminalCommitted { roleErr = fmt.Errorf("%w: terminal callback returned %q, want %q", ErrInvalidStateTransition, returnedState, CommitStateTerminalCommitted) } case boundaryOpRelease, boundaryOpNone: if returnedState != CommitStateStreamOpen { roleErr = fmt.Errorf("%w: %s callback returned %q, want %q", ErrInvalidStateTransition, opLabel(op), returnedState, CommitStateStreamOpen) } } } // 6. Empty state on success is a transition error. if returnedState == "" && sinkErr == nil { transitionErr = fmt.Errorf("%w: empty commit state returned on success", ErrInvalidStateTransition) } // 7. Join all errors: sink, transition, identity, role. return errors.Join(sinkErr, transitionErr, identityErr, roleErr) } // opLabel returns a human-readable label for the operation type. func opLabel(op boundaryOpType) string { switch op { case boundaryOpRelease: return "release" case boundaryOpTerminal: return "terminal" default: return "response-start" } } // validateAttemptIdentity returns ErrBoundaryInvalidAttempt when the // reservation's attemptID does not match the boundary's current attemptID. // This is defense-in-depth for the callback path. func validateAttemptIdentity(resAttemptID, boundAttemptID string) error { if resAttemptID != boundAttemptID { return ErrBoundaryInvalidAttempt } return nil } // validateReservationCallbackOperation returns ErrBoundaryInvalidCallbackOp // when the callback operation is not permitted by the reservation's expected // operation type. A release reservation accepts response-start and release; // a terminal reservation accepts response-start (optional) and terminal. func validateReservationCallbackOperation(reservedOp, callbackOp boundaryOpType) error { switch reservedOp { case boundaryOpRelease: if callbackOp != boundaryOpNone && callbackOp != boundaryOpRelease { return fmt.Errorf("%w: reservation expects release, got %s", ErrBoundaryInvalidCallbackOp, opLabel(callbackOp)) } case boundaryOpTerminal: if callbackOp != boundaryOpNone && callbackOp != boundaryOpTerminal { return fmt.Errorf("%w: reservation expects terminal, got %s", ErrBoundaryInvalidCallbackOp, opLabel(callbackOp)) } } return nil } // BeginAttempt opens a new attempt identified by attemptID. func (b *CommitBoundary) BeginAttempt(attemptID string) error { if attemptID == "" { return errors.New("streamgate: begin attempt id is required") } b.mu.Lock() defer b.mu.Unlock() if b.activeReservation != nil { return ErrBoundarySerializerConflict } if b.state != CommitStateTransportUncommitted { return ErrBoundaryUncommitted } if b.attemptID != "" { return ErrBoundaryDuplicateAttempt } b.attemptID = attemptID b.stagedStart = nil b.confirmed = nil b.generation++ return nil } // StageResponseStart buffers a response start for the given attempt. func (b *CommitBoundary) StageResponseStart(attemptID string, start ResponseStart) error { if attemptID == "" { return errors.New("streamgate: stage response start attempt id is required") } b.mu.Lock() defer b.mu.Unlock() if b.activeReservation != nil { return ErrBoundarySerializerConflict } if b.state != CommitStateTransportUncommitted { return ErrBoundaryUncommitted } if b.attemptID == "" { return ErrBoundaryNoCurrentAttempt } if b.attemptID != attemptID { return ErrBoundaryInvalidAttempt } if b.stagedStart != nil { return errors.New("streamgate: response start already staged") } b.stagedStart = &start return nil } // ReleaseSafe commits the staged response start (if any) exactly once, then // releases each event in order. func (b *CommitBoundary) ReleaseSafe(ctx context.Context, attemptID string, events []ReleaseEvent) (ReleaseProgress, error) { if attemptID == "" { return ReleaseProgress{}, errors.New("streamgate: release safe attempt id is required") } if len(events) == 0 { return ReleaseProgress{}, errors.New("streamgate: release safe requires at least one event") } b.mu.Lock() if b.state == CommitStateTerminalCommitted { b.mu.Unlock() return ReleaseProgress{}, ErrBoundaryAlreadyTerminal } if b.attemptID == "" { b.mu.Unlock() return ReleaseProgress{}, ErrBoundaryNoCurrentAttempt } if b.attemptID != attemptID { b.mu.Unlock() return ReleaseProgress{}, ErrBoundaryInvalidAttempt } res, err := b.reserveLocked(boundaryOpRelease, attemptID) if err != nil { b.mu.Unlock() return ReleaseProgress{}, err } defer func() { b.mu.Lock() b.releaseReservationLocked(res) b.mu.Unlock() }() var rsToCommit *ResponseStart if b.stagedStart != nil { rs := *b.stagedStart rsToCommit = &rs } eventsCopy := make([]ReleaseEvent, len(events)) copy(eventsCopy, events) b.mu.Unlock() var firstErr error if rsToCommit != nil { st, sErr := b.sink.CommitResponseStart(ctx, *rsToCommit) b.mu.Lock() applyErr := b.applySinkOutcomeLockedWithOpLocked(res, boundaryOpNone, st, sErr) b.mu.Unlock() if applyErr != nil { firstErr = applyErr // Short-circuit: response-start callback error prevents proceeding // to release events. State is already applied by applySinkOutcomeLocked. return ReleaseProgress{}, firstErr } } if firstErr != nil && b.State() == CommitStateTransportUncommitted { return ReleaseProgress{}, firstErr } var releaseDone int for _, ev := range eventsCopy { st, sErr := b.sink.Release(ctx, ev) b.mu.Lock() applyErr := b.applySinkOutcomeLockedWithOpLocked(res, boundaryOpRelease, st, sErr) if applyErr == nil { b.confirmed = append(b.confirmed, ev) releaseDone++ } else if firstErr == nil { firstErr = applyErr } b.mu.Unlock() if sErr != nil || applyErr != nil { break } } return ReleaseProgress{releasedEvents: releaseDone}, firstErr } // CommitTerminal commits the terminal result. func (b *CommitBoundary) CommitTerminal(ctx context.Context, attemptID string, result TerminalResult) error { if attemptID == "" { return errors.New("streamgate: commit terminal attempt id is required") } b.mu.Lock() if b.state == CommitStateTerminalCommitted { b.mu.Unlock() return ErrBoundaryAlreadyTerminal } if b.attemptID == "" { b.mu.Unlock() return ErrBoundaryNoCurrentAttempt } if b.attemptID != attemptID { b.mu.Unlock() return ErrBoundaryInvalidAttempt } res, err := b.reserveLocked(boundaryOpTerminal, attemptID) if err != nil { b.mu.Unlock() return err } defer func() { b.mu.Lock() b.releaseReservationLocked(res) b.mu.Unlock() }() isError := result.Error() var rsCopy *ResponseStart if b.stagedStart != nil && !isError { cp := *b.stagedStart rsCopy = &cp } b.mu.Unlock() var firstErr error if rsCopy != nil { st, sErr := b.sink.CommitResponseStart(ctx, *rsCopy) b.mu.Lock() applyErr := b.applySinkOutcomeLockedWithOpLocked(res, boundaryOpNone, st, sErr) b.mu.Unlock() if applyErr != nil { firstErr = applyErr // Short-circuit: response-start callback error prevents proceeding // to terminal commit. State is already applied. return firstErr } } st, sErr := b.sink.CommitTerminal(ctx, result) b.mu.Lock() termErr := b.applySinkOutcomeLockedWithOpLocked(res, boundaryOpTerminal, st, sErr) b.mu.Unlock() if termErr != nil && firstErr == nil { firstErr = termErr } else if termErr != nil { firstErr = errors.Join(firstErr, termErr) } return firstErr } // ReplaceUncommittedAttempt discards the current attempt and its staged start. func (b *CommitBoundary) ReplaceUncommittedAttempt(oldAttemptID, newAttemptID string) error { if newAttemptID == "" { return errors.New("streamgate: replace attempt new id is required") } b.mu.Lock() defer b.mu.Unlock() if b.activeReservation != nil { return ErrBoundarySerializerConflict } if b.state == CommitStateTerminalCommitted { return ErrBoundaryAlreadyTerminal } if b.state != CommitStateTransportUncommitted { return ErrBoundaryUncommitted } if b.attemptID == "" { return ErrBoundaryNoCurrentAttempt } if oldAttemptID == "" || b.attemptID != oldAttemptID { return ErrBoundaryInvalidAttempt } b.attemptID = newAttemptID b.stagedStart = nil b.confirmed = nil b.generation++ return nil } // CommitAllowsStrategy returns true when the current state allows the given recovery strategy. func (b *CommitBoundary) CommitAllowsStrategy(strategy RecoveryStrategy) bool { b.mu.Lock() defer b.mu.Unlock() switch strategy { case RecoveryStrategyExactReplay, RecoveryStrategySchemaRepair: return b.state == CommitStateTransportUncommitted case RecoveryStrategyContinuationRepair: return b.state == CommitStateStreamOpen default: return false } } // State returns the current commit state. func (b *CommitBoundary) State() CommitState { b.mu.Lock() defer b.mu.Unlock() return b.state } // CurrentAttempt returns the current attempt ID, or "" if none. func (b *CommitBoundary) CurrentAttempt() string { b.mu.Lock() defer b.mu.Unlock() return b.attemptID }