feat: add durable Kuknos settlement coordination
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
package settlement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
type EnqueueRepository interface {
|
||||
Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error)
|
||||
Get(context.Context, string, string) (ledger.Settlement, error)
|
||||
RetryByOperator(context.Context, string, string, string, time.Time) (ledger.Settlement, error)
|
||||
}
|
||||
|
||||
func (s *Service) RetryByOperator(ctx context.Context, settlementID, actorID, reason string) (ledger.Settlement, error) {
|
||||
if settlementID == "" || actorID == "" || reason == "" {
|
||||
return ledger.Settlement{}, fmt.Errorf("settlement id, actor id, and reason are required")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.now != nil {
|
||||
now = s.now().UTC()
|
||||
}
|
||||
return s.repository.RetryByOperator(ctx, settlementID, actorID, reason, now)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository EnqueueRepository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(repository EnqueueRepository) *Service { return &Service{repository: repository} }
|
||||
|
||||
func (s *Service) Enqueue(ctx context.Context, sourceService, sourceTxID, idempotencyKey, network, xdr string) (ledger.Settlement, bool, error) {
|
||||
if sourceService == "" || sourceTxID == "" || idempotencyKey == "" || network == "" || xdr == "" {
|
||||
return ledger.Settlement{}, false, fmt.Errorf("settlement identity, network, and signed transaction are required")
|
||||
}
|
||||
id := make([]byte, 16)
|
||||
if _, err := rand.Read(id); err != nil {
|
||||
return ledger.Settlement{}, false, fmt.Errorf("generate settlement id: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.now != nil {
|
||||
now = s.now().UTC()
|
||||
}
|
||||
encoded := hex.EncodeToString(id)
|
||||
record := ledger.Settlement{ID: encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], SourceService: sourceService, SourceTxID: sourceTxID, IdempotencyKey: idempotencyKey, Status: ledger.SettlementPending, Network: network, SignedTransactionXDR: xdr, AvailableAt: now}
|
||||
if err := record.Validate(); err != nil {
|
||||
return ledger.Settlement{}, false, err
|
||||
}
|
||||
return s.repository.Enqueue(ctx, record)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, settlementID, idempotencyKey string) (ledger.Settlement, error) {
|
||||
if settlementID == "" && idempotencyKey == "" {
|
||||
return ledger.Settlement{}, fmt.Errorf("settlement id or idempotency key is required")
|
||||
}
|
||||
return s.repository.Get(ctx, settlementID, idempotencyKey)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package settlement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
type serviceRepository struct {
|
||||
retriedID, actor, reason string
|
||||
}
|
||||
|
||||
func (*serviceRepository) Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error) {
|
||||
return ledger.Settlement{}, false, nil
|
||||
}
|
||||
func (*serviceRepository) Get(context.Context, string, string) (ledger.Settlement, error) {
|
||||
return ledger.Settlement{}, nil
|
||||
}
|
||||
func (r *serviceRepository) RetryByOperator(_ context.Context, id, actor, reason string, _ time.Time) (ledger.Settlement, error) {
|
||||
r.retriedID, r.actor, r.reason = id, actor, reason
|
||||
return ledger.Settlement{ID: id, Status: ledger.SettlementRetryable}, nil
|
||||
}
|
||||
|
||||
func TestRetryByOperatorRequiresCompleteAuditIdentity(t *testing.T) {
|
||||
service := NewService(&serviceRepository{})
|
||||
if _, err := service.RetryByOperator(context.Background(), "id", "actor", ""); err == nil {
|
||||
t.Fatal("expected missing reason to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryByOperatorPassesActorAndReasonToRepository(t *testing.T) {
|
||||
repository := &serviceRepository{}
|
||||
service := NewService(repository)
|
||||
got, err := service.RetryByOperator(context.Background(), "id", "actor-7", "Horizon outage resolved")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != ledger.SettlementRetryable || repository.retriedID != "id" || repository.actor != "actor-7" || repository.reason != "Horizon outage resolved" {
|
||||
t.Fatalf("unexpected retry: %+v repo=%+v", got, repository)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package settlement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
ClaimDue(context.Context, string, int, time.Time) ([]ledger.Settlement, error)
|
||||
Save(context.Context, ledger.Settlement, string) error
|
||||
}
|
||||
|
||||
type Submitter interface {
|
||||
Submit(context.Context, ledger.Settlement) (string, error)
|
||||
}
|
||||
|
||||
type Worker struct {
|
||||
Repository Repository
|
||||
Submitter Submitter
|
||||
WorkerID string
|
||||
MaxAttempts int
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (w Worker) RunOnce(ctx context.Context, limit int) error {
|
||||
if w.Repository == nil || w.Submitter == nil || w.WorkerID == "" || limit <= 0 {
|
||||
return fmt.Errorf("settlement worker is not configured")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if w.Now != nil {
|
||||
now = w.Now().UTC()
|
||||
}
|
||||
records, err := w.Repository.ClaimDue(ctx, w.WorkerID, limit, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
record.Attempts++
|
||||
hash, submitErr := w.Submitter.Submit(ctx, record)
|
||||
if submitErr != nil {
|
||||
record = record.Retry(now, w.MaxAttempts, submitErr)
|
||||
} else {
|
||||
// Horizon returns success only after the transaction is accepted into
|
||||
// a ledger, so a successful response is already confirmation.
|
||||
record.Status = ledger.SettlementConfirmed
|
||||
record.TransactionHash = hash
|
||||
record.LastError = ""
|
||||
}
|
||||
if err := w.Repository.Save(ctx, record, w.WorkerID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package settlement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
type memoryRepository struct{ record ledger.Settlement }
|
||||
|
||||
func (m *memoryRepository) ClaimDue(context.Context, string, int, time.Time) ([]ledger.Settlement, error) {
|
||||
return []ledger.Settlement{m.record}, nil
|
||||
}
|
||||
func (m *memoryRepository) Save(_ context.Context, record ledger.Settlement, _ string) error {
|
||||
m.record = record
|
||||
return nil
|
||||
}
|
||||
|
||||
type submitter struct {
|
||||
hash string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s submitter) Submit(context.Context, ledger.Settlement) (string, error) { return s.hash, s.err }
|
||||
|
||||
func TestWorkerPersistsSubmission(t *testing.T) {
|
||||
repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementPending, AvailableAt: time.Unix(1, 0)}}
|
||||
worker := Worker{Repository: repository, Submitter: submitter{hash: "hash-42"}, WorkerID: "worker-1", Now: func() time.Time { return time.Unix(2, 0) }}
|
||||
if err := worker.RunOnce(context.Background(), 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repository.record.Status != ledger.SettlementConfirmed || repository.record.TransactionHash != "hash-42" || repository.record.Attempts != 1 {
|
||||
t.Fatalf("unexpected settlement: %+v", repository.record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerSchedulesRetryAfterFirstFailedAttempt(t *testing.T) {
|
||||
repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementPending, AvailableAt: time.Unix(1, 0)}}
|
||||
worker := Worker{Repository: repository, Submitter: submitter{err: errors.New("timeout")}, WorkerID: "worker-1", MaxAttempts: 3, Now: func() time.Time { return time.Unix(2, 0) }}
|
||||
if err := worker.RunOnce(context.Background(), 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repository.record.Status != ledger.SettlementRetryable || repository.record.Attempts != 1 || !repository.record.AvailableAt.Equal(time.Unix(3, 0)) {
|
||||
t.Fatalf("unexpected settlement: %+v", repository.record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerMovesExhaustedSubmissionToManualReview(t *testing.T) {
|
||||
repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementRetryable, Attempts: 2, AvailableAt: time.Unix(1, 0)}}
|
||||
worker := Worker{Repository: repository, Submitter: submitter{err: errors.New("timeout")}, WorkerID: "worker-1", MaxAttempts: 3, Now: func() time.Time { return time.Unix(2, 0) }}
|
||||
if err := worker.RunOnce(context.Background(), 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repository.record.Status != ledger.SettlementManualReview {
|
||||
t.Fatalf("status=%s", repository.record.Status)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user