223 lines
6.8 KiB
Go
223 lines
6.8 KiB
Go
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)
|
|
}
|
|
|
|
// ReleaseTerminalEpoch commits a terminal result.
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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)
|
|
}
|