aboutsummaryrefslogtreecommitdiff
path: root/fanout/fanout.go
diff options
context:
space:
mode:
Diffstat (limited to 'fanout/fanout.go')
-rw-r--r--fanout/fanout.go203
1 files changed, 203 insertions, 0 deletions
diff --git a/fanout/fanout.go b/fanout/fanout.go
new file mode 100644
index 0000000..1cd2043
--- /dev/null
+++ b/fanout/fanout.go
@@ -0,0 +1,203 @@
1// Package fanout distributes values from a single input channel to multiple output channels.
2package fanout
3
4import (
5 "context"
6 "errors"
7 "fmt"
8 "sync"
9 "sync/atomic"
10)
11
12// FanOutObserver defines callbacks for monitoring fan-out lifecycle events.
13type FanOutObserver interface {
14 OnDistribute()
15 OnOutputClosed()
16}
17
18// FanOutStrategy defines how values are distributed across output channels.
19type FanOutStrategy int
20
21const (
22 // RoundRobin distributes values evenly across output channels in rotation.
23 RoundRobin FanOutStrategy = iota
24 // Broadcast sends each value to all output channels.
25 Broadcast
26)
27
28// ErrInvalidStrategy is returned when an unknown FanOutStrategy is provided.
29var ErrInvalidStrategy = errors.New("fanout: unknown distribution strategy")
30
31// FanOutConfig holds configuration options for creating a FanOut instance.
32type FanOutConfig struct {
33 WorkerCount int
34 BufferSize int
35 Strategy FanOutStrategy
36 Observer FanOutObserver
37}
38
39// Option is a functional option for configuring a FanOut instance.
40type Option func(*FanOutConfig)
41
42// FanOut distributes values from a single input channel to multiple output channels.
43type FanOut[T any] struct {
44 cfg *FanOutConfig
45 outputs []chan T
46 running atomic.Bool
47 cachedOutputs []<-chan T
48 once sync.Once
49}
50
51// NewFanOut creates a new FanOut instance with the provided options.
52func NewFanOut[T any](opts ...Option) (*FanOut[T], error) {
53 cfg := &FanOutConfig{
54 WorkerCount: 1,
55 BufferSize: 0,
56 Observer: nil,
57 Strategy: RoundRobin,
58 }
59
60 for _, opt := range opts {
61 opt(cfg)
62 }
63
64 if cfg.WorkerCount < 1 {
65 return nil, fmt.Errorf("fanout: worker count must be at least 1, got %d", cfg.WorkerCount)
66 }
67
68 if cfg.BufferSize < 0 {
69 return nil, fmt.Errorf("fanout: buffer size must not be negative, got %d", cfg.BufferSize)
70 }
71
72 if cfg.Strategy != RoundRobin && cfg.Strategy != Broadcast {
73 return nil, ErrInvalidStrategy
74 }
75
76 outputs := make([]chan T, cfg.WorkerCount)
77 for i := range outputs {
78 outputs[i] = make(chan T, cfg.BufferSize)
79 }
80
81 return &FanOut[T]{
82 cfg: cfg,
83 outputs: outputs,
84 }, nil
85}
86
87// Run starts distributing values from the input channel to all output channels.
88func (f *FanOut[T]) Run(ctx context.Context, input <-chan T) {
89 if input == nil {
90 panic("fanout: input channel must not be nil")
91 }
92
93 if !f.running.CompareAndSwap(false, true) {
94 panic("fanout: Run() called multiple times")
95 }
96
97 go func() {
98 idx := 0
99 defer f.running.Store(false)
100 defer f.closeAllOutput()
101 for {
102 select {
103 case value, ok := <-input:
104 if !ok {
105 return
106 }
107 switch f.cfg.Strategy {
108 case RoundRobin:
109 f.roundRobin(ctx, value, &idx)
110 case Broadcast:
111 f.broadcast(ctx, value)
112 }
113 case <-ctx.Done():
114 return
115 }
116 }
117 }()
118}
119
120// Outputs returns receive-only channel references for the distributed streams.
121func (f *FanOut[T]) Outputs() []<-chan T {
122 f.once.Do(func() {
123 f.cachedOutputs = make([]<-chan T, len(f.outputs))
124 for i, ch := range f.outputs {
125 f.cachedOutputs[i] = ch
126 }
127 })
128
129 return f.cachedOutputs
130}
131
132func safeObserve(fn func()) {
133 defer func() { recover() }()
134 fn()
135}
136
137func (f *FanOut[T]) roundRobin(ctx context.Context, value T, index *int) {
138 target := *index % f.cfg.WorkerCount
139 select {
140 case f.outputs[target] <- value:
141 *index = (*index + 1) % f.cfg.WorkerCount
142 if f.cfg.Observer != nil {
143 safeObserve(f.cfg.Observer.OnDistribute)
144 }
145 case <-ctx.Done():
146 return
147 }
148}
149
150func (f *FanOut[T]) broadcast(ctx context.Context, value T) {
151 var wg sync.WaitGroup
152 for _, ch := range f.outputs {
153 wg.Go(func() {
154 select {
155 case ch <- value:
156 if f.cfg.Observer != nil {
157 safeObserve(f.cfg.Observer.OnDistribute)
158 }
159 case <-ctx.Done():
160 return
161 }
162 })
163 }
164 wg.Wait()
165}
166
167func (f *FanOut[T]) closeAllOutput() {
168 for _, ch := range f.outputs {
169 close(ch)
170 }
171
172 if f.cfg.Observer != nil {
173 safeObserve(f.cfg.Observer.OnOutputClosed)
174 }
175}
176
177// WithWorkerCount sets the number of output channels for the fan-out.
178func WithWorkerCount(n int) Option {
179 return func(cfg *FanOutConfig) {
180 cfg.WorkerCount = n
181 }
182}
183
184// WithBufferSize sets the buffer capacity for each output channel.
185func WithBufferSize(size int) Option {
186 return func(cfg *FanOutConfig) {
187 cfg.BufferSize = size
188 }
189}
190
191// WithStrategy sets the distribution strategy for the fan-out.
192func WithStrategy(strategy FanOutStrategy) Option {
193 return func(cfg *FanOutConfig) {
194 cfg.Strategy = strategy
195 }
196}
197
198// WithObserver attaches a FanOutObserver to receive lifecycle event callbacks.
199func WithObserver(observer FanOutObserver) Option {
200 return func(cfg *FanOutConfig) {
201 cfg.Observer = observer
202 }
203}