- 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
598 lines
19 KiB
Go
598 lines
19 KiB
Go
package streamgate
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testNow is a deterministic timestamp used in stream releaser tests.
|
|
var releaserNow = time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
|
|
|
|
// testCanceller records cancel attempts and can be programmed to fail.
|
|
type testCanceller struct {
|
|
cancelCount int
|
|
cancelErr error
|
|
}
|
|
|
|
func (c *testCanceller) CancelAttempt(ctx context.Context, attemptID string) error {
|
|
c.cancelCount++
|
|
return c.cancelErr
|
|
}
|
|
|
|
// setupReleaser creates a StreamReleaser with a rolling_window EvidenceTail,
|
|
// CommitBoundary, and canceller. The tail is configured so that appending
|
|
// enough events triggers an epoch via the rolling threshold.
|
|
//
|
|
// evidenceRunes controls the rolling threshold; maxBuffer is the hard limit.
|
|
func setupReleaser(t *testing.T, channel string, evidenceRunes, maxBuffer int) (*StreamReleaser, *testSink, *testCanceller) {
|
|
t.Helper()
|
|
|
|
// Create a rolling_window plan with a low threshold so a few events
|
|
// trigger an epoch quickly.
|
|
kinds := []EventKind{EventKindTextDelta, EventKindReasoningDelta}
|
|
var req FilterHoldRequirement
|
|
if maxBuffer > 0 {
|
|
var err error
|
|
req, err = NewFilterHoldRequirementRollingWithMaxBuffer(channel, kinds, evidenceRunes, maxBuffer)
|
|
if err != nil {
|
|
t.Fatalf("NewFilterHoldRequirementRollingWithMaxBuffer: %v", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
req, err = NewFilterHoldRequirementRolling(channel, kinds, evidenceRunes)
|
|
if err != nil {
|
|
t.Fatalf("NewFilterHoldRequirementRolling: %v", err)
|
|
}
|
|
}
|
|
binding, err := NewFilterHoldBinding("f1", req, true)
|
|
if err != nil {
|
|
t.Fatalf("NewFilterHoldBinding: %v", err)
|
|
}
|
|
plan, err := NewEvidencePlan([]FilterHoldBinding{binding})
|
|
if err != nil {
|
|
t.Fatalf("NewEvidencePlan: %v", err)
|
|
}
|
|
|
|
tail, err := NewEvidenceTail(plan)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceTail: %v", err)
|
|
}
|
|
|
|
sink := &testSink{}
|
|
boundary, err := NewCommitBoundary(sink)
|
|
if err != nil {
|
|
t.Fatalf("NewCommitBoundary: %v", err)
|
|
}
|
|
canceller := &testCanceller{}
|
|
|
|
releaser, err := NewStreamReleaser(tail, boundary, canceller)
|
|
if err != nil {
|
|
t.Fatalf("NewStreamReleaser: %v", err)
|
|
}
|
|
|
|
return releaser, sink, canceller
|
|
}
|
|
|
|
// appendDataEvents creates and appends text_delta events to the tail until
|
|
// an epoch is triggered (rolling threshold reached). Returns the epoch.
|
|
func appendDataEvents(t *testing.T, tail *EvidenceTail, channel string, count int) EvidenceEpoch {
|
|
t.Helper()
|
|
var epoch EvidenceEpoch
|
|
for i := 0; i < count; i++ {
|
|
ne, err := NewTextDeltaEvent(channel, "chunk", releaserNow)
|
|
if err != nil {
|
|
t.Fatalf("NewTextDeltaEvent: %v", err)
|
|
}
|
|
e, signal, err := tail.Append(ne)
|
|
if err != nil {
|
|
t.Fatalf("Append: %v", err)
|
|
}
|
|
if signal == EvidenceTailSignalThreshold {
|
|
epoch = e
|
|
}
|
|
}
|
|
return epoch
|
|
}
|
|
|
|
// createTextReleaseEvent creates a ReleaseEvent of kind text_delta.
|
|
func createTextReleaseEvent(channel, text string) ReleaseEvent {
|
|
ev, err := NewReleaseTextDeltaEvent(channel, text, releaserNow)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return ev
|
|
}
|
|
|
|
// createSuccessTerminalResult creates a TerminalResult of kind success.
|
|
func createSuccessTerminalResult(channel string) TerminalResult {
|
|
tr, err := NewSuccessTerminalResult(channel, releaserNow)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return tr
|
|
}
|
|
|
|
// createErrorTerminalResult creates an error TerminalResult.
|
|
func createErrorTerminalResult(channel string) TerminalResult {
|
|
desc, err := NewExternalDescriptor("generic", "internal_error", "an_internal_error", "")
|
|
if err != nil {
|
|
panic(fmt.Sprintf("createErrorTerminalResult NewExternalDescriptor failed: %v", err))
|
|
}
|
|
causes, err := NewFailureCauseChain([]FailureCause{})
|
|
if err != nil {
|
|
panic(fmt.Sprintf("createErrorTerminalResult NewFailureCauseChain failed: %v", err))
|
|
}
|
|
tr, err := NewErrorTerminalResult(channel, desc, causes, releaserNow)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("createErrorTerminalResult NewErrorTerminalResult failed: %v", err))
|
|
}
|
|
return tr
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REVIEW_API-2: Evidence prepare/confirm with sink progress
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestStreamReleaserConfirmsExactlySinkProgress verifies zero/partial/full
|
|
// sink progress where confirmed cursor matches sink success count.
|
|
func TestStreamReleaserConfirmsExactlySinkProgress(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("zero_sink_progress", func(t *testing.T) {
|
|
releaser, sink, _ := setupReleaser(t, "ch-zero", 2, 100)
|
|
|
|
// Begin attempt and stage response start on boundary.
|
|
releaser.boundary.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch-zero", 200, nil, releaserNow)
|
|
releaser.boundary.StageResponseStart("a1", rs)
|
|
|
|
// Append data events to trigger epoch.
|
|
epoch := appendDataEvents(t, releaser.tail, "ch-zero", 3)
|
|
|
|
// All releases fail.
|
|
sink.failAll = true
|
|
|
|
prog, err := releaser.ReleaseEpoch(ctx, "a1", epoch.ID())
|
|
if err == nil {
|
|
t.Error("expected error from ReleaseEpoch")
|
|
}
|
|
if prog.ReleasedEvents() != 0 {
|
|
t.Errorf("ReleasedEvents = %d, want 0 (all events failed)", prog.ReleasedEvents())
|
|
}
|
|
})
|
|
|
|
t.Run("partial_sink_progress", func(t *testing.T) {
|
|
releaser, sink, _ := setupReleaser(t, "ch-partial", 2, 100)
|
|
|
|
releaser.boundary.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch-partial", 200, nil, releaserNow)
|
|
releaser.boundary.StageResponseStart("a1", rs)
|
|
|
|
epoch := appendDataEvents(t, releaser.tail, "ch-partial", 5)
|
|
|
|
// 2 successes, then fail.
|
|
sink.failAfterSuccesses = 2
|
|
|
|
prog, err := releaser.ReleaseEpoch(ctx, "a1", epoch.ID())
|
|
if err == nil {
|
|
t.Error("expected partial error")
|
|
}
|
|
if prog.ReleasedEvents() != 2 {
|
|
t.Errorf("ReleasedEvents = %d, want 2", prog.ReleasedEvents())
|
|
}
|
|
})
|
|
|
|
t.Run("partial_sink_progress_two_succeed_then_fail", func(t *testing.T) {
|
|
releaser, sink, _ := setupReleaser(t, "ch-partial2", 2, 100)
|
|
|
|
releaser.boundary.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch-partial2", 200, nil, releaserNow)
|
|
releaser.boundary.StageResponseStart("a1", rs)
|
|
|
|
epoch := appendDataEvents(t, releaser.tail, "ch-partial2", 5)
|
|
|
|
// 2 successes, then fail.
|
|
sink.failAfterSuccesses = 2
|
|
|
|
prog, err := releaser.ReleaseEpoch(ctx, "a1", epoch.ID())
|
|
if err == nil {
|
|
t.Error("expected partial error")
|
|
}
|
|
if prog.ReleasedEvents() != 2 {
|
|
t.Errorf("ReleasedEvents = %d, want 2", prog.ReleasedEvents())
|
|
}
|
|
})
|
|
|
|
t.Run("full_sink_progress", func(t *testing.T) {
|
|
releaser, sink, _ := setupReleaser(t, "ch-full", 2, 100)
|
|
|
|
releaser.boundary.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch-full", 200, nil, releaserNow)
|
|
releaser.boundary.StageResponseStart("a1", rs)
|
|
|
|
epoch := appendDataEvents(t, releaser.tail, "ch-full", 3)
|
|
|
|
prog, err := releaser.ReleaseEpoch(ctx, "a1", epoch.ID())
|
|
if err != nil {
|
|
t.Fatalf("ReleaseEpoch: %v", err)
|
|
}
|
|
if prog.ReleasedEvents() != 3 {
|
|
t.Errorf("ReleasedEvents = %d, want 3", prog.ReleasedEvents())
|
|
}
|
|
// Cursor should advance by 3.
|
|
if got := releaser.tail.CommittedCursor("ch-full"); got != 3 {
|
|
t.Errorf("committed cursor = %d, want 3", got)
|
|
}
|
|
// Sink should have received 3 releases + 1 start.
|
|
if len(sink.releases) != 3 {
|
|
t.Errorf("sink releases = %d, want 3", len(sink.releases))
|
|
}
|
|
if len(sink.responseStarts) != 1 {
|
|
t.Errorf("sink responseStarts = %d, want 1", len(sink.responseStarts))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestStreamReleaserTerminalBeforeEvidenceExposesOneOutcome verifies that the
|
|
// pass path exposes start/output+success terminal and error path exposes
|
|
// only error terminal without releasing pending/start.
|
|
func TestStreamReleaserTerminalBeforeEvidenceExposesOneOutcome(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("pass_exposes_start_and_terminal", func(t *testing.T) {
|
|
releaser, sink, _ := setupReleaser(t, "ch-pass", 2, 100)
|
|
|
|
releaser.boundary.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch-pass", 200, nil, releaserNow)
|
|
releaser.boundary.StageResponseStart("a1", rs)
|
|
|
|
// Create and release an epoch.
|
|
epoch := appendDataEvents(t, releaser.tail, "ch-pass", 2)
|
|
prog, err := releaser.ReleaseEpoch(ctx, "a1", epoch.ID())
|
|
if err != nil {
|
|
t.Fatalf("ReleaseEpoch: %v", err)
|
|
}
|
|
if prog.ReleasedEvents() != 2 {
|
|
t.Errorf("ReleasedEvents = %d, want 2", prog.ReleasedEvents())
|
|
}
|
|
|
|
// Terminal success.
|
|
tr := createSuccessTerminalResult("ch-pass")
|
|
prog2, err := releaser.ReleaseTerminalEpoch(ctx, "a1", epoch.ID(), tr)
|
|
if err != nil {
|
|
t.Fatalf("ReleaseTerminalEpoch: %v", err)
|
|
}
|
|
_ = prog2
|
|
|
|
if len(sink.responseStarts) != 1 {
|
|
t.Errorf("responseStarts = %d, want 1", len(sink.responseStarts))
|
|
}
|
|
if len(sink.terminals) != 1 {
|
|
t.Errorf("terminals = %d, want 1", len(sink.terminals))
|
|
}
|
|
if len(sink.releases) != 2 {
|
|
t.Errorf("releases = %d, want 2", len(sink.releases))
|
|
}
|
|
})
|
|
|
|
t.Run("error_exposes_only_error_terminal", func(t *testing.T) {
|
|
releaser, sink, canceller := setupReleaser(t, "ch-err", 2, 100)
|
|
|
|
releaser.boundary.BeginAttempt("a2")
|
|
rs, _ := NewResponseStart("ch-err", 200, nil, releaserNow)
|
|
releaser.boundary.StageResponseStart("a2", rs)
|
|
|
|
// Create some pending events but don't release them.
|
|
appendDataEvents(t, releaser.tail, "ch-err", 2)
|
|
|
|
// Error terminal without releasing events.
|
|
tr := createErrorTerminalResult("ch-err")
|
|
prog, err := releaser.ReleaseTerminalEpoch(ctx, "a2", 999, tr)
|
|
if err != nil {
|
|
t.Fatalf("ReleaseTerminalEpoch: %v", err)
|
|
}
|
|
_ = prog
|
|
|
|
// Should have called cancel.
|
|
if canceller.cancelCount != 1 {
|
|
t.Errorf("cancelCount = %d, want 1", canceller.cancelCount)
|
|
}
|
|
|
|
if len(sink.responseStarts) != 0 {
|
|
t.Errorf("responseStarts = %d, want 0 (error path)", len(sink.responseStarts))
|
|
}
|
|
if len(sink.terminals) != 1 {
|
|
t.Errorf("terminals = %d, want 1", len(sink.terminals))
|
|
}
|
|
if len(sink.releases) != 0 {
|
|
t.Errorf("releases = %d, want 0 (no pending release)", len(sink.releases))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestStreamReleaserDoesNotDuplicatePreparedRelease verifies that repeated
|
|
// epoch calls do not duplicate downstream events.
|
|
func TestStreamReleaserDoesNotDuplicatePreparedRelease(t *testing.T) {
|
|
ctx := context.Background()
|
|
releaser, sink, _ := setupReleaser(t, "ch-dup", 2, 100)
|
|
|
|
releaser.boundary.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch-dup", 200, nil, releaserNow)
|
|
releaser.boundary.StageResponseStart("a1", rs)
|
|
|
|
// First epoch.
|
|
epoch1 := appendDataEvents(t, releaser.tail, "ch-dup", 2)
|
|
prog, err := releaser.ReleaseEpoch(ctx, "a1", epoch1.ID())
|
|
if err != nil {
|
|
t.Fatalf("ReleaseEpoch 1: %v", err)
|
|
}
|
|
if prog.ReleasedEvents() != 2 {
|
|
t.Errorf("first ReleasedEvents = %d, want 2", prog.ReleasedEvents())
|
|
}
|
|
|
|
// Second epoch (more events to trigger another).
|
|
epoch2 := appendDataEvents(t, releaser.tail, "ch-dup", 2)
|
|
prog2, err := releaser.ReleaseEpoch(ctx, "a1", epoch2.ID())
|
|
if err != nil {
|
|
t.Fatalf("ReleaseEpoch 2: %v", err)
|
|
}
|
|
if prog2.ReleasedEvents() != 2 {
|
|
t.Errorf("second ReleasedEvents = %d, want 2", prog2.ReleasedEvents())
|
|
}
|
|
|
|
// Total should be 4 releases (2+2), no duplicates.
|
|
if len(sink.releases) != 4 {
|
|
t.Errorf("total releases = %d, want 4 (2+2)", len(sink.releases))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REVIEW_API-3: Attempt replacement, idle and overflow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestStreamReleaserReplacementResetsTailGeneration verifies that boundary
|
|
// attempt and tail pending/prepared token are replaced together.
|
|
func TestStreamReleaserReplacementResetsTailGeneration(t *testing.T) {
|
|
ctx := context.Background()
|
|
releaser, _, _ := setupReleaser(t, "ch-rep", 2, 100)
|
|
|
|
// Begin attempt and stage start.
|
|
releaser.boundary.BeginAttempt("old")
|
|
rs, _ := NewResponseStart("ch-rep", 200, nil, releaserNow)
|
|
releaser.boundary.StageResponseStart("old", rs)
|
|
|
|
// Append events.
|
|
appendDataEvents(t, releaser.tail, "ch-rep", 2)
|
|
|
|
// Replace.
|
|
err := releaser.ReplaceUncommittedAttempt(ctx, "old", "new")
|
|
if err != nil {
|
|
t.Fatalf("ReplaceUncommittedAttempt: %v", err)
|
|
}
|
|
|
|
// Tail cursor should be 0 after replace.
|
|
if got := releaser.tail.CommittedCursor("ch-rep"); got != 0 {
|
|
t.Errorf("tail cursor after replace = %d, want 0", got)
|
|
}
|
|
// Boundary attempt should be cleared.
|
|
if got := releaser.boundary.CurrentAttempt(); got != "" {
|
|
t.Errorf("boundary attempt after replace = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// TestStreamReleaserIdleReleasesNoPending verifies that idle error results
|
|
// in zero release/start and correct cancel→terminal ordering.
|
|
func TestStreamReleaserIdleReleasesNoPending(t *testing.T) {
|
|
ctx := context.Background()
|
|
releaser, sink, canceller := setupReleaser(t, "ch-idle", 2, 100)
|
|
|
|
releaser.boundary.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch-idle", 200, nil, releaserNow)
|
|
releaser.boundary.StageResponseStart("a1", rs)
|
|
|
|
// Append some events to create pending state.
|
|
appendDataEvents(t, releaser.tail, "ch-idle", 2)
|
|
|
|
// FailPending.
|
|
tr := createErrorTerminalResult("ch-idle")
|
|
prog, err := releaser.FailPending(ctx, "a1", tr)
|
|
if err != nil {
|
|
t.Fatalf("FailPending: %v", err)
|
|
}
|
|
if prog.ReleasedEvents() != 0 {
|
|
t.Errorf("ReleasedEvents = %d, want 0", prog.ReleasedEvents())
|
|
}
|
|
|
|
// No responses or releases.
|
|
if len(sink.responseStarts) != 0 {
|
|
t.Errorf("responseStarts = %d, want 0", len(sink.responseStarts))
|
|
}
|
|
if len(sink.releases) != 0 {
|
|
t.Errorf("releases = %d, want 0", len(sink.releases))
|
|
}
|
|
// Cancel should have been called.
|
|
if canceller.cancelCount != 1 {
|
|
t.Errorf("cancelCount = %d, want 1", canceller.cancelCount)
|
|
}
|
|
if len(sink.terminals) != 1 {
|
|
t.Errorf("terminals = %d, want 1", len(sink.terminals))
|
|
}
|
|
}
|
|
|
|
// TestStreamReleaserOverflowCancelsBeforeSingleTerminal verifies that S19
|
|
// overflow results in no partial release, and terminal exactly once.
|
|
func TestStreamReleaserOverflowCancelsBeforeSingleTerminal(t *testing.T) {
|
|
ctx := context.Background()
|
|
// Use very small buffer to trigger overflow.
|
|
// rolling_window with evidence 1 and maxBuffer 10 (minimum).
|
|
releaser, sink, canceller := setupReleaser(t, "ch-overflow", 1, 10)
|
|
|
|
// Append events, expecting overflow signal.
|
|
overflowed := false
|
|
for i := 0; i < 50; i++ {
|
|
ne, err := NewTextDeltaEvent("ch-overflow", "chunk", releaserNow)
|
|
if err != nil {
|
|
t.Fatalf("NewTextDeltaEvent: %v", err)
|
|
}
|
|
_, signal, err := releaser.tail.Append(ne)
|
|
if err != nil {
|
|
t.Fatalf("Append: %v", err)
|
|
}
|
|
if signal == EvidenceTailSignalBufferOverflow {
|
|
overflowed = true
|
|
break
|
|
}
|
|
}
|
|
if !overflowed {
|
|
t.Skip("expected overflow signal not generated with current configuration")
|
|
}
|
|
|
|
// FailPending for overflow recovery.
|
|
tr := createErrorTerminalResult("ch-overflow")
|
|
prog, err := releaser.FailPending(ctx, "a1", tr)
|
|
if err != nil {
|
|
t.Fatalf("FailPending: %v", err)
|
|
}
|
|
if prog.ReleasedEvents() != 0 {
|
|
t.Errorf("ReleasedEvents = %d, want 0 (overflow)", prog.ReleasedEvents())
|
|
}
|
|
|
|
if len(sink.terminals) != 1 {
|
|
t.Errorf("terminals = %d, want 1 (exactly once)", len(sink.terminals))
|
|
}
|
|
_ = canceller
|
|
}
|
|
|
|
// TestStreamReleaserCancelFailureAppendsBoundedCause verifies that when
|
|
// cancel fails, the cause is appended to the terminal result.
|
|
func TestStreamReleaserCancelFailureAppendsBoundedCause(t *testing.T) {
|
|
ctx := context.Background()
|
|
releaser, sink, canceller := setupReleaser(t, "ch-cancel-fail", 2, 100)
|
|
|
|
releaser.boundary.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch-cancel-fail", 200, nil, releaserNow)
|
|
releaser.boundary.StageResponseStart("a1", rs)
|
|
|
|
// Program canceller to fail.
|
|
canceller.cancelErr = errors.New("test: cancel failed")
|
|
|
|
appendDataEvents(t, releaser.tail, "ch-cancel-fail", 2)
|
|
|
|
tr := createErrorTerminalResult("ch-cancel-fail")
|
|
prog, err := releaser.FailPending(ctx, "a1", tr)
|
|
if err != nil {
|
|
t.Fatalf("FailPending: %v", err)
|
|
}
|
|
_ = prog
|
|
|
|
// Terminal should still be committed exactly once.
|
|
if len(sink.terminals) != 1 {
|
|
t.Errorf("terminals = %d, want 1", len(sink.terminals))
|
|
}
|
|
// The terminal result should include the cancel failure cause.
|
|
lastTerminal := sink.terminals[len(sink.terminals)-1]
|
|
causes := lastTerminal.FailureCauses()
|
|
if causes.Len() == 0 {
|
|
t.Error("expected failure causes to include cancel failure")
|
|
} else {
|
|
found := false
|
|
for i := 0; i < causes.Len(); i++ {
|
|
c, _ := causes.At(i)
|
|
if c.Code() == "attempt_cancel_failed" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected 'attempt_cancel_failed' cause code")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStreamReleaserConcurrentEpochsAreSerialized verifies that concurrent
|
|
// release calls through the releaser serializer do not cause data races
|
|
// (the tail itself is not thread-safe, so appends must be serialized).
|
|
func TestStreamReleaserConcurrentEpochsAreSerialized(t *testing.T) {
|
|
ctx := context.Background()
|
|
releaser, _, _ := setupReleaser(t, "ch-conc", 2, 100)
|
|
|
|
releaser.boundary.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch-conc", 200, nil, releaserNow)
|
|
releaser.boundary.StageResponseStart("a1", rs)
|
|
|
|
// Pre-create all epochs sequentially (tail is not thread-safe).
|
|
epochs := make([]EvidenceEpoch, 5)
|
|
for i := 0; i < 5; i++ {
|
|
epochs[i] = appendDataEvents(t, releaser.tail, "ch-conc", 2)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
var successful int64
|
|
var errors int64
|
|
|
|
for i := 0; i < 5; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
_, err := releaser.ReleaseEpoch(ctx, "a1", epochs[idx].ID())
|
|
if err == nil {
|
|
atomic.AddInt64(&successful, 1)
|
|
} else {
|
|
atomic.AddInt64(&errors, 1)
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
// All events should have been processed without race.
|
|
total := atomic.LoadInt64(&successful) + atomic.LoadInt64(&errors)
|
|
if total != 5 {
|
|
t.Errorf("total results = %d, want 5", total)
|
|
}
|
|
}
|
|
|
|
// TestStreamReleaserTerminalAfterFullReleaseSkipsSecondaryRelease verifies
|
|
// that after an epoch is fully released and confirmed, ReleaseTerminalEpoch
|
|
// does not attempt another release.
|
|
func TestStreamReleaserTerminalAfterFullReleaseSkipsSecondaryRelease(t *testing.T) {
|
|
ctx := context.Background()
|
|
releaser, sink, _ := setupReleaser(t, "ch-skip", 2, 100)
|
|
|
|
releaser.boundary.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch-skip", 200, nil, releaserNow)
|
|
releaser.boundary.StageResponseStart("a1", rs)
|
|
|
|
// Release epoch fully.
|
|
epoch := appendDataEvents(t, releaser.tail, "ch-skip", 2)
|
|
prog, err := releaser.ReleaseEpoch(ctx, "a1", epoch.ID())
|
|
if err != nil {
|
|
t.Fatalf("ReleaseEpoch: %v", err)
|
|
}
|
|
if prog.ReleasedEvents() != 2 {
|
|
t.Errorf("ReleasedEvents = %d, want 2", prog.ReleasedEvents())
|
|
}
|
|
|
|
// Terminal after full release.
|
|
tr := createSuccessTerminalResult("ch-skip")
|
|
prog2, err := releaser.ReleaseTerminalEpoch(ctx, "a1", epoch.ID(), tr)
|
|
if err != nil {
|
|
t.Fatalf("ReleaseTerminalEpoch: %v", err)
|
|
}
|
|
_ = prog2
|
|
|
|
// Should have 2 releases + 1 start, no extra.
|
|
if len(sink.releases) != 2 {
|
|
t.Errorf("releases = %d, want 2 (no secondary release)", len(sink.releases))
|
|
}
|
|
if len(sink.responseStarts) != 1 {
|
|
t.Errorf("responseStarts = %d, want 1", len(sink.responseStarts))
|
|
}
|
|
if len(sink.terminals) != 1 {
|
|
t.Errorf("terminals = %d, want 1", len(sink.terminals))
|
|
}
|
|
}
|