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. type StreamReleaser struct { mu sync.Mutex busy bool tail *EvidenceTail boundary *CommitBoundary canceller AttemptCanceller completedEpochs map[uint64]bool } // 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, completedEpochs: make(map[uint64]bool), }, nil } func (r *StreamReleaser) isCompletedEpoch(epochID uint64) bool { r.mu.Lock() defer r.mu.Unlock() return r.completedEpochs != nil && r.completedEpochs[epochID] } func (r *StreamReleaser) markCompletedEpoch(epochID uint64) { r.mu.Lock() defer r.mu.Unlock() if r.completedEpochs == nil { r.completedEpochs = make(map[uint64]bool) } r.completedEpochs[epochID] = true } func (r *StreamReleaser) clearCompletedEpochs() { r.mu.Lock() defer r.mu.Unlock() r.completedEpochs = make(map[uint64]bool) } func (r *StreamReleaser) tryAcquireSerializer() error { r.mu.Lock() defer r.mu.Unlock() if r.busy { return ErrReleaserSerializerConflict } r.busy = true return nil } func (r *StreamReleaser) releaseSerializer() { r.mu.Lock() defer r.mu.Unlock() r.busy = false } func (r *StreamReleaser) releaseAndConfirm(ctx context.Context, attemptID string, epochID uint64) (ReleaseProgress, error) { prepared, err := r.tail.PrepareRelease(epochID) if err != nil { return ReleaseProgress{}, err } releaseEvents := prepared.ReleaseEvents() var ( prog ReleaseProgress relErr error ) if len(releaseEvents) > 0 { prog, relErr = r.boundary.ReleaseSafe(ctx, attemptID, releaseEvents) } confirmErr := r.tail.ConfirmRelease( prepared.Token(), ReleaseConfirmation{ReleasedEvents: prog.ReleasedEvents()}, ) jointErr := errors.Join(relErr, confirmErr) if jointErr != nil { return prog, jointErr } r.markCompletedEpoch(epochID) return prog, nil } // ReleaseEpoch serializes a full release cycle: prepare from tail, release // through boundary, and confirm sink progress back to tail. func (r *StreamReleaser) ReleaseEpoch(ctx context.Context, attemptID string, epochID uint64) (ReleaseProgress, error) { if err := r.tryAcquireSerializer(); err != nil { return ReleaseProgress{}, err } defer r.releaseSerializer() return r.releaseAndConfirm(ctx, attemptID, epochID) } // ReleasePassThroughEvent releases an unbuffered event directly through the boundary under serializer lock. func (r *StreamReleaser) ReleasePassThroughEvent(ctx context.Context, attemptID string, ev NormalizedEvent) (ReleaseProgress, error) { if err := r.tryAcquireSerializer(); err != nil { return ReleaseProgress{}, err } defer r.releaseSerializer() relEv, err := normalizedToReleaseEvent(ev) if err != nil { return ReleaseProgress{}, err } return r.boundary.ReleaseSafe(ctx, attemptID, []ReleaseEvent{relEv}) } // releaseReplacementEvents releases an ordered replacement payload in place of // the pending content captured by the given epoch. The tail validates the // payload against that epoch before any tail or sink mutation, so an unknown // epoch, a foreign channel, or a non-releasable kind fails closed with the // stream untouched. Prepare, the single ordered boundary release, and confirm // all run inside one serializer reservation: the original payload can never // reach the sink, and confirm reflects exactly the zero/partial/full progress // the boundary reported back into the committed look-behind and cursor. Confirm // runs even when the release failed, so a partially accepted replacement never // leaves the tail ahead of or behind downstream reality. func (r *StreamReleaser) releaseReplacementEvents(ctx context.Context, attemptID string, epochID uint64, events []NormalizedEvent) (ReleaseProgress, error) { if err := r.tryAcquireSerializer(); err != nil { return ReleaseProgress{}, err } defer r.releaseSerializer() prepared, err := r.tail.prepareReplacement(epochID, events) if err != nil { return ReleaseProgress{}, err } prog, relErr := r.boundary.ReleaseSafe(ctx, attemptID, prepared.ReleaseEvents()) confirmErr := r.tail.confirmReplacement( prepared.Token(), ReleaseConfirmation{ReleasedEvents: prog.ReleasedEvents()}, ) if confirmErr == nil { // The target epoch is settled: its original content is consumed and can // never be released, so a later terminal must not re-release it. r.markCompletedEpoch(epochID) } return prog, errors.Join(relErr, confirmErr) } // CommitTerminalWithoutEpoch commits a terminal result without requiring epoch tail prepare/confirm. func (r *StreamReleaser) CommitTerminalWithoutEpoch(ctx context.Context, attemptID string, result TerminalResult) error { if err := r.tryAcquireSerializer(); err != nil { return err } defer r.releaseSerializer() if !result.Success() { _, err := r.failPendingLocked(ctx, attemptID, result) return err } return r.boundary.CommitTerminal(ctx, attemptID, result) } // ReleaseTerminalEpoch commits a terminal result for a buffered epoch. func (r *StreamReleaser) ReleaseTerminalEpoch(ctx context.Context, attemptID string, epochID uint64, result TerminalResult) (ReleaseProgress, error) { if err := r.tryAcquireSerializer(); err != nil { return ReleaseProgress{}, err } defer r.releaseSerializer() if !result.Success() { return r.failPendingLocked(ctx, attemptID, result) } if r.isCompletedEpoch(epochID) { return ReleaseProgress{}, r.boundary.CommitTerminal(ctx, attemptID, result) } prog, err := r.releaseAndConfirm(ctx, attemptID, epochID) if err != nil { return prog, err } return prog, r.boundary.CommitTerminal(ctx, attemptID, result) } // ReplaceUncommittedAttempt coordinates replacing the current attempt. func (r *StreamReleaser) ReplaceUncommittedAttempt(ctx context.Context, oldAttemptID, newAttemptID string) error { if err := r.tryAcquireSerializer(); err != nil { return err } defer r.releaseSerializer() if err := r.boundary.ReplaceUncommittedAttempt(oldAttemptID, newAttemptID); err != nil { return err } r.tail.ResetForReplace() r.clearCompletedEpochs() return nil } // ContinueOpenAttempt coordinates transferring attempt identity when continuing an open stream. func (r *StreamReleaser) ContinueOpenAttempt(ctx context.Context, oldAttemptID, newAttemptID string) error { if err := r.tryAcquireSerializer(); err != nil { return err } defer r.releaseSerializer() return r.boundary.ContinueOpenAttempt(oldAttemptID, newAttemptID) } // FailPending discards all pending tail state, calls the canceller, and // commits an error terminal through the boundary. func (r *StreamReleaser) FailPending(ctx context.Context, attemptID string, result TerminalResult) (ReleaseProgress, error) { if err := r.tryAcquireSerializer(); err != nil { return ReleaseProgress{}, err } defer r.releaseSerializer() return r.failPendingLocked(ctx, attemptID, result) } // DiscardPendingAndCommitTerminal discards pending tail state without calling attempt canceller // (when previous attempt was already aborted/closed) and commits terminal through the boundary. func (r *StreamReleaser) DiscardPendingAndCommitTerminal(ctx context.Context, attemptID string, result TerminalResult) error { if err := r.tryAcquireSerializer(); err != nil { return err } defer r.releaseSerializer() r.tail.DiscardPendingForTerminal() return r.boundary.CommitTerminal(ctx, attemptID, result) } func (r *StreamReleaser) failPendingLocked(ctx context.Context, attemptID string, result TerminalResult) (ReleaseProgress, error) { r.tail.DiscardPendingForTerminal() if r.canceller != nil { if err := r.canceller.CancelAttempt(ctx, attemptID); err != nil { if result.Error() { causes := result.FailureCauses() newCause, cerr := NewFailureCause("releaser", "attempt_cancel_failed", "", "", "") 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) } } 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. func (r *StreamReleaser) PrepareForReplace(ctx context.Context, attemptID string, epochID uint64) (PreparedRelease, error) { if err := r.tryAcquireSerializer(); err != nil { return PreparedRelease{}, err } defer r.releaseSerializer() return r.tail.PrepareRelease(epochID) }