feat: use postgres
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type DeadLetters struct {
|
||||
NotificationID string `sql:"primary_key"`
|
||||
RouteID string `sql:"primary_key"`
|
||||
Channel string
|
||||
RecipientRef string
|
||||
FinalAttemptCount int32
|
||||
MaxAttempts int32
|
||||
FailureClassification string
|
||||
FailureMessage string
|
||||
RecoveryHint string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type GooseDbVersion struct {
|
||||
ID int32 `sql:"primary_key"`
|
||||
VersionID int64
|
||||
IsApplied bool
|
||||
Tstamp time.Time
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type MalformedIntents struct {
|
||||
StreamEntryID string `sql:"primary_key"`
|
||||
NotificationType string
|
||||
Producer string
|
||||
IdempotencyKey string
|
||||
FailureCode string
|
||||
FailureMessage string
|
||||
RawFields string
|
||||
RecordedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Records struct {
|
||||
NotificationID string `sql:"primary_key"`
|
||||
NotificationType string
|
||||
Producer string
|
||||
AudienceKind string
|
||||
RecipientUserIds string
|
||||
PayloadJSON string
|
||||
IdempotencyKey string
|
||||
RequestFingerprint string
|
||||
RequestID string
|
||||
TraceID string
|
||||
OccurredAt time.Time
|
||||
AcceptedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
IdempotencyExpiresAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Routes struct {
|
||||
NotificationID string `sql:"primary_key"`
|
||||
RouteID string `sql:"primary_key"`
|
||||
Channel string
|
||||
RecipientRef string
|
||||
Status string
|
||||
AttemptCount int32
|
||||
MaxAttempts int32
|
||||
NextAttemptAt *time.Time
|
||||
ResolvedEmail string
|
||||
ResolvedLocale string
|
||||
LastErrorClassification string
|
||||
LastErrorMessage string
|
||||
LastErrorAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
PublishedAt *time.Time
|
||||
DeadLetteredAt *time.Time
|
||||
SkippedAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var DeadLetters = newDeadLettersTable("notification", "dead_letters", "")
|
||||
|
||||
type deadLettersTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
NotificationID postgres.ColumnString
|
||||
RouteID postgres.ColumnString
|
||||
Channel postgres.ColumnString
|
||||
RecipientRef postgres.ColumnString
|
||||
FinalAttemptCount postgres.ColumnInteger
|
||||
MaxAttempts postgres.ColumnInteger
|
||||
FailureClassification postgres.ColumnString
|
||||
FailureMessage postgres.ColumnString
|
||||
RecoveryHint postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type DeadLettersTable struct {
|
||||
deadLettersTable
|
||||
|
||||
EXCLUDED deadLettersTable
|
||||
}
|
||||
|
||||
// AS creates new DeadLettersTable with assigned alias
|
||||
func (a DeadLettersTable) AS(alias string) *DeadLettersTable {
|
||||
return newDeadLettersTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new DeadLettersTable with assigned schema name
|
||||
func (a DeadLettersTable) FromSchema(schemaName string) *DeadLettersTable {
|
||||
return newDeadLettersTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new DeadLettersTable with assigned table prefix
|
||||
func (a DeadLettersTable) WithPrefix(prefix string) *DeadLettersTable {
|
||||
return newDeadLettersTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new DeadLettersTable with assigned table suffix
|
||||
func (a DeadLettersTable) WithSuffix(suffix string) *DeadLettersTable {
|
||||
return newDeadLettersTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newDeadLettersTable(schemaName, tableName, alias string) *DeadLettersTable {
|
||||
return &DeadLettersTable{
|
||||
deadLettersTable: newDeadLettersTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newDeadLettersTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newDeadLettersTableImpl(schemaName, tableName, alias string) deadLettersTable {
|
||||
var (
|
||||
NotificationIDColumn = postgres.StringColumn("notification_id")
|
||||
RouteIDColumn = postgres.StringColumn("route_id")
|
||||
ChannelColumn = postgres.StringColumn("channel")
|
||||
RecipientRefColumn = postgres.StringColumn("recipient_ref")
|
||||
FinalAttemptCountColumn = postgres.IntegerColumn("final_attempt_count")
|
||||
MaxAttemptsColumn = postgres.IntegerColumn("max_attempts")
|
||||
FailureClassificationColumn = postgres.StringColumn("failure_classification")
|
||||
FailureMessageColumn = postgres.StringColumn("failure_message")
|
||||
RecoveryHintColumn = postgres.StringColumn("recovery_hint")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{NotificationIDColumn, RouteIDColumn, ChannelColumn, RecipientRefColumn, FinalAttemptCountColumn, MaxAttemptsColumn, FailureClassificationColumn, FailureMessageColumn, RecoveryHintColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{ChannelColumn, RecipientRefColumn, FinalAttemptCountColumn, MaxAttemptsColumn, FailureClassificationColumn, FailureMessageColumn, RecoveryHintColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{RecoveryHintColumn}
|
||||
)
|
||||
|
||||
return deadLettersTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
NotificationID: NotificationIDColumn,
|
||||
RouteID: RouteIDColumn,
|
||||
Channel: ChannelColumn,
|
||||
RecipientRef: RecipientRefColumn,
|
||||
FinalAttemptCount: FinalAttemptCountColumn,
|
||||
MaxAttempts: MaxAttemptsColumn,
|
||||
FailureClassification: FailureClassificationColumn,
|
||||
FailureMessage: FailureMessageColumn,
|
||||
RecoveryHint: RecoveryHintColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var GooseDbVersion = newGooseDbVersionTable("notification", "goose_db_version", "")
|
||||
|
||||
type gooseDbVersionTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ID postgres.ColumnInteger
|
||||
VersionID postgres.ColumnInteger
|
||||
IsApplied postgres.ColumnBool
|
||||
Tstamp postgres.ColumnTimestamp
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type GooseDbVersionTable struct {
|
||||
gooseDbVersionTable
|
||||
|
||||
EXCLUDED gooseDbVersionTable
|
||||
}
|
||||
|
||||
// AS creates new GooseDbVersionTable with assigned alias
|
||||
func (a GooseDbVersionTable) AS(alias string) *GooseDbVersionTable {
|
||||
return newGooseDbVersionTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new GooseDbVersionTable with assigned schema name
|
||||
func (a GooseDbVersionTable) FromSchema(schemaName string) *GooseDbVersionTable {
|
||||
return newGooseDbVersionTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new GooseDbVersionTable with assigned table prefix
|
||||
func (a GooseDbVersionTable) WithPrefix(prefix string) *GooseDbVersionTable {
|
||||
return newGooseDbVersionTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new GooseDbVersionTable with assigned table suffix
|
||||
func (a GooseDbVersionTable) WithSuffix(suffix string) *GooseDbVersionTable {
|
||||
return newGooseDbVersionTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newGooseDbVersionTable(schemaName, tableName, alias string) *GooseDbVersionTable {
|
||||
return &GooseDbVersionTable{
|
||||
gooseDbVersionTable: newGooseDbVersionTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newGooseDbVersionTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newGooseDbVersionTableImpl(schemaName, tableName, alias string) gooseDbVersionTable {
|
||||
var (
|
||||
IDColumn = postgres.IntegerColumn("id")
|
||||
VersionIDColumn = postgres.IntegerColumn("version_id")
|
||||
IsAppliedColumn = postgres.BoolColumn("is_applied")
|
||||
TstampColumn = postgres.TimestampColumn("tstamp")
|
||||
allColumns = postgres.ColumnList{IDColumn, VersionIDColumn, IsAppliedColumn, TstampColumn}
|
||||
mutableColumns = postgres.ColumnList{VersionIDColumn, IsAppliedColumn, TstampColumn}
|
||||
defaultColumns = postgres.ColumnList{TstampColumn}
|
||||
)
|
||||
|
||||
return gooseDbVersionTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
VersionID: VersionIDColumn,
|
||||
IsApplied: IsAppliedColumn,
|
||||
Tstamp: TstampColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var MalformedIntents = newMalformedIntentsTable("notification", "malformed_intents", "")
|
||||
|
||||
type malformedIntentsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
StreamEntryID postgres.ColumnString
|
||||
NotificationType postgres.ColumnString
|
||||
Producer postgres.ColumnString
|
||||
IdempotencyKey postgres.ColumnString
|
||||
FailureCode postgres.ColumnString
|
||||
FailureMessage postgres.ColumnString
|
||||
RawFields postgres.ColumnString
|
||||
RecordedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type MalformedIntentsTable struct {
|
||||
malformedIntentsTable
|
||||
|
||||
EXCLUDED malformedIntentsTable
|
||||
}
|
||||
|
||||
// AS creates new MalformedIntentsTable with assigned alias
|
||||
func (a MalformedIntentsTable) AS(alias string) *MalformedIntentsTable {
|
||||
return newMalformedIntentsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new MalformedIntentsTable with assigned schema name
|
||||
func (a MalformedIntentsTable) FromSchema(schemaName string) *MalformedIntentsTable {
|
||||
return newMalformedIntentsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new MalformedIntentsTable with assigned table prefix
|
||||
func (a MalformedIntentsTable) WithPrefix(prefix string) *MalformedIntentsTable {
|
||||
return newMalformedIntentsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new MalformedIntentsTable with assigned table suffix
|
||||
func (a MalformedIntentsTable) WithSuffix(suffix string) *MalformedIntentsTable {
|
||||
return newMalformedIntentsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newMalformedIntentsTable(schemaName, tableName, alias string) *MalformedIntentsTable {
|
||||
return &MalformedIntentsTable{
|
||||
malformedIntentsTable: newMalformedIntentsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newMalformedIntentsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newMalformedIntentsTableImpl(schemaName, tableName, alias string) malformedIntentsTable {
|
||||
var (
|
||||
StreamEntryIDColumn = postgres.StringColumn("stream_entry_id")
|
||||
NotificationTypeColumn = postgres.StringColumn("notification_type")
|
||||
ProducerColumn = postgres.StringColumn("producer")
|
||||
IdempotencyKeyColumn = postgres.StringColumn("idempotency_key")
|
||||
FailureCodeColumn = postgres.StringColumn("failure_code")
|
||||
FailureMessageColumn = postgres.StringColumn("failure_message")
|
||||
RawFieldsColumn = postgres.StringColumn("raw_fields")
|
||||
RecordedAtColumn = postgres.TimestampzColumn("recorded_at")
|
||||
allColumns = postgres.ColumnList{StreamEntryIDColumn, NotificationTypeColumn, ProducerColumn, IdempotencyKeyColumn, FailureCodeColumn, FailureMessageColumn, RawFieldsColumn, RecordedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{NotificationTypeColumn, ProducerColumn, IdempotencyKeyColumn, FailureCodeColumn, FailureMessageColumn, RawFieldsColumn, RecordedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{NotificationTypeColumn, ProducerColumn, IdempotencyKeyColumn}
|
||||
)
|
||||
|
||||
return malformedIntentsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
StreamEntryID: StreamEntryIDColumn,
|
||||
NotificationType: NotificationTypeColumn,
|
||||
Producer: ProducerColumn,
|
||||
IdempotencyKey: IdempotencyKeyColumn,
|
||||
FailureCode: FailureCodeColumn,
|
||||
FailureMessage: FailureMessageColumn,
|
||||
RawFields: RawFieldsColumn,
|
||||
RecordedAt: RecordedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Records = newRecordsTable("notification", "records", "")
|
||||
|
||||
type recordsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
NotificationID postgres.ColumnString
|
||||
NotificationType postgres.ColumnString
|
||||
Producer postgres.ColumnString
|
||||
AudienceKind postgres.ColumnString
|
||||
RecipientUserIds postgres.ColumnString
|
||||
PayloadJSON postgres.ColumnString
|
||||
IdempotencyKey postgres.ColumnString
|
||||
RequestFingerprint postgres.ColumnString
|
||||
RequestID postgres.ColumnString
|
||||
TraceID postgres.ColumnString
|
||||
OccurredAt postgres.ColumnTimestampz
|
||||
AcceptedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
IdempotencyExpiresAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type RecordsTable struct {
|
||||
recordsTable
|
||||
|
||||
EXCLUDED recordsTable
|
||||
}
|
||||
|
||||
// AS creates new RecordsTable with assigned alias
|
||||
func (a RecordsTable) AS(alias string) *RecordsTable {
|
||||
return newRecordsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new RecordsTable with assigned schema name
|
||||
func (a RecordsTable) FromSchema(schemaName string) *RecordsTable {
|
||||
return newRecordsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new RecordsTable with assigned table prefix
|
||||
func (a RecordsTable) WithPrefix(prefix string) *RecordsTable {
|
||||
return newRecordsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new RecordsTable with assigned table suffix
|
||||
func (a RecordsTable) WithSuffix(suffix string) *RecordsTable {
|
||||
return newRecordsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newRecordsTable(schemaName, tableName, alias string) *RecordsTable {
|
||||
return &RecordsTable{
|
||||
recordsTable: newRecordsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newRecordsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newRecordsTableImpl(schemaName, tableName, alias string) recordsTable {
|
||||
var (
|
||||
NotificationIDColumn = postgres.StringColumn("notification_id")
|
||||
NotificationTypeColumn = postgres.StringColumn("notification_type")
|
||||
ProducerColumn = postgres.StringColumn("producer")
|
||||
AudienceKindColumn = postgres.StringColumn("audience_kind")
|
||||
RecipientUserIdsColumn = postgres.StringColumn("recipient_user_ids")
|
||||
PayloadJSONColumn = postgres.StringColumn("payload_json")
|
||||
IdempotencyKeyColumn = postgres.StringColumn("idempotency_key")
|
||||
RequestFingerprintColumn = postgres.StringColumn("request_fingerprint")
|
||||
RequestIDColumn = postgres.StringColumn("request_id")
|
||||
TraceIDColumn = postgres.StringColumn("trace_id")
|
||||
OccurredAtColumn = postgres.TimestampzColumn("occurred_at")
|
||||
AcceptedAtColumn = postgres.TimestampzColumn("accepted_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
IdempotencyExpiresAtColumn = postgres.TimestampzColumn("idempotency_expires_at")
|
||||
allColumns = postgres.ColumnList{NotificationIDColumn, NotificationTypeColumn, ProducerColumn, AudienceKindColumn, RecipientUserIdsColumn, PayloadJSONColumn, IdempotencyKeyColumn, RequestFingerprintColumn, RequestIDColumn, TraceIDColumn, OccurredAtColumn, AcceptedAtColumn, UpdatedAtColumn, IdempotencyExpiresAtColumn}
|
||||
mutableColumns = postgres.ColumnList{NotificationTypeColumn, ProducerColumn, AudienceKindColumn, RecipientUserIdsColumn, PayloadJSONColumn, IdempotencyKeyColumn, RequestFingerprintColumn, RequestIDColumn, TraceIDColumn, OccurredAtColumn, AcceptedAtColumn, UpdatedAtColumn, IdempotencyExpiresAtColumn}
|
||||
defaultColumns = postgres.ColumnList{RecipientUserIdsColumn, RequestIDColumn, TraceIDColumn}
|
||||
)
|
||||
|
||||
return recordsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
NotificationID: NotificationIDColumn,
|
||||
NotificationType: NotificationTypeColumn,
|
||||
Producer: ProducerColumn,
|
||||
AudienceKind: AudienceKindColumn,
|
||||
RecipientUserIds: RecipientUserIdsColumn,
|
||||
PayloadJSON: PayloadJSONColumn,
|
||||
IdempotencyKey: IdempotencyKeyColumn,
|
||||
RequestFingerprint: RequestFingerprintColumn,
|
||||
RequestID: RequestIDColumn,
|
||||
TraceID: TraceIDColumn,
|
||||
OccurredAt: OccurredAtColumn,
|
||||
AcceptedAt: AcceptedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
IdempotencyExpiresAt: IdempotencyExpiresAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Routes = newRoutesTable("notification", "routes", "")
|
||||
|
||||
type routesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
NotificationID postgres.ColumnString
|
||||
RouteID postgres.ColumnString
|
||||
Channel postgres.ColumnString
|
||||
RecipientRef postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
AttemptCount postgres.ColumnInteger
|
||||
MaxAttempts postgres.ColumnInteger
|
||||
NextAttemptAt postgres.ColumnTimestampz
|
||||
ResolvedEmail postgres.ColumnString
|
||||
ResolvedLocale postgres.ColumnString
|
||||
LastErrorClassification postgres.ColumnString
|
||||
LastErrorMessage postgres.ColumnString
|
||||
LastErrorAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
PublishedAt postgres.ColumnTimestampz
|
||||
DeadLetteredAt postgres.ColumnTimestampz
|
||||
SkippedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type RoutesTable struct {
|
||||
routesTable
|
||||
|
||||
EXCLUDED routesTable
|
||||
}
|
||||
|
||||
// AS creates new RoutesTable with assigned alias
|
||||
func (a RoutesTable) AS(alias string) *RoutesTable {
|
||||
return newRoutesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new RoutesTable with assigned schema name
|
||||
func (a RoutesTable) FromSchema(schemaName string) *RoutesTable {
|
||||
return newRoutesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new RoutesTable with assigned table prefix
|
||||
func (a RoutesTable) WithPrefix(prefix string) *RoutesTable {
|
||||
return newRoutesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new RoutesTable with assigned table suffix
|
||||
func (a RoutesTable) WithSuffix(suffix string) *RoutesTable {
|
||||
return newRoutesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newRoutesTable(schemaName, tableName, alias string) *RoutesTable {
|
||||
return &RoutesTable{
|
||||
routesTable: newRoutesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newRoutesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newRoutesTableImpl(schemaName, tableName, alias string) routesTable {
|
||||
var (
|
||||
NotificationIDColumn = postgres.StringColumn("notification_id")
|
||||
RouteIDColumn = postgres.StringColumn("route_id")
|
||||
ChannelColumn = postgres.StringColumn("channel")
|
||||
RecipientRefColumn = postgres.StringColumn("recipient_ref")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
AttemptCountColumn = postgres.IntegerColumn("attempt_count")
|
||||
MaxAttemptsColumn = postgres.IntegerColumn("max_attempts")
|
||||
NextAttemptAtColumn = postgres.TimestampzColumn("next_attempt_at")
|
||||
ResolvedEmailColumn = postgres.StringColumn("resolved_email")
|
||||
ResolvedLocaleColumn = postgres.StringColumn("resolved_locale")
|
||||
LastErrorClassificationColumn = postgres.StringColumn("last_error_classification")
|
||||
LastErrorMessageColumn = postgres.StringColumn("last_error_message")
|
||||
LastErrorAtColumn = postgres.TimestampzColumn("last_error_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
PublishedAtColumn = postgres.TimestampzColumn("published_at")
|
||||
DeadLetteredAtColumn = postgres.TimestampzColumn("dead_lettered_at")
|
||||
SkippedAtColumn = postgres.TimestampzColumn("skipped_at")
|
||||
allColumns = postgres.ColumnList{NotificationIDColumn, RouteIDColumn, ChannelColumn, RecipientRefColumn, StatusColumn, AttemptCountColumn, MaxAttemptsColumn, NextAttemptAtColumn, ResolvedEmailColumn, ResolvedLocaleColumn, LastErrorClassificationColumn, LastErrorMessageColumn, LastErrorAtColumn, CreatedAtColumn, UpdatedAtColumn, PublishedAtColumn, DeadLetteredAtColumn, SkippedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{ChannelColumn, RecipientRefColumn, StatusColumn, AttemptCountColumn, MaxAttemptsColumn, NextAttemptAtColumn, ResolvedEmailColumn, ResolvedLocaleColumn, LastErrorClassificationColumn, LastErrorMessageColumn, LastErrorAtColumn, CreatedAtColumn, UpdatedAtColumn, PublishedAtColumn, DeadLetteredAtColumn, SkippedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{AttemptCountColumn, ResolvedEmailColumn, ResolvedLocaleColumn, LastErrorClassificationColumn, LastErrorMessageColumn}
|
||||
)
|
||||
|
||||
return routesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
NotificationID: NotificationIDColumn,
|
||||
RouteID: RouteIDColumn,
|
||||
Channel: ChannelColumn,
|
||||
RecipientRef: RecipientRefColumn,
|
||||
Status: StatusColumn,
|
||||
AttemptCount: AttemptCountColumn,
|
||||
MaxAttempts: MaxAttemptsColumn,
|
||||
NextAttemptAt: NextAttemptAtColumn,
|
||||
ResolvedEmail: ResolvedEmailColumn,
|
||||
ResolvedLocale: ResolvedLocaleColumn,
|
||||
LastErrorClassification: LastErrorClassificationColumn,
|
||||
LastErrorMessage: LastErrorMessageColumn,
|
||||
LastErrorAt: LastErrorAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
PublishedAt: PublishedAtColumn,
|
||||
DeadLetteredAt: DeadLetteredAtColumn,
|
||||
SkippedAt: SkippedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
||||
// this method only once at the beginning of the program.
|
||||
func UseSchema(schema string) {
|
||||
DeadLetters = DeadLetters.FromSchema(schema)
|
||||
GooseDbVersion = GooseDbVersion.FromSchema(schema)
|
||||
MalformedIntents = MalformedIntents.FromSchema(schema)
|
||||
Records = Records.FromSchema(schema)
|
||||
Routes = Routes.FromSchema(schema)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
-- +goose Up
|
||||
-- records holds one durable notification record per accepted intent. The
|
||||
-- (producer, idempotency_key) UNIQUE constraint replaces the previous Redis
|
||||
-- idempotency keyspace: the durable row IS the idempotency reservation.
|
||||
CREATE TABLE records (
|
||||
notification_id text PRIMARY KEY,
|
||||
notification_type text NOT NULL,
|
||||
producer text NOT NULL,
|
||||
audience_kind text NOT NULL,
|
||||
recipient_user_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
payload_json text NOT NULL,
|
||||
idempotency_key text NOT NULL,
|
||||
request_fingerprint text NOT NULL,
|
||||
request_id text NOT NULL DEFAULT '',
|
||||
trace_id text NOT NULL DEFAULT '',
|
||||
occurred_at timestamptz NOT NULL,
|
||||
accepted_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
idempotency_expires_at timestamptz NOT NULL,
|
||||
CONSTRAINT records_idempotency_unique UNIQUE (producer, idempotency_key)
|
||||
);
|
||||
|
||||
-- Newest-first listing index used by operator/audit reads.
|
||||
CREATE INDEX records_listing_idx
|
||||
ON records (accepted_at DESC, notification_id DESC);
|
||||
|
||||
-- routes stores one row per (notification_id, route_id). next_attempt_at is
|
||||
-- non-NULL only while the row is a scheduling candidate (status pending or
|
||||
-- failed); the partial index keeps the scheduler scan tight.
|
||||
CREATE TABLE routes (
|
||||
notification_id text NOT NULL
|
||||
REFERENCES records(notification_id) ON DELETE CASCADE,
|
||||
route_id text NOT NULL,
|
||||
channel text NOT NULL,
|
||||
recipient_ref text NOT NULL,
|
||||
status text NOT NULL,
|
||||
attempt_count integer NOT NULL DEFAULT 0,
|
||||
max_attempts integer NOT NULL,
|
||||
next_attempt_at timestamptz,
|
||||
resolved_email text NOT NULL DEFAULT '',
|
||||
resolved_locale text NOT NULL DEFAULT '',
|
||||
last_error_classification text NOT NULL DEFAULT '',
|
||||
last_error_message text NOT NULL DEFAULT '',
|
||||
last_error_at timestamptz,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
published_at timestamptz,
|
||||
dead_lettered_at timestamptz,
|
||||
skipped_at timestamptz,
|
||||
PRIMARY KEY (notification_id, route_id)
|
||||
);
|
||||
|
||||
-- Drives the publishers' due-route pull. Partial predicate keeps the index
|
||||
-- narrow: terminal rows (published / dead_letter / skipped) never appear.
|
||||
CREATE INDEX routes_due_idx
|
||||
ON routes (next_attempt_at)
|
||||
WHERE next_attempt_at IS NOT NULL;
|
||||
|
||||
-- Coarse status / channel filters used by operator views.
|
||||
CREATE INDEX routes_status_idx ON routes (status);
|
||||
CREATE INDEX routes_channel_idx ON routes (channel);
|
||||
|
||||
-- dead_letters carries the operator-visible record for one route that
|
||||
-- exhausted automated handling. Cascade tied to the parent route row so a
|
||||
-- record-level retention DELETE clears dependent dead-letter rows naturally.
|
||||
CREATE TABLE dead_letters (
|
||||
notification_id text NOT NULL,
|
||||
route_id text NOT NULL,
|
||||
channel text NOT NULL,
|
||||
recipient_ref text NOT NULL,
|
||||
final_attempt_count integer NOT NULL,
|
||||
max_attempts integer NOT NULL,
|
||||
failure_classification text NOT NULL,
|
||||
failure_message text NOT NULL,
|
||||
recovery_hint text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (notification_id, route_id),
|
||||
FOREIGN KEY (notification_id, route_id)
|
||||
REFERENCES routes(notification_id, route_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX dead_letters_listing_idx
|
||||
ON dead_letters (created_at DESC, notification_id DESC, route_id DESC);
|
||||
|
||||
-- malformed_intents stores operator-visible records for stream entries the
|
||||
-- intent validator could not accept. Independent retention pass.
|
||||
CREATE TABLE malformed_intents (
|
||||
stream_entry_id text PRIMARY KEY,
|
||||
notification_type text NOT NULL DEFAULT '',
|
||||
producer text NOT NULL DEFAULT '',
|
||||
idempotency_key text NOT NULL DEFAULT '',
|
||||
failure_code text NOT NULL,
|
||||
failure_message text NOT NULL,
|
||||
raw_fields jsonb NOT NULL,
|
||||
recorded_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX malformed_intents_listing_idx
|
||||
ON malformed_intents (recorded_at DESC, stream_entry_id DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS malformed_intents;
|
||||
DROP TABLE IF EXISTS dead_letters;
|
||||
DROP TABLE IF EXISTS routes;
|
||||
DROP TABLE IF EXISTS records;
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package migrations exposes the embedded goose migration files used by
|
||||
// Notification Service to provision its `notification` schema in PostgreSQL.
|
||||
//
|
||||
// The embedded filesystem is consumed by `pkg/postgres.RunMigrations` during
|
||||
// notification-service startup and by `cmd/jetgen` when regenerating the
|
||||
// `internal/adapters/postgres/jet/` code against a transient PostgreSQL
|
||||
// instance.
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.sql
|
||||
var fs embed.FS
|
||||
|
||||
// FS returns the embedded filesystem containing every numbered goose
|
||||
// migration shipped with Notification Service.
|
||||
func FS() embed.FS {
|
||||
return fs
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package notificationstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"galaxy/notification/internal/api/intentstream"
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
)
|
||||
|
||||
// Compile-time confirmation that *Store satisfies acceptintent.Store. The
|
||||
// runtime wiring depends on this so the accept-intent service can consume
|
||||
// the PostgreSQL adapter directly.
|
||||
var _ acceptintent.Store = (*Store)(nil)
|
||||
|
||||
// CreateAcceptance writes one notification record together with its derived
|
||||
// route slots inside one BEGIN … COMMIT transaction. Idempotency races
|
||||
// surface as `acceptintent.ErrConflict`.
|
||||
func (store *Store) CreateAcceptance(ctx context.Context, input acceptintent.CreateAcceptanceInput) error {
|
||||
if store == nil {
|
||||
return errors.New("create notification acceptance: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("create notification acceptance: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("create notification acceptance: %w", err)
|
||||
}
|
||||
|
||||
return store.withTx(ctx, "create notification acceptance", func(ctx context.Context, tx *sql.Tx) error {
|
||||
if err := insertRecord(ctx, tx, input.Notification, input.Idempotency.ExpiresAt); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return acceptintent.ErrConflict
|
||||
}
|
||||
return fmt.Errorf("create notification acceptance: insert record: %w", err)
|
||||
}
|
||||
for index, route := range input.Routes {
|
||||
if err := insertRoute(ctx, tx, route); err != nil {
|
||||
return fmt.Errorf("create notification acceptance: insert route[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetIdempotency loads one accepted idempotency reservation. Because the
|
||||
// records row IS the idempotency reservation, the lookup keys on
|
||||
// `(producer, idempotency_key)` and projects the relevant subset of the row
|
||||
// into an IdempotencyRecord.
|
||||
func (store *Store) GetIdempotency(ctx context.Context, producer intentstream.Producer, idempotencyKey string) (acceptintent.IdempotencyRecord, bool, error) {
|
||||
if store == nil {
|
||||
return acceptintent.IdempotencyRecord{}, false, errors.New("get notification idempotency: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return acceptintent.IdempotencyRecord{}, false, errors.New("get notification idempotency: nil context")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get notification idempotency")
|
||||
if err != nil {
|
||||
return acceptintent.IdempotencyRecord{}, false, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
scanned, found, err := loadIdempotencyByKey(operationCtx, store.db, string(producer), idempotencyKey)
|
||||
if err != nil {
|
||||
return acceptintent.IdempotencyRecord{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return acceptintent.IdempotencyRecord{}, false, nil
|
||||
}
|
||||
return idempotencyRecordFromScanned(scanned), true, nil
|
||||
}
|
||||
|
||||
// GetNotification loads one accepted notification by NotificationID.
|
||||
func (store *Store) GetNotification(ctx context.Context, notificationID string) (acceptintent.NotificationRecord, bool, error) {
|
||||
if store == nil {
|
||||
return acceptintent.NotificationRecord{}, false, errors.New("get notification record: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return acceptintent.NotificationRecord{}, false, errors.New("get notification record: nil context")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get notification record")
|
||||
if err != nil {
|
||||
return acceptintent.NotificationRecord{}, false, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
scanned, found, err := loadRecord(operationCtx, store.db, notificationID)
|
||||
if err != nil {
|
||||
return acceptintent.NotificationRecord{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return acceptintent.NotificationRecord{}, false, nil
|
||||
}
|
||||
return scanned.Record, true, nil
|
||||
}
|
||||
|
||||
// GetRoute loads one accepted notification route by `(notificationID,
|
||||
// routeID)`. Required by the publisher worker contracts.
|
||||
func (store *Store) GetRoute(ctx context.Context, notificationID string, routeID string) (acceptintent.NotificationRoute, bool, error) {
|
||||
if store == nil {
|
||||
return acceptintent.NotificationRoute{}, false, errors.New("get notification route: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return acceptintent.NotificationRoute{}, false, errors.New("get notification route: nil context")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get notification route")
|
||||
if err != nil {
|
||||
return acceptintent.NotificationRoute{}, false, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
return loadRoute(operationCtx, store.db, notificationID, routeID)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package notificationstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// marshalRecipientUserIDs returns the JSONB bytes for the
|
||||
// `records.recipient_user_ids` column. A nil/empty slice round-trips as `[]`
|
||||
// to keep the column NOT NULL across equality tests.
|
||||
func marshalRecipientUserIDs(userIDs []string) ([]byte, error) {
|
||||
if userIDs == nil {
|
||||
userIDs = []string{}
|
||||
}
|
||||
payload, err := json.Marshal(userIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal recipient user ids: %w", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// unmarshalRecipientUserIDs decodes the JSONB recipient user-id list. nil
|
||||
// payloads round-trip as a nil slice so the read path matches what the
|
||||
// service layer accepts (`nil` and an empty `[]` are equivalent for
|
||||
// audience_kind != user_set).
|
||||
func unmarshalRecipientUserIDs(payload []byte) ([]string, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var userIDs []string
|
||||
if err := json.Unmarshal(payload, &userIDs); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal recipient user ids: %w", err)
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return userIDs, nil
|
||||
}
|
||||
|
||||
// marshalRawFields returns the JSONB bytes for the
|
||||
// `malformed_intents.raw_fields` column. The map is serialised verbatim so
|
||||
// future operator queries can match arbitrary keys.
|
||||
func marshalRawFields(fields map[string]any) ([]byte, error) {
|
||||
if fields == nil {
|
||||
fields = map[string]any{}
|
||||
}
|
||||
payload, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal raw fields: %w", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// unmarshalRawFields decodes the malformed_intents.raw_fields column into a
|
||||
// non-nil map (empty {} when the column is null/empty).
|
||||
func unmarshalRawFields(payload []byte) (map[string]any, error) {
|
||||
out := map[string]any{}
|
||||
if len(payload) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal(payload, &out); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal raw fields: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package notificationstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
pgtable "galaxy/notification/internal/adapters/postgres/jet/notification/table"
|
||||
)
|
||||
|
||||
// deadLetterRow stores the column values written to one dead_letters row.
|
||||
// Kept package-private because the public surface is the routestate
|
||||
// CompleteRouteDeadLetterInput shape; this struct is only the on-disk
|
||||
// projection.
|
||||
type deadLetterRow struct {
|
||||
NotificationID string
|
||||
RouteID string
|
||||
Channel string
|
||||
RecipientRef string
|
||||
FinalAttemptCount int
|
||||
MaxAttempts int
|
||||
FailureClassification string
|
||||
FailureMessage string
|
||||
RecoveryHint string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// insertDeadLetter writes one dead-letter audit row inside an open
|
||||
// transaction. The composite PRIMARY KEY guards against duplicate inserts
|
||||
// for the same `(notification_id, route_id)` pair.
|
||||
func insertDeadLetter(ctx context.Context, tx *sql.Tx, row deadLetterRow) error {
|
||||
stmt := pgtable.DeadLetters.INSERT(
|
||||
pgtable.DeadLetters.NotificationID,
|
||||
pgtable.DeadLetters.RouteID,
|
||||
pgtable.DeadLetters.Channel,
|
||||
pgtable.DeadLetters.RecipientRef,
|
||||
pgtable.DeadLetters.FinalAttemptCount,
|
||||
pgtable.DeadLetters.MaxAttempts,
|
||||
pgtable.DeadLetters.FailureClassification,
|
||||
pgtable.DeadLetters.FailureMessage,
|
||||
pgtable.DeadLetters.RecoveryHint,
|
||||
pgtable.DeadLetters.CreatedAt,
|
||||
).VALUES(
|
||||
row.NotificationID,
|
||||
row.RouteID,
|
||||
row.Channel,
|
||||
row.RecipientRef,
|
||||
row.FinalAttemptCount,
|
||||
row.MaxAttempts,
|
||||
row.FailureClassification,
|
||||
row.FailureMessage,
|
||||
row.RecoveryHint,
|
||||
row.CreatedAt.UTC(),
|
||||
)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package notificationstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/adapters/postgres/migrations"
|
||||
"galaxy/postgres"
|
||||
|
||||
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
const (
|
||||
pkgPostgresImage = "postgres:16-alpine"
|
||||
pkgSuperUser = "galaxy"
|
||||
pkgSuperPassword = "galaxy"
|
||||
pkgSuperDatabase = "galaxy_notification"
|
||||
pkgServiceRole = "notificationservice"
|
||||
pkgServicePassword = "notificationservice"
|
||||
pkgServiceSchema = "notification"
|
||||
pkgContainerStartup = 90 * time.Second
|
||||
pkgOperationTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
pkgContainerOnce sync.Once
|
||||
pkgContainerErr error
|
||||
pkgContainerEnv *postgresEnv
|
||||
)
|
||||
|
||||
type postgresEnv struct {
|
||||
container *tcpostgres.PostgresContainer
|
||||
dsn string
|
||||
pool *sql.DB
|
||||
}
|
||||
|
||||
func ensurePostgresEnv(t testing.TB) *postgresEnv {
|
||||
t.Helper()
|
||||
pkgContainerOnce.Do(func() {
|
||||
pkgContainerEnv, pkgContainerErr = startPostgresEnv()
|
||||
})
|
||||
if pkgContainerErr != nil {
|
||||
t.Skipf("postgres container start failed (Docker unavailable?): %v", pkgContainerErr)
|
||||
}
|
||||
return pkgContainerEnv
|
||||
}
|
||||
|
||||
func startPostgresEnv() (*postgresEnv, error) {
|
||||
ctx := context.Background()
|
||||
container, err := tcpostgres.Run(ctx, pkgPostgresImage,
|
||||
tcpostgres.WithDatabase(pkgSuperDatabase),
|
||||
tcpostgres.WithUsername(pkgSuperUser),
|
||||
tcpostgres.WithPassword(pkgSuperPassword),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(pkgContainerStartup),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := provisionRoleAndSchema(ctx, baseDSN); err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scopedDSN, err := dsnForServiceRole(baseDSN)
|
||||
if err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := postgres.DefaultConfig()
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = pkgOperationTimeout
|
||||
pool, err := postgres.OpenPrimary(ctx, cfg)
|
||||
if err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
if err := postgres.Ping(ctx, pool, pkgOperationTimeout); err != nil {
|
||||
_ = pool.Close()
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
if err := postgres.RunMigrations(ctx, pool, migrations.FS(), "."); err != nil {
|
||||
_ = pool.Close()
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &postgresEnv{
|
||||
container: container,
|
||||
dsn: scopedDSN,
|
||||
pool: pool,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func provisionRoleAndSchema(ctx context.Context, baseDSN string) error {
|
||||
cfg := postgres.DefaultConfig()
|
||||
cfg.PrimaryDSN = baseDSN
|
||||
cfg.OperationTimeout = pkgOperationTimeout
|
||||
db, err := postgres.OpenPrimary(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
statements := []string{
|
||||
`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'notificationservice') THEN
|
||||
CREATE ROLE notificationservice LOGIN PASSWORD 'notificationservice';
|
||||
END IF;
|
||||
END $$;`,
|
||||
`CREATE SCHEMA IF NOT EXISTS notification AUTHORIZATION notificationservice;`,
|
||||
`GRANT USAGE ON SCHEMA notification TO notificationservice;`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := db.ExecContext(ctx, statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dsnForServiceRole(baseDSN string) (string, error) {
|
||||
parsed, err := url.Parse(baseDSN)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("search_path", pkgServiceSchema)
|
||||
values.Set("sslmode", "disable")
|
||||
scoped := url.URL{
|
||||
Scheme: parsed.Scheme,
|
||||
User: url.UserPassword(pkgServiceRole, pkgServicePassword),
|
||||
Host: parsed.Host,
|
||||
Path: parsed.Path,
|
||||
RawQuery: values.Encode(),
|
||||
}
|
||||
return scoped.String(), nil
|
||||
}
|
||||
|
||||
// newTestStore returns a Store backed by the package-scoped pool. Every
|
||||
// invocation truncates the notification-owned tables so individual tests
|
||||
// start from a clean slate while sharing one container start.
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
env := ensurePostgresEnv(t)
|
||||
truncateAll(t, env.pool)
|
||||
store, err := New(Config{DB: env.pool, OperationTimeout: pkgOperationTimeout})
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func truncateAll(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
statement := `TRUNCATE TABLE
|
||||
malformed_intents,
|
||||
dead_letters,
|
||||
routes,
|
||||
records
|
||||
RESTART IDENTITY CASCADE`
|
||||
if _, err := db.ExecContext(context.Background(), statement); err != nil {
|
||||
t.Fatalf("truncate tables: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain runs first when `go test` enters the package. We drive it
|
||||
// through a TestMain so the container started by the first test is shut
|
||||
// down on the way out, even when individual tests panic.
|
||||
func TestMain(m *testing.M) {
|
||||
code := m.Run()
|
||||
if pkgContainerEnv != nil {
|
||||
if pkgContainerEnv.pool != nil {
|
||||
_ = pkgContainerEnv.pool.Close()
|
||||
}
|
||||
if pkgContainerEnv.container != nil {
|
||||
_ = testcontainers.TerminateContainer(pkgContainerEnv.container)
|
||||
}
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package notificationstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// pgUniqueViolationCode identifies the SQLSTATE returned by PostgreSQL when
|
||||
// a UNIQUE constraint is violated by INSERT or UPDATE.
|
||||
const pgUniqueViolationCode = "23505"
|
||||
|
||||
// isUniqueViolation reports whether err is a PostgreSQL unique-violation,
|
||||
// regardless of constraint name.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) {
|
||||
return false
|
||||
}
|
||||
return pgErr.Code == pgUniqueViolationCode
|
||||
}
|
||||
|
||||
// isNoRows reports whether err is sql.ErrNoRows.
|
||||
func isNoRows(err error) bool {
|
||||
return errors.Is(err, sql.ErrNoRows)
|
||||
}
|
||||
|
||||
// nullableTime returns t.UTC() when non-zero, otherwise nil so the column
|
||||
// is bound as SQL NULL. The notification domain uses zero-valued time.Time
|
||||
// to express "absent" timestamps (no pointers), so the helper centralises
|
||||
// the boundary translation.
|
||||
func nullableTime(t time.Time) any {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
|
||||
// timeFromNullable copies an optional sql.NullTime read from PostgreSQL
|
||||
// into a domain time.Time, applying the global UTC normalisation rule.
|
||||
// Invalid (NULL) values become the zero time.Time.
|
||||
func timeFromNullable(value sql.NullTime) time.Time {
|
||||
if !value.Valid {
|
||||
return time.Time{}
|
||||
}
|
||||
return value.Time.UTC()
|
||||
}
|
||||
|
||||
// withTimeout derives a child context bounded by timeout and prefixes
|
||||
// context errors with operation. Callers must always invoke the returned
|
||||
// cancel.
|
||||
func withTimeout(ctx context.Context, operation string, timeout time.Duration) (context.Context, context.CancelFunc, error) {
|
||||
if ctx == nil {
|
||||
return nil, nil, fmt.Errorf("%s: nil context", operation)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return nil, nil, fmt.Errorf("%s: operation timeout must be positive", operation)
|
||||
}
|
||||
bounded, cancel := context.WithTimeout(ctx, timeout)
|
||||
return bounded, cancel, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package notificationstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
pgtable "galaxy/notification/internal/adapters/postgres/jet/notification/table"
|
||||
"galaxy/notification/internal/service/malformedintent"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// Record stores entry idempotently by stream entry id. The helper satisfies
|
||||
// `worker.MalformedIntentRecorder`. Re-recording an entry with the same
|
||||
// `stream_entry_id` is a silent no-op via `ON CONFLICT DO NOTHING`.
|
||||
func (store *Store) Record(ctx context.Context, entry malformedintent.Entry) error {
|
||||
if store == nil {
|
||||
return errors.New("record malformed intent: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("record malformed intent: nil context")
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
return fmt.Errorf("record malformed intent: %w", err)
|
||||
}
|
||||
|
||||
rawFields, err := marshalRawFields(entry.RawFields)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record malformed intent: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "record malformed intent")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.MalformedIntents.INSERT(
|
||||
pgtable.MalformedIntents.StreamEntryID,
|
||||
pgtable.MalformedIntents.NotificationType,
|
||||
pgtable.MalformedIntents.Producer,
|
||||
pgtable.MalformedIntents.IdempotencyKey,
|
||||
pgtable.MalformedIntents.FailureCode,
|
||||
pgtable.MalformedIntents.FailureMessage,
|
||||
pgtable.MalformedIntents.RawFields,
|
||||
pgtable.MalformedIntents.RecordedAt,
|
||||
).VALUES(
|
||||
entry.StreamEntryID,
|
||||
entry.NotificationType,
|
||||
entry.Producer,
|
||||
entry.IdempotencyKey,
|
||||
string(entry.FailureCode),
|
||||
entry.FailureMessage,
|
||||
rawFields,
|
||||
entry.RecordedAt.UTC(),
|
||||
).ON_CONFLICT(pgtable.MalformedIntents.StreamEntryID).DO_NOTHING()
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
|
||||
return fmt.Errorf("record malformed intent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMalformedIntent loads one malformed-intent entry by stream entry id.
|
||||
// Returns found=false when no such row exists.
|
||||
func (store *Store) GetMalformedIntent(ctx context.Context, streamEntryID string) (malformedintent.Entry, bool, error) {
|
||||
if store == nil {
|
||||
return malformedintent.Entry{}, false, errors.New("get malformed intent: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return malformedintent.Entry{}, false, errors.New("get malformed intent: nil context")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get malformed intent")
|
||||
if err != nil {
|
||||
return malformedintent.Entry{}, false, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(
|
||||
pgtable.MalformedIntents.NotificationType,
|
||||
pgtable.MalformedIntents.Producer,
|
||||
pgtable.MalformedIntents.IdempotencyKey,
|
||||
pgtable.MalformedIntents.FailureCode,
|
||||
pgtable.MalformedIntents.FailureMessage,
|
||||
pgtable.MalformedIntents.RawFields,
|
||||
pgtable.MalformedIntents.RecordedAt,
|
||||
).FROM(pgtable.MalformedIntents).
|
||||
WHERE(pgtable.MalformedIntents.StreamEntryID.EQ(pg.String(streamEntryID)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
|
||||
var (
|
||||
notificationType string
|
||||
producer string
|
||||
idempotencyKey string
|
||||
failureCode string
|
||||
failureMessage string
|
||||
rawFields []byte
|
||||
)
|
||||
entry := malformedintent.Entry{StreamEntryID: streamEntryID}
|
||||
if err := row.Scan(
|
||||
¬ificationType,
|
||||
&producer,
|
||||
&idempotencyKey,
|
||||
&failureCode,
|
||||
&failureMessage,
|
||||
&rawFields,
|
||||
&entry.RecordedAt,
|
||||
); err != nil {
|
||||
if isNoRows(err) {
|
||||
return malformedintent.Entry{}, false, nil
|
||||
}
|
||||
return malformedintent.Entry{}, false, fmt.Errorf("get malformed intent: %w", err)
|
||||
}
|
||||
entry.NotificationType = notificationType
|
||||
entry.Producer = producer
|
||||
entry.IdempotencyKey = idempotencyKey
|
||||
entry.FailureCode = malformedintent.FailureCode(failureCode)
|
||||
entry.FailureMessage = failureMessage
|
||||
entry.RecordedAt = entry.RecordedAt.UTC()
|
||||
fields, err := unmarshalRawFields(rawFields)
|
||||
if err != nil {
|
||||
return malformedintent.Entry{}, false, fmt.Errorf("get malformed intent: %w", err)
|
||||
}
|
||||
entry.RawFields = fields
|
||||
return entry, true, nil
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package notificationstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/api/intentstream"
|
||||
pgtable "galaxy/notification/internal/adapters/postgres/jet/notification/table"
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// recordSelectColumns is the canonical SELECT list for the records table,
|
||||
// matching scanRecord's column order.
|
||||
var recordSelectColumns = pg.ColumnList{
|
||||
pgtable.Records.NotificationID,
|
||||
pgtable.Records.NotificationType,
|
||||
pgtable.Records.Producer,
|
||||
pgtable.Records.AudienceKind,
|
||||
pgtable.Records.RecipientUserIds,
|
||||
pgtable.Records.PayloadJSON,
|
||||
pgtable.Records.IdempotencyKey,
|
||||
pgtable.Records.RequestFingerprint,
|
||||
pgtable.Records.RequestID,
|
||||
pgtable.Records.TraceID,
|
||||
pgtable.Records.OccurredAt,
|
||||
pgtable.Records.AcceptedAt,
|
||||
pgtable.Records.UpdatedAt,
|
||||
pgtable.Records.IdempotencyExpiresAt,
|
||||
}
|
||||
|
||||
// rowScanner abstracts *sql.Row and *sql.Rows so scanRecord/scanRoute can be
|
||||
// shared across both single-row reads and iterated reads.
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scannedRecord stores the columns scanned from the records table plus the
|
||||
// idempotency_expires_at value the service layer feeds back into the
|
||||
// IdempotencyRecord constructed from the same row.
|
||||
type scannedRecord struct {
|
||||
Record acceptintent.NotificationRecord
|
||||
IdempotencyExpiresAt time.Time
|
||||
}
|
||||
|
||||
// scanRecord scans one records row from rs. Returns sql.ErrNoRows verbatim
|
||||
// so callers can distinguish "no row" from a hard error.
|
||||
func scanRecord(rs rowScanner) (scannedRecord, error) {
|
||||
var (
|
||||
notificationID string
|
||||
notificationType string
|
||||
producer string
|
||||
audienceKind string
|
||||
recipientUserIDs []byte
|
||||
payloadJSON string
|
||||
idempotencyKey string
|
||||
requestFingerprint string
|
||||
requestID string
|
||||
traceID string
|
||||
occurredAt time.Time
|
||||
acceptedAt time.Time
|
||||
updatedAt time.Time
|
||||
idempotencyExpiresAt time.Time
|
||||
)
|
||||
if err := rs.Scan(
|
||||
¬ificationID,
|
||||
¬ificationType,
|
||||
&producer,
|
||||
&audienceKind,
|
||||
&recipientUserIDs,
|
||||
&payloadJSON,
|
||||
&idempotencyKey,
|
||||
&requestFingerprint,
|
||||
&requestID,
|
||||
&traceID,
|
||||
&occurredAt,
|
||||
&acceptedAt,
|
||||
&updatedAt,
|
||||
&idempotencyExpiresAt,
|
||||
); err != nil {
|
||||
return scannedRecord{}, err
|
||||
}
|
||||
|
||||
users, err := unmarshalRecipientUserIDs(recipientUserIDs)
|
||||
if err != nil {
|
||||
return scannedRecord{}, err
|
||||
}
|
||||
|
||||
return scannedRecord{
|
||||
Record: acceptintent.NotificationRecord{
|
||||
NotificationID: notificationID,
|
||||
NotificationType: intentstream.NotificationType(notificationType),
|
||||
Producer: intentstream.Producer(producer),
|
||||
AudienceKind: intentstream.AudienceKind(audienceKind),
|
||||
RecipientUserIDs: users,
|
||||
PayloadJSON: payloadJSON,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
RequestFingerprint: requestFingerprint,
|
||||
RequestID: requestID,
|
||||
TraceID: traceID,
|
||||
OccurredAt: occurredAt.UTC(),
|
||||
AcceptedAt: acceptedAt.UTC(),
|
||||
UpdatedAt: updatedAt.UTC(),
|
||||
},
|
||||
IdempotencyExpiresAt: idempotencyExpiresAt.UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// insertRecord writes one record row plus its idempotency expiry inside an
|
||||
// open transaction. The (producer, idempotency_key) UNIQUE constraint is
|
||||
// the idempotency reservation; the caller maps `isUniqueViolation` errors
|
||||
// to `acceptintent.ErrConflict`.
|
||||
func insertRecord(ctx context.Context, tx *sql.Tx, record acceptintent.NotificationRecord, idempotencyExpiresAt time.Time) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("insert record: %w", err)
|
||||
}
|
||||
|
||||
users, err := marshalRecipientUserIDs(record.RecipientUserIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert record: %w", err)
|
||||
}
|
||||
|
||||
stmt := pgtable.Records.INSERT(
|
||||
pgtable.Records.NotificationID,
|
||||
pgtable.Records.NotificationType,
|
||||
pgtable.Records.Producer,
|
||||
pgtable.Records.AudienceKind,
|
||||
pgtable.Records.RecipientUserIds,
|
||||
pgtable.Records.PayloadJSON,
|
||||
pgtable.Records.IdempotencyKey,
|
||||
pgtable.Records.RequestFingerprint,
|
||||
pgtable.Records.RequestID,
|
||||
pgtable.Records.TraceID,
|
||||
pgtable.Records.OccurredAt,
|
||||
pgtable.Records.AcceptedAt,
|
||||
pgtable.Records.UpdatedAt,
|
||||
pgtable.Records.IdempotencyExpiresAt,
|
||||
).VALUES(
|
||||
record.NotificationID,
|
||||
string(record.NotificationType),
|
||||
string(record.Producer),
|
||||
string(record.AudienceKind),
|
||||
users,
|
||||
record.PayloadJSON,
|
||||
record.IdempotencyKey,
|
||||
record.RequestFingerprint,
|
||||
record.RequestID,
|
||||
record.TraceID,
|
||||
record.OccurredAt.UTC(),
|
||||
record.AcceptedAt.UTC(),
|
||||
record.UpdatedAt.UTC(),
|
||||
idempotencyExpiresAt.UTC(),
|
||||
)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadRecord returns the record row for notificationID using the store's
|
||||
// default pool. found is false when no such row exists.
|
||||
func loadRecord(ctx context.Context, db *sql.DB, notificationID string) (scannedRecord, bool, error) {
|
||||
stmt := pg.SELECT(recordSelectColumns).
|
||||
FROM(pgtable.Records).
|
||||
WHERE(pgtable.Records.NotificationID.EQ(pg.String(notificationID)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := db.QueryRowContext(ctx, query, args...)
|
||||
scanned, err := scanRecord(row)
|
||||
if isNoRows(err) {
|
||||
return scannedRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return scannedRecord{}, false, fmt.Errorf("load notification record: %w", err)
|
||||
}
|
||||
return scanned, true, nil
|
||||
}
|
||||
|
||||
// loadIdempotencyByKey returns the records row that owns one
|
||||
// `(producer, idempotency_key)` reservation. found is false when no match.
|
||||
func loadIdempotencyByKey(ctx context.Context, db *sql.DB, producer string, idempotencyKey string) (scannedRecord, bool, error) {
|
||||
stmt := pg.SELECT(recordSelectColumns).
|
||||
FROM(pgtable.Records).
|
||||
WHERE(pg.AND(
|
||||
pgtable.Records.Producer.EQ(pg.String(producer)),
|
||||
pgtable.Records.IdempotencyKey.EQ(pg.String(idempotencyKey)),
|
||||
))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := db.QueryRowContext(ctx, query, args...)
|
||||
scanned, err := scanRecord(row)
|
||||
if isNoRows(err) {
|
||||
return scannedRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return scannedRecord{}, false, fmt.Errorf("load notification idempotency: %w", err)
|
||||
}
|
||||
return scanned, true, nil
|
||||
}
|
||||
|
||||
// idempotencyRecordFromScanned constructs an IdempotencyRecord shape from
|
||||
// the scanned record. CreatedAt mirrors AcceptedAt because the durable row
|
||||
// is the idempotency reservation.
|
||||
func idempotencyRecordFromScanned(scanned scannedRecord) acceptintent.IdempotencyRecord {
|
||||
return acceptintent.IdempotencyRecord{
|
||||
Producer: scanned.Record.Producer,
|
||||
IdempotencyKey: scanned.Record.IdempotencyKey,
|
||||
NotificationID: scanned.Record.NotificationID,
|
||||
RequestFingerprint: scanned.Record.RequestFingerprint,
|
||||
CreatedAt: scanned.Record.AcceptedAt,
|
||||
ExpiresAt: scanned.IdempotencyExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
// errRecordNotFound is the package-private sentinel returned by helpers
|
||||
// when a row required by an in-progress transaction is not found.
|
||||
var errRecordNotFound = errors.New("record not found")
|
||||
@@ -0,0 +1,67 @@
|
||||
package notificationstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
pgtable "galaxy/notification/internal/adapters/postgres/jet/notification/table"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// DeleteRecordsOlderThan removes records rows whose `accepted_at` predates
|
||||
// cutoff. The records FK CASCADE clears the dependent routes and
|
||||
// dead_letters rows in the same statement.
|
||||
func (store *Store) DeleteRecordsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||
if store == nil {
|
||||
return 0, errors.New("delete notification records: nil store")
|
||||
}
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "delete notification records")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.Records.DELETE().
|
||||
WHERE(pgtable.Records.AcceptedAt.LT(pg.TimestampzT(cutoff.UTC())))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete notification records: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete notification records: rows affected: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// DeleteMalformedIntentsOlderThan removes malformed-intent rows whose
|
||||
// `recorded_at` predates cutoff.
|
||||
func (store *Store) DeleteMalformedIntentsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||
if store == nil {
|
||||
return 0, errors.New("delete malformed intents: nil store")
|
||||
}
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "delete malformed intents")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.MalformedIntents.DELETE().
|
||||
WHERE(pgtable.MalformedIntents.RecordedAt.LT(pg.TimestampzT(cutoff.UTC())))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete malformed intents: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete malformed intents: rows affected: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package notificationstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/api/intentstream"
|
||||
pgtable "galaxy/notification/internal/adapters/postgres/jet/notification/table"
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// routeSelectColumns is the canonical SELECT list for the routes table,
|
||||
// matching scanRoute's column order.
|
||||
var routeSelectColumns = pg.ColumnList{
|
||||
pgtable.Routes.NotificationID,
|
||||
pgtable.Routes.RouteID,
|
||||
pgtable.Routes.Channel,
|
||||
pgtable.Routes.RecipientRef,
|
||||
pgtable.Routes.Status,
|
||||
pgtable.Routes.AttemptCount,
|
||||
pgtable.Routes.MaxAttempts,
|
||||
pgtable.Routes.NextAttemptAt,
|
||||
pgtable.Routes.ResolvedEmail,
|
||||
pgtable.Routes.ResolvedLocale,
|
||||
pgtable.Routes.LastErrorClassification,
|
||||
pgtable.Routes.LastErrorMessage,
|
||||
pgtable.Routes.LastErrorAt,
|
||||
pgtable.Routes.CreatedAt,
|
||||
pgtable.Routes.UpdatedAt,
|
||||
pgtable.Routes.PublishedAt,
|
||||
pgtable.Routes.DeadLetteredAt,
|
||||
pgtable.Routes.SkippedAt,
|
||||
}
|
||||
|
||||
// scanRoute scans one routes row from rs.
|
||||
func scanRoute(rs rowScanner) (acceptintent.NotificationRoute, error) {
|
||||
var (
|
||||
notificationID string
|
||||
routeID string
|
||||
channel string
|
||||
recipientRef string
|
||||
status string
|
||||
attemptCount int
|
||||
maxAttempts int
|
||||
nextAttemptAt sql.NullTime
|
||||
resolvedEmail string
|
||||
resolvedLocale string
|
||||
lastErrorClassification string
|
||||
lastErrorMessage string
|
||||
lastErrorAt sql.NullTime
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
publishedAt sql.NullTime
|
||||
deadLetteredAt sql.NullTime
|
||||
skippedAt sql.NullTime
|
||||
)
|
||||
if err := rs.Scan(
|
||||
¬ificationID,
|
||||
&routeID,
|
||||
&channel,
|
||||
&recipientRef,
|
||||
&status,
|
||||
&attemptCount,
|
||||
&maxAttempts,
|
||||
&nextAttemptAt,
|
||||
&resolvedEmail,
|
||||
&resolvedLocale,
|
||||
&lastErrorClassification,
|
||||
&lastErrorMessage,
|
||||
&lastErrorAt,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
&publishedAt,
|
||||
&deadLetteredAt,
|
||||
&skippedAt,
|
||||
); err != nil {
|
||||
return acceptintent.NotificationRoute{}, err
|
||||
}
|
||||
|
||||
return acceptintent.NotificationRoute{
|
||||
NotificationID: notificationID,
|
||||
RouteID: routeID,
|
||||
Channel: intentstream.Channel(channel),
|
||||
RecipientRef: recipientRef,
|
||||
Status: acceptintent.RouteStatus(status),
|
||||
AttemptCount: attemptCount,
|
||||
MaxAttempts: maxAttempts,
|
||||
NextAttemptAt: timeFromNullable(nextAttemptAt),
|
||||
ResolvedEmail: resolvedEmail,
|
||||
ResolvedLocale: resolvedLocale,
|
||||
LastErrorClassification: lastErrorClassification,
|
||||
LastErrorMessage: lastErrorMessage,
|
||||
LastErrorAt: timeFromNullable(lastErrorAt),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
UpdatedAt: updatedAt.UTC(),
|
||||
PublishedAt: timeFromNullable(publishedAt),
|
||||
DeadLetteredAt: timeFromNullable(deadLetteredAt),
|
||||
SkippedAt: timeFromNullable(skippedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// insertRoute writes one route row inside an open transaction.
|
||||
func insertRoute(ctx context.Context, tx *sql.Tx, route acceptintent.NotificationRoute) error {
|
||||
if err := route.Validate(); err != nil {
|
||||
return fmt.Errorf("insert route: %w", err)
|
||||
}
|
||||
|
||||
stmt := pgtable.Routes.INSERT(
|
||||
pgtable.Routes.NotificationID,
|
||||
pgtable.Routes.RouteID,
|
||||
pgtable.Routes.Channel,
|
||||
pgtable.Routes.RecipientRef,
|
||||
pgtable.Routes.Status,
|
||||
pgtable.Routes.AttemptCount,
|
||||
pgtable.Routes.MaxAttempts,
|
||||
pgtable.Routes.NextAttemptAt,
|
||||
pgtable.Routes.ResolvedEmail,
|
||||
pgtable.Routes.ResolvedLocale,
|
||||
pgtable.Routes.LastErrorClassification,
|
||||
pgtable.Routes.LastErrorMessage,
|
||||
pgtable.Routes.LastErrorAt,
|
||||
pgtable.Routes.CreatedAt,
|
||||
pgtable.Routes.UpdatedAt,
|
||||
pgtable.Routes.PublishedAt,
|
||||
pgtable.Routes.DeadLetteredAt,
|
||||
pgtable.Routes.SkippedAt,
|
||||
).VALUES(
|
||||
route.NotificationID,
|
||||
route.RouteID,
|
||||
string(route.Channel),
|
||||
route.RecipientRef,
|
||||
string(route.Status),
|
||||
route.AttemptCount,
|
||||
route.MaxAttempts,
|
||||
nullableTime(route.NextAttemptAt),
|
||||
route.ResolvedEmail,
|
||||
route.ResolvedLocale,
|
||||
route.LastErrorClassification,
|
||||
route.LastErrorMessage,
|
||||
nullableTime(route.LastErrorAt),
|
||||
route.CreatedAt.UTC(),
|
||||
route.UpdatedAt.UTC(),
|
||||
nullableTime(route.PublishedAt),
|
||||
nullableTime(route.DeadLetteredAt),
|
||||
nullableTime(route.SkippedAt),
|
||||
)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadRoute returns one route row by composite key. found is false when no
|
||||
// matching row exists.
|
||||
func loadRoute(ctx context.Context, db *sql.DB, notificationID string, routeID string) (acceptintent.NotificationRoute, bool, error) {
|
||||
stmt := pg.SELECT(routeSelectColumns).
|
||||
FROM(pgtable.Routes).
|
||||
WHERE(pg.AND(
|
||||
pgtable.Routes.NotificationID.EQ(pg.String(notificationID)),
|
||||
pgtable.Routes.RouteID.EQ(pg.String(routeID)),
|
||||
))
|
||||
query, args := stmt.Sql()
|
||||
row := db.QueryRowContext(ctx, query, args...)
|
||||
route, err := scanRoute(row)
|
||||
if isNoRows(err) {
|
||||
return acceptintent.NotificationRoute{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return acceptintent.NotificationRoute{}, false, fmt.Errorf("load notification route: %w", err)
|
||||
}
|
||||
return route, true, nil
|
||||
}
|
||||
|
||||
// loadRouteTx returns one route row by composite key inside an open
|
||||
// transaction.
|
||||
func loadRouteTx(ctx context.Context, tx *sql.Tx, notificationID string, routeID string) (acceptintent.NotificationRoute, bool, error) {
|
||||
stmt := pg.SELECT(routeSelectColumns).
|
||||
FROM(pgtable.Routes).
|
||||
WHERE(pg.AND(
|
||||
pgtable.Routes.NotificationID.EQ(pg.String(notificationID)),
|
||||
pgtable.Routes.RouteID.EQ(pg.String(routeID)),
|
||||
))
|
||||
query, args := stmt.Sql()
|
||||
row := tx.QueryRowContext(ctx, query, args...)
|
||||
route, err := scanRoute(row)
|
||||
if isNoRows(err) {
|
||||
return acceptintent.NotificationRoute{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return acceptintent.NotificationRoute{}, false, fmt.Errorf("load notification route: %w", err)
|
||||
}
|
||||
return route, true, nil
|
||||
}
|
||||
|
||||
// updateRouteIfMatching writes the route columns back inside an open
|
||||
// transaction, gated on `updated_at = expectedUpdatedAt`. Returns the
|
||||
// number of rows actually updated; zero indicates an optimistic-concurrency
|
||||
// loss.
|
||||
func updateRouteIfMatching(ctx context.Context, tx *sql.Tx, route acceptintent.NotificationRoute, expectedUpdatedAt time.Time) (int64, error) {
|
||||
stmt := pgtable.Routes.UPDATE(
|
||||
pgtable.Routes.Status,
|
||||
pgtable.Routes.AttemptCount,
|
||||
pgtable.Routes.NextAttemptAt,
|
||||
pgtable.Routes.ResolvedEmail,
|
||||
pgtable.Routes.ResolvedLocale,
|
||||
pgtable.Routes.LastErrorClassification,
|
||||
pgtable.Routes.LastErrorMessage,
|
||||
pgtable.Routes.LastErrorAt,
|
||||
pgtable.Routes.UpdatedAt,
|
||||
pgtable.Routes.PublishedAt,
|
||||
pgtable.Routes.DeadLetteredAt,
|
||||
pgtable.Routes.SkippedAt,
|
||||
).SET(
|
||||
string(route.Status),
|
||||
route.AttemptCount,
|
||||
nullableTime(route.NextAttemptAt),
|
||||
route.ResolvedEmail,
|
||||
route.ResolvedLocale,
|
||||
route.LastErrorClassification,
|
||||
route.LastErrorMessage,
|
||||
nullableTime(route.LastErrorAt),
|
||||
route.UpdatedAt.UTC(),
|
||||
nullableTime(route.PublishedAt),
|
||||
nullableTime(route.DeadLetteredAt),
|
||||
nullableTime(route.SkippedAt),
|
||||
).WHERE(pg.AND(
|
||||
pgtable.Routes.NotificationID.EQ(pg.String(route.NotificationID)),
|
||||
pgtable.Routes.RouteID.EQ(pg.String(route.RouteID)),
|
||||
pgtable.Routes.UpdatedAt.EQ(pg.TimestampzT(expectedUpdatedAt.UTC())),
|
||||
))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := tx.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package notificationstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
pgtable "galaxy/notification/internal/adapters/postgres/jet/notification/table"
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
"galaxy/notification/internal/service/routestate"
|
||||
"galaxy/notification/internal/telemetry"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// scheduledRouteKey synthesises a stable, human-readable key for one
|
||||
// ScheduledRoute. Notification publishers do not interpret the key beyond
|
||||
// requiring it to be non-empty (`ScheduledRoute.Validate`).
|
||||
func scheduledRouteKey(notificationID string, routeID string) string {
|
||||
return notificationID + "/" + routeID
|
||||
}
|
||||
|
||||
// ListDueRoutes returns up to limit routes whose `next_attempt_at` is at or
|
||||
// before now. The query is non-locking; per-row contention is resolved by
|
||||
// the lease (Redis) plus the optimistic-concurrency check inside `Complete*`.
|
||||
func (store *Store) ListDueRoutes(ctx context.Context, now time.Time, limit int64) ([]routestate.ScheduledRoute, error) {
|
||||
if store == nil {
|
||||
return nil, errors.New("list due routes: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("list due routes: nil context")
|
||||
}
|
||||
if err := routestate.ValidateUTCMillisecondTimestamp("list due routes now", now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
return nil, errors.New("list due routes: limit must be positive")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "list due routes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(pgtable.Routes.NotificationID, pgtable.Routes.RouteID).
|
||||
FROM(pgtable.Routes).
|
||||
WHERE(pg.AND(
|
||||
pgtable.Routes.NextAttemptAt.IS_NOT_NULL(),
|
||||
pgtable.Routes.NextAttemptAt.LT_EQ(pg.TimestampzT(now.UTC())),
|
||||
)).
|
||||
ORDER_BY(pgtable.Routes.NextAttemptAt.ASC()).
|
||||
LIMIT(limit)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
rows, err := store.db.QueryContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list due routes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]routestate.ScheduledRoute, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
notificationID string
|
||||
routeID string
|
||||
)
|
||||
if err := rows.Scan(¬ificationID, &routeID); err != nil {
|
||||
return nil, fmt.Errorf("list due routes: scan: %w", err)
|
||||
}
|
||||
out = append(out, routestate.ScheduledRoute{
|
||||
RouteKey: scheduledRouteKey(notificationID, routeID),
|
||||
NotificationID: notificationID,
|
||||
RouteID: routeID,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list due routes: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadRouteScheduleSnapshot returns the current depth of the route schedule
|
||||
// (rows with non-NULL `next_attempt_at`) together with the oldest scheduled
|
||||
// timestamp when one exists. The runtime exposes this through the telemetry
|
||||
// snapshot reader.
|
||||
func (store *Store) ReadRouteScheduleSnapshot(ctx context.Context) (telemetry.RouteScheduleSnapshot, error) {
|
||||
if store == nil {
|
||||
return telemetry.RouteScheduleSnapshot{}, errors.New("read route schedule snapshot: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return telemetry.RouteScheduleSnapshot{}, errors.New("read route schedule snapshot: nil context")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "read route schedule snapshot")
|
||||
if err != nil {
|
||||
return telemetry.RouteScheduleSnapshot{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(
|
||||
pg.COUNT(pg.STAR),
|
||||
pg.MIN(pgtable.Routes.NextAttemptAt),
|
||||
).
|
||||
FROM(pgtable.Routes).
|
||||
WHERE(pgtable.Routes.NextAttemptAt.IS_NOT_NULL())
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
var (
|
||||
depth int64
|
||||
oldest sql.NullTime
|
||||
summary telemetry.RouteScheduleSnapshot
|
||||
)
|
||||
if err := row.Scan(&depth, &oldest); err != nil {
|
||||
return telemetry.RouteScheduleSnapshot{}, fmt.Errorf("read route schedule snapshot: %w", err)
|
||||
}
|
||||
summary.Depth = depth
|
||||
if oldest.Valid {
|
||||
oldestUTC := oldest.Time.UTC()
|
||||
summary.OldestScheduledFor = &oldestUTC
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// CompleteRoutePublished marks the expected route as `published`,
|
||||
// increments attempt_count, and clears retry/error fields. Optimistic
|
||||
// concurrency on `updated_at` rejects races that happened since the
|
||||
// publisher loaded the row; a mismatch surfaces as `routestate.ErrConflict`.
|
||||
//
|
||||
// Note: the outbound stream emission (XADD) happens in the publisher
|
||||
// before this call. The store deliberately ignores the input.Stream and
|
||||
// input.StreamValues fields — they are kept on the input only so the
|
||||
// publisher can pass one struct around through its state machine.
|
||||
func (store *Store) CompleteRoutePublished(ctx context.Context, input routestate.CompleteRoutePublishedInput) error {
|
||||
if store == nil {
|
||||
return errors.New("complete route published: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("complete route published: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("complete route published: %w", err)
|
||||
}
|
||||
|
||||
updated := input.ExpectedRoute
|
||||
updated.Status = acceptintent.RouteStatusPublished
|
||||
updated.AttemptCount++
|
||||
updated.NextAttemptAt = time.Time{}
|
||||
updated.LastErrorClassification = ""
|
||||
updated.LastErrorMessage = ""
|
||||
updated.LastErrorAt = time.Time{}
|
||||
updated.UpdatedAt = input.PublishedAt
|
||||
updated.PublishedAt = input.PublishedAt
|
||||
updated.DeadLetteredAt = time.Time{}
|
||||
|
||||
return store.withTx(ctx, "complete route published", func(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := updateRouteIfMatching(ctx, tx, updated, input.ExpectedRoute.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete route published: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return routestate.ErrConflict
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CompleteRouteFailed records one retryable publication failure: increments
|
||||
// attempt_count, populates the last-error fields, and reschedules the row
|
||||
// at `NextAttemptAt`.
|
||||
func (store *Store) CompleteRouteFailed(ctx context.Context, input routestate.CompleteRouteFailedInput) error {
|
||||
if store == nil {
|
||||
return errors.New("complete route failed: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("complete route failed: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("complete route failed: %w", err)
|
||||
}
|
||||
|
||||
updated := input.ExpectedRoute
|
||||
updated.Status = acceptintent.RouteStatusFailed
|
||||
updated.AttemptCount++
|
||||
updated.NextAttemptAt = input.NextAttemptAt
|
||||
updated.LastErrorClassification = input.FailureClassification
|
||||
updated.LastErrorMessage = input.FailureMessage
|
||||
updated.LastErrorAt = input.FailedAt
|
||||
updated.UpdatedAt = input.FailedAt
|
||||
|
||||
return store.withTx(ctx, "complete route failed", func(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := updateRouteIfMatching(ctx, tx, updated, input.ExpectedRoute.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete route failed: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return routestate.ErrConflict
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CompleteRouteDeadLetter records one terminal publication failure:
|
||||
// marks the route `dead_letter`, clears the schedule, and inserts the
|
||||
// dead-letter audit row.
|
||||
func (store *Store) CompleteRouteDeadLetter(ctx context.Context, input routestate.CompleteRouteDeadLetterInput) error {
|
||||
if store == nil {
|
||||
return errors.New("complete route dead letter: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("complete route dead letter: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("complete route dead letter: %w", err)
|
||||
}
|
||||
|
||||
updated := input.ExpectedRoute
|
||||
updated.Status = acceptintent.RouteStatusDeadLetter
|
||||
updated.AttemptCount++
|
||||
updated.NextAttemptAt = time.Time{}
|
||||
updated.LastErrorClassification = input.FailureClassification
|
||||
updated.LastErrorMessage = input.FailureMessage
|
||||
updated.LastErrorAt = input.DeadLetteredAt
|
||||
updated.UpdatedAt = input.DeadLetteredAt
|
||||
updated.DeadLetteredAt = input.DeadLetteredAt
|
||||
|
||||
if updated.AttemptCount < updated.MaxAttempts {
|
||||
return fmt.Errorf(
|
||||
"complete route dead letter: final attempt count %d is below max attempts %d",
|
||||
updated.AttemptCount,
|
||||
updated.MaxAttempts,
|
||||
)
|
||||
}
|
||||
|
||||
return store.withTx(ctx, "complete route dead letter", func(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := updateRouteIfMatching(ctx, tx, updated, input.ExpectedRoute.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete route dead letter: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return routestate.ErrConflict
|
||||
}
|
||||
if err := insertDeadLetter(ctx, tx, deadLetterRow{
|
||||
NotificationID: updated.NotificationID,
|
||||
RouteID: updated.RouteID,
|
||||
Channel: string(updated.Channel),
|
||||
RecipientRef: updated.RecipientRef,
|
||||
FinalAttemptCount: updated.AttemptCount,
|
||||
MaxAttempts: updated.MaxAttempts,
|
||||
FailureClassification: input.FailureClassification,
|
||||
FailureMessage: input.FailureMessage,
|
||||
RecoveryHint: input.RecoveryHint,
|
||||
CreatedAt: input.DeadLetteredAt,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("complete route dead letter: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Package notificationstore implements the PostgreSQL-backed source-of-truth
|
||||
// persistence used by Notification Service.
|
||||
//
|
||||
// The package owns the on-disk shape of the `notification` schema (defined
|
||||
// in `galaxy/notification/internal/adapters/postgres/migrations`) and
|
||||
// translates the schema-agnostic Store interfaces declared by the
|
||||
// `internal/service/acceptintent` use case and the route publishers into
|
||||
// concrete `database/sql` operations driven by the pgx driver. Atomic
|
||||
// composite operations (acceptance, route-completion transitions) execute
|
||||
// inside explicit `BEGIN … COMMIT` transactions; per-row lifecycle
|
||||
// transitions use optimistic concurrency on the `updated_at` token rather
|
||||
// than retaining a `FOR UPDATE` lock across the publisher's outbound stream
|
||||
// emission.
|
||||
//
|
||||
// Stage 5 of `PG_PLAN.md` migrates Notification Service away from
|
||||
// Redis-backed durable state. The inbound `notification:intents` Redis
|
||||
// Stream and its consumer offset, the outbound `gateway:client-events` and
|
||||
// `mail:delivery_commands` Redis Streams, and the short-lived
|
||||
// `route_leases:*` exclusivity hint all remain on Redis; this store is no
|
||||
// longer aware of any of them.
|
||||
package notificationstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config configures one PostgreSQL-backed notification store instance. The
|
||||
// store does not own the underlying *sql.DB lifecycle: the caller (typically
|
||||
// the service runtime) opens, instruments, migrates, and closes the pool.
|
||||
// The store only borrows the pool and bounds individual round trips with
|
||||
// OperationTimeout.
|
||||
type Config struct {
|
||||
// DB stores the connection pool the store uses for every query.
|
||||
DB *sql.DB
|
||||
|
||||
// OperationTimeout bounds one round trip. The store creates a derived
|
||||
// context for each operation so callers cannot starve the pool with an
|
||||
// unbounded ctx. Multi-statement transactions inherit this bound for the
|
||||
// whole BEGIN … COMMIT span.
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Store persists Notification Service durable state in PostgreSQL and
|
||||
// exposes the per-use-case Store interfaces required by acceptance,
|
||||
// publication completion, malformed-intent recording, and the periodic
|
||||
// retention worker.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs one PostgreSQL-backed notification store from cfg.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("new postgres notification store: db must not be nil")
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
return nil, errors.New("new postgres notification store: operation timeout must be positive")
|
||||
}
|
||||
return &Store{
|
||||
db: cfg.DB,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close is a no-op for the PostgreSQL-backed store: the connection pool is
|
||||
// owned by the caller (the runtime) and closed once the runtime shuts down.
|
||||
// The accessor remains so the runtime wiring can treat the store like the
|
||||
// previous Redis-backed implementation.
|
||||
func (store *Store) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping verifies that the configured PostgreSQL backend is reachable. It
|
||||
// runs `db.PingContext` under the configured operation timeout.
|
||||
func (store *Store) Ping(ctx context.Context) error {
|
||||
operationCtx, cancel, err := withTimeout(ctx, "ping postgres notification store", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if err := store.db.PingContext(operationCtx); err != nil {
|
||||
return fmt.Errorf("ping postgres notification store: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withTx runs fn inside a BEGIN … COMMIT transaction bounded by the store's
|
||||
// operation timeout. It rolls back on any error or panic and returns
|
||||
// whatever fn returned. The transaction uses the default isolation level
|
||||
// (`READ COMMITTED`); per-row contention is resolved through optimistic
|
||||
// concurrency on `updated_at` rather than `SELECT … FOR UPDATE`.
|
||||
func (store *Store) withTx(ctx context.Context, operation string, fn func(ctx context.Context, tx *sql.Tx) error) error {
|
||||
operationCtx, cancel, err := withTimeout(ctx, operation, store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
tx, err := store.db.BeginTx(operationCtx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: begin: %w", operation, err)
|
||||
}
|
||||
|
||||
if err := fn(operationCtx, tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("%s: commit: %w", operation, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// operationContext bounds one read or write that does not need a
|
||||
// transaction envelope (single statement). It mirrors store.withTx for
|
||||
// non-transactional callers.
|
||||
func (store *Store) operationContext(ctx context.Context, operation string) (context.Context, context.CancelFunc, error) {
|
||||
return withTimeout(ctx, operation, store.operationTimeout)
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
package notificationstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/api/intentstream"
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
"galaxy/notification/internal/service/malformedintent"
|
||||
"galaxy/notification/internal/service/routestate"
|
||||
)
|
||||
|
||||
func TestPing(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if err := store.Ping(context.Background()); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAcceptanceAndReads(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
notification := newNotification(t, "n-1", now)
|
||||
pushRoute := newPendingRoute(notification.NotificationID, "push:user-1", intentstream.ChannelPush, "user-1", now)
|
||||
emailRoute := newPendingRoute(notification.NotificationID, "email:user-1", intentstream.ChannelEmail, "user-1", now)
|
||||
idem := newIdempotency(notification, now)
|
||||
|
||||
if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{
|
||||
Notification: notification,
|
||||
Routes: []acceptintent.NotificationRoute{pushRoute, emailRoute},
|
||||
Idempotency: idem,
|
||||
}); err != nil {
|
||||
t.Fatalf("create acceptance: %v", err)
|
||||
}
|
||||
|
||||
gotNotification, found, err := store.GetNotification(ctx, notification.NotificationID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get notification: found=%v err=%v", found, err)
|
||||
}
|
||||
if gotNotification.PayloadJSON != notification.PayloadJSON {
|
||||
t.Fatalf("notification payload mismatch: got %q want %q", gotNotification.PayloadJSON, notification.PayloadJSON)
|
||||
}
|
||||
if len(gotNotification.RecipientUserIDs) != 1 || gotNotification.RecipientUserIDs[0] != "user-1" {
|
||||
t.Fatalf("recipient_user_ids round-trip: %#v", gotNotification.RecipientUserIDs)
|
||||
}
|
||||
|
||||
gotIdem, found, err := store.GetIdempotency(ctx, notification.Producer, notification.IdempotencyKey)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get idempotency: found=%v err=%v", found, err)
|
||||
}
|
||||
if gotIdem.NotificationID != notification.NotificationID {
|
||||
t.Fatalf("idempotency notification id mismatch: got %q want %q", gotIdem.NotificationID, notification.NotificationID)
|
||||
}
|
||||
if !gotIdem.ExpiresAt.Equal(idem.ExpiresAt) {
|
||||
t.Fatalf("idempotency expires_at mismatch: got %v want %v", gotIdem.ExpiresAt, idem.ExpiresAt)
|
||||
}
|
||||
|
||||
gotRoute, found, err := store.GetRoute(ctx, notification.NotificationID, pushRoute.RouteID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get push route: found=%v err=%v", found, err)
|
||||
}
|
||||
if gotRoute.Channel != intentstream.ChannelPush {
|
||||
t.Fatalf("push route channel mismatch: got %q", gotRoute.Channel)
|
||||
}
|
||||
if !gotRoute.NextAttemptAt.Equal(pushRoute.NextAttemptAt) {
|
||||
t.Fatalf("push route next_attempt_at mismatch: got %v want %v", gotRoute.NextAttemptAt, pushRoute.NextAttemptAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAcceptanceIdempotencyConflict(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
notification := newNotification(t, "n-1", now)
|
||||
route := newPendingRoute(notification.NotificationID, "push:user-1", intentstream.ChannelPush, "user-1", now)
|
||||
|
||||
first := acceptintent.CreateAcceptanceInput{
|
||||
Notification: notification,
|
||||
Routes: []acceptintent.NotificationRoute{route},
|
||||
Idempotency: newIdempotency(notification, now),
|
||||
}
|
||||
if err := store.CreateAcceptance(ctx, first); err != nil {
|
||||
t.Fatalf("first acceptance: %v", err)
|
||||
}
|
||||
|
||||
clone := notification
|
||||
clone.NotificationID = "n-2"
|
||||
cloneRoute := route
|
||||
cloneRoute.NotificationID = clone.NotificationID
|
||||
clone.AcceptedAt = now.Add(time.Second)
|
||||
clone.UpdatedAt = clone.AcceptedAt
|
||||
cloneIdem := newIdempotency(clone, now.Add(time.Second))
|
||||
cloneIdem.IdempotencyKey = notification.IdempotencyKey
|
||||
|
||||
err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{
|
||||
Notification: clone,
|
||||
Routes: []acceptintent.NotificationRoute{cloneRoute},
|
||||
Idempotency: cloneIdem,
|
||||
})
|
||||
if !errors.Is(err, acceptintent.ErrConflict) {
|
||||
t.Fatalf("expected acceptintent.ErrConflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDueRoutes(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
base := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
pastNotification := newNotification(t, "past", base)
|
||||
pastRoute := newPendingRoute(pastNotification.NotificationID, "push:past", intentstream.ChannelPush, "user-1", base.Add(-time.Minute))
|
||||
if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{
|
||||
Notification: pastNotification,
|
||||
Routes: []acceptintent.NotificationRoute{pastRoute},
|
||||
Idempotency: newIdempotency(pastNotification, base),
|
||||
}); err != nil {
|
||||
t.Fatalf("acceptance past: %v", err)
|
||||
}
|
||||
|
||||
futureNotification := newNotification(t, "future", base)
|
||||
futureNotification.IdempotencyKey = "key-future"
|
||||
futureRoute := newPendingRoute(futureNotification.NotificationID, "push:future", intentstream.ChannelPush, "user-2", base.Add(time.Hour))
|
||||
if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{
|
||||
Notification: futureNotification,
|
||||
Routes: []acceptintent.NotificationRoute{futureRoute},
|
||||
Idempotency: newIdempotency(futureNotification, base),
|
||||
}); err != nil {
|
||||
t.Fatalf("acceptance future: %v", err)
|
||||
}
|
||||
|
||||
due, err := store.ListDueRoutes(ctx, base, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list due routes: %v", err)
|
||||
}
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("expected one due route, got %d", len(due))
|
||||
}
|
||||
if due[0].NotificationID != "past" || due[0].RouteID != "push:past" {
|
||||
t.Fatalf("unexpected due route: %#v", due[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRoutePublishedHappyPath(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
notification := newNotification(t, "n-1", now)
|
||||
route := newPendingRoute(notification.NotificationID, "email:user-1", intentstream.ChannelEmail, "user-1", now)
|
||||
if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{
|
||||
Notification: notification,
|
||||
Routes: []acceptintent.NotificationRoute{route},
|
||||
Idempotency: newIdempotency(notification, now),
|
||||
}); err != nil {
|
||||
t.Fatalf("acceptance: %v", err)
|
||||
}
|
||||
|
||||
publishedAt := now.Add(time.Second)
|
||||
err := store.CompleteRoutePublished(ctx, routestate.CompleteRoutePublishedInput{
|
||||
ExpectedRoute: route,
|
||||
LeaseToken: "token",
|
||||
PublishedAt: publishedAt,
|
||||
Stream: "mail:delivery_commands",
|
||||
StreamValues: map[string]any{"k": "v"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("complete published: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := store.GetRoute(ctx, route.NotificationID, route.RouteID)
|
||||
if err != nil {
|
||||
t.Fatalf("get route: %v", err)
|
||||
}
|
||||
if got.Status != acceptintent.RouteStatusPublished {
|
||||
t.Fatalf("expected status published, got %q", got.Status)
|
||||
}
|
||||
if got.AttemptCount != 1 {
|
||||
t.Fatalf("expected attempt_count 1, got %d", got.AttemptCount)
|
||||
}
|
||||
if !got.NextAttemptAt.IsZero() {
|
||||
t.Fatalf("expected next_attempt_at cleared, got %v", got.NextAttemptAt)
|
||||
}
|
||||
if !got.PublishedAt.Equal(publishedAt) {
|
||||
t.Fatalf("expected published_at %v, got %v", publishedAt, got.PublishedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRoutePublishedConflictOnUpdatedAtMismatch(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
notification := newNotification(t, "n-1", now)
|
||||
route := newPendingRoute(notification.NotificationID, "email:user-1", intentstream.ChannelEmail, "user-1", now)
|
||||
if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{
|
||||
Notification: notification,
|
||||
Routes: []acceptintent.NotificationRoute{route},
|
||||
Idempotency: newIdempotency(notification, now),
|
||||
}); err != nil {
|
||||
t.Fatalf("acceptance: %v", err)
|
||||
}
|
||||
|
||||
stale := route
|
||||
stale.UpdatedAt = now.Add(-time.Minute) // mismatch on purpose
|
||||
|
||||
err := store.CompleteRoutePublished(ctx, routestate.CompleteRoutePublishedInput{
|
||||
ExpectedRoute: stale,
|
||||
LeaseToken: "token",
|
||||
PublishedAt: now.Add(time.Second),
|
||||
Stream: "mail:delivery_commands",
|
||||
StreamValues: map[string]any{"k": "v"},
|
||||
})
|
||||
if !errors.Is(err, routestate.ErrConflict) {
|
||||
t.Fatalf("expected routestate.ErrConflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRouteFailedReschedule(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
notification := newNotification(t, "n-1", now)
|
||||
route := newPendingRoute(notification.NotificationID, "email:user-1", intentstream.ChannelEmail, "user-1", now)
|
||||
if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{
|
||||
Notification: notification,
|
||||
Routes: []acceptintent.NotificationRoute{route},
|
||||
Idempotency: newIdempotency(notification, now),
|
||||
}); err != nil {
|
||||
t.Fatalf("acceptance: %v", err)
|
||||
}
|
||||
|
||||
failedAt := now.Add(time.Second)
|
||||
nextAttemptAt := now.Add(2 * time.Minute)
|
||||
err := store.CompleteRouteFailed(ctx, routestate.CompleteRouteFailedInput{
|
||||
ExpectedRoute: route,
|
||||
LeaseToken: "token",
|
||||
FailedAt: failedAt,
|
||||
NextAttemptAt: nextAttemptAt,
|
||||
FailureClassification: "smtp_temporary_failure",
|
||||
FailureMessage: "graylisted",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("complete failed: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := store.GetRoute(ctx, route.NotificationID, route.RouteID)
|
||||
if err != nil {
|
||||
t.Fatalf("get route: %v", err)
|
||||
}
|
||||
if got.Status != acceptintent.RouteStatusFailed {
|
||||
t.Fatalf("expected status failed, got %q", got.Status)
|
||||
}
|
||||
if got.AttemptCount != 1 {
|
||||
t.Fatalf("expected attempt_count 1, got %d", got.AttemptCount)
|
||||
}
|
||||
if !got.NextAttemptAt.Equal(nextAttemptAt) {
|
||||
t.Fatalf("expected next_attempt_at %v, got %v", nextAttemptAt, got.NextAttemptAt)
|
||||
}
|
||||
if got.LastErrorClassification != "smtp_temporary_failure" {
|
||||
t.Fatalf("expected error classification, got %q", got.LastErrorClassification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRouteDeadLetter(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
notification := newNotification(t, "n-1", now)
|
||||
route := newPendingRoute(notification.NotificationID, "email:user-1", intentstream.ChannelEmail, "user-1", now)
|
||||
route.MaxAttempts = 1 // single attempt budget so the first failure is terminal.
|
||||
if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{
|
||||
Notification: notification,
|
||||
Routes: []acceptintent.NotificationRoute{route},
|
||||
Idempotency: newIdempotency(notification, now),
|
||||
}); err != nil {
|
||||
t.Fatalf("acceptance: %v", err)
|
||||
}
|
||||
|
||||
deadAt := now.Add(time.Second)
|
||||
err := store.CompleteRouteDeadLetter(ctx, routestate.CompleteRouteDeadLetterInput{
|
||||
ExpectedRoute: route,
|
||||
LeaseToken: "token",
|
||||
DeadLetteredAt: deadAt,
|
||||
FailureClassification: "smtp_permanent_failure",
|
||||
FailureMessage: "rejected",
|
||||
RecoveryHint: "manual review",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("complete dead letter: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := store.GetRoute(ctx, route.NotificationID, route.RouteID)
|
||||
if err != nil {
|
||||
t.Fatalf("get route: %v", err)
|
||||
}
|
||||
if got.Status != acceptintent.RouteStatusDeadLetter {
|
||||
t.Fatalf("expected status dead_letter, got %q", got.Status)
|
||||
}
|
||||
if !got.DeadLetteredAt.Equal(deadAt) {
|
||||
t.Fatalf("expected dead_lettered_at %v, got %v", deadAt, got.DeadLetteredAt)
|
||||
}
|
||||
|
||||
// Check that the dead_letters audit row was inserted.
|
||||
row := store.db.QueryRow(`SELECT failure_classification, recovery_hint FROM dead_letters WHERE notification_id = $1 AND route_id = $2`,
|
||||
route.NotificationID, route.RouteID)
|
||||
var classification string
|
||||
var hint string
|
||||
if err := row.Scan(&classification, &hint); err != nil {
|
||||
t.Fatalf("scan dead_letter row: %v", err)
|
||||
}
|
||||
if classification != "smtp_permanent_failure" || hint != "manual review" {
|
||||
t.Fatalf("dead_letter row mismatch: classification=%q hint=%q", classification, hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRouteScheduleSnapshot(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
base := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
for index, offset := range []time.Duration{-time.Hour, time.Minute, 2 * time.Minute} {
|
||||
notification := newNotification(t, idString("n-", index), base)
|
||||
notification.IdempotencyKey = idString("key-", index)
|
||||
route := newPendingRoute(notification.NotificationID, idString("push:user-", index), intentstream.ChannelPush, idString("user-", index), base.Add(offset))
|
||||
if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{
|
||||
Notification: notification,
|
||||
Routes: []acceptintent.NotificationRoute{route},
|
||||
Idempotency: newIdempotency(notification, base),
|
||||
}); err != nil {
|
||||
t.Fatalf("acceptance %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
snap, err := store.ReadRouteScheduleSnapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("read snapshot: %v", err)
|
||||
}
|
||||
if snap.Depth != 3 {
|
||||
t.Fatalf("expected depth 3, got %d", snap.Depth)
|
||||
}
|
||||
if snap.OldestScheduledFor == nil {
|
||||
t.Fatalf("expected oldest scheduled time, got nil")
|
||||
}
|
||||
if !snap.OldestScheduledFor.Equal(base.Add(-time.Hour)) {
|
||||
t.Fatalf("expected oldest %v, got %v", base.Add(-time.Hour), *snap.OldestScheduledFor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedIntentRecordAndGet(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
entry := malformedintent.Entry{
|
||||
StreamEntryID: "stream-1",
|
||||
NotificationType: "game.turn.ready",
|
||||
Producer: "game-master",
|
||||
IdempotencyKey: "key-1",
|
||||
FailureCode: malformedintent.FailureCodeInvalidPayload,
|
||||
FailureMessage: "decode failed",
|
||||
RawFields: map[string]any{"raw_payload": "abc"},
|
||||
RecordedAt: now,
|
||||
}
|
||||
if err := store.Record(ctx, entry); err != nil {
|
||||
t.Fatalf("record malformed: %v", err)
|
||||
}
|
||||
|
||||
// idempotent re-record
|
||||
if err := store.Record(ctx, entry); err != nil {
|
||||
t.Fatalf("record malformed twice: %v", err)
|
||||
}
|
||||
|
||||
got, found, err := store.GetMalformedIntent(ctx, entry.StreamEntryID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get malformed: found=%v err=%v", found, err)
|
||||
}
|
||||
if got.FailureCode != malformedintent.FailureCodeInvalidPayload {
|
||||
t.Fatalf("failure_code mismatch: %q", got.FailureCode)
|
||||
}
|
||||
if got.FailureMessage != entry.FailureMessage {
|
||||
t.Fatalf("failure_message mismatch: %q", got.FailureMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetentionDeletesAndCascade(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
old := time.Now().UTC().Add(-30 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||
fresh := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
oldNotification := newNotification(t, "old", old)
|
||||
oldNotification.IdempotencyKey = "key-old"
|
||||
oldRoute := newPendingRoute(oldNotification.NotificationID, "push:user-old", intentstream.ChannelPush, "user-old", old)
|
||||
oldRoute.MaxAttempts = 1
|
||||
if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{
|
||||
Notification: oldNotification,
|
||||
Routes: []acceptintent.NotificationRoute{oldRoute},
|
||||
Idempotency: newIdempotency(oldNotification, old),
|
||||
}); err != nil {
|
||||
t.Fatalf("acceptance old: %v", err)
|
||||
}
|
||||
if err := store.CompleteRouteDeadLetter(ctx, routestate.CompleteRouteDeadLetterInput{
|
||||
ExpectedRoute: oldRoute,
|
||||
LeaseToken: "token",
|
||||
DeadLetteredAt: old.Add(time.Second),
|
||||
FailureClassification: "smtp_permanent_failure",
|
||||
FailureMessage: "rejected",
|
||||
}); err != nil {
|
||||
t.Fatalf("dead letter old: %v", err)
|
||||
}
|
||||
|
||||
freshNotification := newNotification(t, "fresh", fresh)
|
||||
freshNotification.IdempotencyKey = "key-fresh"
|
||||
freshRoute := newPendingRoute(freshNotification.NotificationID, "push:user-fresh", intentstream.ChannelPush, "user-fresh", fresh)
|
||||
if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{
|
||||
Notification: freshNotification,
|
||||
Routes: []acceptintent.NotificationRoute{freshRoute},
|
||||
Idempotency: newIdempotency(freshNotification, fresh),
|
||||
}); err != nil {
|
||||
t.Fatalf("acceptance fresh: %v", err)
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour)
|
||||
deleted, err := store.DeleteRecordsOlderThan(ctx, cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("delete records: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Fatalf("expected 1 deleted, got %d", deleted)
|
||||
}
|
||||
|
||||
if _, found, err := store.GetNotification(ctx, "old"); err != nil || found {
|
||||
t.Fatalf("old notification should be gone: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
// Confirm cascade emptied routes/dead_letters for old notification.
|
||||
var routeCount int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM routes WHERE notification_id = 'old'`).Scan(&routeCount); err != nil {
|
||||
t.Fatalf("count routes: %v", err)
|
||||
}
|
||||
if routeCount != 0 {
|
||||
t.Fatalf("expected 0 cascaded routes, got %d", routeCount)
|
||||
}
|
||||
var deadCount int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM dead_letters WHERE notification_id = 'old'`).Scan(&deadCount); err != nil {
|
||||
t.Fatalf("count dead letters: %v", err)
|
||||
}
|
||||
if deadCount != 0 {
|
||||
t.Fatalf("expected 0 cascaded dead letters, got %d", deadCount)
|
||||
}
|
||||
|
||||
// Fresh notification stays.
|
||||
if _, found, err := store.GetNotification(ctx, "fresh"); err != nil || !found {
|
||||
t.Fatalf("fresh notification missing: found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMalformedIntentsOlderThan(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
old := time.Now().UTC().Add(-30 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||
fresh := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
oldEntry := malformedintent.Entry{
|
||||
StreamEntryID: "stream-old",
|
||||
FailureCode: malformedintent.FailureCodeInvalidPayload,
|
||||
FailureMessage: "decode failed",
|
||||
RawFields: map[string]any{},
|
||||
RecordedAt: old,
|
||||
}
|
||||
if err := store.Record(ctx, oldEntry); err != nil {
|
||||
t.Fatalf("record old: %v", err)
|
||||
}
|
||||
freshEntry := malformedintent.Entry{
|
||||
StreamEntryID: "stream-fresh",
|
||||
FailureCode: malformedintent.FailureCodeInvalidPayload,
|
||||
FailureMessage: "decode failed",
|
||||
RawFields: map[string]any{},
|
||||
RecordedAt: fresh,
|
||||
}
|
||||
if err := store.Record(ctx, freshEntry); err != nil {
|
||||
t.Fatalf("record fresh: %v", err)
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour)
|
||||
deleted, err := store.DeleteMalformedIntentsOlderThan(ctx, cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Fatalf("expected 1 deleted, got %d", deleted)
|
||||
}
|
||||
|
||||
if _, found, err := store.GetMalformedIntent(ctx, "stream-old"); err != nil || found {
|
||||
t.Fatalf("old malformed intent should be gone: found=%v err=%v", found, err)
|
||||
}
|
||||
if _, found, err := store.GetMalformedIntent(ctx, "stream-fresh"); err != nil || !found {
|
||||
t.Fatalf("fresh malformed intent missing: found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func newNotification(t testing.TB, id string, occurred time.Time) acceptintent.NotificationRecord {
|
||||
t.Helper()
|
||||
return acceptintent.NotificationRecord{
|
||||
NotificationID: id,
|
||||
NotificationType: intentstream.NotificationTypeGameTurnReady,
|
||||
Producer: intentstream.ProducerGameMaster,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
PayloadJSON: `{"a":1}`,
|
||||
IdempotencyKey: "key-" + id,
|
||||
RequestFingerprint: "fp-" + id,
|
||||
OccurredAt: occurred,
|
||||
AcceptedAt: occurred,
|
||||
UpdatedAt: occurred,
|
||||
}
|
||||
}
|
||||
|
||||
func newIdempotency(record acceptintent.NotificationRecord, createdAt time.Time) acceptintent.IdempotencyRecord {
|
||||
return acceptintent.IdempotencyRecord{
|
||||
Producer: record.Producer,
|
||||
IdempotencyKey: record.IdempotencyKey,
|
||||
NotificationID: record.NotificationID,
|
||||
RequestFingerprint: record.RequestFingerprint,
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: createdAt.Add(7 * 24 * time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
func newPendingRoute(notificationID string, routeID string, channel intentstream.Channel, recipient string, dueAt time.Time) acceptintent.NotificationRoute {
|
||||
return acceptintent.NotificationRoute{
|
||||
NotificationID: notificationID,
|
||||
RouteID: routeID,
|
||||
Channel: channel,
|
||||
RecipientRef: "user:" + recipient,
|
||||
Status: acceptintent.RouteStatusPending,
|
||||
AttemptCount: 0,
|
||||
MaxAttempts: 3,
|
||||
NextAttemptAt: dueAt,
|
||||
ResolvedEmail: recipient + "@example.com",
|
||||
ResolvedLocale: "en",
|
||||
CreatedAt: dueAt,
|
||||
UpdatedAt: dueAt,
|
||||
}
|
||||
}
|
||||
|
||||
func idString(prefix string, index int) string {
|
||||
switch index {
|
||||
case 0:
|
||||
return prefix + "0"
|
||||
case 1:
|
||||
return prefix + "1"
|
||||
case 2:
|
||||
return prefix + "2"
|
||||
default:
|
||||
return prefix + "n"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package routepublisher composes one PostgreSQL-backed route-state store
|
||||
// (notificationstore) with one Redis-backed lease store (redisstate.LeaseStore)
|
||||
// behind the publisher worker contracts. The composition lets push and email
|
||||
// publishers keep their existing one-store dependency while Stage 5 of
|
||||
// `PG_PLAN.md` splits durable state to PostgreSQL and the short-lived
|
||||
// per-replica exclusivity lease to Redis.
|
||||
package routepublisher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/adapters/postgres/notificationstore"
|
||||
"galaxy/notification/internal/adapters/redisstate"
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
"galaxy/notification/internal/service/routestate"
|
||||
"galaxy/notification/internal/telemetry"
|
||||
)
|
||||
|
||||
// Store delegates each route-publisher method to either the durable state
|
||||
// store (PostgreSQL) or the lease store (Redis), preserving the umbrella
|
||||
// contract consumed by `worker.PushPublisher` and `worker.EmailPublisher`.
|
||||
type Store struct {
|
||||
state *notificationstore.Store
|
||||
leases *redisstate.LeaseStore
|
||||
}
|
||||
|
||||
// New constructs one composite route-publisher store. Both dependencies are
|
||||
// required: the SQL store owns route lifecycle and dead-letter persistence,
|
||||
// and the lease store owns the short-lived per-replica exclusivity hint
|
||||
// retained on Redis per PG_PLAN.md §5.
|
||||
func New(state *notificationstore.Store, leases *redisstate.LeaseStore) (*Store, error) {
|
||||
if state == nil {
|
||||
return nil, errors.New("new route publisher store: nil notification state store")
|
||||
}
|
||||
if leases == nil {
|
||||
return nil, errors.New("new route publisher store: nil lease store")
|
||||
}
|
||||
return &Store{state: state, leases: leases}, nil
|
||||
}
|
||||
|
||||
// ListDueRoutes delegates to the SQL store.
|
||||
func (store *Store) ListDueRoutes(ctx context.Context, now time.Time, limit int64) ([]routestate.ScheduledRoute, error) {
|
||||
return store.state.ListDueRoutes(ctx, now, limit)
|
||||
}
|
||||
|
||||
// TryAcquireRouteLease delegates to the Redis lease store.
|
||||
func (store *Store) TryAcquireRouteLease(ctx context.Context, notificationID string, routeID string, token string, ttl time.Duration) (bool, error) {
|
||||
return store.leases.TryAcquireRouteLease(ctx, notificationID, routeID, token, ttl)
|
||||
}
|
||||
|
||||
// ReleaseRouteLease delegates to the Redis lease store.
|
||||
func (store *Store) ReleaseRouteLease(ctx context.Context, notificationID string, routeID string, token string) error {
|
||||
return store.leases.ReleaseRouteLease(ctx, notificationID, routeID, token)
|
||||
}
|
||||
|
||||
// GetNotification delegates to the SQL store.
|
||||
func (store *Store) GetNotification(ctx context.Context, notificationID string) (acceptintent.NotificationRecord, bool, error) {
|
||||
return store.state.GetNotification(ctx, notificationID)
|
||||
}
|
||||
|
||||
// GetRoute delegates to the SQL store.
|
||||
func (store *Store) GetRoute(ctx context.Context, notificationID string, routeID string) (acceptintent.NotificationRoute, bool, error) {
|
||||
return store.state.GetRoute(ctx, notificationID, routeID)
|
||||
}
|
||||
|
||||
// CompleteRoutePublished delegates to the SQL store.
|
||||
func (store *Store) CompleteRoutePublished(ctx context.Context, input routestate.CompleteRoutePublishedInput) error {
|
||||
return store.state.CompleteRoutePublished(ctx, input)
|
||||
}
|
||||
|
||||
// CompleteRouteFailed delegates to the SQL store.
|
||||
func (store *Store) CompleteRouteFailed(ctx context.Context, input routestate.CompleteRouteFailedInput) error {
|
||||
return store.state.CompleteRouteFailed(ctx, input)
|
||||
}
|
||||
|
||||
// CompleteRouteDeadLetter delegates to the SQL store.
|
||||
func (store *Store) CompleteRouteDeadLetter(ctx context.Context, input routestate.CompleteRouteDeadLetterInput) error {
|
||||
return store.state.CompleteRouteDeadLetter(ctx, input)
|
||||
}
|
||||
|
||||
// ReadRouteScheduleSnapshot delegates to the SQL store.
|
||||
func (store *Store) ReadRouteScheduleSnapshot(ctx context.Context) (telemetry.RouteScheduleSnapshot, error) {
|
||||
return store.state.ReadRouteScheduleSnapshot(ctx)
|
||||
}
|
||||
Reference in New Issue
Block a user