Broadleaf Microservices
  • v1.0.0-latest-prod

Managing Subscription Locks and Sandbox Changes

The Broadleaf Subscription framework utilizes a robust locking mechanism combined with a sandbox (draft) state to ensure consistency and prevent race conditions when modifying subscriptions. This document outlines how locks are established, how child-lock tokens are utilized in distributed workflows, and how sandbox changes are ultimately committed or reverted.

Establishing a Lock During Checkout

When a customer initiates a checkout that modifies an existing subscription or creates a new one, the framework ensures exclusive access.

Cart Validation (CartSubscriptionLockActivity)

In CartOperationService, the CartSubscriptionLockActivity is executed early in the cart checkout workflow. It inspects cart items for references to existing subscriptions (EXISTING_SUBSCRIPTION_ID). For every existing subscription found, it attempts to acquire a lock via the SubscriptionOpsProvider. If a subscription is already locked by another process (e.g., a recurring billing job), the lock request is rejected. The activity fails fast and throws a CheckoutSubscriptionLockException, actively blocking the checkout submission, as another process acts upon the subscription.

Order Creation (CheckoutCompletionListener)

Upon successful cart checkout, the CheckoutCompletionListener in OrderOperationServices handles lock management before executing the final fulfillment workflows.

Before generating workflows, the system processes fulfillments and subscriptions:

  • Fulfillment Splitting: DefaultCartOrderFulfillmentGenerationService#splitFulfillmentsByFulfillmentWorkflow splits the generated OrderFulfillments based on the fulfillmentWorkflow specified on each CartItem. This ensures that items destined for different workflows (e.g., standard checkout vs. a subscription creation workflow) are grouped into distinct fulfillments.

  • New Subscriptions: CheckoutCompletionListener#tryCreateSubscriptions identifies items requiring new subscriptions and creates them.

  • Lock Management:

    • For Newly Created Subscriptions: It acquires a new top-level lock on the subscription and reserves child-lock tokens (representing the upcoming workflow executions).

    • For Existing Subscriptions: Since the top-level lock was already acquired by the CartSubscriptionLockActivity, it only needs to generate and reserve child-lock tokens for the workflows.

Each distinct OrderFulfillment corresponding to a subscription action is then associated with its respective subscription and child-lock token before being passed to the workflow engine via WorkflowGenerationService.

The name of the workflow that is started is determined based on the fulfillmentWorkflow OrderItem attribute (and potentially combined with the subscriptionActionFlow attribute on the Order). The resulting payload context provided to the workflow looks similar to:

{
  "Order.id": "ORDER_ID",
  "OrderFulfillment.id": "FULFILLMENT_ID",
  "Subscription.id": "SUBSCRIPTION_ID",
  "LockStatus.lockId": "CHILD_LOCK_TOKEN",
  "Application.id": "APPLICATION_ID",
  "Tenant.id": "TENANT_ID",
  "Order.submitDate": "2026-04-06T00:00:00Z"
}

Passing Lock Tokens and Publishing Sandbox Changes

Subscription lock tokens are produced prior to triggering the workflow, and they are held during the execution of the workflow to ensure that no other operation can act upon the subscription concurrently.

While the subscription is locked, Broadleaf makes subscription modifications within a sandboxed state to ensure the production version remains untouched until the entire process finishes successfully.

  1. Passing Tokens: In CheckoutCompletionListener, the generated lock token is assigned to the workflow request (request.setLockToken(token)). This token is then injected into the workflow execution context (e.g., mapped to WorkflowParams.getKey(LockStatus.class, "lockId")).

  2. Creating the Sandbox Context: During the operation, changes are executed with a specific ContextInfo that is "sandbox-aware". The sandbox context is identified by a specific constant sandbox name and ID (e.g., "EDIT" defined in SubscriptionUtils), which isolates the modifications into a temporary change container linked to the current tenant and application.

  3. Releasing Tokens: Workflows modifying the subscription conclude with DefaultReleaseSubscriptionLockActivity. This activity takes the token from the context and calls subscriptionOpsProvider.unlockToken(subscriptionId, lockToken, true) to release the child lock.

Individual Tokens vs. Full Lock Release and Publishing

When unlockToken is called, it removes the specific child lock reservation. * If there are other active child locks remaining, the top-level subscription remains LOCKED. * If it is the last active child lock and the propagate flag is true, releasing the token will also release the full, top-level lock on the subscription, transitioning it to UNLOCKED.

When the top-level lock is completely released (either via clearLock or the final unlockToken with propagation), the underlying persistence layer (JpaCustomizedSubscriptionLockRepository) fires a LockPurgedEvent.

The LockPurgeHandler in BillingServices listens for this event and acts on the sandbox context: * Commit: If the lock was released successfully, the handler takes the sandboxed subscription changes, publishes them, and promotes the changes from the "EDIT" sandbox to the live production state. * Cleanup: Once the changes are promoted (or if the lock is reverted), the temporary sandbox records and any intermediate drafts for that context are cleaned up.

Workflow Failures and Lock Reversions

If an error occurs during workflow generation or execution (e.g., in CheckoutCompletionListener), the system catches the exception and actively cleans up any acquired locks by calling subscriptionGenerationService.clearLock(subscriptionId, false) or passing an isRevert=true flag.

When clearLock triggers the LockPurgedEvent with the revert flag set, the LockPurgeHandler discards the sandboxed state entirely. The uncommitted changes are rolled back, and the subscription reverts to its original, unmodified production state, guaranteeing data integrity.

Locking in Other System Processes

The lock mechanism is not exclusive to user checkout. It is a fundamental guardrail used across the system: * Recurring Billing: When a scheduled job bills a subscription, it acquires a lock, bills the customer, and clears the lock. * Applying Price Changes: Updating pricing structures acquires a lock to mutate the subscription in a sandbox before committing. * Auto-Renewal and CSR Edits: Handlers like ChangeSubscriptionAutoRenewalHandler or CSR adjustment workflows always lock the subscription, make their changes, and clear the lock.

In all these scenarios, acquiring the lock prevents concurrent actions—such as a customer trying to check out a modification while their recurring billing is processing.

Configuration Properties

Subscription locking behavior can be customized via Spring properties to handle edge cases, such as releasing "stuck" locks through expiration policies.

These configurations are defined in SubscriptionLockProperties:

Property Default Value Description

lock.subscription.subscription-lock-expiration-enabled

false

Whether subscription lock expiration is enabled. When enabled, the framework will assign an expiration timestamp upon lock creation. If a lock request fails due to an existing lock, the system will attempt to "take over" the lock if the existing lock has expired.

lock.subscription.subscription-lock-expiration

24h

The duration of time that a subscription lock is valid for. After expiration, a lock may be vacated and taken over by another process (if expiration is enabled).

lock.subscription.min-subscription-lock-poll-interval

24h

The minimum interval between polling attempts for evaluating subscription locks.

lock.subscription.max-subscription-lock-poll-interval

26h

The maximum interval between polling attempts for evaluating subscription locks.