mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
429 lines
12 KiB
Go
429 lines
12 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/observe/log"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/storage/bun/paginate"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/workflow/temporal"
|
|
"github.com/hanzo-fi/payments/internal/connectors"
|
|
"github.com/hanzo-fi/payments/internal/connectors/engine/workflow"
|
|
"github.com/hanzo-fi/payments/pkg/domain/models"
|
|
"github.com/hanzo-fi/payments/internal/storage"
|
|
"github.com/pkg/errors"
|
|
"go.temporal.io/api/enums/v1"
|
|
"go.temporal.io/api/serviceerror"
|
|
"go.temporal.io/sdk/activity"
|
|
"go.temporal.io/sdk/client"
|
|
sdktemporal "go.temporal.io/sdk/temporal"
|
|
"go.temporal.io/sdk/worker"
|
|
temporalworkflow "go.temporal.io/sdk/workflow"
|
|
)
|
|
|
|
type WorkerPool struct {
|
|
logger logging.Logger
|
|
|
|
stack string
|
|
|
|
temporalClient client.Client
|
|
|
|
workers map[string]Worker
|
|
storage storage.Storage
|
|
rwMutex sync.RWMutex
|
|
|
|
workflows []temporal.DefinitionSet
|
|
activities []temporal.DefinitionSet
|
|
|
|
connectors connectors.Manager
|
|
options worker.Options
|
|
|
|
// skipScheduleCreation if true, skips creating the outbox publisher schedule
|
|
// Useful for tests that don't have a Temporal server available
|
|
skipScheduleCreation bool
|
|
outboxPollingPeriod time.Duration
|
|
outboxCleanupPeriod time.Duration
|
|
}
|
|
|
|
type Worker struct {
|
|
worker worker.Worker
|
|
}
|
|
|
|
func NewWorkerPool(
|
|
logger logging.Logger,
|
|
stack string,
|
|
temporalClient client.Client,
|
|
workflows,
|
|
activities []temporal.DefinitionSet,
|
|
storage storage.Storage,
|
|
connectors connectors.Manager,
|
|
options worker.Options,
|
|
outboxPollingPeriod time.Duration,
|
|
outboxCleanupPeriod time.Duration,
|
|
) *WorkerPool {
|
|
workers := &WorkerPool{
|
|
logger: logger,
|
|
stack: stack,
|
|
temporalClient: temporalClient,
|
|
workers: make(map[string]Worker),
|
|
workflows: workflows,
|
|
activities: activities,
|
|
storage: storage,
|
|
connectors: connectors,
|
|
options: options,
|
|
outboxPollingPeriod: outboxPollingPeriod,
|
|
outboxCleanupPeriod: outboxCleanupPeriod,
|
|
}
|
|
return workers
|
|
}
|
|
|
|
func (w *WorkerPool) OnStart(ctx context.Context) error {
|
|
if err := w.storage.ListenConnectorsChanges(ctx, storage.HandlerConnectorsChanges{
|
|
storage.ConnectorChangesInsert: w.onInsertPlugin,
|
|
storage.ConnectorChangesUpdate: w.onUpdatePlugin,
|
|
storage.ConnectorChangesDelete: w.onDeletePlugin,
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to start worker pool: %w", err)
|
|
}
|
|
|
|
query := storage.NewListConnectorsQuery(
|
|
paginate.NewPaginatedQueryOptions(storage.ConnectorQuery{}).
|
|
WithPageSize(100),
|
|
)
|
|
|
|
shouldCreateDefaultWorker := false
|
|
for {
|
|
connectors, err := w.storage.ConnectorsList(ctx, query)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
shouldCreateDefaultWorker = shouldCreateDefaultWorker || len(connectors.Data) > 0
|
|
for _, connector := range connectors.Data {
|
|
if err := w.onStartPlugin(connector); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !connectors.HasMore {
|
|
break
|
|
}
|
|
|
|
err = paginate.UnmarshalCursor(connectors.Next, &query)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if shouldCreateDefaultWorker {
|
|
// If we have at least one connector, we need to create the default worker
|
|
// to handle the possible tasks that are not related to a specific connector.
|
|
// (ex: pools, bank accounts, uninstallation etc...)
|
|
if err := w.AddDefaultWorker(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Create the outbox publisher schedule (unless explicitly skipped)
|
|
if !w.skipScheduleCreation {
|
|
if err := w.CreateOutboxPublisherSchedule(ctx); err != nil {
|
|
return fmt.Errorf("failed to create outbox publisher schedule: %w", err)
|
|
}
|
|
if err := w.CreateOutboxCleanupSchedule(ctx); err != nil {
|
|
return fmt.Errorf("failed to create outbox cleanup schedule: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (w *WorkerPool) onStartPlugin(connector models.Connector) error {
|
|
// skip strict polling period validation if installed by another instance
|
|
_, _, err := w.connectors.Load(connector, false, false)
|
|
if err != nil {
|
|
w.logger.Errorf("failed to register plugin for connector %q: %s", connector.ID.String(), err.Error())
|
|
// We don't want to crash the pod if the plugin registration fails,
|
|
// otherwise, the client will not be able to remove the failing
|
|
// connector from the database because of the crashes.
|
|
// We just log the error and continue.
|
|
return nil
|
|
}
|
|
|
|
// Even if the connector is scheduled for deletion, we still need to register
|
|
// the plugin to be able to handle the uninstallation.
|
|
// It will be unregistered when the uninstallation is done in the workflow
|
|
// after the deletion of the connector entry in the database.
|
|
if !connector.ScheduledForDeletion {
|
|
if err = w.AddWorker(GetDefaultTaskQueue(w.stack)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if plugin, getErr := w.connectors.Get(connector.ID); getErr == nil {
|
|
if throttle, ok := plugin.(models.PluginWithPayoutThrottle); ok && throttle.PayoutsPerSecond() > 0 {
|
|
if err := w.AddPayoutWorker(GetPayoutTaskQueue(w.stack, connector.ID), throttle.PayoutsPerSecond()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (w *WorkerPool) onInsertPlugin(ctx context.Context, connectorID models.ConnectorID) error {
|
|
w.logger.Debugf("worker got insert notification for %q", connectorID.String())
|
|
connector, err := w.storage.ConnectorsGet(ctx, connectorID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// skip strict polling period validation if installed by another instance
|
|
_, _, err = w.connectors.Load(*connector, false, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := w.AddWorker(GetDefaultTaskQueue(w.stack)); err != nil {
|
|
return err
|
|
}
|
|
|
|
// If we have at least one connector, we need to create the default worker
|
|
// to handle the possible tasks that are not related to a specific connector.
|
|
// (ex: pools, bank accounts, uninstallation etc...)
|
|
if err := w.AddDefaultWorker(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if plugin, getErr := w.connectors.Get(connectorID); getErr == nil {
|
|
if throttle, ok := plugin.(models.PluginWithPayoutThrottle); ok && throttle.PayoutsPerSecond() > 0 {
|
|
if err := w.AddPayoutWorker(GetPayoutTaskQueue(w.stack, connectorID), throttle.PayoutsPerSecond()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (w *WorkerPool) onUpdatePlugin(ctx context.Context, connectorID models.ConnectorID) error {
|
|
w.logger.Debugf("worker got update notification for %q", connectorID.String())
|
|
connector, err := w.storage.ConnectorsGet(ctx, connectorID)
|
|
if err != nil {
|
|
if errors.Is(err, storage.ErrNotFound) {
|
|
return w.onDeletePlugin(ctx, connectorID)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// skip strict polling period validation if installed by another instance
|
|
_, _, err = w.connectors.Load(*connector, true, false)
|
|
if err != nil {
|
|
w.logger.Errorf("failed to register plugin after update to connector %q: %v", connector.ID.String(), err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (w *WorkerPool) onDeletePlugin(ctx context.Context, connectorID models.ConnectorID) error {
|
|
w.logger.Debugf("worker got delete notification for %q", connectorID.String())
|
|
w.stopWorker(GetPayoutTaskQueue(w.stack, connectorID))
|
|
w.connectors.Unload(connectorID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close is called when app is terminated
|
|
func (w *WorkerPool) Close() {
|
|
w.rwMutex.Lock()
|
|
defer w.rwMutex.Unlock()
|
|
|
|
for _, worker := range w.workers {
|
|
worker.worker.Stop()
|
|
}
|
|
}
|
|
|
|
func (w *WorkerPool) AddDefaultWorker() error {
|
|
return w.AddWorker(GetDefaultTaskQueue(w.stack))
|
|
}
|
|
|
|
// AddPayoutWorker creates a dedicated Temporal worker for payout and transfer
|
|
// workflows with TaskQueueActivitiesPerSecond set to payoutsPerSecond.
|
|
func (w *WorkerPool) AddPayoutWorker(name string, payoutsPerSecond float64) error {
|
|
w.rwMutex.Lock()
|
|
defer w.rwMutex.Unlock()
|
|
|
|
if _, ok := w.workers[name]; ok {
|
|
return nil
|
|
}
|
|
|
|
opts := w.options
|
|
opts.TaskQueueActivitiesPerSecond = payoutsPerSecond
|
|
|
|
wkr := worker.New(w.temporalClient, name, opts)
|
|
|
|
for _, set := range w.workflows {
|
|
for _, wf := range set {
|
|
wkr.RegisterWorkflowWithOptions(wf.Func, temporalworkflow.RegisterOptions{
|
|
Name: wf.Name,
|
|
})
|
|
}
|
|
}
|
|
|
|
for _, set := range w.activities {
|
|
for _, act := range set {
|
|
wkr.RegisterActivityWithOptions(act.Func, activity.RegisterOptions{
|
|
Name: act.Name,
|
|
})
|
|
}
|
|
}
|
|
|
|
go func() {
|
|
if err := wkr.Start(); err != nil {
|
|
w.logger.Errorf("payout worker loop stopped: %v", err)
|
|
}
|
|
}()
|
|
|
|
w.workers[name] = Worker{worker: wkr}
|
|
w.logger.Infof("payout worker %s started (%.2f activities/s)", name, payoutsPerSecond)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (w *WorkerPool) stopWorker(name string) {
|
|
w.rwMutex.Lock()
|
|
defer w.rwMutex.Unlock()
|
|
|
|
if wkr, ok := w.workers[name]; ok {
|
|
wkr.worker.Stop()
|
|
delete(w.workers, name)
|
|
}
|
|
}
|
|
|
|
// AddWorker instantiates a temporal worker
|
|
func (w *WorkerPool) AddWorker(name string) error {
|
|
w.rwMutex.Lock()
|
|
defer w.rwMutex.Unlock()
|
|
|
|
if _, ok := w.workers[name]; ok {
|
|
return nil
|
|
}
|
|
|
|
worker := worker.New(w.temporalClient, name, w.options)
|
|
|
|
for _, set := range w.workflows {
|
|
for _, workflow := range set {
|
|
worker.RegisterWorkflowWithOptions(workflow.Func, temporalworkflow.RegisterOptions{
|
|
Name: workflow.Name,
|
|
})
|
|
}
|
|
}
|
|
|
|
for _, set := range w.activities {
|
|
for _, act := range set {
|
|
worker.RegisterActivityWithOptions(act.Func, activity.RegisterOptions{
|
|
Name: act.Name,
|
|
})
|
|
}
|
|
}
|
|
|
|
go func() {
|
|
err := worker.Start()
|
|
if err != nil {
|
|
w.logger.Errorf("worker loop stopped: %v", err)
|
|
}
|
|
}()
|
|
|
|
w.workers[name] = Worker{
|
|
worker: worker,
|
|
}
|
|
|
|
w.logger.Infof("worker for connector %s started", name)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (w *WorkerPool) createSchedule(ctx context.Context, scheduleIDSuffix, workflowName string, interval time.Duration, errorMsg string) error {
|
|
scheduleID := fmt.Sprintf("%s-%s", w.stack, scheduleIDSuffix)
|
|
taskQueue := GetDefaultTaskQueue(w.stack)
|
|
|
|
stackAttr := sdktemporal.NewSearchAttributes(
|
|
sdktemporal.NewSearchAttributeKeyKeyword(workflow.SearchAttributeStack).ValueSet(w.stack),
|
|
)
|
|
|
|
// Create the schedule
|
|
_, err := w.temporalClient.ScheduleClient().Create(ctx, client.ScheduleOptions{
|
|
ID: scheduleID,
|
|
Spec: client.ScheduleSpec{
|
|
Intervals: []client.ScheduleIntervalSpec{
|
|
{
|
|
Every: interval,
|
|
},
|
|
},
|
|
},
|
|
Action: &client.ScheduleWorkflowAction{
|
|
ID: scheduleID,
|
|
Workflow: workflowName,
|
|
Args: []interface{}{}, // No arguments needed
|
|
TaskQueue: taskQueue,
|
|
TypedSearchAttributes: stackAttr,
|
|
},
|
|
Overlap: enums.SCHEDULE_OVERLAP_POLICY_SKIP,
|
|
TriggerImmediately: true,
|
|
TypedSearchAttributes: stackAttr,
|
|
})
|
|
|
|
if err != nil {
|
|
// When triggering immediately or if a workflow with the same ID already exists,
|
|
// Temporal may return either AlreadyExists (schedule exists) or
|
|
// WorkflowExecutionAlreadyStarted (the workflow action with same ID already exists),
|
|
// or the SDK sentinel error temporal.ErrScheduleAlreadyRunning when a schedule with the same ID
|
|
// is already registered. All these cases should be treated as success as the desired state is achieved.
|
|
var already *serviceerror.AlreadyExists
|
|
var wfAlreadyStarted *serviceerror.WorkflowExecutionAlreadyStarted
|
|
if errors.As(err, &wfAlreadyStarted) || errors.As(err, &already) {
|
|
// Workflow already started with the same ID, treat as success
|
|
return nil
|
|
}
|
|
if errors.Is(err, sdktemporal.ErrScheduleAlreadyRunning) {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("%s: %w", errorMsg, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (w *WorkerPool) HasWorker(name string) bool {
|
|
w.rwMutex.RLock()
|
|
defer w.rwMutex.RUnlock()
|
|
_, ok := w.workers[name]
|
|
return ok
|
|
}
|
|
|
|
func (w *WorkerPool) CreateOutboxPublisherSchedule(ctx context.Context) error {
|
|
return w.createSchedule(
|
|
ctx,
|
|
"outbox-publisher",
|
|
"OutboxPublisher",
|
|
w.outboxPollingPeriod,
|
|
"failed to create outbox publisher schedule",
|
|
)
|
|
}
|
|
|
|
func (w *WorkerPool) CreateOutboxCleanupSchedule(ctx context.Context) error {
|
|
return w.createSchedule(
|
|
ctx,
|
|
"outbox-cleanup",
|
|
"OutboxCleanup",
|
|
w.outboxCleanupPeriod,
|
|
"failed to create outbox cleanup schedule",
|
|
)
|
|
}
|
|
|
|
// SetSkipScheduleCreation sets whether to skip creating the outbox publisher schedule.
|
|
// Useful for tests that don't have a Temporal server available.
|
|
func (w *WorkerPool) SetSkipScheduleCreation(skip bool) {
|
|
w.skipScheduleCreation = skip
|
|
}
|