outbox for gateways
This commit is contained in:
@@ -47,7 +47,7 @@ func Error[T any](logger mlogger.Logger, service mservice.Type, code codes.Code,
|
||||
if err != nil {
|
||||
fields = append(fields, zap.Error(err))
|
||||
}
|
||||
logger.Warn("gRPC request failed", fields...)
|
||||
logger.Warn("GRPC request failed", fields...)
|
||||
|
||||
msg := message(err)
|
||||
switch {
|
||||
|
||||
@@ -204,7 +204,7 @@ func (r *Router) Start(ctx context.Context) error {
|
||||
close(r.serveErr)
|
||||
}()
|
||||
|
||||
r.logger.Info("gRPC server started", zap.String("network", r.listener.Addr().Network()), zap.String("address", r.listener.Addr().String()))
|
||||
r.logger.Info("GRPC server started", zap.String("network", r.listener.Addr().Network()), zap.String("address", r.listener.Addr().String()))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*ChainAssetsDB, error) {
|
||||
{Field: "asset.tokenSymbol", Sort: ri.Asc},
|
||||
},
|
||||
}); err != nil {
|
||||
p.Logger.Error("failed index (chain, symbol) unique", zap.Error(err))
|
||||
p.Logger.Error("Failed index (chain, symbol) unique", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*ChainAssetsDB, error) {
|
||||
{Field: "asset.contractAddress", Sort: ri.Asc},
|
||||
},
|
||||
}); err != nil {
|
||||
p.Logger.Error("failed index (chain, contract) unique", zap.Error(err))
|
||||
p.Logger.Error("Failed index (chain, contract) unique", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*ChainAssetsDB, error) {
|
||||
{Field: "asset.contractAddress", Sort: ri.Asc},
|
||||
},
|
||||
}); err != nil {
|
||||
p.Logger.Error("failed index contract lookup", zap.Error(err))
|
||||
p.Logger.Error("Failed index contract lookup", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*ChainAssetsDB, error) {
|
||||
{Field: "asset.chain", Sort: ri.Asc},
|
||||
},
|
||||
}); err != nil {
|
||||
p.Logger.Error("failed index chain list", zap.Error(err))
|
||||
p.Logger.Error("Failed index chain list", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/tech/sendico/pkg
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/casbin/casbin/v2 v2.135.0
|
||||
@@ -92,6 +92,6 @@ require (
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -271,8 +271,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -114,6 +114,42 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e
|
||||
nats.Name(settings.NATSName),
|
||||
nats.MaxReconnects(settings.MaxReconnects),
|
||||
nats.ReconnectWait(time.Duration(settings.ReconnectWait) * time.Second),
|
||||
nats.RetryOnFailedConnect(true),
|
||||
nats.DisconnectErrHandler(func(conn *nats.Conn, err error) {
|
||||
fields := []zap.Field{
|
||||
zap.String("broker", settings.NATSName),
|
||||
}
|
||||
if conn != nil {
|
||||
fields = append(fields, zap.String("connected_url", conn.ConnectedUrl()))
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, zap.Error(err))
|
||||
}
|
||||
l.Warn("Disconnected from NATS", fields...)
|
||||
}),
|
||||
nats.ReconnectHandler(func(conn *nats.Conn) {
|
||||
fields := []zap.Field{
|
||||
zap.String("broker", settings.NATSName),
|
||||
}
|
||||
if conn != nil {
|
||||
fields = append(fields, zap.String("connected_url", conn.ConnectedUrl()))
|
||||
}
|
||||
l.Info("Reconnected to NATS", fields...)
|
||||
}),
|
||||
nats.ClosedHandler(func(conn *nats.Conn) {
|
||||
fields := []zap.Field{
|
||||
zap.String("broker", settings.NATSName),
|
||||
}
|
||||
if conn != nil {
|
||||
if url := conn.ConnectedUrl(); url != "" {
|
||||
fields = append(fields, zap.String("connected_url", url))
|
||||
}
|
||||
if err := conn.LastError(); err != nil {
|
||||
fields = append(fields, zap.Error(err))
|
||||
}
|
||||
}
|
||||
l.Warn("NATS connection closed", fields...)
|
||||
}),
|
||||
}
|
||||
if cfg != nil {
|
||||
opts = append(opts, nats.UserInfo(cfg.User, cfg.Password))
|
||||
|
||||
@@ -17,6 +17,7 @@ func (p *ChannelProducer) SendMessage(envelope me.Envelope) error {
|
||||
// TODO: won't work with Kafka, need to serialize/deserialize
|
||||
if err := p.broker.Publish(envelope); err != nil {
|
||||
p.logger.Warn("Failed to publish message", zap.Error(err), mzap.Envelope(envelope))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
package messaging
|
||||
|
||||
import me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
import (
|
||||
"context"
|
||||
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
)
|
||||
|
||||
type Producer interface {
|
||||
SendMessage(envelope me.Envelope) error
|
||||
}
|
||||
|
||||
type ReliableProducer interface {
|
||||
Producer
|
||||
SendWithOutbox(ctx context.Context, envelope me.Envelope) error
|
||||
}
|
||||
|
||||
26
api/pkg/messaging/reliable/factory.go
Normal file
26
api/pkg/messaging/reliable/factory.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package reliable
|
||||
|
||||
import (
|
||||
pmessaging "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
func NewReliableProducerFromConfig(logger mlogger.Logger, direct pmessaging.Producer, outbox OutboxStore, driverSettings model.SettingsT, opts ...Option) (*ReliableProducer, Settings, error) {
|
||||
settings, err := ParseSettings(driverSettings)
|
||||
if err != nil {
|
||||
return nil, Settings{}, err
|
||||
}
|
||||
if !settings.Enabled {
|
||||
return nil, settings, nil
|
||||
}
|
||||
|
||||
combined := []Option{
|
||||
WithBatchSize(settings.BatchSize),
|
||||
WithPollInterval(settings.PollInterval()),
|
||||
WithMaxAttempts(settings.MaxAttempts),
|
||||
}
|
||||
combined = append(combined, opts...)
|
||||
|
||||
return NewReliableProducer(logger, direct, outbox, combined...), settings, nil
|
||||
}
|
||||
28
api/pkg/messaging/reliable/factory_test.go
Normal file
28
api/pkg/messaging/reliable/factory_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package reliable
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestNewReliableProducerFromConfigUsesDefaults(t *testing.T) {
|
||||
producer, settings, err := NewReliableProducerFromConfig(zap.NewNop(), &recordingDirectProducer{}, &recordingStore{}, model.SettingsT{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, producer)
|
||||
assert.Equal(t, DefaultSettings(), settings)
|
||||
}
|
||||
|
||||
func TestNewReliableProducerFromConfigCanDisable(t *testing.T) {
|
||||
producer, settings, err := NewReliableProducerFromConfig(zap.NewNop(), &recordingDirectProducer{}, &recordingStore{}, model.SettingsT{
|
||||
SettingsBlockKey: map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, producer)
|
||||
assert.False(t, settings.Enabled)
|
||||
}
|
||||
238
api/pkg/messaging/reliable/producer.go
Normal file
238
api/pkg/messaging/reliable/producer.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package reliable
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmessaging "github.com/tech/sendico/pkg/messaging"
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBatchSize = 100
|
||||
defaultPollInterval = time.Second
|
||||
defaultMaxAttempts = 5
|
||||
)
|
||||
|
||||
type OutboxMessage struct {
|
||||
Reference string
|
||||
EventID string
|
||||
Subject string
|
||||
Payload []byte
|
||||
Attempts int
|
||||
OrganizationRef string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type OutboxStore interface {
|
||||
Enqueue(ctx context.Context, msg OutboxMessage) error
|
||||
ListPending(ctx context.Context, limit int) ([]OutboxMessage, error)
|
||||
MarkSent(ctx context.Context, reference string, sentAt time.Time) error
|
||||
MarkFailed(ctx context.Context, reference string) error
|
||||
IncrementAttempts(ctx context.Context, reference string) error
|
||||
}
|
||||
|
||||
type EnvelopeDecoder func(record OutboxMessage) (me.Envelope, error)
|
||||
|
||||
type Option func(*ReliableProducer)
|
||||
|
||||
type ReliableProducer struct {
|
||||
logger mlogger.Logger
|
||||
direct pmessaging.Producer
|
||||
outbox OutboxStore
|
||||
batchSize int
|
||||
pollInterval time.Duration
|
||||
maxAttempts int
|
||||
decode EnvelopeDecoder
|
||||
}
|
||||
|
||||
func NewReliableProducer(logger mlogger.Logger, direct pmessaging.Producer, outbox OutboxStore, opts ...Option) *ReliableProducer {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
res := &ReliableProducer{
|
||||
logger: logger.Named("reliable_producer"),
|
||||
direct: direct,
|
||||
outbox: outbox,
|
||||
batchSize: defaultBatchSize,
|
||||
pollInterval: defaultPollInterval,
|
||||
maxAttempts: defaultMaxAttempts,
|
||||
decode: defaultEnvelopeDecoder,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(res)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func WithBatchSize(size int) Option {
|
||||
return func(p *ReliableProducer) {
|
||||
if size > 0 {
|
||||
p.batchSize = size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithPollInterval(interval time.Duration) Option {
|
||||
return func(p *ReliableProducer) {
|
||||
if interval > 0 {
|
||||
p.pollInterval = interval
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithMaxAttempts(maxAttempts int) Option {
|
||||
return func(p *ReliableProducer) {
|
||||
p.maxAttempts = maxAttempts
|
||||
}
|
||||
}
|
||||
|
||||
func WithEnvelopeDecoder(decoder EnvelopeDecoder) Option {
|
||||
return func(p *ReliableProducer) {
|
||||
if decoder != nil {
|
||||
p.decode = decoder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ReliableProducer) SendMessage(envelope me.Envelope) error {
|
||||
if p == nil {
|
||||
return merrors.Internal("reliable producer is nil")
|
||||
}
|
||||
if p.direct == nil {
|
||||
return merrors.Internal("reliable producer direct publisher is not configured")
|
||||
}
|
||||
return p.direct.SendMessage(envelope)
|
||||
}
|
||||
|
||||
func (p *ReliableProducer) SendWithOutbox(ctx context.Context, envelope me.Envelope) error {
|
||||
if p == nil {
|
||||
return merrors.Internal("reliable producer is nil")
|
||||
}
|
||||
if envelope == nil {
|
||||
return merrors.InvalidArgument("envelope is required")
|
||||
}
|
||||
if p.outbox == nil {
|
||||
return merrors.Internal("reliable producer outbox store is not configured")
|
||||
}
|
||||
|
||||
data, err := envelope.Serialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventID := envelope.GetMessageId().String()
|
||||
if _, err = uuid.Parse(eventID); err != nil {
|
||||
return merrors.InvalidArgument("envelope message id is invalid")
|
||||
}
|
||||
|
||||
return p.outbox.Enqueue(ctx, OutboxMessage{
|
||||
EventID: eventID,
|
||||
Subject: envelope.GetSignature().ToString(),
|
||||
Payload: data,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *ReliableProducer) Run(ctx context.Context) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if p.outbox == nil {
|
||||
p.logger.Warn("Outbox dispatcher disabled: store is not configured")
|
||||
return
|
||||
}
|
||||
if p.direct == nil {
|
||||
p.logger.Warn("Outbox dispatcher disabled: direct producer is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
p.logger.Info("Outbox dispatcher started")
|
||||
defer p.logger.Info("Outbox dispatcher stopped")
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
processed, err := p.DispatchPending(ctx)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
p.logger.Warn("Failed to dispatch outbox events", zap.Error(err))
|
||||
}
|
||||
if processed == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(p.pollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ReliableProducer) DispatchPending(ctx context.Context) (int, error) {
|
||||
if p == nil || p.outbox == nil || p.direct == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
events, err := p.outbox.ListPending(ctx, p.batchSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
if ctx.Err() != nil {
|
||||
return len(events), ctx.Err()
|
||||
}
|
||||
|
||||
env, decodeErr := p.decode(event)
|
||||
if decodeErr != nil {
|
||||
p.logger.Warn("Failed to decode outbox envelope", zap.String("event_id", event.EventID), zap.Error(decodeErr))
|
||||
p.handleFailure(ctx, event, decodeErr)
|
||||
continue
|
||||
}
|
||||
|
||||
if sendErr := p.direct.SendMessage(env); sendErr != nil {
|
||||
p.logger.Warn("Failed to publish outbox event", zap.String("event_id", event.EventID), zap.String("subject", event.Subject), zap.Error(sendErr))
|
||||
p.handleFailure(ctx, event, sendErr)
|
||||
continue
|
||||
}
|
||||
|
||||
if markErr := p.outbox.MarkSent(ctx, event.Reference, time.Now().UTC()); markErr != nil {
|
||||
p.logger.Warn("Failed to mark outbox event sent", zap.String("event_id", event.EventID), zap.String("reference", event.Reference), zap.Error(markErr))
|
||||
}
|
||||
}
|
||||
|
||||
return len(events), nil
|
||||
}
|
||||
|
||||
func (p *ReliableProducer) handleFailure(ctx context.Context, event OutboxMessage, _ error) {
|
||||
if p == nil || p.outbox == nil {
|
||||
return
|
||||
}
|
||||
if event.Reference == "" {
|
||||
p.logger.Warn("Cannot record outbox failure: missing record reference", zap.String("event_id", event.EventID))
|
||||
return
|
||||
}
|
||||
|
||||
if err := p.outbox.IncrementAttempts(ctx, event.Reference); err != nil && !errors.Is(err, context.Canceled) {
|
||||
p.logger.Warn("Failed to increment outbox attempts", zap.String("event_id", event.EventID), zap.String("reference", event.Reference), zap.Error(err))
|
||||
}
|
||||
|
||||
if p.maxAttempts > 0 && event.Attempts+1 >= p.maxAttempts {
|
||||
if err := p.outbox.MarkFailed(ctx, event.Reference); err != nil && !errors.Is(err, context.Canceled) {
|
||||
p.logger.Warn("Failed to mark outbox event failed", zap.String("event_id", event.EventID), zap.String("reference", event.Reference), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func defaultEnvelopeDecoder(record OutboxMessage) (me.Envelope, error) {
|
||||
return me.Deserialize(record.Payload)
|
||||
}
|
||||
|
||||
var _ pmessaging.ReliableProducer = (*ReliableProducer)(nil)
|
||||
164
api/pkg/messaging/reliable/producer_test.go
Normal file
164
api/pkg/messaging/reliable/producer_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package reliable
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
domainmodel "github.com/tech/sendico/pkg/model"
|
||||
notification "github.com/tech/sendico/pkg/model/notification"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestReliableProducerSendWithOutbox(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
producer := NewReliableProducer(zap.NewNop(), nil, store)
|
||||
|
||||
env := me.CreateEnvelope("test-sender", domainmodel.NewNotification(mservice.Payments, notification.NACreated))
|
||||
_, err := env.Wrap([]byte(`{"ok":true}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = producer.SendWithOutbox(context.Background(), env)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, store.enqueued, 1)
|
||||
record := store.enqueued[0]
|
||||
assert.Equal(t, env.GetMessageId().String(), record.EventID)
|
||||
assert.Equal(t, "payments_created", record.Subject)
|
||||
|
||||
decoded, err := me.Deserialize(record.Payload)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.GetMessageId(), decoded.GetMessageId())
|
||||
assert.Equal(t, env.GetSignature().ToString(), decoded.GetSignature().ToString())
|
||||
}
|
||||
|
||||
func TestReliableProducerDispatchPendingSuccess(t *testing.T) {
|
||||
env := me.CreateEnvelope("test-sender", domainmodel.NewNotification(mservice.Payments, notification.NAUpdated))
|
||||
_, err := env.Wrap([]byte(`{"event":"updated"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload, err := env.Serialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
store := &recordingStore{
|
||||
pending: []OutboxMessage{
|
||||
{
|
||||
Reference: "ref-1",
|
||||
EventID: env.GetMessageId().String(),
|
||||
Subject: env.GetSignature().ToString(),
|
||||
Payload: payload,
|
||||
Attempts: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
direct := &recordingDirectProducer{}
|
||||
producer := NewReliableProducer(zap.NewNop(), direct, store)
|
||||
|
||||
processed, err := producer.DispatchPending(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, processed)
|
||||
require.Len(t, direct.sent, 1)
|
||||
assert.Equal(t, env.GetMessageId(), direct.sent[0].GetMessageId())
|
||||
require.Len(t, store.markedSent, 1)
|
||||
assert.Equal(t, "ref-1", store.markedSent[0])
|
||||
assert.Empty(t, store.incremented)
|
||||
assert.Empty(t, store.markedFailed)
|
||||
}
|
||||
|
||||
func TestReliableProducerDispatchPendingFailureMarksFailed(t *testing.T) {
|
||||
env := me.CreateEnvelope("test-sender", domainmodel.NewNotification(mservice.Payments, notification.NAUpdated))
|
||||
_, err := env.Wrap([]byte(`{"event":"updated"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload, err := env.Serialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
store := &recordingStore{
|
||||
pending: []OutboxMessage{
|
||||
{
|
||||
Reference: "ref-2",
|
||||
EventID: env.GetMessageId().String(),
|
||||
Subject: env.GetSignature().ToString(),
|
||||
Payload: payload,
|
||||
Attempts: 4,
|
||||
},
|
||||
},
|
||||
}
|
||||
direct := &recordingDirectProducer{err: errors.New("publish failed")}
|
||||
producer := NewReliableProducer(zap.NewNop(), direct, store, WithMaxAttempts(5))
|
||||
|
||||
processed, err := producer.DispatchPending(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, processed)
|
||||
require.Len(t, store.incremented, 1)
|
||||
assert.Equal(t, "ref-2", store.incremented[0])
|
||||
require.Len(t, store.markedFailed, 1)
|
||||
assert.Equal(t, "ref-2", store.markedFailed[0])
|
||||
assert.Empty(t, store.markedSent)
|
||||
}
|
||||
|
||||
type recordingStore struct {
|
||||
mu sync.Mutex
|
||||
|
||||
enqueued []OutboxMessage
|
||||
pending []OutboxMessage
|
||||
|
||||
markedSent []string
|
||||
markedFailed []string
|
||||
incremented []string
|
||||
}
|
||||
|
||||
func (s *recordingStore) Enqueue(_ context.Context, msg OutboxMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.enqueued = append(s.enqueued, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingStore) ListPending(_ context.Context, _ int) ([]OutboxMessage, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
events := append([]OutboxMessage(nil), s.pending...)
|
||||
s.pending = nil
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (s *recordingStore) MarkSent(_ context.Context, reference string, _ time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.markedSent = append(s.markedSent, reference)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingStore) MarkFailed(_ context.Context, reference string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.markedFailed = append(s.markedFailed, reference)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingStore) IncrementAttempts(_ context.Context, reference string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.incremented = append(s.incremented, reference)
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingDirectProducer struct {
|
||||
mu sync.Mutex
|
||||
sent []me.Envelope
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *recordingDirectProducer) SendMessage(env me.Envelope) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.sent = append(p.sent, env)
|
||||
return p.err
|
||||
}
|
||||
64
api/pkg/messaging/reliable/settings.go
Normal file
64
api/pkg/messaging/reliable/settings.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package reliable
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
const SettingsBlockKey = "reliable_publisher"
|
||||
|
||||
type Settings struct {
|
||||
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
||||
BatchSize int `mapstructure:"batch_size" yaml:"batch_size"`
|
||||
PollIntervalSeconds int `mapstructure:"poll_interval_seconds" yaml:"poll_interval_seconds"`
|
||||
MaxAttempts int `mapstructure:"max_attempts" yaml:"max_attempts"`
|
||||
}
|
||||
|
||||
func DefaultSettings() Settings {
|
||||
return Settings{
|
||||
Enabled: true,
|
||||
BatchSize: defaultBatchSize,
|
||||
PollIntervalSeconds: int(defaultPollInterval.Seconds()),
|
||||
MaxAttempts: defaultMaxAttempts,
|
||||
}
|
||||
}
|
||||
|
||||
func ParseSettings(driverSettings model.SettingsT) (Settings, error) {
|
||||
settings := DefaultSettings()
|
||||
if len(driverSettings) == 0 {
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
raw, ok := driverSettings[SettingsBlockKey]
|
||||
if !ok || raw == nil {
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
if err := mapstructure.Decode(raw, &settings); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
settings.applyDefaults()
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *Settings) PollInterval() time.Duration {
|
||||
if s == nil || s.PollIntervalSeconds <= 0 {
|
||||
return defaultPollInterval
|
||||
}
|
||||
return time.Duration(s.PollIntervalSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (s *Settings) applyDefaults() {
|
||||
if s.BatchSize <= 0 {
|
||||
s.BatchSize = defaultBatchSize
|
||||
}
|
||||
if s.PollIntervalSeconds <= 0 {
|
||||
s.PollIntervalSeconds = int(defaultPollInterval.Seconds())
|
||||
}
|
||||
if s.MaxAttempts <= 0 {
|
||||
s.MaxAttempts = defaultMaxAttempts
|
||||
}
|
||||
}
|
||||
62
api/pkg/messaging/reliable/settings_test.go
Normal file
62
api/pkg/messaging/reliable/settings_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package reliable
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
func TestParseSettingsDefaultsWhenBlockMissing(t *testing.T) {
|
||||
got, err := ParseSettings(model.SettingsT{
|
||||
"url_env": "NATS_URL",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, DefaultSettings(), got)
|
||||
}
|
||||
|
||||
func TestParseSettingsOverrides(t *testing.T) {
|
||||
got, err := ParseSettings(model.SettingsT{
|
||||
SettingsBlockKey: map[string]any{
|
||||
"enabled": true,
|
||||
"batch_size": 250,
|
||||
"poll_interval_seconds": 3,
|
||||
"max_attempts": 7,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, got.Enabled)
|
||||
assert.Equal(t, 250, got.BatchSize)
|
||||
assert.Equal(t, 3, got.PollIntervalSeconds)
|
||||
assert.Equal(t, 7, got.MaxAttempts)
|
||||
}
|
||||
|
||||
func TestParseSettingsAppliesDefaultsForInvalidNumbers(t *testing.T) {
|
||||
got, err := ParseSettings(model.SettingsT{
|
||||
SettingsBlockKey: map[string]any{
|
||||
"enabled": true,
|
||||
"batch_size": 0,
|
||||
"poll_interval_seconds": -1,
|
||||
"max_attempts": 0,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, got.Enabled)
|
||||
assert.Equal(t, defaultBatchSize, got.BatchSize)
|
||||
assert.Equal(t, int(defaultPollInterval.Seconds()), got.PollIntervalSeconds)
|
||||
assert.Equal(t, defaultMaxAttempts, got.MaxAttempts)
|
||||
}
|
||||
|
||||
func TestParseSettingsCanDisableReliablePublisher(t *testing.T) {
|
||||
got, err := ParseSettings(model.SettingsT{
|
||||
SettingsBlockKey: map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, got.Enabled)
|
||||
}
|
||||
@@ -80,6 +80,7 @@ func StringToNotificationAction(s string) (nm.NotificationAction, error) {
|
||||
nm.NAUpdated,
|
||||
nm.NAArchived,
|
||||
nm.NADeleted,
|
||||
nm.NASent,
|
||||
nm.NAAssigned,
|
||||
nm.NAPasswordReset,
|
||||
nm.NAConfirmationRequest,
|
||||
|
||||
54
api/pkg/payments/types/quote_v2.go
Normal file
54
api/pkg/payments/types/quote_v2.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package types
|
||||
|
||||
// QuoteExecutionReadiness classifies whether execution is immediately possible.
|
||||
type QuoteExecutionReadiness string
|
||||
|
||||
const (
|
||||
QuoteExecutionReadinessUnspecified QuoteExecutionReadiness = "unspecified"
|
||||
QuoteExecutionReadinessLiquidityReady QuoteExecutionReadiness = "liquidity_ready"
|
||||
QuoteExecutionReadinessLiquidityObtainable QuoteExecutionReadiness = "liquidity_obtainable"
|
||||
QuoteExecutionReadinessIndicative QuoteExecutionReadiness = "indicative"
|
||||
)
|
||||
|
||||
type QuoteRouteHopRole string
|
||||
|
||||
const (
|
||||
QuoteRouteHopRoleUnspecified QuoteRouteHopRole = "unspecified"
|
||||
QuoteRouteHopRoleSource QuoteRouteHopRole = "source"
|
||||
QuoteRouteHopRoleTransit QuoteRouteHopRole = "transit"
|
||||
QuoteRouteHopRoleDestination QuoteRouteHopRole = "destination"
|
||||
)
|
||||
|
||||
type QuoteRouteHop struct {
|
||||
Index uint32 `bson:"index,omitempty" json:"index,omitempty"`
|
||||
Rail string `bson:"rail,omitempty" json:"rail,omitempty"`
|
||||
Gateway string `bson:"gateway,omitempty" json:"gateway,omitempty"`
|
||||
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
|
||||
Network string `bson:"network,omitempty" json:"network,omitempty"`
|
||||
Role QuoteRouteHopRole `bson:"role,omitempty" json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// QuoteRouteSpecification is an abstract route selected during quotation.
|
||||
// It intentionally omits execution steps/operations.
|
||||
type QuoteRouteSpecification struct {
|
||||
Rail string `bson:"rail,omitempty" json:"rail,omitempty"`
|
||||
Provider string `bson:"provider,omitempty" json:"provider,omitempty"`
|
||||
PayoutMethod string `bson:"payoutMethod,omitempty" json:"payoutMethod,omitempty"`
|
||||
SettlementAsset string `bson:"settlementAsset,omitempty" json:"settlementAsset,omitempty"`
|
||||
SettlementModel string `bson:"settlementModel,omitempty" json:"settlementModel,omitempty"`
|
||||
Network string `bson:"network,omitempty" json:"network,omitempty"`
|
||||
RouteRef string `bson:"routeRef,omitempty" json:"routeRef,omitempty"`
|
||||
PricingProfileRef string `bson:"pricingProfileRef,omitempty" json:"pricingProfileRef,omitempty"`
|
||||
Hops []*QuoteRouteHop `bson:"hops,omitempty" json:"hops,omitempty"`
|
||||
}
|
||||
|
||||
// QuoteExecutionConditions stores quotation-time assumptions and constraints.
|
||||
type QuoteExecutionConditions struct {
|
||||
Readiness QuoteExecutionReadiness `bson:"readiness,omitempty" json:"readiness,omitempty"`
|
||||
BatchingEligible bool `bson:"batchingEligible,omitempty" json:"batchingEligible,omitempty"`
|
||||
PrefundingRequired bool `bson:"prefundingRequired,omitempty" json:"prefundingRequired,omitempty"`
|
||||
PrefundingCostIncluded bool `bson:"prefundingCostIncluded,omitempty" json:"prefundingCostIncluded,omitempty"`
|
||||
LiquidityCheckRequiredAtExecution bool `bson:"liquidityCheckRequiredAtExecution,omitempty" json:"liquidityCheckRequiredAtExecution,omitempty"`
|
||||
LatencyHint string `bson:"latencyHint,omitempty" json:"latencyHint,omitempty"`
|
||||
Assumptions []string `bson:"assumptions,omitempty" json:"assumptions,omitempty"`
|
||||
}
|
||||
@@ -184,10 +184,10 @@ func (a *App[T]) Start() error {
|
||||
a.cleanup(context.Background())
|
||||
return err
|
||||
}
|
||||
a.logger.Debug("gRPC services registered")
|
||||
a.logger.Debug("GRPC services registered")
|
||||
|
||||
a.runCtx, a.cancel = context.WithCancel(context.Background())
|
||||
a.logger.Debug("gRPC server context initialised")
|
||||
a.logger.Debug("GRPC server context initialised")
|
||||
|
||||
if err := a.grpc.Start(a.runCtx); err != nil {
|
||||
a.logger.Error("Failed to start gRPC server", zap.Error(err))
|
||||
@@ -210,9 +210,9 @@ func (a *App[T]) Start() error {
|
||||
|
||||
err = <-a.grpc.Done()
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
a.logger.Error("gRPC server stopped with error", zap.Error(err))
|
||||
a.logger.Error("GRPC server stopped with error", zap.Error(err))
|
||||
} else {
|
||||
a.logger.Info("gRPC server finished")
|
||||
a.logger.Info("GRPC server finished")
|
||||
}
|
||||
|
||||
a.cleanup(context.Background())
|
||||
@@ -230,7 +230,7 @@ func (a *App[T]) Shutdown(ctx context.Context) {
|
||||
if err := a.grpc.Finish(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
a.logger.Warn("Failed to stop gRPC server gracefully", zap.Error(err))
|
||||
} else {
|
||||
a.logger.Info("gRPC server stopped")
|
||||
a.logger.Info("GRPC server stopped")
|
||||
}
|
||||
}
|
||||
a.cleanup(ctx)
|
||||
|
||||
Reference in New Issue
Block a user