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 }