package streamgate import ( "context" "errors" "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") // 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 } // 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. // // A CommitBoundary is created via NewCommitBoundary. BeginAttempt opens a new // attempt from transport_uncommitted. StageResponseStart buffers a response // start that is emitted at most once during the first safe release. ReleaseSafe // commits events one at a time, tracking confirmed progress. CommitTerminal // commits the terminal result exactly once. // // All public methods are safe for concurrent use. The boundary holds a mutex // during input validation and state transitions; sink callback execution // happens outside the lock to avoid deadlocks on State() reads. type CommitBoundary struct { mu sync.Mutex sink ReleaseSink attemptID string stagedStart *ResponseStart state CommitState confirmed []ReleaseEvent } // 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 } // BeginAttempt opens a new attempt identified by attemptID. It requires // transport_uncommitted state and rejects if the boundary has been replaced // (i.e., an active attempt exists that was not properly closed). 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.state != CommitStateTransportUncommitted { return ErrBoundaryUncommitted } if b.attemptID != "" { return ErrBoundaryDuplicateAttempt } b.attemptID = attemptID b.stagedStart = nil b.confirmed = nil return nil } // StageResponseStart buffers a response start for the given attempt. It // requires transport_uncommitted state and rejects if already staged. 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.state != CommitStateTransportUncommitted { return ErrBoundaryUncommitted } 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. It returns the number of events successfully // released. Events that fail at the sink are excluded from progress but do // not regress state. 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() // Validate attempt ID matches current attempt. if b.attemptID != "" && b.attemptID != attemptID { b.mu.Unlock() return ReleaseProgress{}, ErrBoundaryInvalidAttempt } // Snapshot staged start and current state for release outside lock. var rsToCommit *ResponseStart if b.stagedStart != nil { rs := *b.stagedStart rsToCommit = &rs } // Snapshot events to release outside lock. eventsCopy := make([]ReleaseEvent, len(events)) copy(eventsCopy, events) // Capture current state before release. currentState := b.state b.mu.Unlock() // Commit staged response start outside lock if any. if rsToCommit != nil { state, err := b.sink.CommitResponseStart(ctx, *rsToCommit) if err != nil { return ReleaseProgress{}, err } currentState = state } // Release events one at a time, tracking confirmed progress. releaseDone := 0 var lastErr error for _, ev := range eventsCopy { state, err := b.sink.Release(ctx, ev) if err != nil { lastErr = err break } releaseDone++ currentState = state } b.mu.Lock() // Apply confirmed progress atomically. b.confirmed = append(b.confirmed, eventsCopy[:releaseDone]...) // If the first event was released (start or body), transition to stream_open. if releaseDone > 0 && b.state == CommitStateTransportUncommitted { b.state = CommitStateStreamOpen } // If any event transitioned to terminal, update state. if currentState == CommitStateTerminalCommitted { b.state = CommitStateTerminalCommitted } // Clear staged start since it was committed (or would have been). b.stagedStart = nil b.mu.Unlock() return ReleaseProgress{releasedEvents: releaseDone}, lastErr } // CommitTerminal commits the terminal result. It is exactly-once: only the // first call succeeds. It accepts both success and error terminals. // // For uncommitted error terminals, the staged start is discarded. // For success terminals, the start is committed first if pending, then the // terminal is committed. 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 } // Determine if this is an error terminal that should discard staged start. isError := result.Error() // Validate attempt ID matches. if b.attemptID != "" && b.attemptID != attemptID { b.mu.Unlock() return ErrBoundaryInvalidAttempt } // Snapshot data for execution outside lock. rsToCommit := b.stagedStart var rsCopy *ResponseStart if rsToCommit != nil && !isError { cp := *rsToCommit rsCopy = &cp } // Clear the attempt and staged start for error terminals immediately. if isError { b.stagedStart = nil b.attemptID = "" } b.mu.Unlock() // Commit staged start outside lock if not an error terminal. if rsCopy != nil { if _, err := b.sink.CommitResponseStart(ctx, *rsCopy); err != nil { return err } } // Commit terminal outside lock. state, err := b.sink.CommitTerminal(ctx, result) if err != nil { return err } b.mu.Lock() b.state = state if !isError { b.stagedStart = nil b.attemptID = "" } b.mu.Unlock() return nil } // ReplaceUncommittedAttempt discards the current attempt and its staged start, // allowing a new attempt to be opened with newAttemptID via BeginAttempt. // It requires transport_uncommitted or stream_open state with no terminal committed. 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.state == CommitStateTerminalCommitted { return ErrBoundaryAlreadyTerminal } if oldAttemptID != "" && b.attemptID != oldAttemptID { return ErrBoundaryInvalidAttempt } b.attemptID = "" b.stagedStart = nil b.confirmed = nil return nil } // CommitAllowsStrategy returns true when the current state allows the given // recovery strategy. The matrix is: // // transport_uncommitted: exact_replay, schema_repair allowed // stream_open: continuation_repair allowed // terminal_committed: nothing allowed 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. It is safe to call concurrently // during sink callbacks. 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 }