init repo
This commit is contained in:
432
actor_test.go
Normal file
432
actor_test.go
Normal file
@@ -0,0 +1,432 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func await(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("actor operation did not finish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidationAndDefaults(t *testing.T) {
|
||||
a := New(func(int) {})
|
||||
if len(a.mailbox.queue.fixed) != 64 || a.mailbox.batchSize != 64 {
|
||||
t.Fatalf("defaults: capacity=%d batch=%d", len(a.mailbox.queue.fixed), a.mailbox.batchSize)
|
||||
}
|
||||
tests := map[string]func(){
|
||||
"nil handler": func() { New[int](nil) },
|
||||
"nil option": func() { New(func(int) {}, nil) },
|
||||
"zero capacity": func() { New(func(int) {}, WithFixedCapacity[int](0)) },
|
||||
"negative capacity": func() { New(func(int) {}, WithFixedCapacity[int](-1)) },
|
||||
"zero batch": func() { New(func(int) {}, WithBatchSize[int](0)) },
|
||||
"negative batch": func() { New(func(int) {}, WithBatchSize[int](-1)) },
|
||||
"nil dispatcher": func() { New(func(int) {}, WithDispatcher[int](nil)) },
|
||||
"nil dispatcher function": func() {
|
||||
New(func(int) {}, WithDispatcher[int](DispatcherFunc(nil)))
|
||||
},
|
||||
}
|
||||
for name, construct := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("expected constructor panic")
|
||||
}
|
||||
}()
|
||||
construct()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdleSendHandlesSynchronously(t *testing.T) {
|
||||
returned, handled := false, false
|
||||
a := New(func(message int) {
|
||||
if returned {
|
||||
t.Error("handler entered after Send returned")
|
||||
}
|
||||
if message != 42 {
|
||||
t.Errorf("message=%d, want 42", message)
|
||||
}
|
||||
handled = true
|
||||
})
|
||||
a.Send(42)
|
||||
returned = true
|
||||
if !handled {
|
||||
t.Fatal("idle Send returned without handling its message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentSendSerialAndProducerFIFO(t *testing.T) {
|
||||
const producers, messages = 12, 1000
|
||||
type message struct{ producer, sequence int }
|
||||
var active atomic.Int32
|
||||
last := make([]int, producers)
|
||||
for i := range last {
|
||||
last[i] = -1
|
||||
}
|
||||
seen := make([]bool, producers*messages)
|
||||
count := 0
|
||||
a := New(func(m message) {
|
||||
if current := active.Add(1); current != 1 {
|
||||
t.Errorf("%d concurrent handlers", current)
|
||||
}
|
||||
defer active.Add(-1)
|
||||
if m.sequence != last[m.producer]+1 {
|
||||
t.Errorf("producer %d: received %d after %d", m.producer, m.sequence, last[m.producer])
|
||||
}
|
||||
last[m.producer] = m.sequence
|
||||
index := m.producer*messages + m.sequence
|
||||
if seen[index] {
|
||||
t.Errorf("duplicate message: %+v", m)
|
||||
}
|
||||
seen[index] = true
|
||||
count++
|
||||
}, WithFixedCapacity[message](8))
|
||||
start, done := make(chan struct{}), make(chan struct{})
|
||||
var workers sync.WaitGroup
|
||||
for producer := range producers {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
<-start
|
||||
for sequence := range messages {
|
||||
a.Send(message{producer, sequence})
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
workers.Wait()
|
||||
close(done)
|
||||
}()
|
||||
close(start)
|
||||
await(t, done)
|
||||
if count != len(seen) {
|
||||
t.Errorf("handled %d messages, want %d", count, len(seen))
|
||||
}
|
||||
for index, received := range seen {
|
||||
if !received {
|
||||
t.Fatalf("missing message %d", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedStorageReusesCompleteRounds(t *testing.T) {
|
||||
for _, capacity := range []int{1, 2, 4} {
|
||||
t.Run(strconv.Itoa(capacity), func(t *testing.T) {
|
||||
count := 0
|
||||
a := New(func(message int) {
|
||||
if message != count {
|
||||
t.Errorf("received %d, want %d", message, count)
|
||||
}
|
||||
count++
|
||||
}, WithFixedCapacity[int](capacity))
|
||||
first := &a.mailbox.queue.fixed[0]
|
||||
for i := range 1024 {
|
||||
a.Send(i)
|
||||
want := (i + 1) % capacity
|
||||
if got := a.mailbox.queue.reserved.Load(); got != uint64(want) || a.mailbox.queue.consumed != want {
|
||||
t.Fatalf("message %d: reserved=%d consumed=%d, want %d", i, got, a.mailbox.queue.consumed, want)
|
||||
}
|
||||
if &a.mailbox.queue.fixed[0] != first {
|
||||
t.Fatal("fixed storage was replaced")
|
||||
}
|
||||
for j := range a.mailbox.queue.fixed {
|
||||
if a.mailbox.queue.fixed[j].value != 0 || a.mailbox.queue.fixed[j].next != nil || !a.mailbox.queue.fixed[j].fixed {
|
||||
t.Fatalf("fixed slot %d was not cleared", j)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedAndDynamicFIFOAndClearedReferences(t *testing.T) {
|
||||
type message struct {
|
||||
id int
|
||||
reference *int
|
||||
}
|
||||
entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
var got []int
|
||||
var a *Actor[message]
|
||||
reference := 7
|
||||
a = New(func(m message) {
|
||||
got = append(got, m.id)
|
||||
if m.id == -1 {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
if m.id == 1 {
|
||||
// All fixed slots are now reusable, but dynamic messages 2..9
|
||||
// already belong ahead of this new fixed-slot message.
|
||||
a.Send(message{10, &reference})
|
||||
}
|
||||
}, WithFixedCapacity[message](3))
|
||||
go func() {
|
||||
defer close(done)
|
||||
a.Send(message{-1, &reference})
|
||||
}()
|
||||
await(t, entered)
|
||||
for i := range 10 {
|
||||
a.Send(message{i, &reference})
|
||||
}
|
||||
var queued []*node[message]
|
||||
fixed, dynamic := 0, 0
|
||||
for n := a.mailbox.queue.head.Load(); n != nil; n = n.next {
|
||||
queued = append(queued, n)
|
||||
if n.fixed {
|
||||
fixed++
|
||||
} else {
|
||||
dynamic++
|
||||
}
|
||||
}
|
||||
if fixed != 2 || dynamic != 8 {
|
||||
t.Fatalf("queued storage: fixed=%d dynamic=%d", fixed, dynamic)
|
||||
}
|
||||
close(release)
|
||||
await(t, done)
|
||||
want := []int{-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("FIFO: got %v, want %v", got, want)
|
||||
}
|
||||
for i, n := range queued {
|
||||
if n.value != (message{}) || n.next != nil {
|
||||
t.Errorf("consumed node %d retains a message or link", i)
|
||||
}
|
||||
}
|
||||
for i := range a.mailbox.queue.fixed {
|
||||
if a.mailbox.queue.fixed[i].value != (message{}) || a.mailbox.queue.fixed[i].next != nil {
|
||||
t.Errorf("fixed node %d retains a message or link", i)
|
||||
}
|
||||
}
|
||||
if a.mailbox.queue.head.Load() != nil {
|
||||
t.Error("mailbox retains a consumed batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPausedReservationDoesNotBlockPublication(t *testing.T) {
|
||||
var got []int
|
||||
a := New(func(message int) { got = append(got, message) }, WithFixedCapacity[int](1))
|
||||
paused := a.mailbox.queue.reserveNode()
|
||||
// The reserved fixed slot has not been initialized or published. Other
|
||||
// senders must use dynamic storage and finish without waiting for it.
|
||||
a.Send(2)
|
||||
a.Send(3)
|
||||
if !reflect.DeepEqual(got, []int{2, 3}) {
|
||||
t.Fatalf("unpublished reservation blocked messages: %v", got)
|
||||
}
|
||||
if a.mailbox.queue.reserved.Load() != 1 || a.mailbox.queue.consumed != 0 {
|
||||
t.Fatal("fixed storage was reopened before its reserved node was consumed")
|
||||
}
|
||||
paused.value = 1
|
||||
a.mailbox.queue.publish(paused)
|
||||
a.mailbox.tryRun()
|
||||
if !reflect.DeepEqual(got, []int{2, 3, 1}) {
|
||||
t.Errorf("publication order: %v", got)
|
||||
}
|
||||
if a.mailbox.queue.reserved.Load() != 0 || a.mailbox.queue.consumed != 0 {
|
||||
t.Error("completed fixed round was not reopened")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicationWithReusedExpectedHead(t *testing.T) {
|
||||
var got []int
|
||||
a := New(func(message int) { got = append(got, message) }, WithFixedCapacity[int](2))
|
||||
first, second := a.mailbox.queue.reserveNode(), a.mailbox.queue.reserveNode()
|
||||
first.value, second.value = 1, 2
|
||||
a.mailbox.queue.publish(first)
|
||||
a.mailbox.queue.publish(second)
|
||||
paused := a.mailbox.queue.reserveNode() // Dynamic: it cannot prevent the fixed round ending.
|
||||
paused.value = 5
|
||||
expected := a.mailbox.queue.head.Load()
|
||||
paused.next = expected
|
||||
a.mailbox.tryRun()
|
||||
a.Send(3)
|
||||
reused := a.mailbox.queue.reserveNode()
|
||||
if reused != expected {
|
||||
t.Fatal("test did not reuse the old head address")
|
||||
}
|
||||
reused.value = 4
|
||||
a.mailbox.queue.publish(reused)
|
||||
// Emulate a producer resuming its original CAS after the same address has
|
||||
// become a new head. It only compares the pointer; no old links are read.
|
||||
if !a.mailbox.queue.head.CompareAndSwap(expected, paused) {
|
||||
t.Fatal("expected the paused publication CAS to succeed")
|
||||
}
|
||||
a.mailbox.tryRun()
|
||||
if !reflect.DeepEqual(got, []int{1, 2, 3, 4, 5}) {
|
||||
t.Errorf("reused head corrupted publication order: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroValueMessages(t *testing.T) {
|
||||
var got []*int
|
||||
a := New(func(message *int) { got = append(got, message) }, WithFixedCapacity[*int](1))
|
||||
a.Send(nil)
|
||||
a.Send(nil)
|
||||
if len(got) != 2 || got[0] != nil || got[1] != nil {
|
||||
t.Errorf("zero messages: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfSendDoesNotReenter(t *testing.T) {
|
||||
var a *Actor[int]
|
||||
var got []int
|
||||
depth := 0
|
||||
a = New(func(message int) {
|
||||
depth++
|
||||
defer func() { depth-- }()
|
||||
if depth != 1 {
|
||||
t.Errorf("reentered handler at depth %d", depth)
|
||||
}
|
||||
got = append(got, message)
|
||||
if message == 0 {
|
||||
a.Send(1)
|
||||
a.Send(2)
|
||||
if !reflect.DeepEqual(got, []int{0}) {
|
||||
t.Errorf("self-send processed before handler returned: %v", got)
|
||||
}
|
||||
} else if message == 1 {
|
||||
a.Send(3)
|
||||
}
|
||||
})
|
||||
a.Send(0)
|
||||
if !reflect.DeepEqual(got, []int{0, 1, 2, 3}) {
|
||||
t.Errorf("self-send order: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerAndPanicCallbackRecovery(t *testing.T) {
|
||||
var a *Actor[int]
|
||||
var got, reported []int
|
||||
var values []any
|
||||
a = New(func(message int) {
|
||||
got = append(got, message)
|
||||
switch message {
|
||||
case 0:
|
||||
for i := 1; i <= 3; i++ {
|
||||
a.Send(i)
|
||||
}
|
||||
panic("first")
|
||||
case 1:
|
||||
panic("second")
|
||||
case 2:
|
||||
panic(nil)
|
||||
}
|
||||
}, WithPanicHandler(func(message int, value any, stack []byte) {
|
||||
reported = append(reported, message)
|
||||
values = append(values, value)
|
||||
if !bytes.Contains(stack, []byte("TestHandlerAndPanicCallbackRecovery")) {
|
||||
t.Error("panic callback did not receive the handler stack")
|
||||
}
|
||||
if message == 0 {
|
||||
panic("panic callback failed")
|
||||
}
|
||||
}))
|
||||
a.Send(0)
|
||||
a.Send(4)
|
||||
if !reflect.DeepEqual(got, []int{0, 1, 2, 3, 4}) {
|
||||
t.Errorf("processing stopped after panic: %v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(reported, []int{0, 1, 2}) || values[0] != "first" || values[1] != "second" {
|
||||
t.Errorf("panic reports: messages=%v values=%v", reported, values)
|
||||
}
|
||||
if a.mailbox.running.Load() {
|
||||
t.Error("panic left consumption ownership held")
|
||||
}
|
||||
count := 0
|
||||
withoutCallback := New(func(int) { count++; panic("ignored") })
|
||||
withoutCallback.Send(0)
|
||||
withoutCallback.Send(1)
|
||||
if count != 2 {
|
||||
t.Errorf("panic without callback stopped processing: %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulingYieldsDuringConsumptionAndReversal(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
batch, queued int
|
||||
want []int
|
||||
}{
|
||||
{name: "default interval", batch: 64, queued: 130, want: []int{1, 1, 64, 128}},
|
||||
{name: "configured interval", batch: 4, queued: 9, want: []int{1, 1, 4, 8}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var a *Actor[int]
|
||||
processed := 0
|
||||
var yieldedAt []int
|
||||
a = New(func(message int) {
|
||||
if message != processed {
|
||||
t.Errorf("message=%d, want %d", message, processed)
|
||||
}
|
||||
processed++
|
||||
if message == 0 {
|
||||
for i := 1; i <= test.queued; i++ {
|
||||
a.Send(i)
|
||||
}
|
||||
}
|
||||
}, WithBatchSize[int](test.batch))
|
||||
a.mailbox.yield = func() { yieldedAt = append(yieldedAt, processed) }
|
||||
a.Send(0)
|
||||
if !reflect.DeepEqual(yieldedAt, test.want) {
|
||||
t.Errorf("yield checkpoints=%v, want %v", yieldedAt, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumerHandoffDoesNotLoseWakeupOrState(t *testing.T) {
|
||||
type message struct {
|
||||
id int
|
||||
entered, release chan struct{}
|
||||
}
|
||||
// This ordinary state belongs to the actor, whose consumer changes between
|
||||
// rounds. It needs no atomic access inside the serial handler.
|
||||
handled := 0
|
||||
a := New(func(m message) {
|
||||
if m.id != handled {
|
||||
t.Errorf("handoff: message=%d, want %d", m.id, handled)
|
||||
}
|
||||
handled++
|
||||
if m.entered != nil {
|
||||
close(m.entered)
|
||||
<-m.release
|
||||
}
|
||||
}, WithFixedCapacity[message](1), WithBatchSize[message](1))
|
||||
const rounds = 2000
|
||||
for round := range rounds {
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
ownerDone, producerDone, start := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
defer close(ownerDone)
|
||||
a.Send(message{round * 2, entered, release})
|
||||
}()
|
||||
await(t, entered)
|
||||
go func() {
|
||||
defer close(producerDone)
|
||||
<-start
|
||||
a.Send(message{id: round*2 + 1})
|
||||
}()
|
||||
// Race publication against the old consumer leaving its final handler.
|
||||
// No sleeps or timing assumptions are needed to repeat this boundary.
|
||||
close(start)
|
||||
close(release)
|
||||
await(t, ownerDone)
|
||||
await(t, producerDone)
|
||||
}
|
||||
if handled != rounds*2 || a.mailbox.running.Load() || a.mailbox.queue.head.Load() != nil {
|
||||
t.Errorf("incomplete handoff: handled=%d running=%v", handled, a.mailbox.running.Load())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user