Files
CrimsonandGitHub db0561d07e fix(column): Various fixes for Column Payments (#431)
* Set webhook ID as payment adjustment ID when adjustment created via webhook

* Fetch Column payments oldest to newest

* Ensure that webhook secrets are fetched when containers restart

* Regenerate plugin code for tests

* Add paging to fetch webhook request

* Add WorkerPool unit test to increase test coverage

* Guard against unhandled webhooks in Column plugin initiation

* Rename Timeline.LatestID to Timeline.LastSeenID for clarity

* Add metadata column to webhooks_configs table

* Persist webhook_configs.metadata

* Persist Column webhook secrets as metadata in webhooks_configs

* Remove PSPWebhookConfig from InstallResponse

* Stop passing context to initWebhookConfig()

* Don't pass context to RegisterPlugin func

* Don't pass context to plugins.RegisterPlugin
2025-05-12 09:24:59 +00:00

295 lines
7.3 KiB
Go

package engine
import (
"context"
"encoding/json"
"fmt"
"sync"
"github.com/formancehq/go-libs/v3/bun/bunpaginate"
"github.com/formancehq/go-libs/v3/logging"
"github.com/formancehq/go-libs/v3/temporal"
"github.com/formancehq/payments/internal/connectors/engine/plugins"
"github.com/formancehq/payments/internal/models"
"github.com/formancehq/payments/internal/storage"
"github.com/pkg/errors"
"go.temporal.io/sdk/activity"
"go.temporal.io/sdk/client"
"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
plugins plugins.Plugins
options worker.Options
}
type Worker struct {
worker worker.Worker
}
func NewWorkerPool(
logger logging.Logger,
stack string,
temporalClient client.Client,
workflows,
activities []temporal.DefinitionSet,
storage storage.Storage,
plugins plugins.Plugins,
options worker.Options,
) *WorkerPool {
workers := &WorkerPool{
logger: logger,
stack: stack,
temporalClient: temporalClient,
workers: make(map[string]Worker),
workflows: workflows,
activities: activities,
storage: storage,
plugins: plugins,
options: options,
}
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(
bunpaginate.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 = bunpaginate.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
}
}
return nil
}
func (w *WorkerPool) onStartPlugin(connector models.Connector) error {
// 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.
config := models.DefaultConfig()
if err := json.Unmarshal(connector.Config, &config); err != nil {
return err
}
err := w.plugins.RegisterPlugin(connector.ID, connector.Provider, connector.Name, config, connector.Config, false)
if err != nil {
w.logger.Errorf("failed to register plugin: %w", err)
// 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
}
if !connector.ScheduledForDeletion {
err = w.AddWorker(GetDefaultTaskQueue(w.stack))
if 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
}
config := models.DefaultConfig()
if err := json.Unmarshal(connector.Config, &config); err != nil {
return err
}
if err := w.plugins.RegisterPlugin(connector.ID, connector.Provider, connector.Name, config, connector.Config, false); 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
}
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
}
if connector.ScheduledForDeletion {
if err := w.RemoveWorker(connectorID.String()); err != nil {
return err
}
// if we're deleting the plugin no other changes matter
return nil
}
config := models.DefaultConfig()
if err := json.Unmarshal(connector.Config, &config); err != nil {
return err
}
err = w.plugins.RegisterPlugin(connector.ID, connector.Provider, connector.Name, config, connector.Config, true)
if err != nil {
w.logger.Errorf("failed to register plugin after update to connector %q: %w", 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.plugins.UnregisterPlugin(connectorID)
if err := w.RemoveWorker(connectorID.String()); err != nil {
return err
}
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))
}
// Installing a new connector lauches a new worker
// A default one is instantiated when the workers struct is created
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
}
// Uninstalling a connector stops the worker
func (w *WorkerPool) RemoveWorker(name string) error {
w.rwMutex.Lock()
defer w.rwMutex.Unlock()
worker, ok := w.workers[name]
if !ok {
return nil
}
worker.worker.Stop()
delete(w.workers, name)
w.logger.Infof("worker for connector %s removed", name)
return nil
}