Implement notifications service with shared messaging and email drivers (#36)

This commit is contained in:
Stephan D committed 2026-09-12 14:41:55 +02:00
1 parent 36277b6c45
commit 5c2d36281b
54 files changed
+3746 -6

No files matched your search

@@ -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)
}
}