refactor: phase 2 integrated new dialer into worker
This commit is contained in:
@@ -1,81 +0,0 @@
|
||||
# Extending the Pool
|
||||
|
||||
The pool owns peer registration, event plumbing, and lifecycle management. The worker owns everything that happens on the wire between registration and the terminal event. Everything between `pool.Connect` and the final disconnect event is the worker's responsibility, and it is fully replaceable.
|
||||
|
||||
## The Worker Interface
|
||||
|
||||
The pool accepts any type that satisfies:
|
||||
|
||||
```go
|
||||
type Worker interface {
|
||||
Start(pool PoolPlugin)
|
||||
Stop()
|
||||
Send(data []byte) error
|
||||
Stats() WorkerStats
|
||||
}
|
||||
```
|
||||
|
||||
The behavioral contract for each method:
|
||||
|
||||
**`Start(pool PoolPlugin)`** Called by the pool in a goroutine it owns. Must block until the worker is finished. The pool monitors this goroutine via a `sync.WaitGroup`; `Start` returning is the signal that the worker is done. All I/O, goroutine management, and event emission happen inside `Start`.
|
||||
|
||||
**`Stop()`** Must cause `Start` to return in bounded time. Typically cancels a context. May be called from any goroutine, including concurrently with `Start`.
|
||||
|
||||
**`Send(data []byte) error`** Writes data to the remote peer and returns an error if it cannot. Must be safe for concurrent callers. The pool calls `Send` from whatever goroutine the consumer calls `pool.Send` from.
|
||||
|
||||
**`Stats() WorkerStats`** Returns a snapshot of the worker's internal counters and channel depths. Must be safe for concurrent callers and must not block. The pool calls this from `pool.Stats()` and `pool.PeerStats()` while holding a read lock.
|
||||
|
||||
## The PoolPlugin
|
||||
|
||||
The pool constructs a `PoolPlugin` and passes it to `Start`. It gives the worker access to pool-level channels and the logging handler.
|
||||
|
||||
```go
|
||||
type PoolPlugin struct {
|
||||
ID string
|
||||
Inbox chan<- honeybee.InboxMessage
|
||||
Events chan<- honeybee.PoolEvent
|
||||
InboxCounter *atomic.Uint64
|
||||
ConnectionConfig transport.ConnectionConfig
|
||||
Handler slog.Handler
|
||||
}
|
||||
```
|
||||
|
||||
**`Inbox`** The shared channel that delivers received messages to the pool's consumer. All peers in the pool deliver to the same inbox channel. Workers must include their peer ID in each `InboxMessage`.
|
||||
|
||||
**`Events`** The shared channel for lifecycle events. Workers emit `honeybee.EventConnected`, `honeybee.EventDisconnected`, and `honeybee.EventDialFailed` directly. All events include a timestamp in the `At` field. `EventDialFailed` events also carry a non-nil `Err` field containing the dialer error.
|
||||
|
||||
**`InboxCounter`** An atomic counter owned by the pool. Workers must increment this once for each message forwarded to `Inbox`. The pool reads it in `Stats()`.
|
||||
|
||||
**`Handler`** The `slog.Handler` passed to the pool constructor. The pool constructs and injects scoped loggers before calling the factory, so most workers will use the logger they receive from the factory rather than constructing one from `Handler` directly. `Handler` is available for workers that manage sub-components that need their own loggers.
|
||||
|
||||
## Extending the Pool
|
||||
|
||||
### Factory Signature
|
||||
|
||||
```go
|
||||
type honeybee.WorkerFactory func(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
logger *slog.Logger,
|
||||
) (honeybee.Worker, error)
|
||||
```
|
||||
|
||||
The pool calls the factory under its write lock when `Connect` is called. The factory must return without blocking. The worker is responsible for dialing and managing its own connections. The pool constructs `logger` from the worker logging config before calling the factory.
|
||||
|
||||
The factory is set via `honeybee.WithWorkerFactory` on the pool config.
|
||||
|
||||
### Replacing the Worker
|
||||
|
||||
Satisfy the `Worker` interface and register your implementation via `honeybee.WithWorkerFactory`. Your worker is responsible for the full connection lifecycle: dialing and redialing, managing connection state, forwarding received messages to `pool.Inbox`, emitting `EventConnected`, `EventDisconnected`, and `EventDialFailed` to `pool.Events`, and incrementing `pool.InboxCounter` for each message forwarded.
|
||||
|
||||
`DefaultWorker`'s source is the authoritative reference for how those responsibilities are met.
|
||||
|
||||
## Factory Constraints
|
||||
|
||||
The factory is called while the pool holds its write lock. Two constraints follow from this directly.
|
||||
|
||||
**Factories must not block.** Any operation that could wait — dialing a connection, acquiring another lock, reading from a channel — will deadlock or stall the pool. All blocking work belongs inside `Start`, not inside the factory.
|
||||
|
||||
**Factories must not call pool methods.** `pool.Connect`, `pool.Send`, `pool.Remove`, and similar methods all acquire the same lock the factory is called under. Calling them from the factory will deadlock.
|
||||
|
||||
The factory's only job is to construct and return a worker. If construction itself can fail — for example, because a config value is invalid — return the error and the pool will propagate it to the caller of `Connect`.
|
||||
@@ -1,222 +0,0 @@
|
||||
package honeybee
|
||||
|
||||
import (
|
||||
"git.wisehodl.dev/jay/go-honeybee/transport"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Pool Config
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Types
|
||||
|
||||
type PoolConfig struct {
|
||||
InboxBufferSize int
|
||||
EventsBufferSize int
|
||||
ConnectionConfig transport.ConnectionConfig
|
||||
WorkerFactory WorkerFactory
|
||||
WorkerConfig WorkerConfig
|
||||
}
|
||||
|
||||
type PoolOption func(*PoolConfig) error
|
||||
|
||||
// Constructor
|
||||
|
||||
func NewPoolConfig(options ...PoolOption) (*PoolConfig, error) {
|
||||
conf := GetDefaultPoolConfig()
|
||||
if err := applyPoolOptions(conf, options...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidatePoolConfig(conf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
func GetDefaultPoolConfig() *PoolConfig {
|
||||
return &PoolConfig{
|
||||
InboxBufferSize: 256,
|
||||
EventsBufferSize: 10,
|
||||
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
|
||||
WorkerFactory: nil,
|
||||
WorkerConfig: *GetDefaultWorkerConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
func applyPoolOptions(config *PoolConfig, options ...PoolOption) error {
|
||||
for _, option := range options {
|
||||
if err := option(config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validation
|
||||
|
||||
func ValidatePoolConfig(config *PoolConfig) error {
|
||||
var err error
|
||||
|
||||
err = transport.ValidateConnectionConfig(&config.ConnectionConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = ValidateWorkerConfig(&config.WorkerConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBufferSize(value int) error {
|
||||
if value < 1 {
|
||||
return InvalidBufferSize
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Options
|
||||
|
||||
func WithInboxBufferSize(value int) PoolOption {
|
||||
return func(c *PoolConfig) error {
|
||||
if err := validateBufferSize(value); err != nil {
|
||||
return err
|
||||
}
|
||||
c.InboxBufferSize = value
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithEventsBufferSize(value int) PoolOption {
|
||||
return func(c *PoolConfig) error {
|
||||
if err := validateBufferSize(value); err != nil {
|
||||
return err
|
||||
}
|
||||
c.EventsBufferSize = value
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithConnectionConfig(cc transport.ConnectionConfig) PoolOption {
|
||||
return func(c *PoolConfig) error {
|
||||
err := transport.ValidateConnectionConfig(&cc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.ConnectionConfig = cc
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithWorkerConfig(wc WorkerConfig) PoolOption {
|
||||
return func(c *PoolConfig) error {
|
||||
err := ValidateWorkerConfig(&wc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.WorkerConfig = wc
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithWorkerFactory(wf WorkerFactory) PoolOption {
|
||||
return func(c *PoolConfig) error {
|
||||
c.WorkerFactory = wf
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Worker Config
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Types
|
||||
|
||||
type WorkerConfig struct {
|
||||
KeepaliveTimeout time.Duration
|
||||
ReconnectDelay time.Duration
|
||||
}
|
||||
|
||||
type WorkerOption func(*WorkerConfig) error
|
||||
|
||||
// Constructor
|
||||
|
||||
func NewWorkerConfig(options ...WorkerOption) (*WorkerConfig, error) {
|
||||
conf := GetDefaultWorkerConfig()
|
||||
if err := applyWorkerOptions(conf, options...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateWorkerConfig(conf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
func GetDefaultWorkerConfig() *WorkerConfig {
|
||||
return &WorkerConfig{
|
||||
KeepaliveTimeout: 60 * time.Second,
|
||||
ReconnectDelay: 2 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func applyWorkerOptions(config *WorkerConfig, options ...WorkerOption) error {
|
||||
for _, option := range options {
|
||||
if err := option(config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validation
|
||||
|
||||
func ValidateWorkerConfig(config *WorkerConfig) error {
|
||||
err := validateKeepaliveTimeout(config.KeepaliveTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateKeepaliveTimeout(value time.Duration) error {
|
||||
if value < 0 {
|
||||
return InvalidKeepaliveTimeout
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateReconnectDelay(value time.Duration) error {
|
||||
if value < 0 {
|
||||
return InvalidReconnectDelay
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Options
|
||||
|
||||
// When KeepaliveTimeout is set to zero, keepalive timeouts are disabled.
|
||||
func WithKeepaliveTimeout(value time.Duration) WorkerOption {
|
||||
return func(c *WorkerConfig) error {
|
||||
err := validateKeepaliveTimeout(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.KeepaliveTimeout = value
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithReconnectDelay(value time.Duration) WorkerOption {
|
||||
return func(c *WorkerConfig) error {
|
||||
err := validateReconnectDelay(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.ReconnectDelay = value
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package honeybee
|
||||
|
||||
import (
|
||||
"git.wisehodl.dev/jay/go-honeybee/transport"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewPoolConfig(t *testing.T) {
|
||||
conf, err := NewPoolConfig()
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, conf, &PoolConfig{
|
||||
InboxBufferSize: 256,
|
||||
EventsBufferSize: 10,
|
||||
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
|
||||
WorkerConfig: *GetDefaultWorkerConfig(),
|
||||
WorkerFactory: nil,
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultPoolConfig(t *testing.T) {
|
||||
conf := GetDefaultPoolConfig()
|
||||
|
||||
assert.Equal(t, conf, &PoolConfig{
|
||||
InboxBufferSize: 256,
|
||||
EventsBufferSize: 10,
|
||||
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
|
||||
WorkerConfig: *GetDefaultWorkerConfig(),
|
||||
WorkerFactory: nil,
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyPoolOptions(t *testing.T) {
|
||||
conf := &PoolConfig{}
|
||||
err := applyPoolOptions(
|
||||
conf,
|
||||
WithConnectionConfig(transport.ConnectionConfig{
|
||||
Retry: transport.RetryConfig{Disabled: true},
|
||||
}),
|
||||
)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0*time.Second, conf.ConnectionConfig.WriteTimeout)
|
||||
}
|
||||
|
||||
func TestWithBufferSizes(t *testing.T) {
|
||||
conf := &PoolConfig{}
|
||||
|
||||
err := applyPoolOptions(conf,
|
||||
WithInboxBufferSize(100),
|
||||
WithEventsBufferSize(20),
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 100, conf.InboxBufferSize)
|
||||
assert.Equal(t, 20, conf.EventsBufferSize)
|
||||
}
|
||||
|
||||
func TestWithConnectionConfig(t *testing.T) {
|
||||
conf := &PoolConfig{}
|
||||
opt := WithConnectionConfig(transport.ConnectionConfig{
|
||||
WriteTimeout: 1 * time.Second,
|
||||
Retry: transport.RetryConfig{Disabled: true},
|
||||
})
|
||||
err := applyPoolOptions(conf, opt)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1*time.Second, conf.ConnectionConfig.WriteTimeout)
|
||||
|
||||
// invalid config is rejected
|
||||
conf = &PoolConfig{}
|
||||
opt = WithConnectionConfig(
|
||||
transport.ConnectionConfig{
|
||||
WriteTimeout: -1 * time.Second,
|
||||
Retry: transport.RetryConfig{Disabled: true},
|
||||
})
|
||||
err = applyPoolOptions(conf, opt)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestValidatePoolConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
conf PoolConfig
|
||||
wantErr error
|
||||
wantErrText string
|
||||
}{
|
||||
{
|
||||
name: "valid empty (retry disabled)",
|
||||
conf: PoolConfig{
|
||||
ConnectionConfig: transport.ConnectionConfig{
|
||||
Retry: transport.RetryConfig{Disabled: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid defaults",
|
||||
conf: *GetDefaultPoolConfig(),
|
||||
},
|
||||
{
|
||||
name: "valid complete",
|
||||
conf: PoolConfig{
|
||||
ConnectionConfig: transport.ConnectionConfig{
|
||||
Retry: transport.RetryConfig{Disabled: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid connection config",
|
||||
conf: PoolConfig{
|
||||
ConnectionConfig: transport.ConnectionConfig{
|
||||
Retry: transport.RetryConfig{
|
||||
InitialDelay: 10 * time.Second,
|
||||
MaxDelay: 1 * time.Second,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErrText: "initial delay may not exceed maximum delay",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidatePoolConfig(&tc.conf)
|
||||
|
||||
if tc.wantErr != nil || tc.wantErrText != "" {
|
||||
if tc.wantErr != nil {
|
||||
assert.ErrorIs(t, err, tc.wantErr)
|
||||
}
|
||||
|
||||
if tc.wantErrText != "" {
|
||||
assert.ErrorContains(t, err, tc.wantErrText)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package honeybee
|
||||
|
||||
import "errors"
|
||||
import "fmt"
|
||||
|
||||
var (
|
||||
// Config errors
|
||||
InvalidKeepaliveTimeout = errors.New("keepalive timeout cannot be negative")
|
||||
InvalidReconnectDelay = errors.New("reconnect delay cannot be negative")
|
||||
InvalidBufferSize = errors.New("buffer size must be greater than zero")
|
||||
|
||||
// Pool errors
|
||||
ErrPoolClosed = errors.New("pool is closed")
|
||||
ErrPeerNotFound = errors.New("peer not found")
|
||||
ErrPeerExists = errors.New("peer already exists")
|
||||
|
||||
// Worker errors
|
||||
ErrConnectionUnavailable = errors.New("connection unavailable")
|
||||
)
|
||||
|
||||
func NewConfigError(err error) error {
|
||||
return fmt.Errorf("configuration error: %w", err)
|
||||
}
|
||||
|
||||
func NewPoolError(err error) error {
|
||||
return fmt.Errorf("pool error: %w", err)
|
||||
}
|
||||
|
||||
func NewWorkerError(id string, err error) error {
|
||||
return fmt.Errorf("worker %q error: %w", id, err)
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package honeybee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"git.wisehodl.dev/jay/go-honeybee/transport"
|
||||
@@ -19,6 +21,16 @@ type Dialer = types.Dialer
|
||||
|
||||
var NormalizeURL = transport.NormalizeURL
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
ErrPoolClosed = errors.New("pool is closed")
|
||||
ErrPeerNotFound = errors.New("peer not found")
|
||||
ErrPeerExists = errors.New("peer already exists")
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Types
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -58,7 +70,65 @@ type PoolPlugin struct {
|
||||
Inbox chan<- types.InboxMessage
|
||||
Events chan<- PoolEvent
|
||||
InboxCounter *atomic.Uint64
|
||||
ConnectionConfig transport.ConnectionConfig
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Config
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type PoolConfig struct {
|
||||
InboxBufferSize int
|
||||
EventsBufferSize int
|
||||
WorkerConfig WorkerConfig
|
||||
}
|
||||
|
||||
type PoolOption func(*PoolConfig)
|
||||
|
||||
func NewPoolConfig(opts ...PoolOption) (*PoolConfig, error) {
|
||||
workerCfg, _ := NewWorkerConfig()
|
||||
cfg := &PoolConfig{
|
||||
InboxBufferSize: 256,
|
||||
EventsBufferSize: 10,
|
||||
WorkerConfig: *workerCfg,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(cfg)
|
||||
}
|
||||
if err := ValidatePoolConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func WithInboxBufferSize(value int) PoolOption {
|
||||
return func(c *PoolConfig) {
|
||||
c.InboxBufferSize = value
|
||||
}
|
||||
}
|
||||
|
||||
func WithEventsBufferSize(value int) PoolOption {
|
||||
return func(c *PoolConfig) {
|
||||
c.EventsBufferSize = value
|
||||
}
|
||||
}
|
||||
|
||||
func WithWorkerConfig(wc WorkerConfig) PoolOption {
|
||||
return func(c *PoolConfig) {
|
||||
c.WorkerConfig = wc
|
||||
}
|
||||
}
|
||||
|
||||
func ValidatePoolConfig(c *PoolConfig) error {
|
||||
if c.InboxBufferSize < 1 {
|
||||
return fmt.Errorf("invalid inbox buffer size: %d", c.InboxBufferSize)
|
||||
}
|
||||
if c.EventsBufferSize < 1 {
|
||||
return fmt.Errorf("invalid events buffer size: %d", c.EventsBufferSize)
|
||||
}
|
||||
if err := ValidateWorkerConfig(&c.WorkerConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -93,18 +163,7 @@ type Pool struct {
|
||||
func NewPool(ctx context.Context, config *PoolConfig, handler slog.Handler,
|
||||
) (*Pool, error) {
|
||||
if config == nil {
|
||||
config = GetDefaultPoolConfig()
|
||||
}
|
||||
|
||||
// If a custom factory is supplied, config.WorkerConfig is not used.
|
||||
// The factory function should be non-blocking or else Connect() may cause
|
||||
// deadlocks.
|
||||
if config.WorkerFactory == nil {
|
||||
config.WorkerFactory = func(
|
||||
ctx context.Context, id string, handler slog.Handler) (Worker, error) {
|
||||
wc := config.WorkerConfig
|
||||
return NewWorker(ctx, id, &wc, handler)
|
||||
}
|
||||
config, _ = NewPoolConfig()
|
||||
}
|
||||
|
||||
if err := ValidatePoolConfig(config); err != nil {
|
||||
@@ -120,11 +179,11 @@ func NewPool(ctx context.Context, config *PoolConfig, handler slog.Handler,
|
||||
}
|
||||
|
||||
var dialer types.Dialer
|
||||
if config.ConnectionConfig.Dialer != nil {
|
||||
dialer = config.ConnectionConfig.Dialer
|
||||
} else {
|
||||
dialer = transport.NewDialer()
|
||||
}
|
||||
// if config.ConnectionConfig.Dialer != nil {
|
||||
// dialer = config.ConnectionConfig.Dialer
|
||||
// } else {
|
||||
// dialer = transport.NewDialer()
|
||||
// }
|
||||
|
||||
return &Pool{
|
||||
peers: make(map[string]*Peer),
|
||||
@@ -262,15 +321,15 @@ func (p *Pool) Connect(id string, opts ...ConnectOption) error {
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.closed {
|
||||
return NewPoolError(ErrPoolClosed)
|
||||
return ErrPoolClosed
|
||||
}
|
||||
|
||||
if _, exists := p.peers[id]; exists {
|
||||
return NewPoolError(ErrPeerExists)
|
||||
return ErrPeerExists
|
||||
}
|
||||
|
||||
// The worker factory must be non-blocking to avoid deadlocks
|
||||
worker, err := p.config.WorkerFactory(p.ctx, id, p.handler)
|
||||
wc := p.config.WorkerConfig
|
||||
worker, err := NewWorker(p.ctx, id, nil, &wc, p.handler)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -279,19 +338,18 @@ func (p *Pool) Connect(id string, opts ...ConnectOption) error {
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
effectiveDialer := p.dialer
|
||||
if o.dialer != nil {
|
||||
effectiveDialer = o.dialer
|
||||
}
|
||||
// effectiveDialer := p.dialer
|
||||
// if o.dialer != nil {
|
||||
// effectiveDialer = o.dialer
|
||||
// }
|
||||
|
||||
cc := p.config.ConnectionConfig.Clone()
|
||||
cc.Dialer = effectiveDialer
|
||||
// cc := p.config.ConnectionConfig.Clone()
|
||||
// cc.Dialer = effectiveDialer
|
||||
|
||||
pool := PoolPlugin{
|
||||
Inbox: p.inbox,
|
||||
Events: p.events,
|
||||
InboxCounter: p.inboxCounter,
|
||||
ConnectionConfig: cc,
|
||||
}
|
||||
|
||||
p.wg.Go(func() {
|
||||
@@ -321,12 +379,12 @@ func (p *Pool) Remove(id string) error {
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.closed {
|
||||
return NewPoolError(ErrPoolClosed)
|
||||
return ErrPoolClosed
|
||||
}
|
||||
|
||||
peer, exists := p.peers[id]
|
||||
if !exists {
|
||||
return NewPoolError(ErrPeerNotFound)
|
||||
return ErrPeerNotFound
|
||||
}
|
||||
delete(p.peers, id)
|
||||
|
||||
@@ -349,12 +407,12 @@ func (p *Pool) Send(id string, data []byte) error {
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
if p.closed {
|
||||
return NewPoolError(ErrPoolClosed)
|
||||
return ErrPoolClosed
|
||||
}
|
||||
|
||||
peer, exists := p.peers[id]
|
||||
if !exists {
|
||||
return NewPoolError(ErrPeerNotFound)
|
||||
return ErrPeerNotFound
|
||||
}
|
||||
|
||||
err = peer.worker.Send(data)
|
||||
|
||||
+23
-15
@@ -15,6 +15,21 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
ErrConnectionClosed = errors.New("connection closed")
|
||||
ErrWriteFailed = errors.New("write failed")
|
||||
ErrNilSocket = errors.New("socket cannot be nil")
|
||||
ErrSocketExists = errors.New("socket already exists")
|
||||
ErrFailedWriteDeadline = errors.New("failed to set write deadline")
|
||||
ErrPeerClosedClean = errors.New("peer closed connection cleanly")
|
||||
ErrPeerClosedUnexpected = errors.New("peer closed connection unexpectedly")
|
||||
ErrReadError = errors.New("read error")
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Types
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -40,22 +55,19 @@ type ConnectionConfig struct {
|
||||
}
|
||||
|
||||
func NewConnectionConfig(opts ...ConnectionOption) (*ConnectionConfig, error) {
|
||||
conf := &ConnectionConfig{
|
||||
cfg := &ConnectionConfig{
|
||||
WriteTimeout: 30 * time.Second,
|
||||
PingInterval: 20 * time.Second,
|
||||
IncomingBufferSize: 100,
|
||||
ErrorsBufferSize: 10,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(conf)
|
||||
o(cfg)
|
||||
}
|
||||
|
||||
err := ValidateConnectionConfig(*conf)
|
||||
if err != nil {
|
||||
if err := ValidateConnectionConfig(*cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conf, nil
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
type ConnectionOption func(*ConnectionConfig)
|
||||
@@ -90,21 +102,17 @@ func ValidateConnectionConfig(c ConnectionConfig) error {
|
||||
if c.WriteTimeout < 0 {
|
||||
return fmt.Errorf("invalid write timeout: %v", c.WriteTimeout)
|
||||
}
|
||||
|
||||
if c.PingInterval < 0 {
|
||||
return fmt.Errorf("invalid ping interval: %v", c.PingInterval)
|
||||
}
|
||||
|
||||
if c.IncomingBufferSize < 1 {
|
||||
return fmt.Errorf("invalid incoming buffer size: %d",
|
||||
c.IncomingBufferSize)
|
||||
}
|
||||
|
||||
if c.ErrorsBufferSize < 1 {
|
||||
return fmt.Errorf("invalid errors buffer size: %d",
|
||||
c.ErrorsBufferSize)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -142,7 +150,7 @@ func NewConnection(
|
||||
ctx context.Context, socket types.Socket, config *ConnectionConfig, handler slog.Handler,
|
||||
) (*Connection, error) {
|
||||
if socket == nil {
|
||||
return nil, NewConnectionError(ErrNilSocket)
|
||||
return nil, ErrNilSocket
|
||||
}
|
||||
|
||||
if config == nil {
|
||||
@@ -197,7 +205,7 @@ func (c *Connection) Send(data []byte) error {
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
if c.closed {
|
||||
return NewConnectionError(ErrConnectionClosed)
|
||||
return ErrConnectionClosed
|
||||
}
|
||||
|
||||
// setup
|
||||
@@ -206,7 +214,7 @@ func (c *Connection) Send(data []byte) error {
|
||||
if c.logger != nil {
|
||||
c.logger.Error("write deadline error", "error", err)
|
||||
}
|
||||
return NewConnectionError(fmt.Errorf("%w: %w", ErrFailedWriteDeadline, err))
|
||||
return fmt.Errorf("%w: %w", ErrFailedWriteDeadline, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +225,7 @@ func (c *Connection) Send(data []byte) error {
|
||||
if c.logger != nil {
|
||||
c.logger.Error("write error", "error", err)
|
||||
}
|
||||
return NewConnectionError(fmt.Errorf("%w: %w", ErrWriteFailed, err))
|
||||
return fmt.Errorf("%w: %w", ErrWriteFailed, err)
|
||||
}
|
||||
|
||||
c.outgoingCount.Add(1)
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestNewConnection(t *testing.T) {
|
||||
// Accessors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func TestConnectionIncoming(t *testing.T) {
|
||||
func TestConnection_Incoming(t *testing.T) {
|
||||
conn, _, _, _ := setupTestConnection(t)
|
||||
|
||||
incoming := conn.Incoming()
|
||||
@@ -125,7 +125,7 @@ func TestConnectionIncoming(t *testing.T) {
|
||||
assert.Equal(t, testData, received)
|
||||
}
|
||||
|
||||
func TestConnectionErrors(t *testing.T) {
|
||||
func TestConnection_Errors(t *testing.T) {
|
||||
t.Run("clean close by peer", func(t *testing.T) {
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
|
||||
@@ -194,7 +194,7 @@ func TestConnectionErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestConnectionHeartbeat(t *testing.T) {
|
||||
func TestConnection_Heartbeat(t *testing.T) {
|
||||
t.Run("pinger sends ping frames", func(t *testing.T) {
|
||||
pingCount := atomic.Int32{}
|
||||
socket, _, _ := honeybeetest.SetupTestSocket(t)
|
||||
@@ -248,7 +248,7 @@ func TestConnectionHeartbeat(t *testing.T) {
|
||||
// Send
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func TestConnectionSend(t *testing.T) {
|
||||
func TestConnection_Send(t *testing.T) {
|
||||
t.Run("writes message to socket", func(t *testing.T) {
|
||||
conn, _, _, outgoingData := setupTestConnection(t)
|
||||
defer conn.Close()
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package transport
|
||||
|
||||
import "errors"
|
||||
import "fmt"
|
||||
|
||||
var (
|
||||
// URL Errors
|
||||
InvalidProtocol = errors.New("URL must use ws:// or wss:// scheme")
|
||||
|
||||
// Configuration Errors
|
||||
InvalidWriteTimeout = errors.New("write timeout cannot be negative")
|
||||
InvalidPingInterval = errors.New("ping interval cannot be negative")
|
||||
InvalidBufferSize = errors.New("buffer size must be greater than zero")
|
||||
|
||||
InvalidRetryMaxRetries = errors.New("max retry count cannot be negative")
|
||||
InvalidRetryInitialDelay = errors.New("initial delay must be positive")
|
||||
InvalidRetryMaxDelay = errors.New("max delay must be positive")
|
||||
InvalidRetryJitterFactor = errors.New("jitter factor must be between 0.0 and 1.0")
|
||||
InvalidDelays = errors.New("initial delay may not exceed maximum delay")
|
||||
|
||||
// Socket Errors
|
||||
ErrNilDialFunc = errors.New("dial func cannot be nil")
|
||||
|
||||
// Connection Errors
|
||||
ErrConnectionClosed = errors.New("connection closed")
|
||||
ErrWriteFailed = errors.New("write failed")
|
||||
ErrNilSocket = errors.New("socket cannot be nil")
|
||||
ErrSocketExists = errors.New("socket already exists")
|
||||
ErrFailedWriteDeadline = errors.New("failed to set write deadline")
|
||||
ErrPeerClosedClean = errors.New("peer closed connection cleanly")
|
||||
ErrPeerClosedUnexpected = errors.New("peer closed connection unexpectedly")
|
||||
ErrReadError = errors.New("read error")
|
||||
)
|
||||
|
||||
func NewConfigError(err error) error {
|
||||
return fmt.Errorf("configuration error: %w", err)
|
||||
}
|
||||
|
||||
func NewConnectionError(err error) error {
|
||||
return fmt.Errorf("connection error: %w", err)
|
||||
}
|
||||
+14
-9
@@ -2,6 +2,7 @@ package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -12,6 +13,14 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
ErrNilDialFunc = errors.New("dial func cannot be nil")
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Types
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -51,11 +60,11 @@ func (d *GorillaDialer) DialContext(
|
||||
// Retry Dialer
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func AcquireSocket(
|
||||
func DialWithRetry(
|
||||
ctx context.Context,
|
||||
mgr *RetryManager,
|
||||
dialFn func(ctx context.Context) (types.Socket, error),
|
||||
errCh chan<- error,
|
||||
dialFn func(context.Context) (types.Socket, error),
|
||||
onError func(error),
|
||||
handler slog.Handler,
|
||||
) (types.Socket, error) {
|
||||
if component.FromContext(ctx) == nil {
|
||||
@@ -88,12 +97,8 @@ func AcquireSocket(
|
||||
return socket, nil
|
||||
}
|
||||
|
||||
if errCh != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case errCh <- err:
|
||||
}
|
||||
if onError != nil {
|
||||
onError(err)
|
||||
}
|
||||
|
||||
if mgr == nil {
|
||||
|
||||
+21
-25
@@ -28,7 +28,7 @@ func TestNewGorillaDialer(t *testing.T) {
|
||||
assert.NotNil(t, dialer.Dialer.NetDialContext)
|
||||
}
|
||||
|
||||
func TestAcquireSocket(t *testing.T) {
|
||||
func TestDialWithRetry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mockRuns []error
|
||||
@@ -85,7 +85,7 @@ func TestAcquireSocket(t *testing.T) {
|
||||
JitterFactor: 0.0,
|
||||
})
|
||||
|
||||
socket, err := AcquireSocket(
|
||||
socket, err := DialWithRetry(
|
||||
context.Background(), retryMgr, dial, nil, nil)
|
||||
|
||||
assert.Equal(t, tc.wantRetryCount, retryMgr.RetryCount())
|
||||
@@ -103,17 +103,17 @@ func TestAcquireSocket(t *testing.T) {
|
||||
dial := func(ctx context.Context) (types.Socket, error) {
|
||||
return nil, errors.New("dial failed")
|
||||
}
|
||||
_, err := AcquireSocket(context.Background(), nil, dial, nil, nil)
|
||||
_, err := DialWithRetry(context.Background(), nil, dial, nil, nil)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAcquireSocketNoRetry(t *testing.T) {
|
||||
func TestDialWithRetry_NoRetry(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
dial := func(ctx context.Context) (types.Socket, error) {
|
||||
return honeybeetest.NewMockSocket(), nil
|
||||
}
|
||||
socket, err := AcquireSocket(context.Background(), nil, dial, nil, nil)
|
||||
socket, err := DialWithRetry(context.Background(), nil, dial, nil, nil)
|
||||
assert.NotNil(t, socket)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
@@ -122,12 +122,12 @@ func TestAcquireSocketNoRetry(t *testing.T) {
|
||||
dial := func(ctx context.Context) (types.Socket, error) {
|
||||
return nil, errors.New("dial failed")
|
||||
}
|
||||
_, err := AcquireSocket(context.Background(), nil, dial, nil, nil)
|
||||
_, err := DialWithRetry(context.Background(), nil, dial, nil, nil)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAcquireSocketGuards(t *testing.T) {
|
||||
func TestDialWithRetry_Guards(t *testing.T) {
|
||||
validRetryConfig, _ := NewRetryConfig()
|
||||
validRetryMgr := NewRetryManager(validRetryConfig)
|
||||
|
||||
@@ -146,7 +146,7 @@ func TestAcquireSocketGuards(t *testing.T) {
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
socket, err := AcquireSocket(
|
||||
socket, err := DialWithRetry(
|
||||
context.Background(), tc.retryMgr, tc.dialFn, nil, nil)
|
||||
|
||||
assert.Error(t, err)
|
||||
@@ -156,7 +156,7 @@ func TestAcquireSocketGuards(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireSocketContextCancellation(t *testing.T) {
|
||||
func TestDialWithRetry_ContextCancellation(t *testing.T) {
|
||||
t.Run("context cancelled during sleep returns before next attempt", func(t *testing.T) {
|
||||
dialCount := atomic.Int32{}
|
||||
dial := func(ctx context.Context) (types.Socket, error) {
|
||||
@@ -181,7 +181,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := AcquireSocket(ctx, retryMgr, dial, nil, nil)
|
||||
_, err := DialWithRetry(ctx, retryMgr, dial, nil, nil)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
@@ -198,7 +198,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
|
||||
// dial count is 2 because the first retry is always immediate
|
||||
assert.Equal(t, int32(2), dialCount.Load())
|
||||
case <-time.After(honeybeetest.TestTimeout):
|
||||
t.Fatal("AcquireSocket did not return after context cancellation")
|
||||
t.Fatal("DialWithRetry did not return after context cancellation")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -217,7 +217,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
|
||||
retryMgr := NewRetryManager(retryCfg)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := AcquireSocket(ctx, retryMgr, dial, nil, nil)
|
||||
_, err := DialWithRetry(ctx, retryMgr, dial, nil, nil)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
@@ -229,13 +229,13 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
|
||||
case err := <-done:
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
case <-time.After(honeybeetest.TestTimeout):
|
||||
t.Fatal("AcquireSocket did not return after context cancellation")
|
||||
t.Fatal("DialWithRetry did not return after context cancellation")
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func TestAcquireSocketDialErrors(t *testing.T) {
|
||||
func TestDialWithRetry_DialErrors(t *testing.T) {
|
||||
t.Run("receive one error per failed dial", func(t *testing.T) {
|
||||
dialErr1 := errors.New("attempt 1 failed")
|
||||
dialErr2 := errors.New("attempt 2 failed")
|
||||
@@ -252,7 +252,8 @@ func TestAcquireSocketDialErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
var gotErrors []error
|
||||
onError := func(err error) { gotErrors = append(gotErrors, err) }
|
||||
|
||||
retryCfg, _ := NewRetryConfig(
|
||||
WithMaxRetries(3),
|
||||
@@ -263,17 +264,11 @@ func TestAcquireSocketDialErrors(t *testing.T) {
|
||||
retryMgr := NewRetryManager(retryCfg)
|
||||
|
||||
go func() {
|
||||
_, err := AcquireSocket(context.Background(), retryMgr, dial, errCh, nil)
|
||||
_, err := DialWithRetry(context.Background(), retryMgr, dial, onError, nil)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
var gotErrors []error
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case err := <-errCh:
|
||||
gotErrors = append(gotErrors, err)
|
||||
default:
|
||||
}
|
||||
return len(gotErrors) == 2
|
||||
}, "expected errors")
|
||||
|
||||
@@ -286,7 +281,8 @@ func TestAcquireSocketDialErrors(t *testing.T) {
|
||||
return honeybeetest.NewMockSocket(), nil
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
called := false
|
||||
onError := func(err error) { called = true }
|
||||
|
||||
retryCfg, _ := NewRetryConfig(
|
||||
WithMaxRetries(3),
|
||||
@@ -296,9 +292,9 @@ func TestAcquireSocketDialErrors(t *testing.T) {
|
||||
)
|
||||
retryMgr := NewRetryManager(retryCfg)
|
||||
|
||||
_, err := AcquireSocket(context.Background(), retryMgr, dial, errCh, nil)
|
||||
_, err := DialWithRetry(context.Background(), retryMgr, dial, onError, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, errCh, 0)
|
||||
assert.False(t, called)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var InvalidProtocol = errors.New("URL must use ws:// or wss:// scheme")
|
||||
|
||||
func ParseURL(urlStr string) (*url.URL, error) {
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package honeybee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
@@ -14,18 +15,92 @@ import (
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Worker
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------/
|
||||
// Types
|
||||
// -------------------------/
|
||||
var ErrConnectionUnavailable = errors.New("connection unavailable")
|
||||
|
||||
type WorkerFactory func(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
handler slog.Handler,
|
||||
) (Worker, error)
|
||||
// ----------------------------------------------------------------------------
|
||||
// Config
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type WorkerConfig struct {
|
||||
ConnectionConfig transport.ConnectionConfig
|
||||
RetryConfig transport.RetryConfig
|
||||
RetryDisabled bool
|
||||
KeepaliveTimeout time.Duration
|
||||
ReconnectDelay time.Duration
|
||||
}
|
||||
|
||||
type WorkerOption func(*WorkerConfig)
|
||||
|
||||
func NewWorkerConfig(opts ...WorkerOption) (*WorkerConfig, error) {
|
||||
connCfg, _ := transport.NewConnectionConfig()
|
||||
retryCfg, _ := transport.NewRetryConfig()
|
||||
cfg := &WorkerConfig{
|
||||
ConnectionConfig: *connCfg,
|
||||
RetryConfig: retryCfg,
|
||||
KeepaliveTimeout: 60 * time.Second,
|
||||
ReconnectDelay: 2 * time.Second,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(cfg)
|
||||
}
|
||||
if err := ValidateWorkerConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func WithConnectionConfig(value transport.ConnectionConfig) WorkerOption {
|
||||
return func(c *WorkerConfig) {
|
||||
c.ConnectionConfig = value
|
||||
}
|
||||
}
|
||||
|
||||
func WithRetryConfig(value transport.RetryConfig) WorkerOption {
|
||||
return func(c *WorkerConfig) {
|
||||
c.RetryConfig = value
|
||||
}
|
||||
}
|
||||
|
||||
func WithRetryDisabled(value bool) WorkerOption {
|
||||
return func(c *WorkerConfig) {
|
||||
c.RetryDisabled = value
|
||||
}
|
||||
}
|
||||
|
||||
// When KeepaliveTimeout is set to zero, keepalive functionality is disabled.
|
||||
func WithKeepaliveTimeout(value time.Duration) WorkerOption {
|
||||
return func(c *WorkerConfig) {
|
||||
c.KeepaliveTimeout = value
|
||||
}
|
||||
}
|
||||
|
||||
func WithReconnectDelay(value time.Duration) WorkerOption {
|
||||
return func(c *WorkerConfig) {
|
||||
c.ReconnectDelay = value
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateWorkerConfig(c *WorkerConfig) error {
|
||||
if !c.RetryDisabled {
|
||||
if err := transport.ValidateRetryConfig(c.RetryConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.KeepaliveTimeout < 0 {
|
||||
return fmt.Errorf("invalid keepalive timeout: %v", c.KeepaliveTimeout)
|
||||
}
|
||||
if c.ReconnectDelay < 0 {
|
||||
return fmt.Errorf("invalid reconnect delay: %v", c.ReconnectDelay)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Worker
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type Worker interface {
|
||||
Start(pool PoolPlugin)
|
||||
@@ -35,11 +110,9 @@ type Worker interface {
|
||||
}
|
||||
|
||||
type WorkerStats struct {
|
||||
IncomingAvailable bool
|
||||
ChanIncoming int
|
||||
|
||||
ConnectionAvailable bool
|
||||
Connection transport.ConnectionStats
|
||||
ChanIncoming int
|
||||
|
||||
TotalProcessed uint64
|
||||
TotalSent uint64
|
||||
@@ -47,14 +120,15 @@ type WorkerStats struct {
|
||||
}
|
||||
|
||||
type DefaultWorker struct {
|
||||
id string
|
||||
url string
|
||||
dialFn func(context.Context) (types.Socket, error)
|
||||
conn atomic.Pointer[transport.Connection]
|
||||
|
||||
sendHeartbeat chan struct{}
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
config *WorkerConfig
|
||||
config WorkerConfig
|
||||
handler slog.Handler
|
||||
logger *slog.Logger
|
||||
|
||||
@@ -63,18 +137,15 @@ type DefaultWorker struct {
|
||||
restartCount *atomic.Uint64
|
||||
}
|
||||
|
||||
// ---------------------------/
|
||||
// Constructor
|
||||
// -------------------------/
|
||||
|
||||
func NewWorker(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
url string,
|
||||
dialFn func(context.Context) (types.Socket, error),
|
||||
config *WorkerConfig,
|
||||
handler slog.Handler,
|
||||
) (*DefaultWorker, error) {
|
||||
if config == nil {
|
||||
config = GetDefaultWorkerConfig()
|
||||
config, _ = NewWorkerConfig()
|
||||
}
|
||||
if err := ValidateWorkerConfig(config); err != nil {
|
||||
return nil, err
|
||||
@@ -88,13 +159,14 @@ func NewWorker(
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
w := &DefaultWorker{
|
||||
id: id,
|
||||
url: url,
|
||||
dialFn: dialFn,
|
||||
|
||||
sendHeartbeat: make(chan struct{}),
|
||||
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
config: config,
|
||||
config: *config,
|
||||
|
||||
processedCount: &atomic.Uint64{},
|
||||
outgoingCount: &atomic.Uint64{},
|
||||
@@ -103,26 +175,20 @@ func NewWorker(
|
||||
|
||||
if handler != nil {
|
||||
comp := component.FromContext(ctx)
|
||||
w.handler = handler.WithAttrs([]slog.Attr{slog.String("peer", id)})
|
||||
w.handler = handler.WithAttrs([]slog.Attr{slog.String("peer", url)})
|
||||
w.logger = slog.New(w.handler).With(slog.Any("component", comp))
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// ---------------------------/
|
||||
// Session
|
||||
// -------------------------/
|
||||
|
||||
func (w *DefaultWorker) Start(pool PoolPlugin) {
|
||||
if w.logger != nil {
|
||||
w.logger.Debug("starting")
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
w.runSession(w.ctx, pool)
|
||||
})
|
||||
wg.Go(func() { w.runSession(w.ctx, pool) })
|
||||
|
||||
if w.logger != nil {
|
||||
w.logger.Debug("started")
|
||||
@@ -136,63 +202,51 @@ func (w *DefaultWorker) Start(pool PoolPlugin) {
|
||||
}
|
||||
|
||||
func (w *DefaultWorker) runSession(ctx context.Context, pool PoolPlugin) {
|
||||
// setup dialer
|
||||
var dialCancel context.CancelFunc
|
||||
newConn := make(chan *transport.Connection, 1)
|
||||
spawnDialer := func() { dialCancel = w.spawnDialer(ctx, dialCancel, newConn, pool) }
|
||||
|
||||
// setup heartbeat
|
||||
timer, inactive, heartbeat := w.setupHeartbeat()
|
||||
defer timer.Stop()
|
||||
|
||||
// main loop
|
||||
for {
|
||||
// spawn initial dial for this reconnect cycle
|
||||
spawnDialer()
|
||||
|
||||
// obtain new connection
|
||||
var conn *transport.Connection
|
||||
preConn:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if dialCancel != nil {
|
||||
dialCancel()
|
||||
var retryMgr *transport.RetryManager
|
||||
if !w.config.RetryDisabled {
|
||||
retryMgr = transport.NewRetryManager(w.config.RetryConfig)
|
||||
}
|
||||
onError := func(err error) {
|
||||
if errors.Is(err, context.Canceled) ||
|
||||
errors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
|
||||
case conn = <-newConn:
|
||||
if w.logger != nil {
|
||||
w.logger.Info("connected")
|
||||
}
|
||||
break preConn
|
||||
|
||||
case <-w.sendHeartbeat:
|
||||
heartbeat()
|
||||
|
||||
case <-inactive():
|
||||
if w.logger != nil {
|
||||
w.logger.Warn("keepalive: no activity observed")
|
||||
}
|
||||
timer.Reset(w.config.KeepaliveTimeout)
|
||||
spawnDialer()
|
||||
pool.Events <- PoolEvent{
|
||||
ID: w.url, Kind: EventDialFailed, Err: err, At: time.Now()}
|
||||
}
|
||||
socket, err := transport.DialWithRetry(
|
||||
ctx, retryMgr, w.dialFn, onError, w.handler,
|
||||
)
|
||||
if err != nil {
|
||||
// TODO: cleanup worker? surface error?
|
||||
return
|
||||
}
|
||||
|
||||
// setup new connection
|
||||
conn, err := transport.NewConnection(
|
||||
ctx, socket, &w.config.ConnectionConfig, w.handler)
|
||||
if err != nil {
|
||||
// TODO: cleanup worker? surface error?
|
||||
return
|
||||
}
|
||||
|
||||
w.conn.Store(conn)
|
||||
pool.Events <- PoolEvent{ID: w.id, Kind: EventConnected, At: time.Now()}
|
||||
pool.Events <- PoolEvent{ID: w.url, Kind: EventConnected, At: time.Now()}
|
||||
|
||||
if w.logger != nil {
|
||||
w.logger.Debug("session: started")
|
||||
}
|
||||
|
||||
// start keepalive service
|
||||
stopKeepalive, inactive, heartbeat := w.startKeepalive()
|
||||
|
||||
// run session loop
|
||||
conn_loop:
|
||||
session:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break conn_loop
|
||||
break session
|
||||
|
||||
case data, ok := <-conn.Incoming():
|
||||
if !ok {
|
||||
@@ -205,11 +259,11 @@ func (w *DefaultWorker) runSession(ctx context.Context, pool PoolPlugin) {
|
||||
if w.logger != nil {
|
||||
w.logger.Info("websocket: closed", "reason", reason)
|
||||
}
|
||||
break conn_loop
|
||||
break session
|
||||
}
|
||||
|
||||
pool.Inbox <- types.InboxMessage{
|
||||
ID: w.id, Data: data, ReceivedAt: time.Now()}
|
||||
ID: w.url, Data: data, ReceivedAt: time.Now()}
|
||||
|
||||
pool.InboxCounter.Add(1)
|
||||
w.processedCount.Add(1)
|
||||
@@ -226,13 +280,13 @@ func (w *DefaultWorker) runSession(ctx context.Context, pool PoolPlugin) {
|
||||
if w.logger != nil {
|
||||
w.logger.Warn("keepalive: no activity observed")
|
||||
}
|
||||
timer.Reset(w.config.KeepaliveTimeout)
|
||||
break conn_loop
|
||||
break session
|
||||
}
|
||||
}
|
||||
|
||||
// session ended
|
||||
conn.Close()
|
||||
stopKeepalive()
|
||||
|
||||
if w.logger != nil {
|
||||
w.logger.Info("disconnected")
|
||||
@@ -243,7 +297,7 @@ func (w *DefaultWorker) runSession(ctx context.Context, pool PoolPlugin) {
|
||||
|
||||
// tear down connection
|
||||
w.conn.Store(nil)
|
||||
pool.Events <- PoolEvent{ID: w.id, Kind: EventDisconnected, At: time.Now()}
|
||||
pool.Events <- PoolEvent{ID: w.url, Kind: EventDisconnected, At: time.Now()}
|
||||
|
||||
// exit if worker is shutting down
|
||||
select {
|
||||
@@ -258,14 +312,20 @@ func (w *DefaultWorker) runSession(ctx context.Context, pool PoolPlugin) {
|
||||
}
|
||||
}
|
||||
|
||||
func (w *DefaultWorker) setupHeartbeat() (
|
||||
timer *time.Timer, inactive func() <-chan time.Time, heartbeat func(),
|
||||
func (w *DefaultWorker) startKeepalive() (
|
||||
stop func(),
|
||||
inactive func() <-chan time.Time,
|
||||
heartbeat func(),
|
||||
) {
|
||||
var timer *time.Timer
|
||||
stop = func() {}
|
||||
|
||||
if w.config.KeepaliveTimeout > 0 {
|
||||
if w.logger != nil {
|
||||
w.logger.Debug("keepalive: enabled", "timeout", w.config.KeepaliveTimeout)
|
||||
}
|
||||
timer = time.NewTimer(w.config.KeepaliveTimeout)
|
||||
stop = func() { timer.Stop() }
|
||||
} else {
|
||||
if w.logger != nil {
|
||||
w.logger.Debug("keepalive: disabled")
|
||||
@@ -295,65 +355,6 @@ func (w *DefaultWorker) setupHeartbeat() (
|
||||
return
|
||||
}
|
||||
|
||||
func (w *DefaultWorker) spawnDialer(
|
||||
ctx context.Context,
|
||||
dialCancel context.CancelFunc,
|
||||
newConn chan<- *transport.Connection,
|
||||
pool PoolPlugin,
|
||||
) context.CancelFunc {
|
||||
if dialCancel != nil {
|
||||
dialCancel()
|
||||
}
|
||||
|
||||
dialCtx, dialCancel := context.WithCancel(ctx)
|
||||
|
||||
if w.logger != nil {
|
||||
w.logger.Debug("session: dialing")
|
||||
}
|
||||
|
||||
onDialError := func(err error) {
|
||||
if dialCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
pool.Events <- PoolEvent{ID: w.id, Kind: EventDialFailed, Err: err, At: time.Now()}
|
||||
}
|
||||
|
||||
go func() {
|
||||
conn, err := connect(w.id, dialCtx, pool, w.handler, onDialError)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case newConn <- conn:
|
||||
case <-dialCtx.Done():
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
return dialCancel
|
||||
}
|
||||
|
||||
func connect(
|
||||
id string,
|
||||
ctx context.Context,
|
||||
pool PoolPlugin,
|
||||
handler slog.Handler,
|
||||
onDialError func(error),
|
||||
) (*transport.Connection, error) {
|
||||
cc := pool.ConnectionConfig
|
||||
conn, err := transport.NewConnection(ctx, id, &cc, handler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, conn.Connect(ctx, onDialError)
|
||||
}
|
||||
|
||||
// ---------------------------/
|
||||
// Methods
|
||||
// -------------------------/
|
||||
|
||||
func (w *DefaultWorker) Stop() {
|
||||
if w.logger != nil {
|
||||
w.logger.Info("shutting down")
|
||||
@@ -365,12 +366,12 @@ func (w *DefaultWorker) Send(data []byte) error {
|
||||
conn := w.conn.Load()
|
||||
if conn == nil {
|
||||
// connection not established by session
|
||||
return NewWorkerError(w.id, ErrConnectionUnavailable)
|
||||
return ErrConnectionUnavailable
|
||||
}
|
||||
|
||||
err := conn.Send(data)
|
||||
if err != nil {
|
||||
return NewWorkerError(w.id, err)
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
@@ -396,11 +397,9 @@ func (w *DefaultWorker) Stats() WorkerStats {
|
||||
}
|
||||
|
||||
return WorkerStats{
|
||||
IncomingAvailable: connectionAvailable,
|
||||
ChanIncoming: incomingLen,
|
||||
|
||||
ConnectionAvailable: connectionAvailable,
|
||||
Connection: connStats,
|
||||
ChanIncoming: incomingLen,
|
||||
|
||||
TotalProcessed: w.processedCount.Load(),
|
||||
TotalRestarts: w.restartCount.Load(),
|
||||
|
||||
+57
-271
@@ -5,11 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.wisehodl.dev/jay/go-honeybee/honeybeetest"
|
||||
"git.wisehodl.dev/jay/go-honeybee/transport"
|
||||
"git.wisehodl.dev/jay/go-honeybee/types"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -28,21 +26,28 @@ func makeWorkerContext(t *testing.T) (
|
||||
Inbox: inbox,
|
||||
Events: events,
|
||||
InboxCounter: &atomic.Uint64{},
|
||||
ConnectionConfig: *transport.GetDefaultConnectionConfig(),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func makeWorker(t *testing.T, ctx context.Context, cancel context.CancelFunc) *DefaultWorker {
|
||||
func makeWorker(
|
||||
t *testing.T,
|
||||
socket *honeybeetest.MockSocket,
|
||||
config *WorkerConfig,
|
||||
ctx context.Context,
|
||||
cancel context.CancelFunc,
|
||||
) *DefaultWorker {
|
||||
t.Helper()
|
||||
config, _ := NewWorkerConfig(
|
||||
WithReconnectDelay(0 * time.Second),
|
||||
)
|
||||
if config == nil {
|
||||
config, _ = NewWorkerConfig(WithReconnectDelay(0 * time.Second))
|
||||
}
|
||||
dialFn := func(_ context.Context) (types.Socket, error) { return socket, nil }
|
||||
return &DefaultWorker{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: "wss://test",
|
||||
config: config,
|
||||
url: "wss://test",
|
||||
dialFn: dialFn,
|
||||
config: *config,
|
||||
sendHeartbeat: make(chan struct{}),
|
||||
processedCount: &atomic.Uint64{},
|
||||
outgoingCount: &atomic.Uint64{},
|
||||
@@ -50,23 +55,14 @@ func makeWorker(t *testing.T, ctx context.Context, cancel context.CancelFunc) *D
|
||||
}
|
||||
}
|
||||
|
||||
func mockDialer(socket *honeybeetest.MockSocket) *honeybeetest.MockDialer {
|
||||
return &honeybeetest.MockDialer{
|
||||
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||
return socket, nil, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerSession(t *testing.T) {
|
||||
t.Run("EventConnected emitted after dial succeeds", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||
w := makeWorker(t, mockSocket, nil, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
@@ -76,27 +72,23 @@ func TestWorkerSession(t *testing.T) {
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.ID == w.id && e.Kind == EventConnected
|
||||
return e.ID == w.url && e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected")
|
||||
})
|
||||
|
||||
// TODO: should dial failure cleanup worker?
|
||||
t.Run("dial failure exhausted - session stays alive, no events emitted", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
|
||||
pool.ConnectionConfig = *cc
|
||||
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
|
||||
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||
return nil, nil, errors.New("connection refused")
|
||||
},
|
||||
w := makeWorker(t, nil, nil, ctx, cancel)
|
||||
w.dialFn = func(_ context.Context) (types.Socket, error) {
|
||||
return nil, fmt.Errorf("connection refused")
|
||||
}
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
@@ -123,61 +115,16 @@ func TestWorkerSession(t *testing.T) {
|
||||
}(), "expected worker to still be running after dial failure")
|
||||
})
|
||||
|
||||
t.Run("keepalive fires before connection - dial is cancelled and replaced", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
config, _ := NewWorkerConfig(
|
||||
WithReconnectDelay(0),
|
||||
WithKeepaliveTimeout(20*time.Millisecond),
|
||||
)
|
||||
w := &DefaultWorker{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: "wss://test",
|
||||
config: config,
|
||||
sendHeartbeat: make(chan struct{}),
|
||||
processedCount: &atomic.Uint64{},
|
||||
outgoingCount: &atomic.Uint64{},
|
||||
restartCount: &atomic.Uint64{},
|
||||
}
|
||||
_, _, pool := makeWorkerContext(t)
|
||||
|
||||
var dialCount atomic.Uint64
|
||||
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
|
||||
pool.ConnectionConfig = *cc
|
||||
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
|
||||
DialContextFunc: func(dialCtx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
|
||||
dialCount.Add(1)
|
||||
<-dialCtx.Done()
|
||||
return nil, nil, dialCtx.Err()
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
|
||||
// keepalive fires after 20ms; a second dial goroutine must be spawned
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
return dialCount.Load() >= 2
|
||||
}, "expected at least two dial attempts after keepalive fired")
|
||||
})
|
||||
|
||||
t.Run("Stop before connection established - exits cleanly, no events", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
|
||||
pool.ConnectionConfig = *cc
|
||||
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
|
||||
DialContextFunc: func(dialCtx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
|
||||
w := makeWorker(t, nil, nil, ctx, cancel)
|
||||
w.dialFn = func(dialCtx context.Context) (types.Socket, error) {
|
||||
<-dialCtx.Done()
|
||||
return nil, nil, dialCtx.Err()
|
||||
},
|
||||
return nil, dialCtx.Err()
|
||||
}
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
@@ -202,10 +149,9 @@ func TestWorkerSession(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
_, mockSocket, _, outgoingData := setupTestConnection(t)
|
||||
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||
w := makeWorker(t, mockSocket, nil, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
@@ -238,12 +184,9 @@ func TestWorkerSession(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
inbox, events, pool := makeWorkerContext(t)
|
||||
|
||||
incomingData := make(chan honeybeetest.MockIncomingData, 10)
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
mockSocket.CloseFunc = func() error {
|
||||
mockSocket.Once.Do(func() { close(mockSocket.Closed) })
|
||||
return nil
|
||||
@@ -256,7 +199,8 @@ func TestWorkerSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||
w := makeWorker(t, mockSocket, nil, ctx, cancel)
|
||||
inbox, events, pool := makeWorkerContext(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
@@ -287,7 +231,7 @@ func TestWorkerSession(t *testing.T) {
|
||||
return false
|
||||
}
|
||||
}, "expected message on Inbox")
|
||||
assert.Equal(t, w.id, received.ID)
|
||||
assert.Equal(t, w.url, received.ID)
|
||||
assert.Equal(t, []byte("hello"), received.Data)
|
||||
assert.False(t, received.ReceivedAt.IsZero(), "expected non-zero ReceivedAt")
|
||||
})
|
||||
@@ -300,19 +244,9 @@ func TestWorkerSession(t *testing.T) {
|
||||
WithReconnectDelay(0),
|
||||
WithKeepaliveTimeout(60*time.Millisecond),
|
||||
)
|
||||
w := &DefaultWorker{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: "wss://test",
|
||||
config: config,
|
||||
sendHeartbeat: make(chan struct{}),
|
||||
processedCount: &atomic.Uint64{},
|
||||
outgoingCount: &atomic.Uint64{},
|
||||
restartCount: &atomic.Uint64{},
|
||||
}
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
_, mockSocket, incomingData, _ := setupTestConnection(t)
|
||||
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||
w := makeWorker(t, mockSocket, config, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
@@ -358,27 +292,17 @@ func TestWorkerSession(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
config, _ := NewWorkerConfig(
|
||||
WithReconnectDelay(0),
|
||||
WithKeepaliveTimeout(60*time.Millisecond),
|
||||
)
|
||||
w := &DefaultWorker{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: "wss://test",
|
||||
config: config,
|
||||
sendHeartbeat: make(chan struct{}),
|
||||
processedCount: &atomic.Uint64{},
|
||||
outgoingCount: &atomic.Uint64{},
|
||||
restartCount: &atomic.Uint64{},
|
||||
}
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
// socket whose pong handler fires every 20ms; no incoming messages
|
||||
var pongHandler func(string) error
|
||||
mockSocket, incomingData, _ := honeybeetest.SetupTestSocket(t)
|
||||
mockSocket.SetPongHandlerFunc = func(h func(string) error) { pongHandler = h }
|
||||
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||
|
||||
config, _ := NewWorkerConfig(
|
||||
WithReconnectDelay(0),
|
||||
WithKeepaliveTimeout(60*time.Millisecond),
|
||||
)
|
||||
w := makeWorker(t, mockSocket, config, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
@@ -428,19 +352,9 @@ func TestWorkerSession(t *testing.T) {
|
||||
WithReconnectDelay(0),
|
||||
WithKeepaliveTimeout(30*time.Millisecond),
|
||||
)
|
||||
w := &DefaultWorker{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: "wss://test",
|
||||
config: config,
|
||||
sendHeartbeat: make(chan struct{}),
|
||||
processedCount: &atomic.Uint64{},
|
||||
outgoingCount: &atomic.Uint64{},
|
||||
restartCount: &atomic.Uint64{},
|
||||
}
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
_, mockSocket, _, _ := setupTestConnection(t)
|
||||
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||
w := makeWorker(t, mockSocket, config, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
@@ -479,10 +393,9 @@ func TestWorkerSession(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
_, mockSocket, incomingData, _ := setupTestConnection(t)
|
||||
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||
w := makeWorker(t, mockSocket, nil, ctx, cancel)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
@@ -519,50 +432,13 @@ func TestWorkerSession(t *testing.T) {
|
||||
}, "expected second EventConnected")
|
||||
})
|
||||
|
||||
t.Run("connection pointer is nil between disconnect and reconnect", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
_, mockSocket, incomingData, _ := setupTestConnection(t)
|
||||
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected")
|
||||
|
||||
close(incomingData)
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventDisconnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventDisconnected")
|
||||
|
||||
// conn.Store(nil) happens before EventDisconnected is sent
|
||||
assert.Nil(t, w.conn.Load(), "expected connection pointer to be nil after disconnect")
|
||||
})
|
||||
|
||||
t.Run("Stop produces EventDisconnected and wg drains", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||
w := makeWorker(t, mockSocket, nil, ctx, cancel)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
@@ -605,10 +481,9 @@ func TestWorkerSession(t *testing.T) {
|
||||
parentCtx, parentCancel := context.WithCancel(context.Background())
|
||||
workerCtx, workerCancel := context.WithCancel(parentCtx)
|
||||
|
||||
w := makeWorker(t, workerCtx, workerCancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||
w := makeWorker(t, mockSocket, nil, workerCtx, workerCancel)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
@@ -640,20 +515,16 @@ func TestWorkerSession(t *testing.T) {
|
||||
}, "expected wg to drain after parent cancel")
|
||||
})
|
||||
|
||||
t.Run("EventDialFailed emitted with correct ID, non-nil Err, and non-zero At when dialer fails", func(t *testing.T) {
|
||||
t.Run("EventDialFailed emitted when dialer fails", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
w := makeWorker(t, nil, nil, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
|
||||
pool.ConnectionConfig = *cc
|
||||
dialErr := errors.New("connection refused")
|
||||
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
|
||||
DialContextFunc: func(context.Context, string, http.Header) (types.Socket, *http.Response, error) {
|
||||
return nil, nil, dialErr
|
||||
},
|
||||
w.dialFn = func(_ context.Context) (types.Socket, error) {
|
||||
return nil, dialErr
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -663,7 +534,7 @@ func TestWorkerSession(t *testing.T) {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventDialFailed &&
|
||||
e.ID == w.id &&
|
||||
e.ID == w.url &&
|
||||
e.Err == dialErr &&
|
||||
!e.At.IsZero()
|
||||
default:
|
||||
@@ -672,52 +543,16 @@ func TestWorkerSession(t *testing.T) {
|
||||
}, "expected EventDialFailed")
|
||||
})
|
||||
|
||||
t.Run("no EventDialFailed when first dial succeeds", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
mockSocket := honeybeetest.NewMockSocket()
|
||||
pool.ConnectionConfig.Dialer = mockDialer(mockSocket)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventConnected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected EventConnected")
|
||||
|
||||
// drain any remaining events and assert none are EventDialFailed
|
||||
honeybeetest.Never(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventDialFailed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected no EventDialFailed when first dial succeeds")
|
||||
})
|
||||
|
||||
t.Run("no EventDialFailed when worker is stopped mid-dial", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := makeWorker(t, ctx, cancel)
|
||||
w := makeWorker(t, nil, nil, ctx, cancel)
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
|
||||
pool.ConnectionConfig = *cc
|
||||
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
|
||||
DialContextFunc: func(dialCtx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
|
||||
w.dialFn = func(dialCtx context.Context) (types.Socket, error) {
|
||||
<-dialCtx.Done()
|
||||
return nil, nil, dialCtx.Err()
|
||||
},
|
||||
return nil, dialCtx.Err()
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -745,55 +580,6 @@ func TestWorkerSession(t *testing.T) {
|
||||
}
|
||||
}, "expected no EventDialFailed when stopped mid-dial")
|
||||
})
|
||||
|
||||
t.Run("no EventDialFailed when keepalive replaces an in-progress dial", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
config, _ := NewWorkerConfig(
|
||||
WithReconnectDelay(0),
|
||||
WithKeepaliveTimeout(20*time.Millisecond),
|
||||
)
|
||||
w := &DefaultWorker{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: "wss://test",
|
||||
config: config,
|
||||
sendHeartbeat: make(chan struct{}),
|
||||
processedCount: &atomic.Uint64{},
|
||||
outgoingCount: &atomic.Uint64{},
|
||||
restartCount: &atomic.Uint64{},
|
||||
}
|
||||
_, events, pool := makeWorkerContext(t)
|
||||
|
||||
var dialCount atomic.Uint64
|
||||
cc, _ := transport.NewConnectionConfig(transport.WithRetryDisabled())
|
||||
pool.ConnectionConfig = *cc
|
||||
pool.ConnectionConfig.Dialer = &honeybeetest.MockDialer{
|
||||
DialContextFunc: func(dialCtx context.Context, _ string, _ http.Header) (types.Socket, *http.Response, error) {
|
||||
dialCount.Add(1)
|
||||
<-dialCtx.Done()
|
||||
return nil, nil, dialCtx.Err()
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { w.Start(pool) })
|
||||
|
||||
// keepalive fires after 20ms, cancelling the first dial and spawning a second
|
||||
honeybeetest.Eventually(t, func() bool {
|
||||
return dialCount.Load() >= 2
|
||||
}, "expected at least two dial attempts after keepalive fired")
|
||||
|
||||
honeybeetest.Never(t, func() bool {
|
||||
select {
|
||||
case e := <-events:
|
||||
return e.Kind == EventDialFailed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, "expected no EventDialFailed when keepalive replaces dial")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkerSend(t *testing.T) {
|
||||
@@ -809,7 +595,7 @@ func TestWorkerSend(t *testing.T) {
|
||||
w := &DefaultWorker{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: "wss://test",
|
||||
url: "wss://test",
|
||||
sendHeartbeat: heartbeat,
|
||||
outgoingCount: &atomic.Uint64{},
|
||||
}
|
||||
@@ -854,7 +640,7 @@ func TestWorkerSend(t *testing.T) {
|
||||
w := &DefaultWorker{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: "wss://test",
|
||||
url: "wss://test",
|
||||
sendHeartbeat: heartbeat,
|
||||
outgoingCount: &atomic.Uint64{},
|
||||
}
|
||||
@@ -888,7 +674,7 @@ func TestWorkerSend(t *testing.T) {
|
||||
w := &DefaultWorker{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
id: "wss://test",
|
||||
url: "wss://test",
|
||||
sendHeartbeat: heartbeat,
|
||||
}
|
||||
defer w.cancel()
|
||||
|
||||
Reference in New Issue
Block a user