iop/packages/go/streamgate/stream_release.go
toki 8a4f6c55a1 sync: roadmap, skills, test inventory, streamgate package, docs updates
- Update roadmap milestones and phase docs across multiple phases
- Update plan, code-review, create-roadmap, update-roadmap, finalize-task-routing skills
- Update dev-corp-runtime-deploy, dev-runtime-deploy, orchestrate-agent-task-loop skills
- Refactor agent-task-loop dispatch script
- Add streamgate Go package (commit_boundary, evidence_tail, filter_registry, stream_release)
- Add test inventory files (dev, dev-corp, unified)
- Update test smoke tests and rules for dev/dev-corp
- Update docs/edge-local-dev-guide and e2e scripts
- Update inventory-query Go package
- Remove deprecated templates and inventory.yaml files
- Add orchestrate-agent-task-loop tests
2026-07-25 11:41:08 +09:00

201 lines
7.3 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.
//
// 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)
}