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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user