flamingo.me/flamingo-commerce/v3@v3.11.0/checkout/application/placeorder/coordinator.go (about)

     1  package placeorder
     2  
     3  import (
     4  	"context"
     5  	"encoding/gob"
     6  	"errors"
     7  	"fmt"
     8  	"net/url"
     9  	"time"
    10  
    11  	"go.opencensus.io/stats"
    12  	"go.opencensus.io/stats/view"
    13  	"go.opencensus.io/tag"
    14  	"go.opencensus.io/trace"
    15  
    16  	"flamingo.me/flamingo/v3/framework/flamingo"
    17  	"flamingo.me/flamingo/v3/framework/opencensus"
    18  	"flamingo.me/flamingo/v3/framework/web"
    19  
    20  	"flamingo.me/flamingo-commerce/v3/cart/application"
    21  	"flamingo.me/flamingo-commerce/v3/checkout/domain/placeorder/process"
    22  
    23  	cartDomain "flamingo.me/flamingo-commerce/v3/cart/domain/cart"
    24  )
    25  
    26  type (
    27  
    28  	// TryLocker port for a locking implementation
    29  	TryLocker interface {
    30  		// TryLock tries to get the lock for the provided key, if lock is already taken or couldn't be acquired function
    31  		// returns an error. If the lock could be acquired a unlock function is returned which should be called to release the lock.
    32  		// The provided duration is used in case that the node which required the lock dies so that the lock can released anyways.
    33  		// If the node stays alive the lock time is not restricted in any way.
    34  		TryLock(ctx context.Context, key string, maxLockDuration time.Duration) (Unlock, error)
    35  	}
    36  
    37  	// Unlock function to release the previously acquired lock, should be called within defer
    38  	Unlock func() error
    39  
    40  	// Coordinator ensures that certain parts of the place order process are only done once at a time
    41  	Coordinator struct {
    42  		locker         TryLocker
    43  		logger         flamingo.Logger
    44  		cartService    *application.CartService
    45  		processFactory *process.Factory
    46  		contextStore   process.ContextStore
    47  		sessionStore   *web.SessionStore
    48  		sessionName    string
    49  		area           string
    50  	}
    51  )
    52  
    53  // maxRunCount specifies the limit how often the coordinator should try to proceed in the state machine for a single call to Run / RunBlocking
    54  const maxRunCount = 100
    55  
    56  // waitForLockThrottle specifies the time to wait between attempts to get the lock for all blocking operations (cancel / runBlocking)
    57  const waitForLockThrottle = 50 * time.Millisecond
    58  
    59  var (
    60  	// ErrLockTaken to indicate the lock is taken (by another running process)
    61  	ErrLockTaken = errors.New("lock already taken")
    62  	// ErrNoPlaceOrderProcess if a requested process not running
    63  	ErrNoPlaceOrderProcess = errors.New("ErrNoPlaceOrderProcess")
    64  	// ErrAnotherPlaceOrderProcessRunning if a process runs
    65  	ErrAnotherPlaceOrderProcessRunning = errors.New("ErrAnotherPlaceOrderProcessRunning")
    66  
    67  	maxLockDuration = 2 * time.Minute
    68  
    69  	// startCount counts starts of new place order processes
    70  	startCount = stats.Int64("flamingo-commerce/checkout/placeorder/starts", "Counts how often a new place order process was started", stats.UnitDimensionless)
    71  )
    72  
    73  func init() {
    74  	gob.Register(process.Context{})
    75  	err := opencensus.View("flamingo-commerce/checkout/placeorder/starts", startCount, view.Sum())
    76  	if err != nil {
    77  		panic(err)
    78  	}
    79  
    80  	stats.Record(context.Background(), startCount.M(0))
    81  }
    82  
    83  // Inject dependencies
    84  func (c *Coordinator) Inject(
    85  	locker TryLocker,
    86  	logger flamingo.Logger,
    87  	processFactory *process.Factory,
    88  	contextStore process.ContextStore,
    89  	sessionStore *web.SessionStore,
    90  	cartService *application.CartService,
    91  	cfg *struct {
    92  		SessionName string `inject:"config:flamingo.session.name,optional"`
    93  		Area        string `inject:"config:area"`
    94  	},
    95  ) {
    96  	c.locker = locker
    97  	c.logger = logger.WithField(flamingo.LogKeyModule, "checkout").WithField(flamingo.LogKeyCategory, "placeorder")
    98  	c.processFactory = processFactory
    99  	c.contextStore = contextStore
   100  	c.sessionStore = sessionStore
   101  	c.cartService = cartService
   102  
   103  	if cfg != nil {
   104  		c.area = cfg.Area
   105  		c.sessionName = cfg.SessionName
   106  	}
   107  }
   108  
   109  // New acquires lock if possible and creates new process with first run call blocking
   110  // returns error if already locked or error during run
   111  func (c *Coordinator) New(ctx context.Context, cart cartDomain.Cart, returnURL *url.URL) (*process.Context, error) {
   112  	ctx, span := trace.StartSpan(ctx, "checkout/Coordinator/New")
   113  	defer span.End()
   114  
   115  	unlock, err := c.locker.TryLock(ctx, determineLockKeyForCart(cart), maxLockDuration)
   116  	if err != nil {
   117  		if err == ErrLockTaken {
   118  			return nil, ErrAnotherPlaceOrderProcessRunning
   119  		}
   120  		return nil, err
   121  	}
   122  	defer func() {
   123  		_ = unlock()
   124  	}()
   125  
   126  	var runErr error
   127  	var runPCtx *process.Context
   128  	web.RunWithDetachedContext(ctx, func(ctx context.Context) {
   129  		has, err := c.HasUnfinishedProcess(ctx)
   130  		if err != nil {
   131  			runErr = err
   132  			c.logger.Error(err)
   133  			return
   134  		}
   135  		if has {
   136  			runErr = ErrAnotherPlaceOrderProcessRunning
   137  			c.logger.Info(runErr)
   138  			return
   139  		}
   140  
   141  		censusCtx, _ := tag.New(ctx, tag.Upsert(opencensus.KeyArea, c.area))
   142  		stats.Record(censusCtx, startCount.M(1))
   143  		newProcess, err := c.processFactory.New(returnURL, cart)
   144  		if err != nil {
   145  			runErr = err
   146  			c.logger.Error(err)
   147  			return
   148  		}
   149  		pctx := newProcess.Context()
   150  		runPCtx = &pctx
   151  		err = c.storeProcessContext(ctx, pctx)
   152  		if err != nil {
   153  			runErr = err
   154  			c.logger.Error(err)
   155  			return
   156  		}
   157  
   158  		c.Run(ctx)
   159  	})
   160  
   161  	return runPCtx, runErr
   162  }
   163  
   164  // HasUnfinishedProcess checks for processes not in final state
   165  func (c *Coordinator) HasUnfinishedProcess(ctx context.Context) (bool, error) {
   166  	ctx, span := trace.StartSpan(ctx, "checkout/Coordinator/HasUnfinishedProcess")
   167  	defer span.End()
   168  
   169  	last, err := c.LastProcess(ctx)
   170  	if err == ErrNoPlaceOrderProcess {
   171  		return false, nil
   172  	}
   173  	if err != nil {
   174  		return true, err
   175  	}
   176  
   177  	currentState, err := last.CurrentState()
   178  	if err != nil {
   179  		return true, err
   180  	}
   181  
   182  	return !currentState.IsFinal(), nil
   183  }
   184  
   185  func (c *Coordinator) storeProcessContext(ctx context.Context, pctx process.Context) error {
   186  	ctx, span := trace.StartSpan(ctx, "checkout/Coordinator/storeProcessContext")
   187  	defer span.End()
   188  
   189  	session := web.SessionFromContext(ctx)
   190  	if session == nil {
   191  		return errors.New("session not available to check for last place order context")
   192  	}
   193  
   194  	return c.contextStore.Store(ctx, session.ID(), pctx)
   195  }
   196  
   197  func (c *Coordinator) clearProcessContext(ctx context.Context) error {
   198  	ctx, span := trace.StartSpan(ctx, "checkout/Coordinator/clearProcessContext")
   199  	defer span.End()
   200  
   201  	session := web.SessionFromContext(ctx)
   202  	if session == nil {
   203  		return errors.New("session not available to check for last place order context")
   204  	}
   205  
   206  	return c.contextStore.Delete(ctx, session.ID())
   207  }
   208  
   209  // LastProcess current place order process
   210  func (c *Coordinator) LastProcess(ctx context.Context) (*process.Process, error) {
   211  	ctx, span := trace.StartSpan(ctx, "checkout/Coordinator/LastProcess")
   212  	defer span.End()
   213  
   214  	session := web.SessionFromContext(ctx)
   215  	if session == nil {
   216  		return nil, errors.New("session not available to check for last place order context")
   217  	}
   218  	poContext, found := c.contextStore.Get(ctx, session.ID())
   219  	if !found {
   220  		return nil, ErrNoPlaceOrderProcess
   221  	}
   222  
   223  	p, err := c.processFactory.NewFromProcessContext(poContext)
   224  	if err != nil {
   225  		return nil, err
   226  	}
   227  
   228  	return p, nil
   229  }
   230  
   231  // Cancel the process if it exists (blocking)
   232  // be aware that all rollback callbacks are executed
   233  func (c *Coordinator) Cancel(ctx context.Context) error {
   234  	ctx, span := trace.StartSpan(ctx, "checkout/Coordinator/Cancel")
   235  	defer span.End()
   236  
   237  	var returnErr error
   238  	web.RunWithDetachedContext(ctx, func(ctx context.Context) {
   239  		{
   240  			// scope things here to avoid using old process later
   241  			p, err := c.LastProcess(ctx)
   242  			if err != nil {
   243  				returnErr = err
   244  				return
   245  			}
   246  			var unlock Unlock
   247  			err = ErrLockTaken
   248  			for err == ErrLockTaken {
   249  				unlock, err = c.locker.TryLock(ctx, determineLockKeyForProcess(p), maxLockDuration)
   250  				// todo: add proper throttling
   251  
   252  				time.Sleep(waitForLockThrottle)
   253  			}
   254  			if err != nil {
   255  				returnErr = err
   256  				return
   257  			}
   258  			defer func() {
   259  				_ = unlock()
   260  			}()
   261  		}
   262  
   263  		// lock acquired get fresh process state
   264  		p, err := c.LastProcess(ctx)
   265  		if err != nil {
   266  			returnErr = err
   267  			return
   268  		}
   269  
   270  		currentState, err := p.CurrentState()
   271  		if err != nil {
   272  			returnErr = err
   273  			return
   274  		}
   275  
   276  		if currentState.IsFinal() {
   277  			err = errors.New("process already in final state, cancel not possible")
   278  			returnErr = err
   279  			return
   280  		}
   281  
   282  		p.Failed(ctx, process.CanceledByCustomerReason{})
   283  		err = c.storeProcessContext(ctx, p.Context())
   284  		if err != nil {
   285  			returnErr = err
   286  		}
   287  	})
   288  	return returnErr
   289  }
   290  
   291  // ClearLastProcess removes last stored process
   292  func (c *Coordinator) ClearLastProcess(ctx context.Context) error {
   293  	ctx, span := trace.StartSpan(ctx, "checkout/Coordinator/ClearLastProcess")
   294  	defer span.End()
   295  
   296  	var returnErr error
   297  	web.RunWithDetachedContext(ctx, func(ctx context.Context) {
   298  		err := c.clearProcessContext(ctx)
   299  		if err != nil {
   300  			returnErr = err
   301  		}
   302  	})
   303  	return returnErr
   304  }
   305  
   306  // Run starts the next processing if not already running
   307  // Run is NOP if the process is locked
   308  // Run returns immediately
   309  func (c *Coordinator) Run(ctx context.Context) {
   310  	go func(ctx context.Context) {
   311  		ctx, span := trace.StartSpan(ctx, "checkout/Coordinator/Run")
   312  		defer span.End()
   313  
   314  		web.RunWithDetachedContext(ctx, func(ctx context.Context) {
   315  			has, err := c.HasUnfinishedProcess(ctx)
   316  			if err != nil || !has {
   317  				return
   318  			}
   319  
   320  			p, err := c.LastProcess(ctx)
   321  			if err != nil {
   322  				c.logger.Error("no last process on run: ", err)
   323  				return
   324  			}
   325  
   326  			unlock, err := c.locker.TryLock(ctx, determineLockKeyForProcess(p), maxLockDuration)
   327  			if err != nil {
   328  				return
   329  			}
   330  			defer func() {
   331  				_ = unlock()
   332  			}()
   333  
   334  			p, err = c.LastProcess(ctx)
   335  			if err != nil {
   336  				c.logger.Error("no last process on run: ", err)
   337  				return
   338  			}
   339  
   340  			err = c.proceedInStateMachineUntilNoStateChange(ctx, p)
   341  			if err != nil {
   342  				c.logger.Error("proceeding in state machine failed: ", err)
   343  				return
   344  			}
   345  		})
   346  	}(ctx)
   347  }
   348  
   349  func (c *Coordinator) proceedInStateMachineUntilNoStateChange(ctx context.Context, p *process.Process) error {
   350  	ctx, span := trace.StartSpan(ctx, "checkout/Coordinator/proceedInStateMachineUntilNoStateChange")
   351  	defer span.End()
   352  
   353  	stateBeforeRun := p.Context().CurrentStateName
   354  	for i := 0; i < maxRunCount; i++ {
   355  
   356  		p.Run(ctx)
   357  		err := c.storeProcessContext(ctx, p.Context())
   358  		if err != nil {
   359  			return err
   360  		}
   361  		c.forceSessionUpdate(ctx)
   362  		stateAfterRun := p.Context().CurrentStateName
   363  		if stateBeforeRun == stateAfterRun {
   364  			return nil
   365  		}
   366  		stateBeforeRun = stateAfterRun
   367  	}
   368  
   369  	p.Failed(ctx, process.ErrorOccurredReason{
   370  		Error: fmt.Sprintf("max run count %d of state machine reached", maxRunCount),
   371  	})
   372  	return nil
   373  }
   374  
   375  // RunBlocking waits for the lock and starts the next processing
   376  // RunBlocking waits until the process is finished and returns its result
   377  func (c *Coordinator) RunBlocking(ctx context.Context) (*process.Context, error) {
   378  	ctx, span := trace.StartSpan(ctx, "checkout/Coordinator/RunBlocking")
   379  	defer span.End()
   380  
   381  	var pctx *process.Context
   382  	var returnErr error
   383  	web.RunWithDetachedContext(ctx, func(ctx context.Context) {
   384  		{
   385  			// scope things here to avoid continuing with an old process state
   386  			p, err := c.LastProcess(ctx)
   387  			if err != nil {
   388  				returnErr = err
   389  				return
   390  			}
   391  
   392  			var unlock Unlock
   393  			err = ErrLockTaken
   394  			for err == ErrLockTaken {
   395  				unlock, err = c.locker.TryLock(ctx, determineLockKeyForProcess(p), maxLockDuration)
   396  				// todo: add proper throttling
   397  				time.Sleep(waitForLockThrottle)
   398  			}
   399  			if err != nil {
   400  				returnErr = err
   401  				return
   402  			}
   403  
   404  			defer func() {
   405  				_ = unlock()
   406  			}()
   407  		}
   408  
   409  		// lock acquired fetch everything new
   410  		has, err := c.HasUnfinishedProcess(ctx)
   411  		if err != nil {
   412  			returnErr = err
   413  			return
   414  		}
   415  
   416  		p, err := c.LastProcess(ctx)
   417  		if err != nil {
   418  			returnErr = err
   419  			return
   420  		}
   421  
   422  		if !has {
   423  			lastPctx := p.Context()
   424  			pctx = &lastPctx
   425  			return
   426  		}
   427  
   428  		// Load the most recent session, as we could have waited quite a while for the TryLock.
   429  		session, err := c.sessionStore.LoadByID(ctx, web.SessionFromContext(ctx).ID())
   430  		if err != nil {
   431  			returnErr = err
   432  			return
   433  		}
   434  
   435  		ctx = web.ContextWithSession(ctx, session)
   436  
   437  		err = c.proceedInStateMachineUntilNoStateChange(ctx, p)
   438  		if err != nil {
   439  			returnErr = err
   440  			return
   441  		}
   442  		runPctx := p.Context()
   443  		pctx = &runPctx
   444  	})
   445  
   446  	return pctx, returnErr
   447  }
   448  
   449  func (c *Coordinator) forceSessionUpdate(ctx context.Context) {
   450  	ctx, span := trace.StartSpan(ctx, "checkout/Coordinator/forceSessionUpdate")
   451  	defer span.End()
   452  
   453  	session := web.SessionFromContext(ctx)
   454  	_, err := c.sessionStore.Save(ctx, session)
   455  	if err != nil {
   456  		c.logger.Error(err)
   457  	}
   458  }
   459  
   460  func determineLockKeyForCart(cart cartDomain.Cart) string {
   461  	return "checkout_placeorder_lock_" + cart.ID
   462  }
   463  
   464  func determineLockKeyForProcess(p *process.Process) string {
   465  	return "checkout_placeorder_lock_" + p.Context().Cart.ID
   466  }