- 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
201 lines
7.3 KiB
Go
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)
|
|
}
|