diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cdf4c1d..92946b2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,8 @@ name: test on: [push] +permissions: + contents: write + pull-requests: write jobs: test: name: test @@ -11,13 +14,56 @@ jobs: uses: actions/setup-go@v5 with: go-version-file: go.mod + - name: test run: make test + - name: generate test coverage run: make generate-coverage + - name: check test coverage + id: coverage uses: vladopajic/go-test-coverage@v2 with: config: ./.testcoverage.yml git-branch: badges git-token: ${{ github.ref_name == 'main' && secrets.GITHUB_TOKEN || '' }} + + # Post coverage report as comment + - name: find pull request ID + run: | + PR_DATA=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:${{ github.ref_name }}&state=open") + PR_ID=$(echo "$PR_DATA" | jq -r '.[0].number') + + if [ "$PR_ID" != "null" ]; then + echo "pull_request_id=$PR_ID" >> $GITHUB_ENV + else + echo "No open pull request found for this branch." + fi + - name: find if coverage report is already present + if: env.pull_request_id + uses: peter-evans/find-comment@v3 + id: fc + with: + issue-number: ${{ env.pull_request_id }} + comment-author: 'github-actions[bot]' + body-includes: 'go-test-coverage report:' + - name: post coverage report + if: env.pull_request_id + uses: peter-evans/create-or-update-comment@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ env.pull_request_id }} + comment-id: ${{ steps.fc.outputs.comment-id }} + body: | + go-test-coverage report: + ``` + ${{ fromJSON(steps.coverage.outputs.report) }} + ``` + edit-mode: replace + + - name: "finally check coverage" + if: steps.coverage.outcome == 'failure' + shell: bash + run: echo "coverage check failed" && exit 1 \ No newline at end of file diff --git a/.testcoverage.yml b/.testcoverage.yml index e0ac485..28344e8 100644 --- a/.testcoverage.yml +++ b/.testcoverage.yml @@ -4,4 +4,5 @@ profile: cover.out local-prefix: "github.com/vladopajic/go-actor" threshold: + file: 100 total: 100 diff --git a/Makefile b/Makefile index 8c4f792..c6ee41c 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,11 @@ install-golangcilint: lint: install-golangcilint $(GOLANGCI_LINT) run ./... +# Runs benchmark on entire repo +.PHONY: benchmark +benchmark: + go test -benchmem -count 5 -run=^# -bench=. github.com/vladopajic/go-actor/actor + # Runs tests on entire repo .PHONY: test test: diff --git a/README.md b/README.md index 0c14724..7316b07 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,6 @@ While `go-actor` is designed to be a minimal library with lean interfaces, devel - [super](https://github.com/vladopajic/go-super-actor): An add-on for unifying the testing of actors and workers. - [commence](https://github.com/vladopajic/go-actor-commence): An add-on that provides a mechanism for waiting for actor execution to begin. -- [netbox](https://github.com/vladopajic/go-actor-netbox): Mailbox implemented with different network functionality. ## Pro Tips diff --git a/actor/combine.go b/actor/combine.go index 531bcb6..303c67f 100644 --- a/actor/combine.go +++ b/actor/combine.go @@ -30,6 +30,11 @@ type CombineBuilder struct { // The returned Actor will manage the lifecycle of each underlying Actor, // allowing you to start or stop them collectively. func (b *CombineBuilder) Build() Actor { + if len(b.actors) == 0 { + options := combinedOptionsToRegularList(b.options.Combined) + return Idle(options...) + } + a := &combinedActor{ actors: b.actors, onStopFunc: b.options.Combined.OnStopFunc, @@ -108,7 +113,6 @@ func (a *combinedActor) Stop() { a.ctx = nil } - a.running = false a.runningLock.Unlock() for _, actor := range a.actors { @@ -208,3 +212,17 @@ func (a *wrappedActor) Stop() { a.actor.Stop() a.onStopFunc() } + +func combinedOptionsToRegularList(combined optionsCombined) []Option { + var options []Option + + if fn := combined.OnStartFunc; fn != nil { + options = append(options, OptOnStart(fn)) + } + + if fn := combined.OnStopFunc; fn != nil { + options = append(options, OptOnStop(fn)) + } + + return options +} diff --git a/actor/combine_test.go b/actor/combine_test.go index b5a00da..39d1d2e 100644 --- a/actor/combine_test.go +++ b/actor/combine_test.go @@ -11,21 +11,33 @@ import ( func Test_Combine_TestSuite(t *testing.T) { t.Parallel() - const actorsCount = 10 + TestSuite(t, func() Actor { + actors := createActors(0) + return Combine(actors...).Build() + }) TestSuite(t, func() Actor { - actors := createActors(actorsCount) + actors := createActors(1) + return Combine(actors...).Build() + }) + TestSuite(t, func() Actor { + actors := createActors(10) return Combine(actors...).Build() }) } -// Test asserts that all Start and Stop is -// delegated to all combined actors. +// Test asserts that all Start and Stop is delegated to all combined actors. func Test_Combine(t *testing.T) { t.Parallel() - const actorsCount = 5 + testCombine(t, 0) + testCombine(t, 1) + testCombine(t, 5) +} + +func testCombine(t *testing.T, actorsCount int) { + t.Helper() onStartC := make(chan any, actorsCount) onStopC := make(chan any, actorsCount) @@ -46,43 +58,20 @@ func Test_Combine(t *testing.T) { assert.Len(t, onStopC, actorsCount) } -// Test_Combine_OptStopTogether asserts that all actors will end as soon -// as first actors ends. -func Test_Combine_OptStopTogether(t *testing.T) { +func Test_Combine_OptOnStopOptOnStart(t *testing.T) { t.Parallel() - const actorsCount = 5 * 2 - - for i := range actorsCount/2 + 1 { - onStartC := make(chan any, actorsCount) - onStopC := make(chan any, actorsCount) - onStart := OptOnStart(func(Context) { onStartC <- `🌞` }) - onStop := OptOnStop(func() { onStopC <- `🌚` }) - actors := createActors(actorsCount/2, onStart, onStop) - - // append one more actor to actors list - cmb := Combine(createActors(actorsCount/2, onStart, onStop)...).Build() - actors = append(actors, cmb) - - a := Combine(actors...).WithOptions(OptStopTogether()).Build() - - a.Start() - drainC(onStartC, actorsCount) - - // stop actor and assert that all actors will be stopped - actors[i].Stop() - drainC(onStopC, actorsCount) - } + testCombineOptOnStopOptOnStart(t, 0) + testCombineOptOnStopOptOnStart(t, 1) + testCombineOptOnStopOptOnStart(t, 5) } -func Test_Combine_OptOnStopOptOnStart(t *testing.T) { - t.Parallel() - - const actorsCount = 5 +func testCombineOptOnStopOptOnStart(t *testing.T, count int) { + t.Helper() onStatC, onStartOpt := createCombinedOnStartOption(t, 1) onStopC, onStopOpt := createCombinedOnStopOption(t, 1) - actors := createActors(actorsCount) + actors := createActors(count) a := Combine(actors...). WithOptions(onStopOpt, onStartOpt). @@ -96,6 +85,42 @@ func Test_Combine_OptOnStopOptOnStart(t *testing.T) { a.Stop() // should have no effect assert.Equal(t, `🌚`, <-onStopC) assert.Equal(t, `🌞`, <-onStatC) + assert.Empty(t, onStopC) + assert.Empty(t, onStatC) +} + +// Test_Combine_OptStopTogether asserts that all actors will end as soon +// as first actors ends. +func Test_Combine_OptStopTogether(t *testing.T) { + t.Parallel() + + // no need to test OptStopTogether for actors count < 2 + // because single actor is always "stopped together" + + testCombineOptStopTogether(t, 1) + testCombineOptStopTogether(t, 2) + testCombineOptStopTogether(t, 10) +} + +func testCombineOptStopTogether(t *testing.T, actorsCount int) { + t.Helper() + + for i := range actorsCount { + onStartC := make(chan any, actorsCount) + onStopC := make(chan any, actorsCount) + onStart := OptOnStart(func(Context) { onStartC <- `🌞` }) + onStop := OptOnStop(func() { onStopC <- `🌚` }) + actors := createActors(actorsCount, onStart, onStop) + + a := Combine(actors...).WithOptions(OptStopTogether()).Build() + + a.Start() + drainC(onStartC, actorsCount) + + // stop actor and assert that all actors will be stopped + actors[i].Stop() + drainC(onStopC, actorsCount) + } } func Test_Combine_OptOnStop_AfterActorStops(t *testing.T) { diff --git a/actor/mailbox_benchmark_test.go b/actor/mailbox_benchmark_test.go index c6aaf5d..f5649ca 100644 --- a/actor/mailbox_benchmark_test.go +++ b/actor/mailbox_benchmark_test.go @@ -38,20 +38,14 @@ func benchmarkMailbox(b *testing.B, mbx Mailbox[any]) { mbx.Start() defer mbx.Stop() - doneC := make(chan any) - go func() { + ctx := ContextStarted() for range b.N { - <-mbx.ReceiveC() + mbx.Send(ctx, `🌞`) //nolint:errcheck // error should never happen } - - close(doneC) }() - ctx := ContextStarted() for range b.N { - mbx.Send(ctx, `🌞`) //nolint:errcheck // error should never happen + <-mbx.ReceiveC() } - - <-doneC } diff --git a/actor/mailbox_test.go b/actor/mailbox_test.go index 6c09e6b..531fca3 100644 --- a/actor/mailbox_test.go +++ b/actor/mailbox_test.go @@ -26,7 +26,7 @@ func Test_FromMailboxes(t *testing.T) { // After combined Agent is started all Mailboxes should be executing for _, m := range mm { - assertSendReceive(t, m, `🌹`) + assertSendReceiveAsync(t, m, `🌹`) } a.Stop() @@ -86,7 +86,7 @@ func Test_FanOut(t *testing.T) { // Assert that Mailbox actor is still working for _, m := range fanMbxx { - assertSendReceive(t, m, `🌹`) + assertSendReceiveAsync(t, m, `🌹`) } } @@ -107,7 +107,7 @@ func Test_MailboxWorker_EndSignal(t *testing.T) { func Test_Mailbox_Invariants(t *testing.T) { t.Parallel() - AssertMailboxInvariantsBuffered(t, func() Mailbox[any] { + AssertMailboxInvariantsAsync(t, func() Mailbox[any] { return NewMailbox[any]() }) } @@ -204,32 +204,24 @@ func Test_Mailbox_AsChan(t *testing.T) { t.Parallel() m := NewMailbox[any](OptAsChan()) - - assert.ErrorIs(t, m.Send(ContextStarted(), `👹`), ErrMailboxNotStarted) + assertMailboxNotStarted(t, m) m.Start() assertSendBlocking(t, m) assertReceiveBlocking(t, m) - - // Send when there is receiver - go func() { - assert.NoError(t, m.Send(ContextStarted(), `🌹`)) - }() - assert.Equal(t, `🌹`, <-m.ReceiveC()) - - // Because send is blocking, it must return error when context is canceled - assert.ErrorIs(t, m.Send(ContextEnded(), `🌹`), ContextEnded().Err()) + assertSendWithCanceledCtx(t, m, true) + assertSendReceiveSync(t, m, `🌹`) + assertSendReceiveSync(t, m, nil) m.Stop() - assertMailboxStopped(t, m) }) t.Run("non zero cap", func(t *testing.T) { t.Parallel() - AssertMailboxInvariantsBuffered(t, func() Mailbox[any] { + AssertMailboxInvariantsAsync(t, func() Mailbox[any] { return NewMailbox[any](OptAsChan(), OptCapacity(1)) }) }) @@ -261,33 +253,21 @@ func Test_Mailbox_AsChan_SendStopped(t *testing.T) { <-testDoneC } -func AssertMailboxInvariantsBuffered(t *testing.T, mFact func() Mailbox[any]) { +func AssertMailboxInvariantsAsync(t *testing.T, mFact func() Mailbox[any]) { t.Helper() t.Run("basic invariants", func(t *testing.T) { t.Parallel() m := mFact() - - // Should not be able to send as Mailbox is not started - assert.ErrorIs(t, m.Send(ContextStarted(), `👹`), ErrMailboxNotStarted) - - // Should have not data to receive - assertReceiveBlocking(t, m) + assertMailboxNotStarted(t, m) m.Start() - - // Should be able to send and receive value - assertSendReceive(t, m, `🌹`) - - // Assert that sending nil value should't cause panic - assertSendReceive(t, m, nil) - - assertSendWithCanceledCtx(t, m) + assertSendReceiveAsync(t, m, `🌹`) + assertSendReceiveAsync(t, m, nil) + assertSendWithCanceledCtx(t, m, false) m.Stop() - - // After Mailbox is stopped assert that all channels are closed assertMailboxStopped(t, m) }) @@ -295,26 +275,49 @@ func AssertMailboxInvariantsBuffered(t *testing.T, mFact func() Mailbox[any]) { t.Parallel() m := mFact() + m.Start() m.Start() - - assertSendReceive(t, m, `🌹`) + assertSendReceiveAsync(t, m, `🌹`) m.Stop() m.Stop() - assertMailboxStopped(t, m) m.Start() // Should have no effect assertMailboxStopped(t, m) }) + + //nolint:testifylint // intentionally using == + t.Run("receive channel is not changed", func(t *testing.T) { + t.Parallel() + + m := mFact() + initialC := m.ReceiveC() + + // when mailbox is started assert that ReceiveC is the same as initial + m.Start() + // sending some data to ensure that mbx has fully started + assertSendReceiveAsync(t, m, `🌹`) + assert.True(t, initialC == m.ReceiveC(), "expecting the same reference for ReceiveC") + + // when mailbox is stopped assert ReceiveC should be the same as initial + m.Stop() + assert.True(t, initialC == m.ReceiveC(), "expecting the same reference for ReceiveC") + }) } -func assertSendWithCanceledCtx(t *testing.T, m Mailbox[any]) { +// Asserts that sending with canceled context will end with error. +func assertSendWithCanceledCtx(t *testing.T, m Mailbox[any], immediate bool) { t.Helper() - // Assert that sending with canceled context will end with error. - // Since sendC has some buffer it is possible that some attempts will succeed. + // Because send is blocking, it must return error immediately + if immediate { + assert.ErrorIs(t, m.Send(ContextEnded(), `🌹`), ContextEnded().Err()) + return + } + + // Since sendC has some buffer it is possible that some attempts will succeed for { err := m.Send(ContextEnded(), `🌹`) if err != nil { @@ -322,18 +325,26 @@ func assertSendWithCanceledCtx(t *testing.T, m Mailbox[any]) { return } - // Drain message it went through - <-m.ReceiveC() + <-m.ReceiveC() // Drain message since it went through } } -func assertSendReceive(t *testing.T, m Mailbox[any], val any) { +func assertSendReceiveAsync(t *testing.T, m Mailbox[any], val any) { t.Helper() assert.NoError(t, m.Send(ContextStarted(), val)) assert.Equal(t, val, <-m.ReceiveC()) } +func assertSendReceiveSync(t *testing.T, m Mailbox[any], val any) { + t.Helper() + + go func() { + assert.NoError(t, m.Send(ContextStarted(), val)) + }() + assert.Equal(t, val, <-m.ReceiveC()) +} + func assertMailboxStopped(t *testing.T, m Mailbox[any]) { t.Helper() @@ -343,6 +354,15 @@ func assertMailboxStopped(t *testing.T, m Mailbox[any]) { assert.False(t, ok) } +func assertMailboxNotStarted(t *testing.T, m Mailbox[any]) { + t.Helper() + + assert.ErrorIs(t, m.Send(ContextStarted(), `👹`), ErrMailboxNotStarted, + "should not be able to send as Mailbox is not started") + + assertReceiveBlocking(t, m) +} + func assertReceiveBlocking(t *testing.T, m Mailbox[any]) { t.Helper() diff --git a/actor/queue.go b/actor/queue.go index 9589974..51beb0e 100644 --- a/actor/queue.go +++ b/actor/queue.go @@ -5,14 +5,11 @@ import ( ) func newQueue[T any](capacity int) *queue[T] { - minimum := capacity - if minimum < minQueueCapacity { - minimum = minQueueCapacity - } - - return &queue[T]{ - q: queueImpl.New[T](capacity, minimum), - } + q := &queueImpl.Deque[T]{} + q.SetBaseCap(max(minQueueCapacity, capacity)) + q.Grow(capacity) + + return &queue[T]{q} } type queue[T any] struct { diff --git a/actor/queue_test.go b/actor/queue_test.go index 40c9115..b75d6a4 100644 --- a/actor/queue_test.go +++ b/actor/queue_test.go @@ -52,31 +52,27 @@ func TestQueue_Basic(t *testing.T) { func TestQueue_Cap(t *testing.T) { t.Parallel() - { - q := NewQueue[any](10) - assert.Equal(t, MinQueueCapacity, q.Cap()) - assert.Equal(t, 0, q.Len()) + { // push over capacity, then pop to zero + q := NewQueue[any](MinQueueCapacity / 2) + assert.Equal(t, MinQueueCapacity/2, q.Cap()) - q.PushBack(`🌊`) + for range 2 * MinQueueCapacity { + q.PushBack(`🌊`) + } - assert.Equal(t, MinQueueCapacity, q.Cap()) - assert.Equal(t, 1, q.Len()) - } + assert.Equal(t, 2*MinQueueCapacity, q.Len()) + + for !q.IsEmpty() { + q.PopFront() + } - { - q := NewQueue[any](10) assert.Equal(t, MinQueueCapacity, q.Cap()) assert.Equal(t, 0, q.Len()) } - { + { // should used supplied capacity since value is larger then minimal q := NewQueue[any](MinQueueCapacity * 2) assert.Equal(t, MinQueueCapacity*2, q.Cap()) assert.Equal(t, 0, q.Len()) - - q.PushBack(`🌊`) - - assert.Equal(t, MinQueueCapacity*2, q.Cap()) - assert.Equal(t, 1, q.Len()) } } diff --git a/go.mod b/go.mod index a5a0199..e45d710 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/vladopajic/go-actor go 1.23 require ( - github.com/gammazero/deque v0.2.1 - github.com/stretchr/testify v1.9.0 + github.com/gammazero/deque v1.0.0 + github.com/stretchr/testify v1.10.0 go.uber.org/goleak v1.3.0 ) diff --git a/go.sum b/go.sum index 31f9443..fe3cac8 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,16 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gammazero/deque v0.2.1 h1:qSdsbG6pgp6nL7A0+K/B7s12mcCY/5l5SIUpMOl+dC0= -github.com/gammazero/deque v0.2.1/go.mod h1:LFroj8x4cMYCukHJDbxFCkT+r9AndaJnFMuZDV34tuU= +github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34= +github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=