Files
fgueryandGitHub a63e3a20a8 feat(EN-214): create events via storage instead of manually calling event activity (#594)
* feat(EN-214): add outbox table migration

* feat(EN-214): add outbox models and storage layer

* feat(EN-214): implement outbox publisher activity

* feat(EN-214): implement outbox schedule activity

* feat(EN-214): add outbox publisher workflow

* feat(EN-214): migrate accounts to outbox pattern

* feat(EN-214): migrate balances to outbox pattern

* feat(EN-214): migrate payments to outbox pattern

* feat(EN-214): migrate bank accounts to outbox pattern

* feat(EN-214): migrate pools to outbox pattern

* feat(EN-214): migrate payment initiations to outbox pattern

* feat(EN-214): migrate PSU and connections to outbox pattern

* feat(EN-214): migrate connectors to outbox pattern

* feat(EN-214): remove legacy send_events workflow

* feat(EN-214): update workflows to use outbox pattern

* feat(EN-214): improve outbox idempotency and error handling

* feat(EN-214): update storage tasks and tests

* test(EN-214): update e2e tests for outbox pattern

* test(EN-214): clean up workflow tests

* chore(EN-214): update docker compose configuration

* feat(EN-214): add e2e run config in ide

* fix: add back docker-compose-dev run config

* chore: improve connector uninstall comments & remove connector id being passed

* fix: ensure events are not pushed if balances are not created

* fix: ensure err is not overwritten when using rollback

* fix: ensure we use v3 provider format

* chore: apply the nitpick comments from coderabbit

* chore: consistentely use e() error handling

* feat: optionally disable publisher schedule to make e2e test more consistent

* fix: improve error handling on uninstall connector

* avoid using custom payload and rely on existing interfaces; also avoid using different types for events

* fix: remove unecessary unmarshalling

* fix: ensure that rollbackOnTxError picks up errors on tx.Commit()

* fix: add extra validation on iban & account number in the test

* test: cover marshall/unmarshalling of event payload

* fix: increase test timeout

* try to make test more resiliant

* fix: wrong pointer use in loop

* fix: error formatting issue

* fix: move oubtox schedule creation out of an activity and do it in the worker itself

* feat: OutboxEventDeleteAndSend as batch

* change outbox_event to have id only instead of idempotency key

* make e2e test less brittle, save eventID as idempotency key in publication

* feat: make outbox_event keep an history

* feat(EN-214): added a schedule to cleanup outbox events

* test: make cleanup actually clean

* ignore linting rules for now

* feat: do not add psu metadata to event anymore as it's irrelevant

* fix: create local variable instead of relying on pointers, even if not 100% necessary

* fix: remove temporal schedule describe calls
2025-11-24 16:05:42 +00:00

256 lines
7.0 KiB
Go

package storage
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/formancehq/go-libs/v3/pointer"
internalTime "github.com/formancehq/go-libs/v3/time"
"github.com/formancehq/payments/internal/models"
internalErrors "github.com/formancehq/payments/internal/utils/errors"
"github.com/uptrace/bun"
)
type outboxEvent struct {
bun.BaseModel `bun:"table:outbox_events"`
// Primary key
ID models.EventID `bun:"id,pk,type:character varying,notnull"`
// Mandatory fields
EventType string `bun:"event_type,type:text,notnull"`
EntityID string `bun:"entity_id,type:character varying,notnull"`
Payload json.RawMessage `bun:"payload,type:jsonb,notnull"`
CreatedAt internalTime.Time `bun:"created_at,type:timestamp without time zone,notnull"`
Status models.OutboxEventStatus `bun:"status,type:text,notnull"`
// Optional fields
ConnectorID *models.ConnectorID `bun:"connector_id,type:character varying,nullzero"`
RetryCount int `bun:"retry_count,type:integer,notnull"`
LastRetryAt *internalTime.Time `bun:"last_retry_at,type:timestamp without time zone,nullzero"`
Error *string `bun:"error,type:text,nullzero"`
}
func (s *store) OutboxEventsInsert(ctx context.Context, tx bun.Tx, events []models.OutboxEvent) error {
if len(events) == 0 {
return nil
}
// Filter out events that already exist in events_sent table
toInsert := make([]outboxEvent, 0, len(events))
for _, event := range events {
// EventID must be set on the event
if event.ID.EventIdempotencyKey == "" {
return e("event ID must be set with EventIdempotencyKey", errors.New("missing event ID"))
}
// Check if this EventID already exists in events_sent using the transaction
exists, err := tx.NewSelect().
Model((*eventSent)(nil)).
Where("id = ?", event.ID).
Exists(ctx)
if err != nil {
return e("failed to check if event already sent", err)
}
// Skip events that already exist in events_sent
if exists {
continue
}
toInsert = append(toInsert, fromOutboxEventModel(event))
}
// If all events were filtered out, nothing to insert
if len(toInsert) == 0 {
return nil
}
// Insert with ON CONFLICT DO NOTHING for (id)
_, err := tx.NewInsert().
Model(&toInsert).
On("CONFLICT (id) DO NOTHING").
Exec(ctx)
return e("failed to insert outbox events", err)
}
// OutboxEventsInsertWithTx is meant to be used only when we don't have a related entity;
// outbox event should always be inserted at the same time as the entity they're linked to.
func (s *store) OutboxEventsInsertWithTx(ctx context.Context, events []models.OutboxEvent) error {
if len(events) == 0 {
return nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return e("failed to begin transaction", err)
}
defer func() {
rollbackOnTxError(ctx, &tx, err)
}()
if err = s.OutboxEventsInsert(ctx, tx, events); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return e("failed to commit transaction", err)
}
return nil
}
func (s *store) OutboxEventsPollPending(ctx context.Context, limit int) ([]models.OutboxEvent, error) {
var events []outboxEvent
err := s.db.NewSelect().
TableExpr("outbox_events").
Where("status = ?", models.OUTBOX_STATUS_PENDING).
Order("created_at ASC").
Limit(limit).
Scan(ctx, &events)
if err != nil {
return nil, e("failed to poll pending outbox events", err)
}
result := make([]models.OutboxEvent, 0, len(events))
for _, event := range events {
result = append(result, toOutboxEventModel(event))
}
return result, nil
}
func (s *store) OutboxEventsMarkFailed(ctx context.Context, eventID models.EventID, retryCount int, err error) error {
now := internalTime.Now().UTC()
maxRetries := models.MaxOutboxRetries
// Determine status based on retry count or error type
status := models.OUTBOX_STATUS_PENDING
var nonRetriable internalErrors.NonRetryableError
if retryCount >= maxRetries || errors.As(err, &nonRetriable) {
status = models.OUTBOX_STATUS_FAILED
}
var errMsg *string
if err != nil {
msg := err.Error()
errMsg = &msg
}
_, updateErr := s.db.NewUpdate().
TableExpr("outbox_events").
Set("status = ?", status).
Set("retry_count = ?", retryCount).
Set("last_retry_at = ?", now).
Set("error = ?", errMsg).
Where("id = ?", eventID).
Exec(ctx)
return e("failed to mark outbox event", updateErr)
}
func (s *store) OutboxEventsMarkProcessedAndRecordSent(ctx context.Context, eventIDs []models.EventID, eventsSent []models.EventSent) error {
if len(eventIDs) == 0 {
return nil // Nothing to do
}
if len(eventIDs) != len(eventsSent) {
return e("eventIDs and eventsSent must have the same length", errors.New("length mismatch"))
}
// Begin transaction
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return e("failed to create transaction", err)
}
defer func() {
rollbackOnTxError(ctx, &tx, err)
}()
// Mark events as processed (batch update using id)
_, err = tx.NewUpdate().
TableExpr("outbox_events").
Set("status = ?", models.OUTBOX_STATUS_PROCESSED).
Where("id IN (?)", bun.In(eventIDs)).
Exec(ctx)
if err != nil {
return e("failed to mark published events as processed", err)
}
// Record in events_sent table (batch insert)
toInsert := make([]eventSent, 0, len(eventsSent))
for _, eventSent := range eventsSent {
toInsert = append(toInsert, fromEventSentModel(eventSent))
}
if len(toInsert) > 0 {
_, err = tx.NewInsert().
Model(&toInsert).
On("CONFLICT (id) DO NOTHING").
Exec(ctx)
if err != nil {
return e("failed to record events as sent", err)
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
return e("failed to commit transaction", err)
}
return nil
}
func (s *store) OutboxEventsDeleteOldProcessed(ctx context.Context, olderThan time.Time) error {
_, err := s.db.NewDelete().
TableExpr("outbox_events").
Where("status = ?", models.OUTBOX_STATUS_PROCESSED).
Where("created_at < ?", olderThan).
Exec(ctx)
return e("failed to delete old processed outbox events", err)
}
func fromOutboxEventModel(from models.OutboxEvent) outboxEvent {
return outboxEvent{
ID: from.ID,
EventType: from.EventType,
EntityID: from.EntityID,
Payload: from.Payload,
CreatedAt: internalTime.New(from.CreatedAt),
Status: from.Status,
ConnectorID: from.ConnectorID,
RetryCount: from.RetryCount,
LastRetryAt: func() *internalTime.Time {
if from.LastRetryAt == nil {
return nil
}
return pointer.For(internalTime.New(*from.LastRetryAt))
}(),
Error: from.Error,
}
}
func toOutboxEventModel(from outboxEvent) models.OutboxEvent {
return models.OutboxEvent{
ID: from.ID,
EventType: from.EventType,
EntityID: from.EntityID,
Payload: from.Payload,
CreatedAt: from.CreatedAt.Time,
Status: from.Status,
ConnectorID: from.ConnectorID,
RetryCount: from.RetryCount,
LastRetryAt: func() *time.Time {
if from.LastRetryAt == nil {
return nil
}
return pointer.For(from.LastRetryAt.Time)
}(),
Error: from.Error,
}
}