refactored orchestrator and callbacks service to use pkg messsaging + envelope factory / handler

This commit is contained in:
Stephan D
2026-02-28 20:56:26 +01:00
parent 363d6474f2
commit 12c67361dd
14 changed files with 316 additions and 311 deletions

View File

@@ -28,15 +28,6 @@ messaging:
reconnect_wait: 5 reconnect_wait: 5
buffer_size: 1024 buffer_size: 1024
ingest:
stream: CALLBACKS
subject: callbacks.events
durable: callbacks-ingest
batch_size: 32
fetch_timeout_ms: 2000
idle_sleep_ms: 500
delivery: delivery:
worker_concurrency: 8 worker_concurrency: 8
worker_poll_ms: 200 worker_poll_ms: 200

View File

@@ -28,14 +28,6 @@ messaging:
reconnect_wait: 5 reconnect_wait: 5
buffer_size: 1024 buffer_size: 1024
ingest:
stream: CALLBACKS
subject: callbacks.events
durable: callbacks-ingest
batch_size: 32
fetch_timeout_ms: 2000
idle_sleep_ms: 500
delivery: delivery:
worker_concurrency: 8 worker_concurrency: 8
worker_poll_ms: 200 worker_poll_ms: 200

View File

@@ -10,15 +10,6 @@ import (
const ( const (
defaultShutdownTimeoutSeconds = 15 defaultShutdownTimeoutSeconds = 15
defaultMetricsAddress = ":9420" defaultMetricsAddress = ":9420"
defaultIngestStream = "CALLBACKS"
defaultIngestSubject = "callbacks.events"
defaultIngestDurable = "callbacks-ingest"
defaultIngestBatchSize = 32
defaultIngestFetchTimeoutMS = 2000
defaultIngestIdleSleepMS = 500
defaultTaskCollection = "callback_tasks"
defaultInboxCollection = "callback_inbox"
defaultEndpointsCollection = "webhook_endpoints"
defaultWorkerConcurrency = 8 defaultWorkerConcurrency = 8
defaultWorkerPollIntervalMS = 200 defaultWorkerPollIntervalMS = 200
defaultLockTTLSeconds = 30 defaultLockTTLSeconds = 30
@@ -42,7 +33,6 @@ type Config struct {
Metrics *MetricsConfig `yaml:"metrics"` Metrics *MetricsConfig `yaml:"metrics"`
Database *db.Config `yaml:"database"` Database *db.Config `yaml:"database"`
Messaging *messaging.Config `yaml:"messaging"` Messaging *messaging.Config `yaml:"messaging"`
Ingest IngestConfig `yaml:"ingest"`
Delivery DeliveryConfig `yaml:"delivery"` Delivery DeliveryConfig `yaml:"delivery"`
Security SecurityConfig `yaml:"security"` Security SecurityConfig `yaml:"security"`
Secrets SecretsConfig `yaml:"secrets"` Secrets SecretsConfig `yaml:"secrets"`
@@ -72,30 +62,6 @@ func (c *MetricsConfig) ListenAddress() string {
return c.Address return c.Address
} }
// IngestConfig configures JetStream ingestion.
type IngestConfig struct {
Stream string `yaml:"stream"`
Subject string `yaml:"subject"`
Durable string `yaml:"durable"`
BatchSize int `yaml:"batch_size"`
FetchTimeoutMS int `yaml:"fetch_timeout_ms"`
IdleSleepMS int `yaml:"idle_sleep_ms"`
}
func (c *IngestConfig) FetchTimeout() time.Duration {
if c.FetchTimeoutMS <= 0 {
return time.Duration(defaultIngestFetchTimeoutMS) * time.Millisecond
}
return time.Duration(c.FetchTimeoutMS) * time.Millisecond
}
func (c *IngestConfig) IdleSleep() time.Duration {
if c.IdleSleepMS <= 0 {
return time.Duration(defaultIngestIdleSleepMS) * time.Millisecond
}
return time.Duration(c.IdleSleepMS) * time.Millisecond
}
// DeliveryConfig controls dispatcher behavior. // DeliveryConfig controls dispatcher behavior.
type DeliveryConfig struct { type DeliveryConfig struct {
WorkerConcurrency int `yaml:"worker_concurrency"` WorkerConcurrency int `yaml:"worker_concurrency"`

View File

@@ -1,6 +1,7 @@
package config package config
import ( import (
"bytes"
"os" "os"
"strings" "strings"
@@ -34,7 +35,9 @@ func (s *service) Load(path string) (*Config, error) {
} }
cfg := &Config{} cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil { decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err := decoder.Decode(cfg); err != nil {
s.logger.Error("Failed to parse config yaml", zap.String("path", path), zap.Error(err)) s.logger.Error("Failed to parse config yaml", zap.String("path", path), zap.Error(err))
return nil, merrors.InternalWrap(err, "failed to parse callbacks config") return nil, merrors.InternalWrap(err, "failed to parse callbacks config")
} }
@@ -58,25 +61,6 @@ func (s *service) applyDefaults(cfg *Config) {
cfg.Metrics.Address = defaultMetricsAddress cfg.Metrics.Address = defaultMetricsAddress
} }
if strings.TrimSpace(cfg.Ingest.Stream) == "" {
cfg.Ingest.Stream = defaultIngestStream
}
if strings.TrimSpace(cfg.Ingest.Subject) == "" {
cfg.Ingest.Subject = defaultIngestSubject
}
if strings.TrimSpace(cfg.Ingest.Durable) == "" {
cfg.Ingest.Durable = defaultIngestDurable
}
if cfg.Ingest.BatchSize <= 0 {
cfg.Ingest.BatchSize = defaultIngestBatchSize
}
if cfg.Ingest.FetchTimeoutMS <= 0 {
cfg.Ingest.FetchTimeoutMS = defaultIngestFetchTimeoutMS
}
if cfg.Ingest.IdleSleepMS <= 0 {
cfg.Ingest.IdleSleepMS = defaultIngestIdleSleepMS
}
if cfg.Delivery.WorkerConcurrency <= 0 { if cfg.Delivery.WorkerConcurrency <= 0 {
cfg.Delivery.WorkerConcurrency = defaultWorkerConcurrency cfg.Delivery.WorkerConcurrency = defaultWorkerConcurrency
} }
@@ -139,9 +123,6 @@ func (s *service) validate(cfg *Config) error {
if cfg.Delivery.MaxAttempts < 1 { if cfg.Delivery.MaxAttempts < 1 {
return merrors.InvalidArgument("delivery.max_attempts must be > 0", "delivery.max_attempts") return merrors.InvalidArgument("delivery.max_attempts must be > 0", "delivery.max_attempts")
} }
if cfg.Ingest.BatchSize < 1 {
return merrors.InvalidArgument("ingest.batch_size must be > 0", "ingest.batch_size")
}
vaultAddress := strings.TrimSpace(cfg.Secrets.Vault.Address) vaultAddress := strings.TrimSpace(cfg.Secrets.Vault.Address)
vaultTokenEnv := strings.TrimSpace(cfg.Secrets.Vault.TokenEnv) vaultTokenEnv := strings.TrimSpace(cfg.Secrets.Vault.TokenEnv)
vaultMountPath := strings.TrimSpace(cfg.Secrets.Vault.MountPath) vaultMountPath := strings.TrimSpace(cfg.Secrets.Vault.MountPath)

View File

@@ -4,10 +4,10 @@ import (
"context" "context"
"time" "time"
"github.com/nats-io/nats.go"
"github.com/tech/sendico/edge/callbacks/internal/events" "github.com/tech/sendico/edge/callbacks/internal/events"
"github.com/tech/sendico/edge/callbacks/internal/storage" "github.com/tech/sendico/edge/callbacks/internal/storage"
"github.com/tech/sendico/edge/callbacks/internal/subscriptions" "github.com/tech/sendico/edge/callbacks/internal/subscriptions"
mb "github.com/tech/sendico/pkg/messaging/broker"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
) )
@@ -16,21 +16,10 @@ type Observer interface {
ObserveIngest(result string, duration time.Duration) ObserveIngest(result string, duration time.Duration)
} }
// Config contains JetStream ingest settings.
type Config struct {
Stream string
Subject string
Durable string
BatchSize int
FetchTimeout time.Duration
IdleSleep time.Duration
}
// Dependencies configure the ingest service. // Dependencies configure the ingest service.
type Dependencies struct { type Dependencies struct {
Logger mlogger.Logger Logger mlogger.Logger
JetStream nats.JetStreamContext Broker mb.Broker
Config Config
Events events.Service Events events.Service
Resolver subscriptions.Resolver Resolver subscriptions.Resolver
InboxRepo storage.InboxRepo InboxRepo storage.InboxRepo
@@ -39,7 +28,7 @@ type Dependencies struct {
Observer Observer Observer Observer
} }
// Service runs JetStream ingest workers. // Service runs ingest workers.
type Service interface { type Service interface {
Start(ctx context.Context) Start(ctx context.Context)
Stop() Stop()

View File

@@ -2,59 +2,86 @@ package ingest
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/nats-io/nats.go" "github.com/tech/sendico/edge/callbacks/internal/events"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
pkgmsg "github.com/tech/sendico/pkg/messaging"
cons "github.com/tech/sendico/pkg/messaging/consumer"
me "github.com/tech/sendico/pkg/messaging/envelope"
pon "github.com/tech/sendico/pkg/messaging/notifications/paymentorchestrator"
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap" "go.uber.org/zap"
) )
const (
loggerNameIngest = "ingest"
logFieldSubject = "subject"
errBrokerRequired = "ingest: broker is required"
errEventsRequired = "ingest: events service is required"
errResolverRequired = "ingest: subscriptions resolver is required"
errInboxRepoRequired = "ingest: inbox repo is required"
errTaskRepoRequired = "ingest: task repo is required"
configFieldBroker = "broker"
configFieldEvents = "events"
configFieldResolver = "resolver"
configFieldInboxRepo = "inboxRepo"
configFieldTaskRepo = "taskRepo"
logFailedStartConsumer = "Failed to start messaging consumer"
logIngestConsumerStarted = "Ingest consumer started"
logIngestConsumerStopped = "Ingest consumer stopped"
logIngestConsumerWarn = "Ingest consumer stopped with error"
ingestResultOK = "ok"
ingestResultEmptyPayload = "empty_payload"
ingestResultInvalidEvent = "invalid_event"
ingestResultPayloadError = "payload_error"
ingestResultInboxError = "inbox_error"
ingestResultDuplicate = "duplicate"
ingestResultResolveError = "resolve_error"
ingestResultNoEndpoints = "no_endpoints"
ingestResultTaskError = "task_error"
)
type service struct { type service struct {
logger mlogger.Logger logger mlogger.Logger
js nats.JetStreamContext
cfg Config
deps Dependencies deps Dependencies
event model.NotificationEvent
cancel context.CancelFunc cancel context.CancelFunc
wg sync.WaitGroup wg sync.WaitGroup
once sync.Once once sync.Once
stop sync.Once stop sync.Once
mu sync.Mutex
consumer pkgmsg.Consumer
processor np.EnvelopeProcessor
} }
func newService(deps Dependencies) (Service, error) { func newService(deps Dependencies) (Service, error) {
if deps.JetStream == nil { if deps.Broker == nil {
return nil, merrors.InvalidArgument("ingest: jetstream context is required", "jetstream") return nil, merrors.InvalidArgument(errBrokerRequired, configFieldBroker)
} }
if deps.Events == nil { if deps.Events == nil {
return nil, merrors.InvalidArgument("ingest: events service is required", "events") return nil, merrors.InvalidArgument(errEventsRequired, configFieldEvents)
} }
if deps.Resolver == nil { if deps.Resolver == nil {
return nil, merrors.InvalidArgument("ingest: subscriptions resolver is required", "resolver") return nil, merrors.InvalidArgument(errResolverRequired, configFieldResolver)
} }
if deps.InboxRepo == nil { if deps.InboxRepo == nil {
return nil, merrors.InvalidArgument("ingest: inbox repo is required", "inboxRepo") return nil, merrors.InvalidArgument(errInboxRepoRequired, configFieldInboxRepo)
} }
if deps.TaskRepo == nil { if deps.TaskRepo == nil {
return nil, merrors.InvalidArgument("ingest: task repo is required", "taskRepo") return nil, merrors.InvalidArgument(errTaskRepoRequired, configFieldTaskRepo)
}
if strings.TrimSpace(deps.Config.Subject) == "" {
return nil, merrors.InvalidArgument("ingest: subject is required", "config.subject")
}
if strings.TrimSpace(deps.Config.Durable) == "" {
return nil, merrors.InvalidArgument("ingest: durable is required", "config.durable")
}
if deps.Config.BatchSize <= 0 {
deps.Config.BatchSize = 1
}
if deps.Config.FetchTimeout <= 0 {
deps.Config.FetchTimeout = 2 * time.Second
}
if deps.Config.IdleSleep <= 0 {
deps.Config.IdleSleep = 500 * time.Millisecond
} }
logger := deps.Logger logger := deps.Logger
@@ -62,12 +89,14 @@ func newService(deps Dependencies) (Service, error) {
logger = zap.NewNop() logger = zap.NewNop()
} }
return &service{ svc := &service{
logger: logger.Named("ingest"), logger: logger.Named(loggerNameIngest),
js: deps.JetStream,
cfg: deps.Config,
deps: deps, deps: deps,
}, nil }
svc.processor = pon.NewPaymentStatusUpdatedProcessor(svc.logger, svc.handlePaymentStatusUpdated)
svc.event = svc.processor.GetSubject()
return svc, nil
} }
func (s *service) Start(ctx context.Context) { func (s *service) Start(ctx context.Context) {
@@ -91,114 +120,119 @@ func (s *service) Stop() {
if s.cancel != nil { if s.cancel != nil {
s.cancel() s.cancel()
} }
s.closeConsumer()
s.wg.Wait() s.wg.Wait()
}) })
} }
func (s *service) run(ctx context.Context) { func (s *service) run(ctx context.Context) {
subOpts := []nats.SubOpt{} consumer, err := cons.NewConsumer(s.logger, s.deps.Broker, s.event)
if stream := strings.TrimSpace(s.cfg.Stream); stream != "" {
subOpts = append(subOpts, nats.BindStream(stream))
}
sub, err := s.js.PullSubscribe(strings.TrimSpace(s.cfg.Subject), strings.TrimSpace(s.cfg.Durable), subOpts...)
if err != nil { if err != nil {
s.logger.Error("Failed to start JetStream subscription", zap.String("subject", s.cfg.Subject), zap.String("durable", s.cfg.Durable), zap.Error(err)) s.logger.Error(logFailedStartConsumer, zap.String(logFieldSubject, s.event.ToString()), zap.Error(err))
return return
} }
s.setConsumer(consumer)
defer s.closeConsumer()
s.logger.Info("Ingest consumer started", zap.String("subject", s.cfg.Subject), zap.String("durable", s.cfg.Durable), zap.Int("batch_size", s.cfg.BatchSize)) s.logger.Info(logIngestConsumerStarted, zap.String(logFieldSubject, s.event.ToString()))
if err := consumer.ConsumeMessages(func(messageCtx context.Context, envelope me.Envelope) error {
for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
s.logger.Info("Ingest consumer stopped") return ctx.Err()
return
default: default:
} }
return s.processor.Process(messageCtx, envelope)
}); err != nil && !errors.Is(err, context.Canceled) {
s.logger.Warn(logIngestConsumerWarn, zap.String(logFieldSubject, s.event.ToString()), zap.Error(err))
}
s.logger.Info(logIngestConsumerStopped, zap.String(logFieldSubject, s.event.ToString()))
}
msgs, err := sub.Fetch(s.cfg.BatchSize, nats.MaxWait(s.cfg.FetchTimeout)) func (s *service) setConsumer(consumer pkgmsg.Consumer) {
if err != nil { s.mu.Lock()
if errors.Is(err, nats.ErrTimeout) { s.consumer = consumer
time.Sleep(s.cfg.IdleSleep) s.mu.Unlock()
continue }
}
if ctx.Err() != nil {
return
}
s.logger.Warn("Failed to fetch JetStream messages", zap.Error(err))
time.Sleep(s.cfg.IdleSleep)
continue
}
for _, msg := range msgs { func (s *service) closeConsumer() {
s.handleMessage(ctx, msg) s.mu.Lock()
} consumer := s.consumer
s.consumer = nil
s.mu.Unlock()
if consumer != nil {
consumer.Close()
} }
} }
func (s *service) handleMessage(ctx context.Context, msg *nats.Msg) { func (s *service) handlePaymentStatusUpdated(ctx context.Context, msg *model.PaymentStatusUpdated) error {
start := time.Now() start := time.Now()
result := "ok" result := ingestResultOK
nak := false
defer func() { defer func() {
if s.deps.Observer != nil { if s.deps.Observer != nil {
s.deps.Observer.ObserveIngest(result, time.Since(start)) s.deps.Observer.ObserveIngest(result, time.Since(start))
} }
var ackErr error
if nak {
ackErr = msg.Nak()
} else {
ackErr = msg.Ack()
}
if ackErr != nil {
s.logger.Warn("Failed to ack ingest message", zap.Bool("nak", nak), zap.Error(ackErr))
}
}() }()
envelope, err := s.deps.Events.Parse(msg.Data) if msg == nil {
if err != nil { result = ingestResultEmptyPayload
result = "invalid_event" return nil
nak = false }
return if strings.TrimSpace(msg.EventID) == "" || strings.TrimSpace(msg.ClientID) == "" || msg.OccurredAt.IsZero() {
result = ingestResultInvalidEvent
return nil
} }
inserted, err := s.deps.InboxRepo.TryInsert(ctx, envelope.EventID, envelope.ClientID, envelope.Type, time.Now().UTC()) eventType := strings.TrimSpace(msg.Type)
if eventType == "" {
eventType = model.PaymentStatusUpdatedType
}
data, err := json.Marshal(msg.Data)
if err != nil { if err != nil {
result = "inbox_error" result = ingestResultPayloadError
nak = true return err
return }
parsed := &events.Envelope{
EventID: strings.TrimSpace(msg.EventID),
Type: eventType,
ClientID: strings.TrimSpace(msg.ClientID),
OccurredAt: msg.OccurredAt.UTC(),
PublishedAt: msg.PublishedAt.UTC(),
Data: data,
}
inserted, err := s.deps.InboxRepo.TryInsert(ctx, parsed.EventID, parsed.ClientID, parsed.Type, time.Now().UTC())
if err != nil {
result = ingestResultInboxError
return err
} }
if !inserted { if !inserted {
result = "duplicate" result = ingestResultDuplicate
nak = false return nil
return
} }
endpoints, err := s.deps.Resolver.Resolve(ctx, envelope.ClientID, envelope.Type) endpoints, err := s.deps.Resolver.Resolve(ctx, parsed.ClientID, parsed.Type)
if err != nil { if err != nil {
result = "resolve_error" result = ingestResultResolveError
nak = true return err
return
} }
if len(endpoints) == 0 { if len(endpoints) == 0 {
result = "no_endpoints" result = ingestResultNoEndpoints
nak = false return nil
return
} }
payload, err := s.deps.Events.BuildPayload(ctx, envelope) payload, err := s.deps.Events.BuildPayload(ctx, parsed)
if err != nil { if err != nil {
result = "payload_error" result = ingestResultPayloadError
nak = true return err
return
} }
if err := s.deps.TaskRepo.UpsertTasks(ctx, envelope.EventID, endpoints, payload, s.deps.TaskDefaults, time.Now().UTC()); err != nil { if err := s.deps.TaskRepo.UpsertTasks(ctx, parsed.EventID, endpoints, payload, s.deps.TaskDefaults, time.Now().UTC()); err != nil {
result = "task_error" result = ingestResultTaskError
nak = true return err
return
} }
return nil
} }

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"time" "time"
"github.com/nats-io/nats.go"
"github.com/tech/sendico/edge/callbacks/internal/config" "github.com/tech/sendico/edge/callbacks/internal/config"
"github.com/tech/sendico/edge/callbacks/internal/delivery" "github.com/tech/sendico/edge/callbacks/internal/delivery"
"github.com/tech/sendico/edge/callbacks/internal/events" "github.com/tech/sendico/edge/callbacks/internal/events"
@@ -18,7 +17,6 @@ import (
"github.com/tech/sendico/edge/callbacks/internal/subscriptions" "github.com/tech/sendico/edge/callbacks/internal/subscriptions"
"github.com/tech/sendico/pkg/api/routers/health" "github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/db" "github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging" msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/vault/kv" "github.com/tech/sendico/pkg/vault/kv"
@@ -27,10 +25,6 @@ import (
const defaultShutdownTimeout = 15 * time.Second const defaultShutdownTimeout = 15 * time.Second
type jetStreamProvider interface {
JetStream() nats.JetStreamContext
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{ return &Imp{
logger: logger.Named("server"), logger: logger.Named("server"),
@@ -118,23 +112,9 @@ func (i *Imp) Start() error {
} }
i.broker = broker i.broker = broker
jsProvider, ok := broker.(jetStreamProvider)
if !ok || jsProvider.JetStream() == nil {
i.shutdownRuntime(context.Background())
return merrors.Internal("callbacks: messaging broker does not provide JetStream")
}
ingestSvc, err := ingest.New(ingest.Dependencies{ ingestSvc, err := ingest.New(ingest.Dependencies{
Logger: i.logger, Logger: i.logger,
JetStream: jsProvider.JetStream(), Broker: broker,
Config: ingest.Config{
Stream: cfg.Ingest.Stream,
Subject: cfg.Ingest.Subject,
Durable: cfg.Ingest.Durable,
BatchSize: cfg.Ingest.BatchSize,
FetchTimeout: cfg.Ingest.FetchTimeout(),
IdleSleep: cfg.Ingest.IdleSleep(),
},
Events: eventSvc, Events: eventSvc,
Resolver: resolver, Resolver: resolver,
InboxRepo: repo.Inbox(), InboxRepo: repo.Inbox(),
@@ -176,8 +156,6 @@ func (i *Imp) Start() error {
i.opServer.SetStatus(health.SSRunning) i.opServer.SetStatus(health.SSRunning)
i.logger.Info("Callbacks service ready", i.logger.Info("Callbacks service ready",
zap.String("subject", cfg.Ingest.Subject),
zap.String("stream", cfg.Ingest.Stream),
zap.Int("workers", cfg.Delivery.WorkerConcurrency), zap.Int("workers", cfg.Delivery.WorkerConcurrency),
) )

View File

@@ -128,7 +128,7 @@ func TestExecutePayment_PublishesStatusUpdates(t *testing.T) {
if strings.TrimSpace(outer.EventID) == "" { if strings.TrimSpace(outer.EventID) == "" {
t.Fatalf("expected non-empty event_id at %d", i) t.Fatalf("expected non-empty event_id at %d", i)
} }
if got, want := outer.Type, paymentStatusEventType; got != want { if got, want := outer.Type, pm.PaymentStatusUpdatedType; got != want {
t.Fatalf("event type mismatch at %d: got=%q want=%q", i, got, want) t.Fatalf("event type mismatch at %d: got=%q want=%q", i, got, want)
} }
@@ -423,9 +423,16 @@ func (p *capturingProducer) SendMessage(envelope menv.Envelope) error {
if envelope == nil { if envelope == nil {
return nil return nil
} }
data, err := envelope.Serialize() data := envelope.GetData()
if err != nil { if len(data) == 0 {
return err serialized, err := envelope.Serialize()
if err != nil {
return err
}
data = envelope.GetData()
if len(data) == 0 {
data = serialized
}
} }
p.mu.Lock() p.mu.Lock()
p.items = append(p.items, capturedMessage{ p.items = append(p.items, capturedMessage{

View File

@@ -2,25 +2,19 @@ package psvc
import ( import (
"context" "context"
"encoding/json"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/google/uuid"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging" msg "github.com/tech/sendico/pkg/messaging"
me "github.com/tech/sendico/pkg/messaging/envelope" pon "github.com/tech/sendico/pkg/messaging/notifications/paymentorchestrator"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
nm "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap" "go.uber.org/zap"
) )
const ( const (
paymentStatusEventType = "payment.status.updated"
paymentStatusEventSender = "payments.orchestrator.v2" paymentStatusEventSender = "payments.orchestrator.v2"
) )
@@ -47,36 +41,6 @@ type brokerPaymentStatusPublisher struct {
producer msg.Producer producer msg.Producer
} }
type callbackEventEnvelope struct {
EventID string `json:"event_id"`
Type string `json:"type"`
ClientID string `json:"client_id"`
OccurredAt time.Time `json:"occurred_at"`
PublishedAt time.Time `json:"published_at,omitempty"`
Data json.RawMessage `json:"data"`
}
type paymentStatusEventData struct {
OrganizationRef string `json:"organization_ref"`
PaymentRef string `json:"payment_ref"`
QuotationRef string `json:"quotation_ref"`
ClientPaymentRef string `json:"client_payment_ref,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
State string `json:"state"`
PreviousState string `json:"previous_state,omitempty"`
Version uint64 `json:"version"`
IsTerminal bool `json:"is_terminal"`
Event string `json:"event"`
}
type rawEnvelope struct {
timestamp time.Time
messageID uuid.UUID
data []byte
sender string
signature model.NotificationEvent
}
func newPaymentStatusPublisher(logger mlogger.Logger, producer msg.Producer) paymentStatusPublisher { func newPaymentStatusPublisher(logger mlogger.Logger, producer msg.Producer) paymentStatusPublisher {
if producer == nil { if producer == nil {
return noopPaymentStatusPublisher{} return noopPaymentStatusPublisher{}
@@ -114,42 +78,27 @@ func (p *brokerPaymentStatusPublisher) Publish(_ context.Context, in paymentStat
eventName = "state_changed" eventName = "state_changed"
} }
body, err := json.Marshal(paymentStatusEventData{ message := &model.PaymentStatusUpdated{
OrganizationRef: payment.OrganizationRef.Hex(),
PaymentRef: paymentRef,
QuotationRef: strings.TrimSpace(payment.QuotationRef),
ClientPaymentRef: strings.TrimSpace(payment.ClientPaymentRef),
IdempotencyKey: strings.TrimSpace(payment.IdempotencyKey),
State: string(in.CurrentState),
PreviousState: normalizePreviousState(in.PreviousState, in.CurrentState),
Version: payment.Version,
IsTerminal: isTerminalState(in.CurrentState),
Event: eventName,
})
if err != nil {
return merrors.InternalWrap(err, "payment status publish: marshal body failed")
}
message, err := json.Marshal(callbackEventEnvelope{
EventID: buildPaymentStatusEventID(paymentRef, payment.Version, in.CurrentState), EventID: buildPaymentStatusEventID(paymentRef, payment.Version, in.CurrentState),
Type: paymentStatusEventType, Type: model.PaymentStatusUpdatedType,
ClientID: payment.OrganizationRef.Hex(), ClientID: payment.OrganizationRef.Hex(),
OccurredAt: occurredAt, OccurredAt: occurredAt,
PublishedAt: time.Now().UTC(), PublishedAt: time.Now().UTC(),
Data: body, Data: model.PaymentStatusUpdatedData{
}) OrganizationRef: payment.OrganizationRef.Hex(),
if err != nil { PaymentRef: paymentRef,
return merrors.InternalWrap(err, "payment status publish: marshal envelope failed") QuotationRef: strings.TrimSpace(payment.QuotationRef),
ClientPaymentRef: strings.TrimSpace(payment.ClientPaymentRef),
IdempotencyKey: strings.TrimSpace(payment.IdempotencyKey),
State: string(in.CurrentState),
PreviousState: normalizePreviousState(in.PreviousState, in.CurrentState),
Version: payment.Version,
IsTerminal: isTerminalState(in.CurrentState),
Event: eventName,
},
} }
signature := model.NewNotification(mservice.PaymentOrchestrator, nm.NAUpdated) envelope := pon.PaymentStatusUpdated(paymentStatusEventSender, message)
envelope := &rawEnvelope{
timestamp: occurredAt,
messageID: uuid.New(),
data: message,
sender: paymentStatusEventSender,
signature: signature,
}
if err := p.producer.SendMessage(envelope); err != nil { if err := p.producer.SendMessage(envelope); err != nil {
return err return err
@@ -176,34 +125,3 @@ func isTerminalState(state agg.State) bool {
func buildPaymentStatusEventID(paymentRef string, version uint64, state agg.State) string { func buildPaymentStatusEventID(paymentRef string, version uint64, state agg.State) string {
return paymentRef + ":" + strconv.FormatUint(version, 10) + ":" + string(state) return paymentRef + ":" + strconv.FormatUint(version, 10) + ":" + string(state)
} }
func (e *rawEnvelope) Serialize() ([]byte, error) {
return append([]byte(nil), e.data...), nil
}
func (e *rawEnvelope) GetTimeStamp() time.Time {
return e.timestamp
}
func (e *rawEnvelope) GetMessageId() uuid.UUID {
return e.messageID
}
func (e *rawEnvelope) GetData() []byte {
return append([]byte(nil), e.data...)
}
func (e *rawEnvelope) GetSender() string {
return e.sender
}
func (e *rawEnvelope) GetSignature() model.NotificationEvent {
return e.signature
}
func (e *rawEnvelope) Wrap(data []byte) ([]byte, error) {
e.data = append([]byte(nil), data...)
return e.Serialize()
}
var _ me.Envelope = (*rawEnvelope)(nil)

View File

@@ -0,0 +1,42 @@
package notifications
import (
"encoding/json"
"strings"
messaging "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/model"
nm "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
)
type PaymentStatusUpdatedNotification struct {
messaging.Envelope
payload model.PaymentStatusUpdated
}
func (psn *PaymentStatusUpdatedNotification) Serialize() ([]byte, error) {
data, err := json.Marshal(psn.payload)
if err != nil {
return nil, err
}
return psn.Envelope.Wrap(data)
}
func paymentStatusUpdatedEvent() model.NotificationEvent {
return model.NewNotification(mservice.PaymentOrchestrator, nm.NAUpdated)
}
func NewPaymentStatusUpdatedEnvelope(sender string, status *model.PaymentStatusUpdated) messaging.Envelope {
payload := model.PaymentStatusUpdated{}
if status != nil {
payload = *status
}
if strings.TrimSpace(payload.Type) == "" {
payload.Type = model.PaymentStatusUpdatedType
}
return &PaymentStatusUpdatedNotification{
Envelope: messaging.CreateEnvelope(sender, paymentStatusUpdatedEvent()),
payload: payload,
}
}

View File

@@ -0,0 +1,51 @@
package notifications
import (
"context"
"encoding/json"
"strings"
me "github.com/tech/sendico/pkg/messaging/envelope"
ch "github.com/tech/sendico/pkg/messaging/notifications/paymentorchestrator/handler"
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
type PaymentStatusUpdatedProcessor struct {
logger mlogger.Logger
handler ch.PaymentStatusUpdatedHandler
event model.NotificationEvent
}
func (p *PaymentStatusUpdatedProcessor) Process(ctx context.Context, envelope me.Envelope) error {
var msg model.PaymentStatusUpdated
if err := json.Unmarshal(envelope.GetData(), &msg); err != nil {
p.logger.Warn("Failed to decode payment status updated envelope", zap.Error(err), zap.String("topic", p.event.ToString()))
return err
}
if strings.TrimSpace(msg.Type) == "" {
msg.Type = model.PaymentStatusUpdatedType
}
if p.handler == nil {
p.logger.Warn("Payment status updated handler is not configured", zap.String("topic", p.event.ToString()))
return nil
}
return p.handler(ctx, &msg)
}
func (p *PaymentStatusUpdatedProcessor) GetSubject() model.NotificationEvent {
return p.event
}
func NewPaymentStatusUpdatedProcessor(logger mlogger.Logger, handler ch.PaymentStatusUpdatedHandler) np.EnvelopeProcessor {
if logger != nil {
logger = logger.Named("payment_status_updated_processor")
}
return &PaymentStatusUpdatedProcessor{
logger: logger,
handler: handler,
event: paymentStatusUpdatedEvent(),
}
}

View File

@@ -0,0 +1,9 @@
package notifications
import (
"context"
"github.com/tech/sendico/pkg/model"
)
type PaymentStatusUpdatedHandler = func(context.Context, *model.PaymentStatusUpdated) error

View File

@@ -0,0 +1,18 @@
package notifications
import (
messaging "github.com/tech/sendico/pkg/messaging/envelope"
pinternal "github.com/tech/sendico/pkg/messaging/internal/notifications/paymentorchestrator"
ch "github.com/tech/sendico/pkg/messaging/notifications/paymentorchestrator/handler"
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
func PaymentStatusUpdated(sender string, status *model.PaymentStatusUpdated) messaging.Envelope {
return pinternal.NewPaymentStatusUpdatedEnvelope(sender, status)
}
func NewPaymentStatusUpdatedProcessor(logger mlogger.Logger, handler ch.PaymentStatusUpdatedHandler) np.EnvelopeProcessor {
return pinternal.NewPaymentStatusUpdatedProcessor(logger, handler)
}

View File

@@ -0,0 +1,29 @@
package model
import "time"
const (
PaymentStatusUpdatedType = "payment.status.updated"
)
type PaymentStatusUpdated struct {
EventID string `json:"event_id,omitempty"`
Type string `json:"type,omitempty"`
ClientID string `json:"client_id,omitempty"`
OccurredAt time.Time `json:"occurred_at,omitempty"`
PublishedAt time.Time `json:"published_at,omitempty"`
Data PaymentStatusUpdatedData `json:"data"`
}
type PaymentStatusUpdatedData struct {
OrganizationRef string `json:"organization_ref,omitempty"`
PaymentRef string `json:"payment_ref,omitempty"`
QuotationRef string `json:"quotation_ref,omitempty"`
ClientPaymentRef string `json:"client_payment_ref,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
State string `json:"state,omitempty"`
PreviousState string `json:"previous_state,omitempty"`
Version uint64 `json:"version,omitempty"`
IsTerminal bool `json:"is_terminal"`
Event string `json:"event,omitempty"`
}