├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── go.mod ├── go.sum ├── pubsub.go └── pubsub_test.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Go Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | name: Run Go Unit Tests 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v4 22 | with: 23 | go-version: '1.21' # or your version 24 | 25 | - name: Cache Go modules 26 | uses: actions/cache@v3 27 | with: 28 | path: | 29 | ~/.cache/go-build 30 | ~/go/pkg/mod 31 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 32 | restore-keys: | 33 | ${{ runner.os }}-go- 34 | 35 | - name: Install dependencies 36 | run: go mod download 37 | 38 | - name: Run tests 39 | run: go test -v ./... 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | 27 | # ide specific ignores 28 | .idea 29 | 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sean Esopenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # genericpubsub 2 | 3 | [![Go Tests](https://github.com/sesopenko/genericpubsub/actions/workflows/test.yml/badge.svg)](https://github.com/sesopenko/genericpubsub/actions/workflows/test.yml) 4 | [![Go Reference](https://pkg.go.dev/badge/github.com/sesopenko/genericpubsub.svg)](https://pkg.go.dev/github.com/sesopenko/genericpubsub) 5 | [![GitHub tag](https://img.shields.io/github/v/tag/sesopenko/genericpubsub?label=version)](https://github.com/sesopenko/genericpubsub/tags) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt) 7 | [![Go Version](https://img.shields.io/badge/go-1.24+-blue)](https://golang.org/doc/go1.24) 8 | 9 | 10 | `genericpubsub` is a lightweight, type-safe publish/subscribe system for Go, using generics and context-based 11 | cancellation. It allows you to publish messages of any type to multiple subscribers concurrently, with clean shutdown 12 | and resource management. 13 | 14 | ## Features 15 | 16 | - Type-safe using Go generics 17 | - Simple API: `Send`, `Subscribe` 18 | - Context-based cancellation 19 | - Graceful shutdown of subscribers 20 | - Fully tested with unit tests 21 | 22 | ## Installation 23 | 24 | ```bash 25 | go get github.com/sesopenko/genericpubsub 26 | ```` 27 | 28 | ## Documentation 29 | 30 | https://pkg.go.dev/github.com/sesopenko/genericpubsub 31 | 32 | ## Example 33 | 34 | ```go 35 | package main 36 | 37 | import ( 38 | "context" 39 | "fmt" 40 | "time" 41 | "github.com/sesopenko/genericpubsub" 42 | ) 43 | 44 | type Message struct { 45 | Value string 46 | } 47 | 48 | func main() { 49 | channelBuffer := 64 50 | ps := genericpubsub.New[Message](context.Background(), channelBuffer) 51 | sub := ps.Subscribe(context.TODO(), channelBuffer) 52 | 53 | go ps.Send(Message{Value: "hello"}) 54 | time.Sleep(50 * time.Millisecond) 55 | msg, ok := <-sub 56 | fmt.Println("Received:", msg.Value) 57 | fmt.Printf("channel wasn't closed: %t\n", ok) 58 | } 59 | 60 | ``` 61 | 62 | ## License 63 | 64 | This project is licensed under the MIT license. See [LICENSE.txt](LICENSE.txt) for details. 65 | 66 | © 2025 Sean Esopenko -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sesopenko/genericpubsub 2 | 3 | go 1.24 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /pubsub.go: -------------------------------------------------------------------------------- 1 | package genericpubsub 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "sync/atomic" 7 | ) 8 | 9 | var nextSubId uint64 10 | 11 | func getNextSubId() uint64 { 12 | return atomic.AddUint64(&nextSubId, 1) 13 | } 14 | 15 | type Sub[T any] struct { 16 | receiver chan T 17 | } 18 | 19 | type PubSub[T any] struct { 20 | pubChan chan T 21 | subs sync.Map 22 | ctx context.Context 23 | } 24 | 25 | type Subscription[T any] struct { 26 | Receiver chan T 27 | Ctx context.Context 28 | } 29 | 30 | func (s *PubSub[T]) Send(value T) { 31 | s.pubChan <- value 32 | } 33 | 34 | func (ps *PubSub[T]) start() { 35 | go func() { 36 | for { 37 | select { 38 | case <-ps.ctx.Done(): 39 | // Cleanup and delete all subs to free memory. 40 | ps.subs.Range(func(subKey, v interface{}) bool { 41 | ps.subs.Delete(subKey) 42 | return true 43 | }) 44 | return 45 | case m := <-ps.pubChan: 46 | ps.subs.Range(func(subid, v interface{}) bool { 47 | sub, _ := v.(*Sub[T]) 48 | // do this in a go routine so that a bad sub doesn't lock 49 | // the system up. 50 | go func() { 51 | sub.receiver <- m 52 | }() 53 | return true 54 | }) 55 | } 56 | } 57 | }() 58 | 59 | } 60 | 61 | // Subscribe provides a channel which the main PubSub will send values to. 62 | // callerCtx is used to signal a cancellation of the single subscription, such as when a 63 | // websocket is closed and the subscription for the websocket needs to be also closed. 64 | // The bufferSize needs to be large enough to not cause performance issues under high load. 65 | // Returns a channel the registered receiver receives values from 66 | // The channel will be closed when messages are no longer going to be sent, such as when 67 | // the PubSub itself is closed or this registered subscription is cancelled. 68 | func (ps *PubSub[T]) Subscribe(callerCtx context.Context, bufferSize int) chan T { 69 | id := getNextSubId() 70 | recv := make(chan T, bufferSize) 71 | go func() { 72 | select { 73 | case <-callerCtx.Done(): 74 | ps.cleanupSubscription(id) 75 | break 76 | case <-ps.ctx.Done(): 77 | ps.cleanupSubscription(id) 78 | break 79 | } 80 | }() 81 | 82 | sub := Sub[T]{ 83 | receiver: recv, 84 | } 85 | ps.subs.Store(id, &sub) 86 | return recv 87 | } 88 | 89 | func (ps *PubSub[T]) cleanupSubscription(id uint64) { 90 | if val, ok := ps.subs.Load(id); ok == true { 91 | if sub, ok := val.(*Sub[T]); ok == true { 92 | close(sub.receiver) 93 | } 94 | ps.subs.Delete(id) 95 | 96 | } 97 | } 98 | 99 | func New[T any](ctx context.Context, bufferSize int) *PubSub[T] { 100 | pubSub := &PubSub[T]{ 101 | pubChan: make(chan T, bufferSize), 102 | subs: sync.Map{}, 103 | ctx: ctx, 104 | } 105 | pubSub.start() 106 | return pubSub 107 | } 108 | -------------------------------------------------------------------------------- /pubsub_test.go: -------------------------------------------------------------------------------- 1 | package genericpubsub 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/stretchr/testify/assert" 7 | "log" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | const testBufferSize = 64 13 | 14 | func TestPubSub_RegisterShouldNotifySub(t *testing.T) { 15 | type message struct { 16 | Value string 17 | } 18 | 19 | // Arrange 20 | input := message{ 21 | Value: "hello", 22 | } 23 | expected := message{ 24 | Value: "hello", 25 | } 26 | ps := New[message](context.TODO(), testBufferSize) 27 | events := ps.Subscribe(context.TODO(), testBufferSize) 28 | 29 | // Act 30 | go func() { 31 | ps.Send(input) 32 | }() 33 | 34 | // Assert 35 | wasClosed := true 36 | select { 37 | case result := <-events: 38 | wasClosed = false 39 | assert.Equal(t, expected.Value, result.Value) 40 | case <-time.After(200 * time.Millisecond): 41 | wasClosed = false 42 | t.Fatalf("Timed out waiting for event") 43 | } 44 | assert.False(t, wasClosed, "Should not have closed the channel") 45 | } 46 | 47 | func TestPubSub_RegisterShouldNotifyLateSubscriber(t *testing.T) { 48 | // Given 2 subs 49 | // And 2nd sub has a delayed subscription 50 | // And 2nd sub is slow to read channel 51 | // Should not delay 1st sub 52 | // And 2nd sub should only read 2nd message 53 | type message struct { 54 | Value string 55 | } 56 | 57 | // Arrange 58 | firstInput := message{ 59 | Value: "first", 60 | } 61 | secondInput := message{ 62 | Value: "second", 63 | } 64 | firstExpected := message{ 65 | Value: "first", 66 | } 67 | secondExpected := message{ 68 | Value: "second", 69 | } 70 | ps := New[message](context.TODO(), testBufferSize) 71 | firstEvents := ps.Subscribe(context.TODO(), testBufferSize) 72 | 73 | // Send first message 74 | ps.Send(firstInput) 75 | time.Sleep(50 * time.Millisecond) 76 | 77 | select { 78 | case result, ok := <-firstEvents: 79 | assert.True(t, ok) 80 | assert.Equal(t, firstExpected, result) 81 | } 82 | 83 | time.Sleep(100 * time.Millisecond) 84 | secondEvents := ps.Subscribe(context.TODO(), testBufferSize) 85 | 86 | secondDone := make(chan bool) 87 | go func() { 88 | // Make the 2nd sub sluggish, should not block other sub 89 | time.Sleep(50 * time.Millisecond) 90 | select { 91 | case secondResultSeconSub, ok := <-secondEvents: 92 | assert.True(t, ok) 93 | assert.Equal(t, secondExpected, secondResultSeconSub) 94 | case <-time.After(200 * time.Millisecond): 95 | // This is a pass scenario, should receive no more messages 96 | } 97 | secondDone <- true 98 | }() 99 | 100 | sendTime := time.Now() 101 | ps.Send(secondInput) 102 | 103 | select { 104 | case result, ok := <-firstEvents: 105 | elapsed := time.Since(sendTime) 106 | assert.LessOrEqual(t, elapsed, 10*time.Millisecond, "Should not be held up by other subscribers") 107 | assert.True(t, ok) 108 | assert.Equal(t, secondExpected, result) 109 | case <-time.After(500 * time.Millisecond): 110 | t.Errorf("Timed out waiting for 2nd event for 1st sub") 111 | } 112 | 113 | select { 114 | case <-secondDone: 115 | log.Printf("second done") 116 | select { 117 | case _, ok := <-secondEvents: 118 | assert.False(t, ok, "Should not have received a 2nd event on 2nd sub") 119 | case <-time.After(400 * time.Millisecond): 120 | // do nothing, this is expected 121 | } 122 | case <-time.After(400 * time.Millisecond): 123 | // do nothing, this is expected 124 | } 125 | 126 | } 127 | 128 | func TestPubSub_RegisterShouldNotNotifyCancelledSub(t *testing.T) { 129 | type message struct { 130 | Value string 131 | } 132 | input := message{ 133 | Value: "hello", 134 | } 135 | cancellableCtx, cancel := context.WithCancel(context.Background()) 136 | ps := New[message](context.TODO(), testBufferSize) 137 | cancel() 138 | events := ps.Subscribe(cancellableCtx, testBufferSize) 139 | go func() { 140 | time.Sleep(100 * time.Millisecond) 141 | ps.Send(input) 142 | }() 143 | select { 144 | case _, chanWasOpen := <-events: 145 | if chanWasOpen { 146 | t.Fatalf("Should not receive a message after cancellation of context.") 147 | } 148 | break 149 | case <-time.After(200 * time.Millisecond): 150 | // We've given it enough time for the message send and should 151 | // now reach this point. Simply break, because we pass if this 152 | // happens. 153 | break 154 | } 155 | } 156 | 157 | func TestPubSub_RegisterShouldNotNotifyCancelledSystem(t *testing.T) { 158 | type message struct { 159 | Value string 160 | } 161 | input := message{ 162 | Value: "hello", 163 | } 164 | ctx, cancel := context.WithCancel(context.Background()) 165 | ps := New[message](ctx, testBufferSize) 166 | cancel() 167 | events := ps.Subscribe(context.TODO(), testBufferSize) 168 | go func() { 169 | time.Sleep(100 * time.Millisecond) 170 | ps.Send(input) 171 | }() 172 | select { 173 | case _, chanWasOpen := <-events: 174 | if chanWasOpen { 175 | t.Fatalf("Should not receive a message after cancellation of context.") 176 | } 177 | break 178 | case <-time.After(200 * time.Millisecond): 179 | // We've given it enough time for the message send and should 180 | // now reach this point. Simply break, because we pass if this 181 | // happens. 182 | break 183 | } 184 | } 185 | 186 | func ExampleNew() { 187 | type Message struct { 188 | Value string 189 | } 190 | 191 | ctx := context.Background() 192 | ps := New[Message](ctx, 10) 193 | 194 | sub := ps.Subscribe(context.TODO(), 10) 195 | ps.Send(Message{Value: "Hello"}) 196 | 197 | time.Sleep(50 * time.Millisecond) 198 | msg, ok := <-sub 199 | fmt.Println(msg.Value) 200 | fmt.Printf("channel wasn't closed: %t\n", ok) 201 | 202 | // Output: 203 | // Hello 204 | // channel wasn't closed: true 205 | 206 | } 207 | 208 | func ExamplePubSub_Send() { 209 | type Message struct { 210 | Value string 211 | } 212 | 213 | ctx := context.Background() 214 | ps := New[Message](ctx, 10) 215 | 216 | sub := ps.Subscribe(context.TODO(), 10) 217 | ps.Send(Message{Value: "Hello"}) 218 | 219 | time.Sleep(50 * time.Millisecond) 220 | msg, ok := <-sub 221 | fmt.Println(msg.Value) 222 | fmt.Printf("channel wasn't closed: %t\n", ok) 223 | 224 | // Output: 225 | // Hello 226 | // channel wasn't closed: true 227 | } 228 | 229 | func ExamplePubSub_Subscribe() { 230 | type Message struct { 231 | Value string 232 | } 233 | 234 | ctx := context.Background() 235 | ps := New[Message](ctx, 10) 236 | 237 | sub := ps.Subscribe(context.TODO(), 10) 238 | ps.Send(Message{Value: "Hello"}) 239 | 240 | time.Sleep(50 * time.Millisecond) 241 | msg, ok := <-sub 242 | fmt.Println(msg.Value) 243 | fmt.Printf("channel wasn't closed: %t\n", ok) 244 | 245 | // Output: 246 | // Hello 247 | // channel wasn't closed: true 248 | } 249 | --------------------------------------------------------------------------------