From 466b0ea05d5317299765533ff0c5cd20db5a7a0a Mon Sep 17 00:00:00 2001 From: billy Date: Tue, 20 Oct 2026 18:53:09 +0800 Subject: [PATCH] init repo --- README.md | 68 +++++ actor.go | 34 +++ actor_test.go | 432 ++++++++++++++++++++++++++++++ dispatcher.go | 25 ++ dispatcher_test.go | 329 +++++++++++++++++++++++ doc.go | 36 +++ go.mod | 3 + invoker.go | 27 ++ mailbox.go | 69 +++++ options.go | 71 +++++ queue.go | 98 +++++++ queue_fuzz_test.go | 37 +++ queue_test.go | 648 +++++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 1877 insertions(+) create mode 100644 README.md create mode 100644 actor.go create mode 100644 actor_test.go create mode 100644 dispatcher.go create mode 100644 dispatcher_test.go create mode 100644 doc.go create mode 100644 go.mod create mode 100644 invoker.go create mode 100644 mailbox.go create mode 100644 options.go create mode 100644 queue.go create mode 100644 queue_fuzz_test.go create mode 100644 queue_test.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..7506839 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Actor + +每个 Actor 使用一个泛型 MPSC mailbox。队列不使用互斥锁或自旋锁。默认取得消费权的发送者在自己的 goroutine 中处理消息,直到 mailbox 清空,库不会创建后台 goroutine;也可以在构造时注入 dispatcher,将整个消费任务交给其他 goroutine 或已有线程池。 + +默认同步执行(inline): + +```go +var total int +counter := actor.New(func(value int) { + total += value +}, actor.WithFixedCapacity[int](64), actor.WithBatchSize[int](64)) + +counter.Send(1) // 空闲时,在当前 goroutine 中处理。 +``` + +- `New(handler, opts...)`:创建 Actor。处理函数或 option 为空、固定容量或批量大小不为正数、dispatcher 接口或 `DispatcherFunc` 为空时 panic。 +- `Send(message)`:无返回值。发布消息后,取得消费权的发送者提交一次完整消费任务;已有任务排队或执行时仅入队。默认同步执行,返回不保证该消息已经处理,也不支持撤消。 +- `WithFixedCapacity[T](n)`:预分配并保留 `n` 个节点,默认 64。固定区全部消费后循环复用;不足时分配动态节点,因此这不是队列总容量限制。 +- `WithBatchSize[T](n)`:默认每消费 64 条消息调用 `runtime.Gosched()`。长链反转同样定期让出 CPU;单条处理函数不会被这个限制中断。 +- `WithPanicHandler[T](func(message T, value any, stack []byte))`:可选 panic 回调。每条处理函数及回调自身的 panic 独立恢复,之后继续处理后续消息;没有回调时也继续处理。 +- `WithDispatcher[T](Dispatcher)`:在构造时配置消费任务的执行方式,不提供运行中切换。调度策略不改变批量让出 CPU 的频率。 + +使用 `DispatcherFunc` 将消费任务交给其他 goroutine: + +```go +processed := make(chan int, 1) +receiver := actor.New(func(value int) { + processed <- value +}, actor.WithDispatcher[int](actor.DispatcherFunc(func(task func()) { + go task() +}))) + +receiver.Send(42) +value := <-processed // 用业务自己的同步方式等待结果。 +``` + +注入已有线程池,可以通过适配器接入其提交方法: + +```go +type taskPool interface { + Submit(task func()) +} + +type poolDispatcher struct { + pool taskPool +} + +func (d poolDispatcher) Schedule(task func()) { + d.pool.Submit(task) +} + +func newCounter(pool taskPool) *actor.Actor[int] { + total := 0 + return actor.New(func(value int) { + total += value + }, actor.WithDispatcher[int](poolDispatcher{pool: pool})) +} +``` + +`Dispatcher` 只有 `Schedule(task func())` 方法。它可以同步或异步执行,但必须接受任务并最终恰好执行一次,不得拒绝、静默丢弃或 panic;共享实例还必须支持并发调用。上述线程池的 `Submit` 同样需要满足这个约定。mailbox 在调用 `Schedule` 前已取得消费权,任务尚未执行时的新消息不会重复提交任务;库不为违反调度契约的实现提供回退。自定义 dispatcher 接口中包含 nil 指针时,调用方负责保证其 `Schedule` 仍有效。 + +FIFO 按消息成功发布的原子操作排序,固定区与动态区共享这个顺序。生产者先预留固定位置并不会占据 FIFO 顺序;它在发布前暂停时,其他生产者仍能发布并处理消息。消费者通过原子交换取出一批节点,反转链接后按 FIFO 处理,不复制消息来扩容。 + +处理函数不会并发或重入。处理函数向自身 Actor 发送的消息只会入队,必须等当前处理结束才能执行;不要同步等待它的执行结果。向使用默认 dispatcher 的其他空闲 Actor 发送消息会同步进入对方的处理函数,因此也应避免循环等待和过深的同步调用链。持续繁忙的 mailbox 可能让同步 `Send` 一直不返回;`Gosched` 只让出 CPU,不会切换调度策略,也不会打断单条处理函数。 + +`Send` 复制的是 Go 值,不深复制其中的指针、切片或 map;调用方需自行同步对共享数据的访问。固定槽位消费后清零,动态节点清除引用后交由 GC 回收。Actor 的可变状态应只在处理函数中访问,或自行同步。Actor 必须通过 `New` 创建,开始使用后不可复制。 + +内部职责分别放在 `actor.go`(门面与组装)、`queue.go`(存储与 FIFO)、`mailbox.go`(消费权、消费循环与调度)、`invoker.go`(处理函数与 panic 隔离)、`dispatcher.go`(执行策略契约)和 `options.go`(构造配置)。不包含生命周期、取消或自动重试。 diff --git a/actor.go b/actor.go new file mode 100644 index 0000000..fc71af3 --- /dev/null +++ b/actor.go @@ -0,0 +1,34 @@ +package actor + +// Actor serializes calls to a handler. By default the sender consumes messages +// inline; WithDispatcher can provide another execution policy. An Actor must be +// created with New and must not be copied after first use. +type Actor[T any] struct { + mailbox *mailbox[T] +} + +// New creates an Actor. A nil handler, nil option, invalid dispatcher, or +// nonpositive fixed capacity or batch size causes a panic. The default +// dispatcher runs inline and does not start any goroutine. +func New[T any](handler func(T), opts ...Option[T]) *Actor[T] { + if handler == nil { + panic("actor: nil handler") + } + c := configure(opts) + return &Actor[T]{mailbox: newMailbox( + newQueue[T](c.fixedCapacity), + invoker[T]{handler: handler, onPanic: c.onPanic}, + c.batchSize, + c.dispatcher, + )} +} + +// Send publishes a message. The sender that obtains consumption ownership +// schedules a task to drain the mailbox. By default it runs synchronously; +// a custom dispatcher may run it asynchronously. Other senders only enqueue. +// Returning does not guarantee message completion. Sending to the same Actor +// from its handler enqueues without reentering it. Concurrent sends are ordered +// by their successful publication CAS. +func (a *Actor[T]) Send(message T) { + a.mailbox.send(message) +} diff --git a/actor_test.go b/actor_test.go new file mode 100644 index 0000000..5f647e6 --- /dev/null +++ b/actor_test.go @@ -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()) + } +} diff --git a/dispatcher.go b/dispatcher.go new file mode 100644 index 0000000..e85010a --- /dev/null +++ b/dispatcher.go @@ -0,0 +1,25 @@ +package actor + +// Dispatcher chooses where a mailbox's entire drain task executes. Schedule +// may run the task inline or asynchronously. It must accept every task, execute +// it exactly once eventually, and must not reject, silently discard, or panic. +// Schedule must be safe for concurrent calls when shared by actors. The actor +// does not provide a fallback for a dispatcher that violates this contract. +type Dispatcher interface { + Schedule(task func()) +} + +// DispatcherFunc adapts a scheduling function to Dispatcher. A nil function is +// rejected by New when supplied through WithDispatcher. +type DispatcherFunc func(task func()) + +// Schedule delegates to f. The function must satisfy the Dispatcher contract. +func (f DispatcherFunc) Schedule(task func()) { + f(task) +} + +type inlineDispatcher struct{} + +func (inlineDispatcher) Schedule(task func()) { + task() +} diff --git a/dispatcher_test.go b/dispatcher_test.go new file mode 100644 index 0000000..bbd1cad --- /dev/null +++ b/dispatcher_test.go @@ -0,0 +1,329 @@ +package actor + +import ( + "reflect" + "sync" + "sync/atomic" + "testing" +) + +// queuedDispatcher deliberately postpones execution until the test takes a +// task. Its mutex protects the test scheduler, not the actor's mailbox. +type queuedDispatcher struct { + mu sync.Mutex + tasks []func() + submitted int +} + +func (d *queuedDispatcher) Schedule(task func()) { + d.mu.Lock() + defer d.mu.Unlock() + d.tasks = append(d.tasks, task) + d.submitted++ +} + +func (d *queuedDispatcher) stats() (submitted, pending int) { + d.mu.Lock() + defer d.mu.Unlock() + return d.submitted, len(d.tasks) +} + +func (d *queuedDispatcher) take(t *testing.T) func() { + t.Helper() + d.mu.Lock() + defer d.mu.Unlock() + if len(d.tasks) == 0 { + t.Fatal("dispatcher has no pending task") + } + task := d.tasks[0] + d.tasks[0] = nil + d.tasks = d.tasks[1:] + return task +} + +func TestQueuedDispatcherOwnsMailboxBeforeTaskStarts(t *testing.T) { + d := &queuedDispatcher{} + var got []int + var active atomic.Int32 + a := New(func(message int) { + if count := active.Add(1); count != 1 { + t.Errorf("%d concurrent handlers", count) + } + defer active.Add(-1) + got = append(got, message) + }, WithFixedCapacity[int](4), WithDispatcher[int](d)) + for message := range 200 { + a.Send(message) + } + if len(got) != 0 || !a.mailbox.running.Load() { + t.Fatal("Send did not retain consumption ownership for the queued task") + } + if submitted, pending := d.stats(); submitted != 1 || pending != 1 { + t.Fatalf("before execution: submitted=%d pending=%d, want 1, 1", submitted, pending) + } + d.take(t)() + if len(got) != 200 || a.mailbox.running.Load() { + t.Fatalf("first task did not finish draining: handled=%d", len(got)) + } + for message, value := range got { + if message != value { + t.Fatalf("FIFO: got[%d]=%d", message, value) + } + } + a.Send(200) + if len(got) != 200 { + t.Error("next wave ran before its task was executed") + } + if submitted, pending := d.stats(); submitted != 2 || pending != 1 { + t.Fatalf("second wave: submitted=%d pending=%d, want 2, 1", submitted, pending) + } + d.take(t)() + if len(got) != 201 || got[200] != 200 || a.mailbox.running.Load() { + t.Error("second task did not drain the next wave") + } +} + +func TestSelfSendAndYieldDoNotRescheduleActiveTask(t *testing.T) { + d := &queuedDispatcher{} + var a *Actor[int] + var got []int + depth, yields := 0, 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(1001) + } + }, WithDispatcher[int](d)) + a.mailbox.yield = func() { + yields++ + if !a.mailbox.running.Load() { + t.Error("yield released consumption ownership") + } + if yields == 1 { + a.Send(1000) + } + if submitted, pending := d.stats(); submitted != 1 || pending != 0 { + t.Errorf("active task rescheduled: submitted=%d pending=%d", submitted, pending) + } + } + for message := range 130 { + a.Send(message) + } + d.take(t)() + want := make([]int, 130, 132) + for i := range want { + want[i] = i + } + // The first reversal yields before handler 0 runs, so publication of 1000 + // precedes the self-send of 1001. Both follow the already detached batch. + want = append(want, 1000, 1001) + if !reflect.DeepEqual(got, want) { + t.Errorf("yield/self-send publication order: got %v, want %v", got, want) + } + if yields == 0 || a.mailbox.running.Load() { + t.Error("task did not yield and finish") + } +} + +func TestSharedDispatcherKeepsActorsIndependent(t *testing.T) { + const actors, messages = 3, 100 + d := &queuedDispatcher{} + var all [actors]*Actor[int] + var entered, release [actors]chan struct{} + var count [actors]int + var active [actors]atomic.Int32 + for i := range actors { + entered[i], release[i] = make(chan struct{}), make(chan struct{}) + all[i] = New(func(message int) { + if current := active[i].Add(1); current != 1 { + t.Errorf("actor %d has %d concurrent handlers", i, current) + } + defer active[i].Add(-1) + if message != count[i] { + t.Errorf("actor %d: message=%d, want %d", i, message, count[i]) + } + count[i]++ + if message == 0 { + close(entered[i]) + <-release[i] + } + }, WithFixedCapacity[int](4), WithDispatcher[int](d)) + for message := range messages { + all[i].Send(message) + } + } + var workers sync.WaitGroup + for range actors { + task := d.take(t) + workers.Add(1) + go func() { + defer workers.Done() + task() + }() + } + for i := range actors { + await(t, entered[i]) + all[i].Send(messages) + } + if submitted, pending := d.stats(); submitted != actors || pending != 0 { + t.Errorf("shared dispatcher: submitted=%d pending=%d", submitted, pending) + } + for i := range actors { + close(release[i]) + } + done := make(chan struct{}) + go func() { + workers.Wait() + close(done) + }() + await(t, done) + for i := range actors { + if count[i] != messages+1 || all[i].mailbox.running.Load() { + t.Errorf("actor %d did not finish independently: handled=%d", i, count[i]) + } + } +} + +func TestDispatcherFuncAsynchronousConcurrentDrain(t *testing.T) { + const producers, messages, selfMessages = 8, 400, 16 + type message struct{ producer, sequence int } + var tasks sync.WaitGroup + var submitted, active atomic.Int32 + d := DispatcherFunc(func(task func()) { + submitted.Add(1) + tasks.Add(1) + go func() { + defer tasks.Done() + task() + }() + }) + entered, release := make(chan struct{}), make(chan struct{}) + last := make([]int, producers+1) + seen := make([][]bool, producers+1) + for producer := range producers + 1 { + last[producer] = -1 + seen[producer] = make([]bool, messages+1) + } + handled, panics := 0, 0 + var a *Actor[message] + a = New(func(m message) { + if current := active.Add(1); current != 1 { + t.Errorf("async actor has %d concurrent handlers", current) + } + defer active.Add(-1) + if m.producer == -1 { + a.Send(message{producers, 0}) + close(entered) + <-release + return + } + if m.sequence != last[m.producer]+1 || seen[m.producer][m.sequence] { + t.Errorf("async producer %d: sequence=%d after %d", m.producer, m.sequence, last[m.producer]) + } + last[m.producer] = m.sequence + seen[m.producer][m.sequence] = true + handled++ + if m.producer == producers { + if m.sequence+1 < selfMessages { + a.Send(message{producers, m.sequence + 1}) + } + } else if m.sequence == 7 { + panic(m.producer) + } + }, WithFixedCapacity[message](4), WithBatchSize[message](8), WithDispatcher[message](d), + WithPanicHandler(func(m message, value any, stack []byte) { + panics++ + if value != m.producer || len(stack) == 0 { + t.Error("async panic callback lost message, panic value, or stack") + } + if m.producer == 0 { + panic("callback failed") + } + })) + a.Send(message{producer: -1}) + await(t, entered) + var senders sync.WaitGroup + for producer := range producers { + senders.Add(1) + go func() { + defer senders.Done() + for sequence := range messages { + a.Send(message{producer, sequence}) + } + }() + } + sendersDone := make(chan struct{}) + go func() { + senders.Wait() + close(sendersDone) + }() + await(t, sendersDone) + if got := submitted.Load(); got != 1 { + t.Errorf("queued/running async task submitted %d times", got) + } + close(release) + awaitTasks := func() { + done := make(chan struct{}) + go func() { + tasks.Wait() + close(done) + }() + await(t, done) + } + // Wait for the task itself to return, not just its final handler, before + // inspecting ownership and the consumer's ordinary state. + awaitTasks() + if handled != producers*messages+selfMessages || panics != producers { + t.Errorf("async drain: handled=%d panics=%d", handled, panics) + } + for producer := range producers { + for sequence := range messages { + if !seen[producer][sequence] { + t.Fatalf("async missing producer=%d sequence=%d", producer, sequence) + } + } + } + if last[producers] != selfMessages-1 { + t.Error("async self-send chain did not finish") + } + a.Send(message{0, messages}) + awaitTasks() + if submitted.Load() != 2 || handled != producers*messages+selfMessages+1 { + t.Error("async next wave did not schedule and finish") + } + if a.mailbox.running.Load() || !a.mailbox.queue.empty() { + t.Error("async task returned with consumption ownership or queued messages") + } + for i := range a.mailbox.queue.fixed { + n := &a.mailbox.queue.fixed[i] + if n.value != (message{}) || n.next != nil { + t.Errorf("async fixed node %d retains a message or link", i) + } + } +} + +type nilSafeDispatcher struct{} + +func (*nilSafeDispatcher) Schedule(task func()) { task() } + +func TestCustomTypedNilDispatcherUsesItsContract(t *testing.T) { + var dispatcher *nilSafeDispatcher + handled := false + a := New(func(int) { handled = true }, WithDispatcher[int](dispatcher)) + a.Send(0) + if !handled { + t.Error("valid typed-nil dispatcher was not called") + } +} + +func TestInlineFixedStorageSendDoesNotAllocate(t *testing.T) { + a := New(func(int) {}, WithFixedCapacity[int](4)) + if allocations := testing.AllocsPerRun(1000, func() { a.Send(1) }); allocations != 0 { + t.Errorf("light-load inline Send allocated %v times per call", allocations) + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..cc11401 --- /dev/null +++ b/doc.go @@ -0,0 +1,36 @@ +// Package actor provides generic actors with a multi-producer, single-consumer +// mailbox. By default a sender that acquires consumption ownership runs the +// handler synchronously until the mailbox is empty, without starting or retaining +// a goroutine. WithDispatcher can instead send the entire drain task to a custom +// execution policy, such as an existing worker pool or another goroutine. +// Concurrent senders only publish messages while a task is queued or running. +// +// Dispatcher.Schedule may execute synchronously or asynchronously, but must +// accept each task and eventually execute it exactly once. It must not reject, +// silently discard, or panic. Shared dispatchers must support concurrent calls. +// Ownership is claimed before scheduling, so delayed task execution does not +// cause duplicate submissions. Dispatchers are configured only at construction; +// the actor does not provide a fallback for violations of their contract. +// +// Successful publication defines FIFO order, including across fixed and dynamic +// storage. Concurrent calls have no ordering before their publication point. A +// producer paused before publication does not block other producers. Pending +// batches are detached atomically and reversed by relinking nodes, without +// copying their messages. Fixed nodes are retained and reused after the entire +// reservation round has been consumed; dynamic nodes are cleared and released +// for garbage collection. The mailbox has no capacity limit. +// +// The same handler never runs concurrently or reentrantly. It may send to its own +// Actor, but must not wait synchronously for a message queued to that Actor. +// Sending to another idle Actor with the default dispatcher executes that +// Actor's handler synchronously. A busy mailbox can keep its owning inline Send +// call active indefinitely. Scheduling yields do not interrupt an individual +// handler or change the dispatcher's execution policy. Send returning does not +// guarantee that its message has been handled. +// +// Messages are copied as Go values, not deep-copied. Callers must synchronize any +// subsequent access to referenced data that the handler may also access. Actor +// state should be accessed only by its handler or with external synchronization. +// Handler panics, and panics in the optional panic callback, are recovered so +// processing can continue. There is no cancellation, close, or automatic retry. +package actor diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f2bde9b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.ybilly.com/billy/actor + +go 1.27.1 diff --git a/invoker.go b/invoker.go new file mode 100644 index 0000000..eda8098 --- /dev/null +++ b/invoker.go @@ -0,0 +1,27 @@ +package actor + +import "runtime/debug" + +type invoker[T any] struct { + handler func(T) + onPanic func(T, any, []byte) +} + +func (i *invoker[T]) receive(message T) { + completed := false + defer func() { + if !completed { + value := recover() + if i.onPanic != nil { + i.reportPanic(message, value, debug.Stack()) + } + } + }() + i.handler(message) + completed = true +} + +func (i *invoker[T]) reportPanic(message T, value any, stack []byte) { + defer func() { _ = recover() }() + i.onPanic(message, value, stack) +} diff --git a/mailbox.go b/mailbox.go new file mode 100644 index 0000000..0082c6b --- /dev/null +++ b/mailbox.go @@ -0,0 +1,69 @@ +package actor + +import ( + "runtime" + "sync/atomic" +) + +// mailbox owns the single-consumer handoff and scheduling policy. running also +// covers the interval after scheduling and before the task starts executing. +type mailbox[T any] struct { + queue *queue[T] + invoker invoker[T] + dispatcher Dispatcher + task func() + batchSize int + yield func() + running atomic.Bool +} + +func newMailbox[T any](q *queue[T], invoke invoker[T], batchSize int, dispatcher Dispatcher) *mailbox[T] { + m := &mailbox[T]{ + queue: q, + invoker: invoke, + dispatcher: dispatcher, + batchSize: batchSize, + yield: runtime.Gosched, + } + // Reuse this method value for every scheduling request, without allocating + // a closure on each Send or ownership handoff. + m.task = m.drain + return m +} + +func (m *mailbox[T]) send(message T) { + m.queue.push(message) + m.tryRun() +} + +func (m *mailbox[T]) tryRun() { + if m.running.CompareAndSwap(false, true) { + m.dispatcher.Schedule(m.task) + } +} + +func (m *mailbox[T]) drain() { + processed := 0 + for { + batch := m.queue.takeBatch(m.batchSize, m.yield) + if batch == nil { + m.running.Store(false) + // A producer may have published while running was still true. + // Either we reclaim ownership or that producer schedules a task. + if m.queue.empty() || !m.running.CompareAndSwap(false, true) { + return + } + continue + } + for n := batch; n != nil; { + message, next := m.queue.pop(n) + n = next + m.invoker.receive(message) + processed++ + if processed == m.batchSize { + processed = 0 + m.yield() + } + } + } +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..beb12a7 --- /dev/null +++ b/options.go @@ -0,0 +1,71 @@ +package actor + +const ( + defaultFixedCapacity = 64 + defaultBatchSize = 64 +) + +type configuration[T any] struct { + fixedCapacity int + batchSize int + onPanic func(T, any, []byte) + dispatcher Dispatcher +} + +// Option configures an Actor during construction. +type Option[T any] func(*configuration[T]) + +// WithFixedCapacity sets the number of retained mailbox nodes. The default is +// 64. This is not a mailbox limit: further messages use dynamic nodes. +func WithFixedCapacity[T any](capacity int) Option[T] { + return func(c *configuration[T]) { c.fixedCapacity = capacity } +} + +// WithBatchSize sets how many messages are consumed between calls to +// runtime.Gosched. Long pending-batch reversals also yield at this interval. +// The default is 64, independent of the dispatcher. +func WithBatchSize[T any](size int) Option[T] { + return func(c *configuration[T]) { c.batchSize = size } +} + +// WithPanicHandler sets an optional callback for handler panics. It receives the +// message, the recovered value, and a stack trace. Its own panics are recovered. +// A nil callback disables panic reporting; messages still continue to drain. +func WithPanicHandler[T any](handler func(message T, value any, stack []byte)) Option[T] { + return func(c *configuration[T]) { c.onPanic = handler } +} + +// WithDispatcher chooses the construction-time scheduling policy. The default +// dispatcher runs inline. New rejects a nil interface or nil DispatcherFunc. +// Custom typed-nil implementations must themselves satisfy the Dispatcher +// contract; their validity is the caller's responsibility. +func WithDispatcher[T any](dispatcher Dispatcher) Option[T] { + return func(c *configuration[T]) { c.dispatcher = dispatcher } +} + +func configure[T any](opts []Option[T]) configuration[T] { + c := configuration[T]{ + fixedCapacity: defaultFixedCapacity, + batchSize: defaultBatchSize, + dispatcher: inlineDispatcher{}, + } + for _, opt := range opts { + if opt == nil { + panic("actor: nil option") + } + opt(&c) + } + if c.fixedCapacity <= 0 { + panic("actor: fixed capacity must be positive") + } + if c.batchSize <= 0 { + panic("actor: batch size must be positive") + } + if c.dispatcher == nil { + panic("actor: nil dispatcher") + } + if f, ok := c.dispatcher.(DispatcherFunc); ok && f == nil { + panic("actor: nil dispatcher function") + } + return c +} diff --git a/queue.go b/queue.go new file mode 100644 index 0000000..8d5dfcc --- /dev/null +++ b/queue.go @@ -0,0 +1,98 @@ +package actor + +import "sync/atomic" + +// queue separates message storage and FIFO publication from consumer ownership. +// Only its current consumer may call takeBatch and pop. +type queue[T any] struct { + head atomic.Pointer[node[T]] + + fixed []node[T] + reserved atomic.Uint64 + consumed int // Accessed only by the current consumer. +} + +type node[T any] struct { + value T + next *node[T] + fixed bool // Set at construction and never changed. +} + +func newQueue[T any](capacity int) *queue[T] { + q := &queue[T]{fixed: make([]node[T], capacity)} + for i := range q.fixed { + q.fixed[i].fixed = true + } + return q +} + +func (q *queue[T]) push(message T) { + n := q.reserveNode() + n.value = message + q.publish(n) +} + +func (q *queue[T]) reserveNode() *node[T] { + capacity := uint64(len(q.fixed)) + for { + index := q.reserved.Load() + if index == capacity { + return new(node[T]) + } + if q.reserved.CompareAndSwap(index, index+1) { + // A slot belongs to this producer only after the reservation CAS. + return &q.fixed[index] + } + } +} + +func (q *queue[T]) publish(n *node[T]) { + for { + head := q.head.Load() + n.next = head + if q.head.CompareAndSwap(head, n) { + // The consumer may immediately clear and reuse n. Do not access + // any of its fields after this successful publication. + return + } + } +} + +func (q *queue[T]) empty() bool { + return q.head.Load() == nil +} + +func (q *queue[T]) takeBatch(batchSize int, yield func()) *node[T] { + n := q.head.Swap(nil) + var reversed *node[T] + steps := 0 + for n != nil { + next := n.next + n.next = reversed + reversed = n + n = next + steps++ + if steps == batchSize { + steps = 0 + yield() + } + } + return reversed +} + +func (q *queue[T]) pop(n *node[T]) (message T, next *node[T]) { + next, message, fixed := n.next, n.value, n.fixed + var zero T + n.value = zero + n.next = nil + // Save everything first: reopening the fixed storage allows a producer to + // reuse n immediately. Neither this method nor its caller may read it again. + if fixed { + q.consumed++ + if q.consumed == len(q.fixed) { + q.consumed = 0 + q.reserved.Store(0) + } + } + return message, next +} diff --git a/queue_fuzz_test.go b/queue_fuzz_test.go new file mode 100644 index 0000000..0e8577c --- /dev/null +++ b/queue_fuzz_test.go @@ -0,0 +1,37 @@ +package actor + +import "testing" + +func FuzzQueueOperations(f *testing.F) { + seeds := [][]byte{ + {}, + {2, 3, 4}, // Singleton zero value. + {0, 0, 8, 1, 3, 4, 2, 4, 3, 4}, // Publish reservations out of order. + {2, 2, 2, 2, 3, 4, 2, 4, 2, 4, 4, 3, 4, 4}, // Partial batch, then republish. + {2, 2, 2, 2, 2, 6, 4, 4, 2, 4, 4, 4, 3, 4}, // Publish during reversal. + {0, 2, 2, 3, 4, 4, 1, 3, 4, 2, 3, 4}, // Delayed fixed reservation. + } + for capacity := range byte(5) { + for _, seed := range seeds { + f.Add(capacity, seed) + } + // Give every capacity a seed with repeated complete fixed rounds and + // dynamic overflow, in addition to the partial-consumption seeds above. + var cycles []byte + for range 3 { + count := [...]int{1, 2, 3, 8, 64}[capacity] + 2 + for range count { + cycles = append(cycles, 2) + } + cycles = append(cycles, 3) + for range count { + cycles = append(cycles, 4) + } + } + f.Add(capacity, cycles) + } + f.Fuzz(func(t *testing.T, capacity byte, operations []byte) { + capacities := [...]int{1, 2, 3, 8, 64} + runQueueModel(t, capacities[int(capacity)%len(capacities)], operations) + }) +} diff --git a/queue_test.go b/queue_test.go new file mode 100644 index 0000000..84391b9 --- /dev/null +++ b/queue_test.go @@ -0,0 +1,648 @@ +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) + }) + } + } +}