package streamgate import ( "context" "errors" "sync" ) // ErrReleaserSerializerConflict is returned when an operation conflicts // with the current serializer state (e.g., concurrent epoch calls). var ErrReleaserSerializerConflict = errors.New("streamgate: releaser serializer conflict") // ErrReleaserEpochConsumed is returned when an epoch has already been fully // confirmed. var ErrReleaserEpochConsumed = errors.New("streamgate: releaser epoch consumed") // ErrReleaserNoEpoch is returned when the given epoch ID does not exist. var ErrReleaserNoEpoch = errors.New("streamgate: releaser epoch not found") // ErrReleaserReplaceNotPermitted is returned when replace is called but the // boundary or tail state does not permit it. var ErrReleaserReplaceNotPermitted = errors.New("streamgate: replace not permitted") // AttemptCanceller is the host seam for cancelling a provider attempt. The // stream releaser calls this for idle and overflow recovery; the actual // implementation lives in the transport adapter. type AttemptCanceller interface { CancelAttempt(ctx context.Context, attemptID string) error } // StreamReleaser serializes EvidenceTail prepare/confirm with CommitBoundary // release/terminal operations. It ensures zero/partial/full sink progress is // confirmed correctly, exactly-once terminal semantics, and coordinated // replacement. // // All public methods are safe for concurrent use. A single serializer mutex // prevents overlapping PrepareRelease→ReleaseSafe→ConfirmRelease sequences. type StreamReleaser struct { mu sync.Mutex tail *EvidenceTail boundary *CommitBoundary canceller AttemptCanceller } // NewStreamReleaser creates a new StreamReleaser. Both tail and boundary // must be non-nil. The canceller may be nil if FailPending is not expected // to be called. func NewStreamReleaser(tail *EvidenceTail, boundary *CommitBoundary, canceller AttemptCanceller) (*StreamReleaser, error) { if tail == nil { return nil, errors.New("streamgate: stream releaser tail is required") } if boundary == nil { return nil, errors.New("streamgate: stream releaser boundary is required") } return &StreamReleaser{ tail: tail, boundary: boundary, canceller: canceller, }, nil } // ReleaseEpoch serializes a full release cycle: prepare from tail, release // through boundary, and confirm sink progress back to tail. It returns the // number of events confirmed through the entire pipeline. // // The serializer guarantees: // - PrepareRelease→ReleaseSafe→ConfirmRelease runs atomically under the // serializer lock. // - Only events the boundary accepted are confirmed to the tail. func (r *StreamReleaser) ReleaseEpoch(ctx context.Context, attemptID string, epochID uint64) (ReleaseProgress, error) { r.mu.Lock() defer r.mu.Unlock() // Prepare release from tail. prepared, err := r.tail.PrepareRelease(epochID) if err != nil { return ReleaseProgress{}, err } releaseEvents := prepared.ReleaseEvents() // Release through boundary. prog, err := r.boundary.ReleaseSafe(ctx, attemptID, releaseEvents) if err != nil { return prog, err } // Confirm exactly the number the boundary accepted. confirmation := ReleaseConfirmation{ReleasedEvents: prog.ReleasedEvents()} if err := r.tail.ConfirmRelease(prepared.Token(), confirmation); err != nil { return prog, err } return prog, nil } // ReleaseTerminalEpoch commits a terminal result. For error terminals it // discards pending, cancels the attempt, and commits the error terminal. // For success terminals it first releases any remaining epoch, then commits // the terminal through the boundary. func (r *StreamReleaser) ReleaseTerminalEpoch(ctx context.Context, attemptID string, epochID uint64, result TerminalResult) (ReleaseProgress, error) { r.mu.Lock() defer r.mu.Unlock() if !result.Success() { return r.failPendingLocked(ctx, attemptID, result) } // Success terminal: try to release remaining epoch first. prepared, err := r.tail.PrepareRelease(epochID) if err == nil { releaseEvents := prepared.ReleaseEvents() if len(releaseEvents) > 0 { prog, relErr := r.boundary.ReleaseSafe(ctx, attemptID, releaseEvents) if relErr == nil { confirmation := ReleaseConfirmation{ReleasedEvents: prog.ReleasedEvents()} relErr = r.tail.ConfirmRelease(prepared.Token(), confirmation) } if relErr == nil { return prog, r.boundary.CommitTerminal(ctx, attemptID, result) } // Release failed; proceed with terminal anyway. } } return ReleaseProgress{}, r.boundary.CommitTerminal(ctx, attemptID, result) } // ReplaceUncommittedAttempt coordinates replacing the current attempt. It // calls boundary.ReplaceUncommittedAttempt and tail.ResetForReplace together // so that both the boundary state and the tail pending/prepared tokens are // reset atomically. func (r *StreamReleaser) ReplaceUncommittedAttempt(ctx context.Context, oldAttemptID, newAttemptID string) error { r.mu.Lock() defer r.mu.Unlock() if err := r.boundary.ReplaceUncommittedAttempt(oldAttemptID, newAttemptID); err != nil { return err } // Reset tail: discards pending entries, prepared tokens, and the token // nonce so that a new generation starts cleanly. r.tail.ResetForReplace() return nil } // FailPending discards all pending tail state, calls the canceller, and // commits an error terminal through the boundary. This is the S04/S19 // recovery path: no partial release, no start release, cancel then terminal. // // If cancel fails, the failure cause is appended to the terminal result and // the single terminal is still committed. func (r *StreamReleaser) FailPending(ctx context.Context, attemptID string, result TerminalResult) (ReleaseProgress, error) { r.mu.Lock() defer r.mu.Unlock() return r.failPendingLocked(ctx, attemptID, result) } // failPendingLocked is the shared implementation for FailPending and the // error path of ReleaseTerminalEpoch. Caller must hold r.mu. func (r *StreamReleaser) failPendingLocked(ctx context.Context, attemptID string, result TerminalResult) (ReleaseProgress, error) { // Discard all pending state. r.tail.DiscardPendingForTerminal() // Cancel the attempt if a canceller is configured. if r.canceller != nil { if err := r.canceller.CancelAttempt(ctx, attemptID); err != nil { // Append bounded cause to the error terminal. if result.Error() { causes := result.FailureCauses() newCause, cerr := NewFailureCause("releaser", "attempt_cancel_failed", "", "", attemptID) if cerr == nil { causes, _ = causes.Append(newCause) } newResult, rerr := NewErrorTerminalResult( result.Channel(), *result.ExternalDesc(), causes, result.OccurredAt(), ) if rerr == nil { return ReleaseProgress{}, r.boundary.CommitTerminal(ctx, attemptID, newResult) } } // If we couldn't build the result, commit the original terminal. return ReleaseProgress{}, r.boundary.CommitTerminal(ctx, attemptID, result) } } return ReleaseProgress{}, r.boundary.CommitTerminal(ctx, attemptID, result) } // PrepareForReplace prepares a release for the given epoch without executing // the full release pipeline. This is used in tests to simulate a prepared // but unconfirmed release before replacement. func (r *StreamReleaser) PrepareForReplace(ctx context.Context, attemptID string, epochID uint64) (PreparedRelease, error) { r.mu.Lock() defer r.mu.Unlock() return r.tail.PrepareRelease(epochID) }