Implement notifications service with shared messaging and email drivers (#36)
This commit is contained in:
1 parent
36277b6c45
commit
5c2d36281b
54 files changed
+3746
-6
No files matched your search
@@ -0,0 +1,19 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
var ErrCapabilityUnavailable = merrors.Define("capability unavailable")
|
||||
|
||||
var ErrClosed = merrors.Define("closed")
|
||||
|
||||
func Unavailable(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ErrCapabilityUnavailable
|
||||
}
|
||||
return merrors.Wrap(ErrCapabilityUnavailable, name)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package messaging
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/shared/model"
|
||||
|
||||
type BrokerBus string
|
||||
|
||||
const (
|
||||
BBNats BrokerBus = "NATS"
|
||||
)
|
||||
|
||||
type (
|
||||
Config = model.DriverConfig[BrokerBus]
|
||||
)
|
||||
@@ -0,0 +1,304 @@
|
||||
package natsb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
nc "git.sendico.io/tech/sxchnge/shared/messaging/internal/natsb/config"
|
||||
"github.com/nats-io/nats.go"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type NatsBroker struct {
|
||||
logger mlogger.Logger
|
||||
nc *nats.Conn
|
||||
js nats.JetStreamContext
|
||||
}
|
||||
|
||||
type envConfig struct {
|
||||
User, Password, Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
const redactedNATSPassword = "xxxxx"
|
||||
|
||||
func buildSafePublishableNATSURL(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return rawURL
|
||||
}
|
||||
|
||||
parts := strings.Split(rawURL, ",")
|
||||
safe := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
built, ok := buildSafePublishableNATSEntry(trimmed)
|
||||
if !ok {
|
||||
safe = append(safe, "<invalid NATS URL>")
|
||||
continue
|
||||
}
|
||||
safe = append(safe, built)
|
||||
}
|
||||
|
||||
if len(safe) == 0 {
|
||||
return "<invalid NATS URL>"
|
||||
}
|
||||
return strings.Join(safe, ",")
|
||||
}
|
||||
|
||||
func buildSafePublishableNATSEntry(raw string) (string, bool) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err == nil && parsed.Host != "" {
|
||||
safe := &url.URL{
|
||||
Scheme: parsed.Scheme,
|
||||
Host: parsed.Host,
|
||||
Path: parsed.Path,
|
||||
RawPath: parsed.RawPath,
|
||||
RawQuery: "",
|
||||
Fragment: "",
|
||||
}
|
||||
if safe.Scheme == "" {
|
||||
safe.Scheme = "nats"
|
||||
}
|
||||
if parsed.User != nil {
|
||||
username := redactedNATSPassword
|
||||
if username == "" {
|
||||
username = redactedNATSPassword
|
||||
}
|
||||
safe.User = url.UserPassword(redactedNATSPassword, redactedNATSPassword)
|
||||
}
|
||||
return safe.String(), true
|
||||
}
|
||||
|
||||
return buildSafePublishableFromAuthority(raw)
|
||||
}
|
||||
|
||||
func buildSafePublishableFromAuthority(raw string) (string, bool) {
|
||||
scheme := "nats"
|
||||
authorityAndSuffix := raw
|
||||
if schemeIndex := strings.Index(raw, "://"); schemeIndex >= 0 {
|
||||
if candidate := strings.TrimSpace(raw[:schemeIndex]); candidate != "" {
|
||||
scheme = candidate
|
||||
}
|
||||
authorityAndSuffix = raw[schemeIndex+3:]
|
||||
}
|
||||
|
||||
authorityEnd := strings.IndexAny(authorityAndSuffix, "/?#")
|
||||
if authorityEnd < 0 {
|
||||
authorityEnd = len(authorityAndSuffix)
|
||||
}
|
||||
|
||||
authority := authorityAndSuffix[:authorityEnd]
|
||||
suffix := authorityAndSuffix[authorityEnd:]
|
||||
atIndex := strings.LastIndex(authority, "@")
|
||||
hostPort := authority
|
||||
username := ""
|
||||
if atIndex >= 0 {
|
||||
userInfo := authority[:atIndex]
|
||||
hostPort = authority[atIndex+1:]
|
||||
if hostPort == "" {
|
||||
return "", false
|
||||
}
|
||||
username = userInfo
|
||||
if colonIndex := strings.Index(userInfo, ":"); colonIndex >= 0 {
|
||||
username = userInfo[:colonIndex]
|
||||
}
|
||||
if username == "" {
|
||||
username = redactedNATSPassword
|
||||
}
|
||||
}
|
||||
|
||||
if hostPort == "" || strings.ContainsAny(hostPort, " \t\r\n") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
safe := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: hostPort,
|
||||
}
|
||||
if username != "" {
|
||||
safe.User = url.UserPassword(redactedNATSPassword, redactedNATSPassword)
|
||||
}
|
||||
_ = suffix
|
||||
return safe.String(), true
|
||||
}
|
||||
|
||||
// loadEnv gathers and validates connection details from environment variables
|
||||
// listed in the Settings struct. Invalid or missing values surface as a typed
|
||||
// InvalidArgument error so callers can decide how to handle them.
|
||||
func loadEnv(settings *nc.Settings, l mlogger.Logger) (*envConfig, error) {
|
||||
get := func(key, label string) (string, error) {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
l.Error(fmt.Sprintf("NATS %s not found in environment", label), zap.String("env_var", key))
|
||||
return "", merrors.InvalidArgument(fmt.Sprintf("NATS %s not found in environment variable: %s", label, key), key)
|
||||
}
|
||||
|
||||
user, err := get(settings.UsernameEnv, "user name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
password, err := get(settings.PasswordEnv, "password")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
host, err := get(settings.HostEnv, "host")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
portStr, err := get(settings.PortEnv, "port")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
l.Error("Invalid NATS port value", zap.String("port", portStr))
|
||||
return nil, merrors.InvalidArgument("Invalid NATS port: "+portStr, settings.PortEnv)
|
||||
}
|
||||
|
||||
return &envConfig{
|
||||
User: user,
|
||||
Password: password,
|
||||
Host: host,
|
||||
Port: port,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, error) {
|
||||
if settings == nil {
|
||||
return nil, merrors.InvalidArgument("NATS settings are required")
|
||||
}
|
||||
l := logger.Named("broker")
|
||||
var err error
|
||||
var cfg *envConfig
|
||||
var natsURL string
|
||||
if settings != nil && strings.TrimSpace(settings.URLEnv) != "" {
|
||||
urlVal := strings.TrimSpace(os.Getenv(settings.URLEnv))
|
||||
if urlVal != "" {
|
||||
natsURL = urlVal
|
||||
}
|
||||
}
|
||||
if natsURL == "" {
|
||||
// Helper function to get environment variables
|
||||
cfg, err = loadEnv(settings, l)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u := &url.URL{
|
||||
Scheme: "nats",
|
||||
Host: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
||||
}
|
||||
natsURL = u.String()
|
||||
}
|
||||
publishableNATSURL := buildSafePublishableNATSURL(natsURL)
|
||||
|
||||
opts := []nats.Option{
|
||||
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", buildSafePublishableNATSURL(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", buildSafePublishableNATSURL(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", buildSafePublishableNATSURL(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))
|
||||
} else if settings != nil {
|
||||
userEnv := strings.TrimSpace(settings.UsernameEnv)
|
||||
passEnv := strings.TrimSpace(settings.PasswordEnv)
|
||||
if userEnv != "" && passEnv != "" {
|
||||
user := strings.TrimSpace(os.Getenv(userEnv))
|
||||
pass := strings.TrimSpace(os.Getenv(passEnv))
|
||||
if user != "" || pass != "" {
|
||||
opts = append(opts, nats.UserInfo(user, pass))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res := &NatsBroker{
|
||||
logger: l.Named("nats"),
|
||||
}
|
||||
|
||||
if res.nc, err = nats.Connect(natsURL, opts...); err != nil {
|
||||
l.Error("Failed to connect to NATS", zap.String("url", publishableNATSURL), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if res.js, err = res.nc.JetStream(); err != nil {
|
||||
l.Warn("Failed to initialise JetStream context", zap.Error(err))
|
||||
}
|
||||
|
||||
logger.Info("Connected to NATS", zap.String("broker", settings.NATSName),
|
||||
zap.String("url", publishableNATSURL))
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (b *NatsBroker) JetStream() nats.JetStreamContext {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return b.js
|
||||
}
|
||||
|
||||
func (b *NatsBroker) HasJetStream() bool {
|
||||
return b != nil && b.js != nil
|
||||
}
|
||||
|
||||
func (b *NatsBroker) Logger() mlogger.Logger {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return b.logger
|
||||
}
|
||||
|
||||
func (b *NatsBroker) Close() {
|
||||
if b != nil && b.nc != nil {
|
||||
b.nc.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package natsb
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLogsRedactNATSCredentials(t *testing.T) {
|
||||
for _, url := range []string{"nats://secret-user:secret-password@localhost:4222", "nats://secret-token@localhost:4222", "nats://localhost:4222?token=secret-token", "invalid secret-password"} {
|
||||
safe := buildSafePublishableNATSURL(url)
|
||||
if strings.Contains(safe, "secret") {
|
||||
t.Fatalf("credential leaked: %q", safe)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package natsb
|
||||
|
||||
type Settings struct {
|
||||
URLEnv string `mapstructure:"url_env" yaml:"url_env"`
|
||||
HostEnv string `mapstructure:"host_env" yaml:"host_env"`
|
||||
PortEnv string `mapstructure:"port_env" yaml:"port_env"`
|
||||
UsernameEnv string `mapstructure:"username_env" yaml:"username_env"`
|
||||
PasswordEnv string `mapstructure:"password_env" yaml:"password_env"`
|
||||
NATSName string `mapstructure:"broker_name" yaml:"broker_name"`
|
||||
MaxReconnects int `mapstructure:"max_reconnects" yaml:"max_reconnects"`
|
||||
ReconnectWait int `mapstructure:"reconnect_wait" yaml:"reconnect_wait"`
|
||||
BufferSize int `mapstructure:"buffer_size" yaml:"buffer_size"`
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package natscap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/capability"
|
||||
basenats "git.sendico.io/tech/sxchnge/shared/messaging/internal/natsb"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
type DurableAdapter struct {
|
||||
broker *basenats.NatsBroker
|
||||
}
|
||||
|
||||
type durableAdminContext interface {
|
||||
AddStream(cfg *nats.StreamConfig, opts ...nats.JSOpt) (*nats.StreamInfo, error)
|
||||
UpdateStream(cfg *nats.StreamConfig, opts ...nats.JSOpt) (*nats.StreamInfo, error)
|
||||
StreamInfo(stream string, opts ...nats.JSOpt) (*nats.StreamInfo, error)
|
||||
AddConsumer(stream string, cfg *nats.ConsumerConfig, opts ...nats.JSOpt) (*nats.ConsumerInfo, error)
|
||||
UpdateConsumer(stream string, cfg *nats.ConsumerConfig, opts ...nats.JSOpt) (*nats.ConsumerInfo, error)
|
||||
ConsumerInfo(stream, consumer string, opts ...nats.JSOpt) (*nats.ConsumerInfo, error)
|
||||
}
|
||||
|
||||
func NewDurableAdapter(broker *basenats.NatsBroker) *DurableAdapter {
|
||||
if broker == nil || broker.JetStream() == nil {
|
||||
return nil
|
||||
}
|
||||
return &DurableAdapter{broker: broker}
|
||||
}
|
||||
|
||||
func (a *DurableAdapter) Publish(ctx context.Context, record reliable.Record) error {
|
||||
if err := reliable.ValidatePublicRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
normalized, err := reliable.NormalizePublicRecord(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.publishRecord(ctx, normalized)
|
||||
}
|
||||
|
||||
func (a *DurableAdapter) PublishRuntime(ctx context.Context, record reliable.Record) error {
|
||||
normalized, err := reliable.NormalizeRuntimeRecord(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.publishRecord(ctx, normalized)
|
||||
}
|
||||
|
||||
func (a *DurableAdapter) publishRecord(ctx context.Context, record reliable.Record) error {
|
||||
js := a.jetStream()
|
||||
if js == nil {
|
||||
return capability.Unavailable("durable transport")
|
||||
}
|
||||
msg := &nats.Msg{
|
||||
Subject: strings.TrimSpace(record.Subject),
|
||||
Data: append([]byte(nil), record.Payload...),
|
||||
Header: nats.Header{},
|
||||
}
|
||||
for key, values := range record.Headers {
|
||||
for _, value := range values {
|
||||
msg.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
if record.MessageID != "" {
|
||||
msg.Header.Set(nats.MsgIdHdr, record.MessageID)
|
||||
}
|
||||
_, err := js.PublishMsg(msg, nats.Context(ctx))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *DurableAdapter) OpenConsumer(ctx context.Context, settings reliable.DurableSettings, consumer reliable.DurableConsumerSettings) (reliable.Subscription, error) {
|
||||
js := a.jetStream()
|
||||
if js == nil {
|
||||
return nil, capability.Unavailable("durable transport")
|
||||
}
|
||||
sub, err := js.PullSubscribe(
|
||||
consumer.Subject,
|
||||
consumer.Durable,
|
||||
nats.Bind(settings.Stream.Name, consumer.Durable),
|
||||
nats.Context(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &natsSubscription{sub: sub}, nil
|
||||
}
|
||||
|
||||
func (a *DurableAdapter) EnsureProducerResources(_ context.Context, settings reliable.DurableSettings) error {
|
||||
if a == nil || a.broker == nil {
|
||||
return capability.Unavailable("durable admin")
|
||||
}
|
||||
return ensureDurableStream(a.broker.Logger(), a.jetStream(), settings)
|
||||
}
|
||||
|
||||
func (a *DurableAdapter) EnsureConsumerResources(_ context.Context, settings reliable.DurableSettings, consumer reliable.DurableConsumerSettings) error {
|
||||
if a == nil || a.broker == nil {
|
||||
return capability.Unavailable("durable admin")
|
||||
}
|
||||
js := a.jetStream()
|
||||
if err := ensureDurableStream(a.broker.Logger(), js, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureDurableConsumerConfig(a.broker.Logger(), js, settings, consumer)
|
||||
}
|
||||
|
||||
func (a *DurableAdapter) jetStream() nats.JetStreamContext {
|
||||
if a == nil || a.broker == nil {
|
||||
return nil
|
||||
}
|
||||
return a.broker.JetStream()
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package natscap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/capability"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||
"github.com/nats-io/nats.go"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func ensureDurableStream(logger *zap.Logger, js durableAdminContext, settings reliable.DurableSettings) error {
|
||||
if js == nil {
|
||||
return capability.Unavailable("durable admin")
|
||||
}
|
||||
expected := &nats.StreamConfig{
|
||||
Name: settings.Stream.Name,
|
||||
Subjects: append([]string(nil), settings.Stream.Subjects...),
|
||||
Retention: nats.LimitsPolicy,
|
||||
MaxAge: settings.StreamMaxAge(),
|
||||
Replicas: settings.Stream.Replicas,
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(settings.Stream.Storage), "memory") {
|
||||
expected.Storage = nats.MemoryStorage
|
||||
} else {
|
||||
expected.Storage = nats.FileStorage
|
||||
}
|
||||
|
||||
info, err := js.StreamInfo(expected.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrStreamNotFound) {
|
||||
if !settings.AllowsAutoProvision() {
|
||||
if logger != nil {
|
||||
logger.Warn("Durable stream is missing and auto provisioning is disabled", zap.String("stream", expected.Name))
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err = js.AddStream(expected)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
current := info.Config
|
||||
if err := validateStreamConfig(current, *expected, settings.IsProductionValidation()); err != nil {
|
||||
if settings.AllowsAutoProvision() {
|
||||
reconciled, reconcileErr := reconcileDurableStream(logger, js, current, *expected, settings)
|
||||
if reconciled {
|
||||
return reconcileErr
|
||||
}
|
||||
if reconcileErr != nil {
|
||||
return reconcileErr
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
if settings.AllowsAutoProvision() && current.MaxAge != expected.MaxAge {
|
||||
current.MaxAge = expected.MaxAge
|
||||
_, err = js.UpdateStream(¤t)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reconcileDurableStream(logger *zap.Logger, js durableAdminContext, current nats.StreamConfig, expected nats.StreamConfig, settings reliable.DurableSettings) (bool, error) {
|
||||
if js == nil {
|
||||
return false, capability.Unavailable("durable admin")
|
||||
}
|
||||
if strings.TrimSpace(current.Name) != strings.TrimSpace(expected.Name) {
|
||||
return false, merrors.DataConflictf("durable stream name drift: got=%s want=%s", current.Name, expected.Name)
|
||||
}
|
||||
if current.Storage != expected.Storage {
|
||||
return false, merrors.DataConflictf("durable stream storage drift: got=%s want=%s", current.Storage.String(), expected.Storage.String())
|
||||
}
|
||||
if current.Retention != expected.Retention {
|
||||
return false, merrors.DataConflictf("durable stream retention drift: got=%s want=%s", current.Retention.String(), expected.Retention.String())
|
||||
}
|
||||
if current.Replicas != expected.Replicas {
|
||||
return false, merrors.DataConflictf("durable stream replicas drift: got=%d want=%d", current.Replicas, expected.Replicas)
|
||||
}
|
||||
missing, extra := stringSetDiff(expected.Subjects, current.Subjects), stringSetDiff(current.Subjects, expected.Subjects)
|
||||
if len(extra) > 0 {
|
||||
return false, merrors.DataConflict("durable stream has unexpected subjects")
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
current.Subjects = append([]string(nil), expected.Subjects...)
|
||||
current.MaxAge = expected.MaxAge
|
||||
_, err := js.UpdateStream(¤t)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func ensureDurableConsumerConfig(logger *zap.Logger, js durableAdminContext, settings reliable.DurableSettings, consumer reliable.DurableConsumerSettings) error {
|
||||
if js == nil {
|
||||
return capability.Unavailable("durable admin")
|
||||
}
|
||||
deliverPolicy, err := natsDeliverPolicy(consumer.DeliverPolicy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expected := &nats.ConsumerConfig{
|
||||
Durable: consumer.Durable,
|
||||
DeliverPolicy: deliverPolicy,
|
||||
AckPolicy: nats.AckExplicitPolicy,
|
||||
AckWait: time.Duration(consumer.AckWaitSeconds) * time.Second,
|
||||
MaxDeliver: consumer.MaxDeliver,
|
||||
FilterSubject: consumer.Subject,
|
||||
ReplayPolicy: nats.ReplayInstantPolicy,
|
||||
}
|
||||
if consumer.InactiveThresholdSeconds > 0 {
|
||||
expected.InactiveThreshold = time.Duration(consumer.InactiveThresholdSeconds) * time.Second
|
||||
}
|
||||
info, err := js.ConsumerInfo(settings.Stream.Name, consumer.Durable)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrConsumerNotFound) {
|
||||
if !settings.AllowsAutoProvision() {
|
||||
if logger != nil {
|
||||
logger.Warn("Durable consumer is missing and auto provisioning is disabled", zap.String("stream", settings.Stream.Name), zap.String("subject", consumer.Subject), zap.String("durable", consumer.Durable))
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err = js.AddConsumer(settings.Stream.Name, expected)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
current := info.Config
|
||||
if current.DeliverPolicy != expected.DeliverPolicy && logger != nil {
|
||||
logger.Warn("Durable consumer deliver policy drift requires delete and recreate",
|
||||
zap.String("stream", settings.Stream.Name),
|
||||
zap.String("subject", consumer.Subject),
|
||||
zap.String("durable", consumer.Durable),
|
||||
zap.String("current_deliver_policy", natsDeliverPolicyName(current.DeliverPolicy)),
|
||||
zap.String("expected_deliver_policy", natsDeliverPolicyName(expected.DeliverPolicy)),
|
||||
zap.Bool("recreate_required", true),
|
||||
)
|
||||
}
|
||||
if err := validateConsumerConfig(current, *expected, settings.IsProductionValidation()); err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.AllowsAutoProvision() && (current.AckWait != expected.AckWait || current.MaxDeliver != expected.MaxDeliver || current.InactiveThreshold != expected.InactiveThreshold) {
|
||||
current.AckWait = expected.AckWait
|
||||
current.MaxDeliver = expected.MaxDeliver
|
||||
current.InactiveThreshold = expected.InactiveThreshold
|
||||
_, err = js.UpdateConsumer(settings.Stream.Name, ¤t)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func natsDeliverPolicy(deliveryMode reliable.DurableDeliverPolicy) (nats.DeliverPolicy, error) {
|
||||
switch deliveryMode {
|
||||
case "", reliable.DurableDeliverAll:
|
||||
return nats.DeliverAllPolicy, nil
|
||||
case reliable.DurableDeliverNew:
|
||||
return nats.DeliverNewPolicy, nil
|
||||
default:
|
||||
return nats.DeliverAllPolicy, merrors.InvalidArgumentf("unsupported durable consumer deliver policy: %q", deliveryMode)
|
||||
}
|
||||
}
|
||||
|
||||
func natsDeliverPolicyName(deliveryMode nats.DeliverPolicy) string {
|
||||
switch deliveryMode {
|
||||
case nats.DeliverAllPolicy:
|
||||
return "all"
|
||||
case nats.DeliverLastPolicy:
|
||||
return "last"
|
||||
case nats.DeliverNewPolicy:
|
||||
return "new"
|
||||
case nats.DeliverByStartSequencePolicy:
|
||||
return "by_start_sequence"
|
||||
case nats.DeliverByStartTimePolicy:
|
||||
return "by_start_time"
|
||||
case nats.DeliverLastPerSubjectPolicy:
|
||||
return "last_per_subject"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func validateStreamConfig(current nats.StreamConfig, expected nats.StreamConfig, strict bool) error {
|
||||
if strings.TrimSpace(current.Name) != strings.TrimSpace(expected.Name) {
|
||||
return merrors.DataConflictf("durable stream name drift: got=%s want=%s", current.Name, expected.Name)
|
||||
}
|
||||
if !sameStrings(current.Subjects, expected.Subjects) {
|
||||
return merrors.DataConflictf("durable stream subjects drift: current=%v expected=%v", current.Subjects, expected.Subjects)
|
||||
}
|
||||
if current.Storage != expected.Storage {
|
||||
return merrors.DataConflict("durable stream storage drift")
|
||||
}
|
||||
if current.Retention != expected.Retention {
|
||||
return merrors.DataConflict("durable stream retention drift")
|
||||
}
|
||||
if current.Replicas != expected.Replicas {
|
||||
return merrors.DataConflict("durable stream replicas drift")
|
||||
}
|
||||
if strict && current.MaxAge != expected.MaxAge {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConsumerConfig(current nats.ConsumerConfig, expected nats.ConsumerConfig, strict bool) error {
|
||||
if strings.TrimSpace(current.Durable) != strings.TrimSpace(expected.Durable) {
|
||||
return merrors.DataConflict("durable consumer name drift")
|
||||
}
|
||||
if strings.TrimSpace(current.FilterSubject) != strings.TrimSpace(expected.FilterSubject) {
|
||||
return merrors.DataConflict("durable consumer filter subject drift")
|
||||
}
|
||||
if current.DeliverPolicy != expected.DeliverPolicy {
|
||||
return merrors.DataConflict("durable consumer deliver policy drift; delete and recreate consumer to apply deliver policy")
|
||||
}
|
||||
if current.AckPolicy != expected.AckPolicy {
|
||||
return merrors.DataConflict("durable consumer ack policy drift")
|
||||
}
|
||||
if strict && current.AckWait != expected.AckWait {
|
||||
return merrors.DataConflict("durable consumer ack wait drift")
|
||||
}
|
||||
if strict && current.MaxDeliver != expected.MaxDeliver {
|
||||
return merrors.DataConflict("durable consumer max deliver drift")
|
||||
}
|
||||
if strict && current.InactiveThreshold != expected.InactiveThreshold {
|
||||
return merrors.DataConflict("durable consumer inactive threshold drift")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameStrings(left, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for _, value := range left {
|
||||
seen[strings.TrimSpace(value)]++
|
||||
}
|
||||
for _, value := range right {
|
||||
key := strings.TrimSpace(value)
|
||||
if seen[key] == 0 {
|
||||
return false
|
||||
}
|
||||
seen[key]--
|
||||
}
|
||||
for _, count := range seen {
|
||||
if count != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringSetDiff(left, right []string) []string {
|
||||
if len(left) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for _, value := range right {
|
||||
seen[strings.TrimSpace(value)]++
|
||||
}
|
||||
result := make([]string, 0)
|
||||
for _, value := range left {
|
||||
key := strings.TrimSpace(value)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if seen[key] > 0 {
|
||||
seen[key]--
|
||||
continue
|
||||
}
|
||||
result = append(result, key)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package natscap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/capability"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
type natsSubscription struct {
|
||||
mu sync.Mutex
|
||||
sub *nats.Subscription
|
||||
closed bool
|
||||
}
|
||||
|
||||
func fetchPollContext(ctx context.Context, wait time.Duration) (context.Context, context.CancelFunc, bool) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if wait <= 0 {
|
||||
return ctx, nil, false
|
||||
}
|
||||
if deadline, hasDeadline := ctx.Deadline(); hasDeadline && time.Until(deadline) <= wait {
|
||||
return ctx, nil, false
|
||||
}
|
||||
nextCtx, cancel := context.WithTimeout(ctx, wait)
|
||||
return nextCtx, cancel, true
|
||||
}
|
||||
|
||||
func (s *natsSubscription) Fetch(ctx context.Context, limit int, wait time.Duration) ([]reliable.Delivery, error) {
|
||||
if limit <= 0 {
|
||||
return nil, merrors.InvalidArgument("fetch limit must be greater than zero", "limit")
|
||||
}
|
||||
s.mu.Lock()
|
||||
closed := s.closed
|
||||
sub := s.sub
|
||||
s.mu.Unlock()
|
||||
if closed || sub == nil {
|
||||
return nil, capability.ErrClosed
|
||||
}
|
||||
fetchCtx, cancel, derivedDeadline := fetchPollContext(ctx, wait)
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
msgs, err := sub.Fetch(limit, nats.Context(fetchCtx))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, nats.ErrTimeout):
|
||||
return nil, nil
|
||||
case derivedDeadline && errors.Is(err, context.DeadlineExceeded):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
closed = s.closed
|
||||
s.mu.Unlock()
|
||||
if closed {
|
||||
return nil, capability.ErrClosed
|
||||
}
|
||||
result := make([]reliable.Delivery, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, &natsDelivery{sub: s, msg: msg, record: messageRecord(msg), attempt: deliveryAttempt(msg)})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *natsSubscription) Close() error {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
sub := s.sub
|
||||
s.sub = nil
|
||||
s.mu.Unlock()
|
||||
if sub == nil {
|
||||
return nil
|
||||
}
|
||||
if err := sub.Unsubscribe(); err != nil && !errors.Is(err, nats.ErrBadSubscription) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *natsSubscription) isClosed() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.closed
|
||||
}
|
||||
|
||||
type natsDelivery struct {
|
||||
sub *natsSubscription
|
||||
msg *nats.Msg
|
||||
record reliable.Record
|
||||
attempt int
|
||||
|
||||
mu sync.Mutex
|
||||
acked bool
|
||||
}
|
||||
|
||||
func (d *natsDelivery) Record() reliable.Record {
|
||||
return d.record
|
||||
}
|
||||
|
||||
func (d *natsDelivery) Attempt() int {
|
||||
if d.attempt <= 0 {
|
||||
return 1
|
||||
}
|
||||
return d.attempt
|
||||
}
|
||||
|
||||
func (d *natsDelivery) Ack(ctx context.Context) error {
|
||||
d.mu.Lock()
|
||||
if d.acked {
|
||||
d.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if d.sub != nil && d.sub.isClosed() {
|
||||
d.mu.Unlock()
|
||||
return capability.ErrClosed
|
||||
}
|
||||
msg := d.msg
|
||||
d.mu.Unlock()
|
||||
if msg == nil {
|
||||
return capability.ErrClosed
|
||||
}
|
||||
if err := msg.AckSync(nats.Context(ctx)); err != nil {
|
||||
return err
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.acked = true
|
||||
d.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func messageRecord(msg *nats.Msg) reliable.Record {
|
||||
headers := make(map[string][]string, len(msg.Header)+1)
|
||||
for key, values := range msg.Header {
|
||||
headers[strings.ToLower(strings.TrimSpace(key))] = append([]string(nil), values...)
|
||||
}
|
||||
messageID := strings.TrimSpace(msg.Header.Get(nats.MsgIdHdr))
|
||||
if businessID := reliable.HeaderValue(headers, reliable.HeaderOriginalBusinessMsgID); businessID != "" {
|
||||
messageID = businessID
|
||||
}
|
||||
headers[reliable.HeaderTransportMessageID] = []string{strings.TrimSpace(msg.Header.Get(nats.MsgIdHdr))}
|
||||
return reliable.Record{
|
||||
Subject: strings.TrimSpace(msg.Subject),
|
||||
Payload: append([]byte(nil), msg.Data...),
|
||||
Headers: headers,
|
||||
MessageID: strings.TrimSpace(messageID),
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryAttempt(msg *nats.Msg) int {
|
||||
if msg == nil {
|
||||
return 1
|
||||
}
|
||||
meta, err := msg.Metadata()
|
||||
if err != nil || meta == nil || meta.NumDelivered == 0 {
|
||||
return 1
|
||||
}
|
||||
if meta.NumDelivered > uint64(^uint(0)>>1) {
|
||||
return int(^uint(0) >> 1)
|
||||
}
|
||||
return int(meta.NumDelivered)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package natscap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/capability"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func TestFetchPollDeadlineIsBounded(t *testing.T) {
|
||||
parent, cancel := context.WithTimeout(context.Background(), time.Hour)
|
||||
defer cancel()
|
||||
ctx, stop, derived := fetchPollContext(parent, time.Second)
|
||||
if stop != nil {
|
||||
defer stop()
|
||||
}
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok || !derived || time.Until(deadline) > time.Second {
|
||||
t.Fatal("fetch escaped poll deadline")
|
||||
}
|
||||
}
|
||||
func TestClosedSubscriptionAndAck(t *testing.T) {
|
||||
s := &natsSubscription{}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Fetch(context.Background(), 1, time.Second); !errors.Is(err, capability.ErrClosed) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &natsDelivery{sub: s}
|
||||
if err := d.Ack(context.Background()); !errors.Is(err, capability.ErrClosed) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.acked = true
|
||||
if err := d.Ack(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestConsumerDriftRejected(t *testing.T) {
|
||||
want := nats.ConsumerConfig{Durable: "email", FilterSubject: "notifications.email", AckPolicy: nats.AckExplicitPolicy, DeliverPolicy: nats.DeliverAllPolicy, AckWait: time.Minute}
|
||||
current := want
|
||||
current.FilterSubject = "other"
|
||||
if err := validateConsumerConfig(current, want, true); err == nil {
|
||||
t.Fatal("subject drift accepted")
|
||||
}
|
||||
current = want
|
||||
current.AckWait = time.Second
|
||||
if err := validateConsumerConfig(current, want, true); err == nil {
|
||||
t.Fatal("ack drift accepted")
|
||||
}
|
||||
if _, err := natsDeliverPolicy(reliable.DurableDeliverPolicy("invalid")); err == nil {
|
||||
t.Fatal("invalid deliver policy accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package reliable
|
||||
|
||||
const DurableSettingsBlockKey = "durable_delivery"
|
||||
|
||||
type DurableTransport string
|
||||
|
||||
const (
|
||||
DurableTransportDirect DurableTransport = "direct"
|
||||
DurableTransportJetStream DurableTransport = "jetstream"
|
||||
)
|
||||
|
||||
type DurableProvisioningMode string
|
||||
|
||||
const (
|
||||
DurableProvisioningHybrid DurableProvisioningMode = "hybrid"
|
||||
DurableProvisioningApp DurableProvisioningMode = "app"
|
||||
DurableProvisioningOps DurableProvisioningMode = "ops"
|
||||
)
|
||||
|
||||
type DurableEnvironment string
|
||||
|
||||
const (
|
||||
DurableEnvironmentNonProduction DurableEnvironment = "non-production"
|
||||
DurableEnvironmentProduction DurableEnvironment = "production"
|
||||
)
|
||||
|
||||
type DurableDeliverPolicy string
|
||||
|
||||
const (
|
||||
DurableDeliverAll DurableDeliverPolicy = "all"
|
||||
DurableDeliverNew DurableDeliverPolicy = "new"
|
||||
)
|
||||
|
||||
type DurablePublisherSettings struct {
|
||||
Transport DurableTransport `mapstructure:"transport" yaml:"transport"`
|
||||
DurableSubjects []string `mapstructure:"durable_subjects" yaml:"durable_subjects"`
|
||||
}
|
||||
|
||||
type DurableStreamSettings struct {
|
||||
Name string `mapstructure:"name" yaml:"name"`
|
||||
Subjects []string `mapstructure:"subjects" yaml:"subjects"`
|
||||
MaxAgeHours int `mapstructure:"max_age_hours" yaml:"max_age_hours"`
|
||||
Storage string `mapstructure:"storage" yaml:"storage"`
|
||||
Replicas int `mapstructure:"replicas" yaml:"replicas"`
|
||||
}
|
||||
|
||||
type DurableParkingSettings struct {
|
||||
StreamName string `mapstructure:"stream_name" yaml:"stream_name"`
|
||||
SubjectPrefix string `mapstructure:"subject_prefix" yaml:"subject_prefix"`
|
||||
MaxOperatorReplays int `mapstructure:"max_operator_replays" yaml:"max_operator_replays"`
|
||||
}
|
||||
|
||||
type DurableProvisioningSettings struct {
|
||||
Mode DurableProvisioningMode `mapstructure:"mode" yaml:"mode"`
|
||||
Environment DurableEnvironment `mapstructure:"environment" yaml:"environment"`
|
||||
}
|
||||
|
||||
type DurableConsumerSettings struct {
|
||||
Subject string `mapstructure:"subject" yaml:"subject"`
|
||||
Durable string `mapstructure:"durable" yaml:"durable"`
|
||||
DeliverPolicy DurableDeliverPolicy `mapstructure:"deliver_policy" yaml:"deliver_policy"`
|
||||
AckWaitSeconds int `mapstructure:"ack_wait_seconds" yaml:"ack_wait_seconds"`
|
||||
MaxDeliver int `mapstructure:"max_deliver" yaml:"max_deliver"`
|
||||
InactiveThresholdSeconds int `mapstructure:"inactive_threshold_seconds" yaml:"inactive_threshold_seconds"`
|
||||
}
|
||||
|
||||
type DurableSettings struct {
|
||||
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
||||
Publisher DurablePublisherSettings `mapstructure:"publisher" yaml:"publisher"`
|
||||
Stream DurableStreamSettings `mapstructure:"stream" yaml:"stream"`
|
||||
Parking DurableParkingSettings `mapstructure:"parking" yaml:"parking"`
|
||||
Provisioning DurableProvisioningSettings `mapstructure:"provisioning" yaml:"provisioning"`
|
||||
Consumers []DurableConsumerSettings `mapstructure:"consumers" yaml:"consumers"`
|
||||
ServiceName string `mapstructure:"-" yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package reliable
|
||||
|
||||
import "time"
|
||||
|
||||
func (s DurableSettings) StreamMaxAge() time.Duration {
|
||||
if s.Stream.MaxAgeHours <= 0 {
|
||||
return 48 * time.Hour
|
||||
}
|
||||
return time.Duration(s.Stream.MaxAgeHours) * time.Hour
|
||||
}
|
||||
|
||||
func (s DurableSettings) IsProductionValidation() bool {
|
||||
return s.Provisioning.Environment == DurableEnvironmentProduction
|
||||
}
|
||||
|
||||
func (s DurableSettings) AllowsAutoProvision() bool {
|
||||
switch s.Provisioning.Mode {
|
||||
case DurableProvisioningApp:
|
||||
return true
|
||||
case DurableProvisioningHybrid:
|
||||
return s.Provisioning.Environment != DurableEnvironmentProduction
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package reliable
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
const ReservedHeaderPrefix = "sxchnge-runtime-"
|
||||
|
||||
const (
|
||||
HeaderTransportMessageID = ReservedHeaderPrefix + "transport-message-id"
|
||||
HeaderOriginalSubject = ReservedHeaderPrefix + "original-subject"
|
||||
HeaderOriginalBusinessMsgID = ReservedHeaderPrefix + "original-business-message-id"
|
||||
HeaderOriginalTransportMsgID = ReservedHeaderPrefix + "original-transport-message-id"
|
||||
HeaderFirstSeenAt = ReservedHeaderPrefix + "first-seen-at"
|
||||
HeaderLastDeliveryAt = ReservedHeaderPrefix + "last-delivery-at"
|
||||
HeaderDeliveryAttempts = ReservedHeaderPrefix + "delivery-attempts"
|
||||
HeaderConsumerDurable = ReservedHeaderPrefix + "consumer"
|
||||
HeaderParkReason = ReservedHeaderPrefix + "park-reason"
|
||||
HeaderParkedRecordID = ReservedHeaderPrefix + "parked-record-id"
|
||||
HeaderReplaySource = ReservedHeaderPrefix + "replay-source"
|
||||
HeaderReplayAttempt = ReservedHeaderPrefix + "replay-attempt"
|
||||
HeaderReplayCount = ReservedHeaderPrefix + "replay-count"
|
||||
HeaderLastReplayAt = ReservedHeaderPrefix + "last-replay-at"
|
||||
HeaderLastReplayTransportMsgID = ReservedHeaderPrefix + "last-replay-transport-message-id"
|
||||
replaySourceParked = "parked"
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
// Subject identifies the logical topic or routing key for the record.
|
||||
Subject string
|
||||
// Payload carries the application bytes. Callers and adapters must treat payload data as read-only.
|
||||
Payload []byte
|
||||
// Headers carries case-insensitive metadata. Public publishes normalize keys to lowercase, preserve per-key value order,
|
||||
// treat nil and empty maps as equivalent, drop empty value slices, and reject caller-supplied keys that use ReservedHeaderPrefix.
|
||||
Headers map[string][]string
|
||||
// MessageID is optional and caller-supplied. When non-empty, it is the logical message identity used for correlation and
|
||||
// redelivery contract checks, and runtime-owned durable paths must preserve it unchanged end to end.
|
||||
MessageID string
|
||||
}
|
||||
|
||||
// Transport is the durable data-plane capability.
|
||||
// Publish accepts public records only.
|
||||
// OpenConsumer returns a subscription whose deliveries obey the Delivery and Subscription contracts below.
|
||||
type Transport interface {
|
||||
Publish(ctx context.Context, record Record) error
|
||||
OpenConsumer(ctx context.Context, settings DurableSettings, consumer DurableConsumerSettings) (Subscription, error)
|
||||
}
|
||||
|
||||
// Admin provisions durable transport resources.
|
||||
// EnsureProducerResources and EnsureConsumerResources must be idempotent.
|
||||
type Admin interface {
|
||||
EnsureProducerResources(ctx context.Context, settings DurableSettings) error
|
||||
EnsureConsumerResources(ctx context.Context, settings DurableSettings, consumer DurableConsumerSettings) error
|
||||
}
|
||||
|
||||
// Subscription fetches durable deliveries.
|
||||
// Fetch rejects limit <= 0 with merrors.InvalidArgument.
|
||||
// Fetch may return fewer than limit deliveries, including zero deliveries with nil error, when fewer records are available
|
||||
// or the wait window expires.
|
||||
// Close is idempotent and concurrency-safe. After Close, Fetch returns capability.ErrClosed and no further successful
|
||||
// Fetch calls are allowed.
|
||||
type Subscription interface {
|
||||
Fetch(ctx context.Context, limit int, wait time.Duration) ([]Delivery, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Delivery represents one delivery attempt of a durable record.
|
||||
// Attempt is 1-based.
|
||||
// A redelivery is any later delivery of the same logical message after a prior delivery was not successfully acked.
|
||||
// When Record.MessageID is non-empty, that exact value defines logical message identity for contract purposes.
|
||||
// Ack is idempotent on the same delivery instance. Repeated calls after a successful ack return nil.
|
||||
// If a delivery is not acked, it remains eligible for redelivery under adapter policy.
|
||||
// After Subscription.Close, Ack on a previously fetched but unacked delivery returns capability.ErrClosed unless it already succeeded earlier.
|
||||
type Delivery interface {
|
||||
Record() Record
|
||||
Attempt() int
|
||||
Ack(ctx context.Context) error
|
||||
}
|
||||
|
||||
type runtimePublisher interface {
|
||||
PublishRuntime(ctx context.Context, record Record) error
|
||||
}
|
||||
|
||||
func publishRuntimeRecord(ctx context.Context, transport Transport, record Record) error {
|
||||
if transport == nil {
|
||||
return nil
|
||||
}
|
||||
if publisher, ok := transport.(runtimePublisher); ok {
|
||||
return publisher.PublishRuntime(ctx, record)
|
||||
}
|
||||
return transport.Publish(ctx, record)
|
||||
}
|
||||
|
||||
func normalizeRecord(record Record, allowReserved bool) (Record, error) {
|
||||
record.Subject = strings.TrimSpace(record.Subject)
|
||||
record.MessageID = strings.TrimSpace(record.MessageID)
|
||||
if record.Subject == "" {
|
||||
return Record{}, merrors.InvalidArgument("record subject is required", "record.subject")
|
||||
}
|
||||
record.Headers = normalizeHeaders(record.Headers, allowReserved)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func NormalizePublicRecord(record Record) (Record, error) {
|
||||
if err := validatePublicRecord(record); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
return normalizeRecord(record, false)
|
||||
}
|
||||
|
||||
func NormalizeRuntimeRecord(record Record) (Record, error) {
|
||||
return normalizeRecord(record, true)
|
||||
}
|
||||
|
||||
func normalizeHeaders(headers map[string][]string, allowReserved bool) map[string][]string {
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string][]string, len(headers))
|
||||
for key, values := range headers {
|
||||
key = normalizeHeaderKey(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if !allowReserved && strings.HasPrefix(key, ReservedHeaderPrefix) {
|
||||
continue
|
||||
}
|
||||
normalized := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
normalized = append(normalized, trimmed)
|
||||
}
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
continue
|
||||
}
|
||||
result[key] = append(result[key], normalized...)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validatePublicRecord(record Record) error {
|
||||
for key := range record.Headers {
|
||||
normalized := normalizeHeaderKey(key)
|
||||
if normalized == "" {
|
||||
return merrors.InvalidArgument("record header key is empty", "record.headers")
|
||||
}
|
||||
if strings.HasPrefix(normalized, ReservedHeaderPrefix) {
|
||||
return merrors.InvalidArgument("record header key uses reserved runtime prefix", "record.headers")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidatePublicRecord(record Record) error {
|
||||
return validatePublicRecord(record)
|
||||
}
|
||||
|
||||
func normalizeHeaderKey(key string) string {
|
||||
return strings.ToLower(strings.TrimSpace(key))
|
||||
}
|
||||
|
||||
func cloneHeaderMap(headers map[string][]string) map[string][]string {
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string][]string, len(headers))
|
||||
for key, values := range headers {
|
||||
result[key] = append([]string(nil), values...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func CloneHeaders(headers map[string][]string) map[string][]string {
|
||||
return cloneHeaderMap(headers)
|
||||
}
|
||||
|
||||
func headerValue(headers map[string][]string, key string) string {
|
||||
if len(headers) == 0 {
|
||||
return ""
|
||||
}
|
||||
values := headers[normalizeHeaderKey(key)]
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(values[0])
|
||||
}
|
||||
|
||||
func HeaderValue(headers map[string][]string, key string) string {
|
||||
return headerValue(headers, key)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package reliable
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPublicRecordValidation(t *testing.T) {
|
||||
if _, err := NormalizePublicRecord(Record{Subject: "email", Headers: map[string][]string{HeaderTransportMessageID: {"forged"}}}); err == nil {
|
||||
t.Fatal("reserved header accepted")
|
||||
}
|
||||
if _, err := NormalizePublicRecord(Record{}); err == nil {
|
||||
t.Fatal("empty subject accepted")
|
||||
}
|
||||
r, err := NormalizePublicRecord(Record{Subject: " email ", MessageID: " id ", Headers: map[string][]string{"TRACE": {" trace "}}})
|
||||
if err != nil || r.Subject != "email" || r.MessageID != "id" || HeaderValue(r.Headers, "trace") != "trace" {
|
||||
t.Fatal(r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Package runtime composes the locally owned Sendico durable messaging adapters.
|
||||
// SXCHNGE does not include Sendico's discovery or business-event registries.
|
||||
package runtime
|
||||
|
||||
import (
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
msg "git.sendico.io/tech/sxchnge/shared/messaging"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/internal/natsb"
|
||||
config "git.sendico.io/tech/sxchnge/shared/messaging/internal/natsb/config"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/internal/natscap"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
type Runtime struct {
|
||||
Durable reliable.Transport
|
||||
DurableAdmin reliable.Admin
|
||||
broker *natsb.NatsBroker
|
||||
}
|
||||
|
||||
func New(logger mlogger.Logger, c *msg.Config) (*Runtime, error) {
|
||||
if c == nil || c.Driver != msg.BBNats {
|
||||
return nil, merrors.InvalidArgument("NATS messaging configuration required")
|
||||
}
|
||||
var settings config.Settings
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &settings, ErrorUnused: true, TagName: "mapstructure"})
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "create messaging settings decoder")
|
||||
}
|
||||
if err = decoder.Decode(c.Settings); err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid messaging settings")
|
||||
}
|
||||
broker, err := natsb.NewNatsBroker(mlogger.Named(logger, "messaging"), &settings)
|
||||
if err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "connect messaging broker")
|
||||
}
|
||||
adapter := natscap.NewDurableAdapter(broker)
|
||||
if adapter == nil {
|
||||
broker.Close()
|
||||
return nil, merrors.FailedPrecondition("durable messaging unavailable")
|
||||
}
|
||||
return &Runtime{Durable: adapter, DurableAdmin: adapter, broker: broker}, nil
|
||||
}
|
||||
func (r *Runtime) Close() {
|
||||
if r != nil {
|
||||
r.broker.Close()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user