mirror of
https://github.com/hanzo-fi/go-libs.git
synced 2026-10-30 20:53:44 +00:00
418 lines
8.7 KiB
Go
418 lines
8.7 KiB
Go
package circuit
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ThreeDotsLabs/watermill/message"
|
|
"github.com/google/uuid"
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/propagation"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/messaging/publish/circuit/storage"
|
|
logging "github.com/hanzo-fi/go-libs/v5/pkg/observe/log"
|
|
)
|
|
|
|
type State string
|
|
|
|
const (
|
|
// StateOpen is the state when the circuit breaker is open and does not allow
|
|
// requests to pass through. Instead, it writes the message in the database
|
|
// and waits for the "openInterval" to pass before switching to the
|
|
// "half-open" state.
|
|
StateOpen State = "open"
|
|
// StateClosed is the default state. It allows requests to pass through.
|
|
StateClose State = "close"
|
|
)
|
|
|
|
type CircuitBreaker struct {
|
|
logger logging.Logger
|
|
|
|
publisher message.Publisher
|
|
store storage.Store
|
|
|
|
stateMu sync.RWMutex
|
|
state State
|
|
|
|
stopOnce sync.Once
|
|
doneOnce sync.Once
|
|
|
|
publisherCloseOnce sync.Once
|
|
publisherCloseErr error
|
|
|
|
loopMu sync.Mutex
|
|
loopCancel context.CancelFunc
|
|
loopStarted bool
|
|
|
|
// openInterval is the time interval for the "open" state, before switching
|
|
// to the "half-open" state.
|
|
openInterval time.Duration
|
|
openIntervalTimer *time.Timer
|
|
|
|
sendChan chan *internalMessage
|
|
stopChannel chan struct{}
|
|
stopped chan struct{}
|
|
}
|
|
|
|
type internalMessage struct {
|
|
topic string
|
|
msg *message.Message
|
|
|
|
errChan chan error
|
|
}
|
|
|
|
func NewCircuitBreaker(
|
|
logger logging.Logger,
|
|
publisher message.Publisher,
|
|
store storage.Store,
|
|
openIntervalDuration time.Duration,
|
|
) *CircuitBreaker {
|
|
return &CircuitBreaker{
|
|
stopChannel: make(chan struct{}),
|
|
stopped: make(chan struct{}),
|
|
logger: logger,
|
|
publisher: publisher,
|
|
store: store,
|
|
|
|
state: StateClose,
|
|
|
|
openInterval: openIntervalDuration,
|
|
openIntervalTimer: time.NewTimer(openIntervalDuration),
|
|
|
|
// no capacity, we want to block the loop if the sendChan is not consumed
|
|
sendChan: make(chan *internalMessage, 1),
|
|
}
|
|
}
|
|
|
|
func (cb *CircuitBreaker) GetState() State {
|
|
cb.stateMu.RLock()
|
|
defer cb.stateMu.RUnlock()
|
|
return cb.state
|
|
}
|
|
|
|
func (cb *CircuitBreaker) setState(state State) {
|
|
cb.stateMu.Lock()
|
|
cb.state = state
|
|
cb.stateMu.Unlock()
|
|
}
|
|
|
|
func (cb *CircuitBreaker) OpenState() {
|
|
cb.setState(StateOpen)
|
|
cb.openIntervalTimer.Reset(cb.openInterval)
|
|
|
|
cb.logger.Info("Circuit breaker switched to the open state")
|
|
}
|
|
|
|
func (cb *CircuitBreaker) HalfOpenState() {
|
|
cb.openIntervalTimer.Stop()
|
|
cb.logger.Info("Circuit breaker switched to the half open state")
|
|
}
|
|
|
|
func (cb *CircuitBreaker) CloseState() {
|
|
cb.setState(StateClose)
|
|
cb.openIntervalTimer.Stop()
|
|
|
|
cb.logger.Info("Circuit breaker switched to the close state")
|
|
}
|
|
|
|
func (cb *CircuitBreaker) Loop(ctx context.Context) {
|
|
loopCtx, cancel := context.WithCancel(ctx)
|
|
if !cb.startLoop(cancel) {
|
|
cancel()
|
|
return
|
|
}
|
|
defer func() {
|
|
cb.clearLoopCancel()
|
|
cancel()
|
|
cb.markStopped()
|
|
}()
|
|
|
|
select {
|
|
case <-loopCtx.Done():
|
|
return
|
|
case <-cb.stopChannel:
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Start in the half open state to fetch the messages from the database
|
|
cb.HalfOpenState()
|
|
if err := cb.catchUpDatabase(loopCtx, ctx); err != nil {
|
|
if loopCtx.Err() != nil {
|
|
return
|
|
}
|
|
cb.OpenState()
|
|
// Don't switch to closed state if there was an error
|
|
} else {
|
|
// Only switch to closed state if catchup was successful
|
|
cb.CloseState()
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-loopCtx.Done():
|
|
return
|
|
|
|
case <-cb.stopChannel:
|
|
return
|
|
|
|
case <-cb.openIntervalTimer.C:
|
|
// openInterval passed, let's switch to the half-open state
|
|
|
|
cb.HalfOpenState()
|
|
|
|
if err := cb.catchUpDatabase(loopCtx, ctx); err != nil {
|
|
if loopCtx.Err() != nil {
|
|
return
|
|
}
|
|
cb.OpenState()
|
|
continue
|
|
}
|
|
|
|
// we successfully published the messages, let's switch to the closed state
|
|
cb.CloseState()
|
|
|
|
case msg, ok := <-cb.sendChan:
|
|
if !ok {
|
|
// sendChan closed
|
|
return
|
|
}
|
|
|
|
switch cb.GetState() {
|
|
case StateClose:
|
|
// We are in the closed state, send the message to the publisher
|
|
|
|
cb.logger.Info("Circular breaker is in the closed state, sending the message to the publisher")
|
|
err := cb.publisher.Publish(msg.topic, msg.msg)
|
|
if err != nil {
|
|
// error publishing the message, let's switch to the open state
|
|
cb.OpenState()
|
|
|
|
cb.logger.Info("Failed to publish the message, switching to the open state")
|
|
// write the message in the database
|
|
err = cb.store.Insert(ctx, msg.topic, msg.msg.Payload, msg.msg.Metadata)
|
|
if err != nil {
|
|
msg.errChan <- err
|
|
continue
|
|
}
|
|
}
|
|
case StateOpen:
|
|
// We are in the open state, write the message in the database
|
|
cb.logger.Info("Circuit breaker is in the open state, writing the message in the database")
|
|
|
|
err := cb.store.Insert(ctx, msg.topic, msg.msg.Payload, msg.msg.Metadata)
|
|
if err != nil {
|
|
msg.errChan <- err
|
|
continue
|
|
}
|
|
}
|
|
|
|
msg.errChan <- nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (cb *CircuitBreaker) catchUpDatabase(loopCtx context.Context, storeCtx context.Context) error {
|
|
for {
|
|
if err := loopCtx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// fetch the oldest messages from the database
|
|
messages, err := cb.store.List(loopCtx)
|
|
if err != nil {
|
|
// error fetching messages, let's switch back to the open state
|
|
return err
|
|
}
|
|
|
|
if len(messages) == 0 {
|
|
return nil
|
|
}
|
|
|
|
messagesToDelete := make([]uint64, 0)
|
|
var publishError error
|
|
var stopError error
|
|
for _, msg := range messages {
|
|
if err := loopCtx.Err(); err != nil {
|
|
stopError = err
|
|
break
|
|
}
|
|
|
|
if err := storeCtx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// We need to publish the messages one by one in order to know
|
|
// which one failed.
|
|
|
|
message, err := newMessage(storeCtx, msg.Data, msg.Metadata)
|
|
if err != nil {
|
|
publishError = err
|
|
break
|
|
}
|
|
|
|
err = cb.publisher.Publish(msg.Topic, message)
|
|
if err != nil {
|
|
publishError = err
|
|
break
|
|
}
|
|
|
|
messagesToDelete = append(messagesToDelete, msg.ID)
|
|
}
|
|
|
|
if len(messagesToDelete) > 0 {
|
|
err = cb.store.Delete(storeCtx, messagesToDelete)
|
|
if err != nil {
|
|
// error deleting messages, let's switch back to the open state
|
|
return err
|
|
}
|
|
}
|
|
|
|
if publishError != nil {
|
|
// we failed to publish all the messages
|
|
return publishError
|
|
}
|
|
if stopError != nil {
|
|
return stopError
|
|
}
|
|
}
|
|
}
|
|
|
|
func (cb *CircuitBreaker) Publish(topic string, messages ...*message.Message) error {
|
|
for _, msg := range messages {
|
|
select {
|
|
case <-cb.stopChannel:
|
|
return errors.New("circuit breaker closed")
|
|
default:
|
|
}
|
|
|
|
errChan := make(chan error, 1)
|
|
|
|
internalMessage := &internalMessage{
|
|
topic: topic,
|
|
msg: msg,
|
|
errChan: errChan,
|
|
}
|
|
|
|
select {
|
|
case cb.sendChan <- internalMessage:
|
|
case <-cb.stopChannel:
|
|
return errors.New("circuit breaker closed")
|
|
case <-cb.stopped:
|
|
return errors.New("circuit breaker closed")
|
|
}
|
|
|
|
if err := waitAcceptedMessageResult(errChan, cb.stopped); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func waitAcceptedMessageResult(errChan <-chan error, stopped <-chan struct{}) error {
|
|
select {
|
|
case err := <-errChan:
|
|
return err
|
|
case <-stopped:
|
|
select {
|
|
case err := <-errChan:
|
|
return err
|
|
default:
|
|
return errors.New("circuit breaker closed")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (cb *CircuitBreaker) Close() error {
|
|
cb.stopOnce.Do(func() {
|
|
close(cb.stopChannel)
|
|
cb.cancelLoop()
|
|
})
|
|
|
|
if cb.hasLoopStarted() {
|
|
<-cb.stopped
|
|
} else {
|
|
cb.markStopped()
|
|
}
|
|
|
|
cb.publisherCloseOnce.Do(func() {
|
|
cb.publisherCloseErr = cb.publisher.Close()
|
|
})
|
|
|
|
return cb.publisherCloseErr
|
|
}
|
|
|
|
func (cb *CircuitBreaker) startLoop(cancel context.CancelFunc) bool {
|
|
cb.loopMu.Lock()
|
|
defer cb.loopMu.Unlock()
|
|
|
|
if cb.loopStarted {
|
|
return false
|
|
}
|
|
|
|
cb.loopStarted = true
|
|
cb.loopCancel = cancel
|
|
return true
|
|
}
|
|
|
|
func (cb *CircuitBreaker) clearLoopCancel() {
|
|
cb.loopMu.Lock()
|
|
cb.loopCancel = nil
|
|
cb.loopMu.Unlock()
|
|
}
|
|
|
|
func (cb *CircuitBreaker) cancelLoop() {
|
|
cb.loopMu.Lock()
|
|
cancel := cb.loopCancel
|
|
cb.loopMu.Unlock()
|
|
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
func (cb *CircuitBreaker) hasLoopStarted() bool {
|
|
cb.loopMu.Lock()
|
|
defer cb.loopMu.Unlock()
|
|
return cb.loopStarted
|
|
}
|
|
|
|
func (cb *CircuitBreaker) markStopped() {
|
|
cb.doneOnce.Do(func() {
|
|
close(cb.stopped)
|
|
})
|
|
}
|
|
|
|
const (
|
|
otelContextKey = "otel-context"
|
|
)
|
|
|
|
func newMessage(ctx context.Context, data []byte, metadata map[string]string) (*message.Message, error) {
|
|
if metadata == nil {
|
|
metadata = make(map[string]string)
|
|
}
|
|
|
|
msg := message.NewMessage(uuid.NewString(), data)
|
|
|
|
otelContext, ok := metadata[otelContextKey]
|
|
if ok {
|
|
carrier := propagation.MapCarrier{}
|
|
err := json.Unmarshal([]byte(otelContext), &carrier)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctx = otel.GetTextMapPropagator().Extract(ctx, carrier)
|
|
}
|
|
|
|
msg.SetContext(ctx)
|
|
for k, v := range metadata {
|
|
msg.Metadata.Set(k, v)
|
|
}
|
|
|
|
return msg, nil
|
|
}
|