TIL: Atomic Locks with Laravel Batch Jobs

I am writing this to gain a comprehensive understanding of the concepts of raise conditions and atomic locks.

Raise conditions occur when two or more processes attempt to modify the same resource simultaneously. This is a situation we want to avoid, particularly when dealing with fund transfers.

To prevent such issues, we employ the atomic lock pattern, which ensures that only one process can access and modify a resource at any given time.

In the context of processing payouts, we dispatch a job through ConfirmationController.php to handle the payout processing.

Bus::batch($this->accountTransactionJobs($payout))
            ->then(fn () => PayoutProcessed::dispatch($payout))
            ->name("Process Payout #{$payout->id}")
            ->dispatch();

private function accountTransactionJobs(Payout $payout): array
    {
        return $payout
            ->transactions
            ->groupBy('lineItem.order.account_id')
            ->map(fn (Collection $transactions) => new BatchProcessPayouts($transactions))
            ->toArray();
    }

class BatchProcessPayouts implements ShouldQueue
{
    use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * @param  Collection<PayoutTransaction>  $transactions
     */
    public function __construct(public Collection $transactions)
    {
    }

    public function handle(): void
    {
        Bus::batch($this->jobs())
            ->then($this->sendAccountOwnersOfPayout())
            ->dispatch();
    }

    private function jobs(): array
    {
        return $this
            ->transactions
            ->map(fn (PayoutTransaction $transaction) => new ProcessTransaction($transaction))
            ->toArray();
    }
}

Now, at this stage, we implement a locking mechanism for the payout transactions.

class ProcessTransaction implements ShouldQueue
{
    use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(private PayoutTransaction $transaction)
    {
        $this->transaction->load('lineItem.order');
    }

    public function handle(): void
    {
        if ($this->transaction->status->isLocked()) {
            return;
        }

        $this->transaction->update([
            'status' => 'processing',
            'processing_at' => Carbon::now(),
        ]);

        try {
            $transfer = Transfer::create([
                'amount' => $this->transaction->amount_in_cents,
                'currency' => 'usd',
                'destination' => $this->transaction->lineItem->order->account->stripe_token,
                'transfer_group' => "Payout (#{$this->transaction->payout->id}) for Order #{$this->transaction->lineItem->order->id}",
            ]);

            $this->transaction->update([
                'stripe_transaction_id' => $transfer->id,
                'stripe_json_response' => json_encode($transfer->toArray()),
                'status' => 'success',
            ]);
        } catch (ApiErrorException $error) {
            $this->transaction->update([
                'stripe_json_response' => $error->getJsonBody(),
                'status' => 'failed',
            ]);
        }
    }
}

Take note that we have an early condition statement, $this->transaction->status->isLocked(), to check if the payout is currently locked. If the payout is not locked, the transaction process will commence. This method proves to be highly valuable in preventing duplicate payouts.