Canceling Abandoned Orders with Laravel Queue

Canceling Abandoned Orders with Laravel Queue

Trong cuốn sách Laravel Queues in Action của Mohamed Said (một trong những thành viên trong core team Laravel), có một section rất hay về kỹ thuật xử lý các Đơn hàng bị bỏ rơi (Abandoned Orders), mình xin chia sẻ kỹ thuật ấy trong bài này, nếu bạn thấy hay có thể mua ủng hộ tác giả quyển sách trên, trong đó có chia sẻ rất nhiều các kỹ thuật hay khi làm việc với Queues trong Laravel, rất đáng để các bạn tham khảo. Sau đây là nội dung của section ấy.

Trong lúc mua hàng, đôi khi người dùng chưa kịp hoàn tất đơn hàng, hoặc là để thanh toán sau nhằm mục đích giữ lại các món hàng này lại, nhưng nếu người dùng ấy quên mất thì đơn hàng ấy sẽ ra sao? Chúng sẽ chẳng bao giờ được hủy hoặc được hoàn thành, những đơn như thế này sẽ thành rác trong hệ thống. Bạn cần xử lý chúng, để trả lại các món hàng đang bị giữ vào lại kho.

Để giải quyết vấn đề trên, chúng ta sẽ lên lịch trình cho một job, và job này sẽ được thực thi kể từ khi người dùng checkout đơn hàng. Job ấy sẽ thực hiện việc kiểm tra trạng thái đơn hàng sau một giờ kể từ khi đơn hàng được checkout và sẽ hủy đơn ấy nếu như chúng chưa được hoàn thành.

Tạm hoãn thực thi của một job

Chúng ta bắt đầu bằng việc làm thế nào 1 job được hoãn thực thi bên trong một controller:

class CheckoutController
{
    public function store()
    {
        $order = Order::create([
            'status' => Order::PENDING,
            // ...
        ]);

        MonitorPendingOrder::dispatch($order)->delay(3600);
    }
}

Bằng phương thức chaining delay(3600) từ phương thức dispatch(), thì job MonitorPendingOrder sẽ được thực thi sau 3600 giây, tức là worker sẽ chỉ thực thi job này sau 1 giờ.

Ngoài việc dùng số giây, bạn cũng có thể truyền 1 đối tượng dùng interface DateTimeInterface:

MonitorPendingOrder::dispatch($order)->delay(
    now()->addHour()
);
Chú ý: Khi sử dụng driver SQS, bạn chỉ có thể hoãn job sau 15 phút. Nếu bạn muốn hoãn lâu hơn, bạn cần hoãn job lần đầu 15 phút trước, sau đó bạn dùng phương thức release() để giải phóng job đấy trở lại hàng đợi. Thêm một thông tin nữa là SQS chỉ lưu job trong 24 giờ kể từ khi chúng được đưa vào hàng đợi.

Tiếp tục, ta xem job trên sẽ làm gì khi được thực thi:

public function handle()
{
    if ($this->order->status == Order::CONFIRMED ||
        $this->order->status == Order::CANCELED) {
        return;
    }

    $this->order->markAsCanceled();
}

Khi job được thực thi (sau 1 giờ kể từ khi đưa vào hàng đợi), chúng sẽ kiểm tra trạng thái đơn hàng, nếu như đơn hàng đã được xác nhận hoặc được hủy, thì coi như hoàn tất, chúng ta sẽ return để dừng tác vụ. Lúc này worker sẽ cho rằng job đã hoàn tất  và xóa chúng ra khỏi hàng đợi. Ngược lại, đơn hàng ấy sẽ chuyển sang trạng thái hủy bằng phương thức markAsCanceled() (bạn tự hình dung logic bên trong đấy).

Nhắc người dùng về những đơn hàng này

Xử lý như trên thì cũng ổn rồi, nhưng sẽ tuyệt vời hơn nếu chúng ta gửi một lời nhắc cho người dùng trước khi hủy đơn hàng đó. Hãy hình dung như sau, chúng ta sẽ gửi SMS hoặc Email đến cho người dùng ấy về việc các đơn hàng đang chờ được xử lý cứ mỗi sau 15 phút, nếu 3 lần như vậy mà khách hàng vẫn bỏ qua, chúng ta mới tiến hành hủy đơn. Chúng ta sẽ thực hiện như thế nào?

OK, để làm việc đó, thì đầu tiên, thay vì hoãn 1 giờ, ta sẽ hoãn tác vụ xuống còn 15 phút:

MonitorPendingOrder::dispatch($order)->delay(
    now()->addMinutes(15)
);

Lúc thực thi, ta sẽ thay đổi tí về logic xử lý như sau: vẫn kiểm tra trước là đơn hàng đó nếu như đã xác nhận hoặc hủy rồi thì thôi - dừng tác vụ, tiếp theo nếu đơn hàng lâu hơn 59 phút (1 giờ) thì hủy đơn, nhưng nếu chưa thì ta sẽ tiến hành gửi thông báo nhắc nhở, sau đó sẽ giải phóng job hiện tại trở lại hàng đợi sau 15 phút, và trình tự này sẽ được lặp lại cho đến khi tác vụ dừng, không còn nhắc nhở nữa.

public function handle()
{
    if ($this->order->status == Order::CONFIRMED ||
        $this->order->status == Order::CANCELED) {
        return;
    }
    
    if ($this->order->olderThan(59, 'minutes')) {
        $this->order->markAsCanceled();
        return;
    }

    SMS::send(...);
    // Or email notification...
    
    $this->release(
        now()->addMinutes(15)
    );
}

Như có đề cập ở các phần trên, phương thức release() trong job đang thực thi nó có tác dụng như phương thức delay() trong lúc thực thi/dispatching. Job đó sẽ được giải phóng đưa vào hàng đợi lại sau khoảng thời gian nhất định, lúc đó các worker sẽ thực hiện tại job đó sau khoảng thời gian đó.

Đảm bảo job thực hiện đúng số lần nhất định

Cứ mỗi lần job giải phóng ngược vào hàng đợi, số lần được thực thi sẽ được đếm, chúng ta cần chắc chắn rằng số lần thực hiện đạt đúng như ta mong đợi, tránh việc chúng ta cần job thực hiện tối đa 4 lần mà trong khi worker chỉ cho phép chạy 3 lần. Để làm được việc này, ta cần thay đổi giá trị của thuộc tính $tries thành 4.

class MonitorPendingOrder implements ShouldQueue
{
    public $tries = 4;
}

Job lúc này sẽ được chạy lại 4 lần:

15 minutes after checkout
30 minutes after checkout
45 minutes after checkout
60 minutes after checkout

Nếu người dùng xác nhận đơn hoặc hủy đơn trong 20 phút, job sẽ được xóa sau lần chạy thứ 2 (30 phút) mà không hề có SMS hay Email gì gửi đến khách hàng. Việc này được xử lý bởi những dòng sau:

if ($this->order->status == Order::CONFIRMED ||
    $this->order->status == Order::CANCELED) {
    return;
}

Lưu ý về việc tạm hoãn job

Không có gì đảm bảo worker sẽ nhận công việc chính xác sau khi thời gian hoãn trôi qua. Nếu như các worker đều đang bận, thì MonitorPendingOrder sẽ không đảm bảo rằng chúng sẽ gửi đúng 3 lời nhắc nhở đến khác hàng trước khi đơn hàng bị hủy.

Để tăng cơ hội các job bị hoãn của bạn được xử lý đúng hạn, bạn cần đảm bảo rằng bạn có đủ worker để làm trống hàng đợi càng nhanh càng tốt. Bằng cách này, vào thời điểm công việc có sẵn, một worker process sẽ sẵn sàng để chạy nó.