package actor import ( "fmt" "math/rand" "reflect" "runtime" "sync" "testing" "time" ) func queueAwait(t *testing.T, done <-chan struct{}) { t.Helper() timer := time.NewTimer(10 * time.Second) defer timer.Stop() select { case <-done: case <-timer.C: t.Fatal("queue operation did not finish") } } func queuePopInts(t *testing.T, q *queue[int], batch *node[int], want []int) { t.Helper() for _, expected := range want { if batch == nil { t.Fatalf("batch ended before message %d", expected) } retired := batch actual, next := q.pop(batch) batch = next if actual != expected { t.Fatalf("message=%d, want %d", actual, expected) } // These tests have no concurrent producer that could reuse the node // between pop and inspection. if retired.value != 0 || retired.next != nil { t.Fatal("consumed node retains its value or next link") } } if batch != nil { t.Fatal("batch contains unexpected messages") } } func TestQueueEmptyAndCapacityBoundaries(t *testing.T) { for _, capacity := range []int{1, 2, 3, 8, 64} { for _, count := range []int{capacity - 1, capacity, capacity + 1, capacity*3 + 1} { t.Run(fmt.Sprintf("capacity=%d/messages=%d", capacity, count), func(t *testing.T) { q := newQueue[int](capacity) addresses := make([]*node[int], capacity) for i := range q.fixed { addresses[i] = &q.fixed[i] } for range 3 { if !q.empty() || q.takeBatch(4, runtime.Gosched) != nil { t.Fatal("new queue is not empty") } } want := make([]int, count) for i := range want { want[i] = i // Includes the zero value and singleton zero case. q.push(i) } fixed, dynamic := 0, 0 for n := q.head.Load(); n != nil; n = n.next { if n.fixed { fixed++ } else { dynamic++ } } if fixed != min(count, capacity) || dynamic != max(0, count-capacity) { t.Fatalf("storage boundary: fixed=%d dynamic=%d", fixed, dynamic) } batch := q.takeBatch(4, runtime.Gosched) // empty describes the published stack, not the detached batch. for range 3 { if !q.empty() || q.takeBatch(4, runtime.Gosched) != nil { t.Fatal("detached messages remained on the published stack") } } queuePopInts(t, q, batch, want) for i := range q.fixed { if &q.fixed[i] != addresses[i] || !q.fixed[i].fixed || q.fixed[i].value != 0 || q.fixed[i].next != nil { t.Fatalf("fixed slot %d moved or retained consumed data", i) } } }) } } } func TestQueueNewMessageAtFixedStorageConsumptionStages(t *testing.T) { for _, test := range []struct { name string initial int consumed int wantFixed bool }{ {name: "fixed_full", initial: 4}, {name: "half_fixed_consumed_with_dynamic_messages", initial: 7, consumed: 2}, {name: "all_fixed_consumed_with_dynamic_messages", initial: 7, consumed: 4, wantFixed: true}, } { t.Run(test.name, func(t *testing.T) { q := newQueue[int](4) for value := 1; value <= test.initial; value++ { q.push(value) } var batch *node[int] if test.consumed > 0 { batch = q.takeBatch(4, runtime.Gosched) for expected := 1; expected <= test.consumed; expected++ { if batch == nil { t.Fatal("initial batch ended before the requested consumption stage") } value, next := q.pop(batch) batch = next if value != expected { t.Fatalf("initial message=%d, want %d", value, expected) } } fixed, dynamic := 0, 0 for n := batch; n != nil; n = n.next { if n.fixed { fixed++ } else { dynamic++ } } if fixed != 4-test.consumed || dynamic != 3 { t.Fatalf("remaining batch: fixed=%d dynamic=%d", fixed, dynamic) } } message := test.initial + 1 q.push(message) published := q.head.Load() if published == nil || published.value != message || published.fixed != test.wantFixed { t.Fatalf("new message node=%+v, want value=%d fixed=%v", published, message, test.wantFixed) } if test.wantFixed && published != &q.fixed[0] { t.Fatal("completed fixed round did not reuse its first slot") } var remaining []int for value := test.consumed + 1; value <= test.initial; value++ { remaining = append(remaining, value) } if batch != nil { // Older dynamic messages stay ahead of the new message even // when that message has reused the fixed storage. queuePopInts(t, q, batch, remaining) remaining = nil } remaining = append(remaining, message) queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), remaining) if !q.empty() { t.Fatal("new message left extra published data") } }) } } func TestQueueFixedRoundReopensOnlyAfterFullConsumption(t *testing.T) { q := newQueue[int](3) first := &q.fixed[0] for value := 1; value <= 7; value++ { q.push(value) } batch := q.takeBatch(4, runtime.Gosched) for expected := 1; expected <= 3; expected++ { value, next := q.pop(batch) batch = next if value != expected { t.Fatalf("fixed message=%d, want %d", value, expected) } if expected < 3 { if q.reserved.Load() != 3 || q.consumed != expected { t.Fatal("partially consumed fixed round was reopened") } q.push(7 + expected) if q.head.Load().fixed { t.Fatal("producer reused a fixed slot before the round finished") } } } if q.reserved.Load() != 0 || q.consumed != 0 { t.Fatal("fully consumed fixed round did not reopen") } q.push(10) if q.head.Load() != first { t.Fatal("new round did not reuse the original first fixed node") } // Fixed storage has reopened while the old batch still holds dynamic 4..7. queuePopInts(t, q, batch, []int{4, 5, 6, 7}) queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{8, 9, 10}) q.push(11) q.push(12) queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{11, 12}) for round := range 32 { want := []int{round*3 + 13, round*3 + 14, round*3 + 15} for _, value := range want { q.push(value) } queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), want) if q.reserved.Load() != 0 || q.consumed != 0 || &q.fixed[0] != first { t.Fatalf("fixed round %d did not complete in the same storage", round) } } } func TestQueuePublicationDuringBatchReversalRemainsInNextBatch(t *testing.T) { q := newQueue[int](2) for value := 1; value <= 6; value++ { q.push(value) } yields := 0 batch := q.takeBatch(2, func() { yields++ switch yields { case 1: if !q.empty() { t.Error("published stack was not detached before reversal") } q.push(100) case 2: q.push(101) } }) if yields != 3 || q.empty() { t.Fatalf("reversal did not isolate new publications: yields=%d empty=%v", yields, q.empty()) } for expected := 1; expected <= 2; expected++ { value, next := q.pop(batch) batch = next if value != expected { t.Fatalf("detached message=%d, want %d", value, expected) } q.push(101 + expected) } queuePopInts(t, q, batch, []int{3, 4, 5, 6}) queuePopInts(t, q, q.takeBatch(2, runtime.Gosched), []int{100, 101, 102, 103}) if !q.empty() { t.Fatal("new publications were lost or duplicated during reversal") } } func TestQueuePausedReservationDoesNotBlockPublishedMessages(t *testing.T) { q := newQueue[int](1) reserved, publish, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) releaseProducer := sync.OnceFunc(func() { close(publish) }) t.Cleanup(releaseProducer) go func() { n := q.reserveNode() close(reserved) <-publish n.value = 1 q.publish(n) close(done) }() queueAwait(t, reserved) q.push(2) q.push(3) queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{2, 3}) if q.reserved.Load() != 1 || q.consumed != 0 || q.fixed[0].value != 0 { t.Fatal("unpublished fixed reservation was consumed or reused") } releaseProducer() queueAwait(t, done) queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{1}) q.push(4) if q.head.Load() != &q.fixed[0] { t.Fatal("completed paused reservation did not allow fixed reuse") } queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{4}) } func TestQueueStaleHeadAfterDetachFailsCASAndRepublishes(t *testing.T) { q := newQueue[int](2) q.push(1) paused := q.reserveNode() paused.value = 2 expected := q.head.Load() paused.next = expected queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{1}) if q.head.CompareAndSwap(expected, paused) { t.Fatal("publication CAS accepted a detached old head") } q.push(3) // Real publish must replace paused.next, which still points at the retired // head, with the latest published stack after the failed stale attempt. q.publish(paused) queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{3, 2}) if !q.empty() || q.reserved.Load() != 0 { t.Fatal("retry lost a message or held the completed fixed round") } } func TestQueueStaleHeadAddressReuseCanSuccessfullyPublish(t *testing.T) { q := newQueue[int](1) q.push(1) paused := q.reserveNode() paused.value = 3 expected := q.head.Load() paused.next = expected queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{1}) q.push(2) if q.head.Load() != expected { t.Fatal("test did not reuse the old fixed head address") } if !q.head.CompareAndSwap(expected, paused) { t.Fatal("stale pointer CAS should match the current reused head") } q.push(4) queuePopInts(t, q, q.takeBatch(4, runtime.Gosched), []int{2, 3, 4}) if !q.empty() || paused.value != 0 || paused.next != nil { t.Fatal("address reuse corrupted FIFO or retained the dynamic node") } } func TestQueueReferencePayloadsAndRetiredNodesAreCleared(t *testing.T) { type payload struct { id int pointer *int bytes []byte values map[string]int } want := []payload{{}} for id := 1; id <= 3; id++ { value := id want = append(want, payload{id, &value, []byte{byte(id)}, map[string]int{"id": id}}) } q := newQueue[payload](2) for _, message := range want { q.push(message) } var nodes []*node[payload] for n := q.head.Load(); n != nil; n = n.next { nodes = append(nodes, n) } batch := q.takeBatch(4, runtime.Gosched) for _, expected := range want { if batch == nil { t.Fatal("reference payload batch ended early") } actual, next := q.pop(batch) batch = next if !reflect.DeepEqual(actual, expected) || actual.pointer != expected.pointer { t.Fatalf("payload=%+v, want %+v", actual, expected) } if actual.id != 0 { if &actual.bytes[0] != &expected.bytes[0] { t.Fatal("slice payload was not passed as a Go value") } actual.values["consumer"] = actual.id if expected.values["consumer"] != actual.id { t.Fatal("map payload was not passed as a Go value") } } } if batch != nil || !q.empty() { t.Fatal("reference payload messages were duplicated") } for _, n := range nodes { if !reflect.DeepEqual(n.value, payload{}) || n.next != nil { t.Fatal("retired fixed or dynamic node retains payload references or a link") } } } func TestQueueConcurrentProducersAndContinuousConsumer(t *testing.T) { const producers, messages, chunk = 6, 400, 32 type payload struct { producer, sequence, id int token *int check [3]uint64 } makePayload := func(producer, sequence, id int) payload { return payload{producer, sequence, id, &id, [3]uint64{uint64(id), uint64(id) ^ 0x9e3779b9, uint64(id)*17 + 3}} } for _, capacity := range []int{1, 3, 8} { t.Run(fmt.Sprintf("capacity=%d", capacity), func(t *testing.T) { q := newQueue[payload](capacity) prefill := capacity + 3 for sequence := range prefill { q.push(makePayload(-1, sequence, producers*messages+sequence)) } held := q.takeBatch(4, runtime.Gosched) if !q.empty() || held == nil { t.Fatal("prefilled batch was not detached") } stop := make(chan struct{}) stopWorkers := sync.OnceFunc(func() { close(stop) }) t.Cleanup(stopWorkers) var ack [producers]chan struct{} var ready, senders sync.WaitGroup ready.Add(producers) senders.Add(producers) for producer := range producers { ack[producer] = make(chan struct{}, 1) go func() { defer senders.Done() reportReady := sync.OnceFunc(ready.Done) defer reportReady() for sequence := range messages { select { case <-stop: return default: } q.push(makePayload(producer, sequence, producer*messages+sequence)) if (sequence+1)%chunk == 0 { if sequence+1 == chunk { reportReady() } // The next chunk cannot be produced until the single // consumer has processed this chunk's last message. select { case <-ack[producer]: case <-stop: return } } } }() } readyDone, sendersDone := make(chan struct{}), make(chan struct{}) go func() { ready.Wait(); close(readyDone) }() go func() { senders.Wait(); close(sendersDone) }() queueAwait(t, readyDone) consumedDone := make(chan struct{}) fixedPops, consumed := 0, 0 total := producers*messages + prefill go func() { defer close(consumedDone) defer stopWorkers() last := make([]int, producers+1) for i := range last { last[i] = -1 } seen := make([]bool, total) batch := held for consumed < total { select { case <-stop: return default: } if batch == nil { batch = q.takeBatch(4, runtime.Gosched) if batch == nil { runtime.Gosched() continue } } fixed := batch.fixed message, next := q.pop(batch) batch = next if fixed { fixedPops++ } producer := message.producer expectedID := producer*messages + message.sequence if producer == -1 { producer = producers expectedID = producers*messages + message.sequence } if producer < 0 || producer > producers || message.id < 0 || message.id >= total { t.Errorf("corrupt payload: %+v", message) return } if message.sequence != last[producer]+1 || message.id != expectedID || seen[message.id] { t.Errorf("FIFO/uniqueness: message=%+v previous=%d", message, last[producer]) } if message.token == nil || *message.token != message.id || message.check != makePayload(message.producer, message.sequence, message.id).check { t.Errorf("incomplete published payload: %+v", message) } last[producer] = message.sequence seen[message.id] = true consumed++ if producer < producers && (message.sequence+1)%chunk == 0 { select { case ack[producer] <- struct{}{}: case <-stop: return } } // Yield while producers have subsequent chunks to publish, // including after fixed nodes have become reusable. runtime.Gosched() } for id, present := range seen { if !present { t.Errorf("missing message %d", id) } } if batch != nil || !q.empty() { t.Error("consumer finished with extra queued messages") } }() queueAwait(t, consumedDone) queueAwait(t, sendersDone) if consumed != total { t.Fatalf("consumer stopped after %d messages, want %d", consumed, total) } if fixedPops < capacity*2 { t.Fatalf("test did not exercise multiple fixed rounds: %d fixed pops", fixedPops) } if !q.empty() { t.Fatal("published stack not empty after all producers and consumer finished") } for i := range q.fixed { if q.fixed[i].value != (payload{}) || q.fixed[i].next != nil { t.Fatalf("fixed slot %d retained a consumed payload or link", i) } } }) } } // runQueueModel uses slices for the two observable FIFO domains: the published // queue and a separately held, possibly partially consumed batch. Reservations // have no position in either FIFO until explicitly published. func runQueueModel(t *testing.T, capacity int, operations []byte) { t.Helper() if len(operations) > 4096 { operations = operations[:4096] } type reservation struct { node *node[int] value int } q := newQueue[int](capacity) fixed := &q.fixed[0] var pending []reservation var published, detached []int var batch *node[int] nextID := 0 nextMessage := func() int { value := nextID; nextID++; return value } publish := func(index int) { r := pending[index] q.publish(r.node) published = append(published, r.value) pending = append(pending[:index], pending[index+1:]...) } take := func(inject bool) { if batch != nil { return } detached, published = published, nil injected := false batch = q.takeBatch(2, func() { if inject && !injected { value := nextMessage() q.push(value) published = append(published, value) injected = true } }) if (batch == nil) != (len(detached) == 0) { t.Fatalf("model take: batch present=%v detached=%v", batch != nil, detached) } } pop := func() { if batch == nil { if len(detached) != 0 { t.Fatalf("model retained detached messages without a batch: %v", detached) } return } if len(detached) == 0 { t.Fatal("queue has a batch absent from the reference model") } retired := batch actual, next := q.pop(batch) batch = next if actual != detached[0] { t.Fatalf("model pop: got %d, want %d", actual, detached[0]) } detached = detached[1:] if retired.value != 0 || retired.next != nil || (batch == nil) != (len(detached) == 0) { t.Fatal("model pop: node not cleared or detached batch length differs") } } for step, operation := range operations { switch operation % 7 { case 0: value := nextMessage() n := q.reserveNode() n.value = value pending = append(pending, reservation{n, value}) case 1: if len(pending) != 0 { publish(int(operation/7) % len(pending)) } case 2: value := nextMessage() q.push(value) published = append(published, value) case 3: take(false) case 4, 5: pop() case 6: take(true) } if q.empty() != (len(published) == 0) { t.Fatalf("step %d: empty disagrees with published FIFO (detached=%v reserved=%d)", step, detached, len(pending)) } if &q.fixed[0] != fixed || len(q.fixed) != capacity { t.Fatalf("step %d: fixed storage changed", step) } } // Publishing pending reservations in reverse order deliberately separates // reservation order from FIFO order, including when a batch is held. for len(pending) > 0 { publish(len(pending) - 1) } for batch != nil { pop() } for len(published) > 0 { take(false) for batch != nil { pop() } } if !q.empty() || q.takeBatch(2, runtime.Gosched) != nil { t.Fatal("model flush left published messages") } for i := range q.fixed { if q.fixed[i].value != 0 || q.fixed[i].next != nil { t.Fatalf("model flush retained data in fixed slot %d", i) } } } func TestQueueMatchesIndependentFIFOModel(t *testing.T) { for _, capacity := range []int{1, 2, 3, 8, 64} { for _, seed := range []int64{1, 17, 2026, 8675309} { t.Run(fmt.Sprintf("capacity=%d/seed=%d", capacity, seed), func(t *testing.T) { operations := make([]byte, 2048) _, _ = rand.New(rand.NewSource(seed)).Read(operations) runQueueModel(t, capacity, operations) }) } } }