วิธี Run Job หลังจาก Commit Database Transaction แล้วเท่านั้นใน Laravel

12 กุมภาพันธ์ 2564
วิธี Run Job หลังจาก Commit Database Transaction แล้วเท่านั้นใน Laravel

การใช้ Database Transaction เป็นเครื่องมือที่ทำให้เรามั่นใจได้เรื่อง Data Integrity ว่าข้อมูลจะไม่ถูกบันทึกลง Database แบบครึ่ง ๆ กลาง ๆ กรณีที่มี Error เกิดขึ้น ตั้งแต่ แต่หากใน Transaction เรามีการ run job ไปแล้วก่อนที่จะเกิด Error Job นั้นอาจถูกทำงานทั้ง ๆ ที่ยังไม่ควรจะทำ ตั้งแต่ Laravel 8.19.0 เป็นต้นไปได้เพิ่มการป้องกันลักษณะนี้ไว้แล้วด้วยการระบุ property $afterCommit ไว้ที่ Job หรือ Queue ที่เราต้องการให้ทำงานหลังจาก Transaction เสร็จสมบูรณ์แล้วเท่านั้น

Database Transaction

ลองดูตัวอย่างจาก Code ต่อไปนี้

$user = User::create([...]);

Team::create([
    'owner_id' => $user->id,
    ...
]);

ถ้าหากว่าสร้าง Team ไม่สำเร็จ User จะถูกสร้างขึ้นโดยไม่ขึ้นกับ Team ใด ๆ ถ้าหากเราไม่ต้องการให้เกิดเหตุการณ์แบบนี้ในระบบของเรา เราสามารถใช้ Database Transaction เพื่อป้องกันได้แบบนี้

DB::transaction(function(){
    $user = User::create([...]);

    Team::create([
        'owner_id' => $user->id,
        ...
    ]);
});

ทีนี้ถ้าสร้าง Team ไม่สำเร็จ Transaction จะถูก rollback รวมทั้งคำสั่งที่สร้าง User ในตอนต้นด้วย

แต่ Database Transaction จะคลอบคลุมเฉพาะ Database Query เท่านั้น ถ้าหากเรามีการประมวลผลอย่างอื่น เช่น มีการส่ง Mail ระหว่าง Transaction ก็อาจทำให้เกิดการส่ง Mail ออกไปก่อนทั้ง ๆ ที่มี error หลังจากนั้น

DB::transaction(function(){
    $user = User::create([...]);

    Mail::to($user)->send(new WelcomeEmail());

    Team::create([
        'owner_id' => $user->id,
        ...
    ]);
    // ถ้า code มีการ error หลังจาก Mail::send Team จะมี WelcomeEmail ส่งออกไปแล้ว
});

จากตัวอย่างจะมี WelcomeEmail ส่งออกไปแล้วแม้ว่าถ้าสร้าง Team ไม่สำเร็จและไม่มี User ใน Database เพราะ Transaction ถูก rollback ไปแล้ว

วิธีการแก้ง่าย ๆ วิธีนึงก็คือทำการส่งเมล์ภายนอก Database Transaction

$user = DB::transaction(function(){
    $user = User::create([...]);

    Team::create([
        'owner_id' => $user->id,
        ...
    ]);
    
    return $user;
});

Mail::to($user)->send(new WelcomeEmail());

จากตัวอย่างด้านบนถ้ามีการ rollback transaction จะเกิด Exception ขึ้นและ Mail จะไม่ถูกส่งออกไป แต่ในบางที Code ที่ทำงานเกี่ยวกับ Job อาจไม่ได้ถูกเขียนใน Database Transaction โดยตรง Code ยังอาจถูก run ผ่าน Listener ได้ เช่น ถ้าในคลาส Model User ข้างต้นมีการผูก Listener UserCreated ไว้ให้ทำการส่ง WelcomeEmail หลังจาก User::create() แบบนี้ Email ก็จะถูกส่งออกไปอยู่ดีก่อนที่ Transaction จะถูก commit

ตั้งแต่ Laravel 8.19.0 เป็นต้นไปได้มีการแก้ปัญหานี้ โดยเพิ่มวิธีการเซ็ทให้ run job หลังจาก commit database transaction แล้วเท่านั้นไว้ 3 วิธี ดังนี้

  • เมธอด DB::afterCommit()
  • config 'after_commit' ใน Queue connection
  • เรียกเมธอด afterCommit() หลังจาก Dispatch Job

เมธอด DB::afterCommit()

เราสามารถคลุมการเรียก Job ไว้ในฟังก์ชัน DB::afterCommit() ได้โดย code ข้างในจะถูกทำงานเมื่อ Database Transaction ได้ถูก commit เรียบร้อยทั้งหมดแล้วเท่านั้น ไม่ว่าจะมี Transaction คลุมกี่ชั้นก็ตาม

ลองดูตัวอย่างจาก Listener SendWelcomeEmail ดังนี้

class SendWelcomeEmail{
    public function handle()
    {
        DB::afterCommit(function(){
            Mail::to($user)->send(new WelcomeEmail());
        });
    }    
}

ถ้า User ถูกสร้างขึ้นและมีการผูก Event เอาไว้แล้ว ใน Listener มีการเรียก เมธอด afterCommit โดย code ภายในนั้นจะถูกเก็บอยู่ใน Local Cache ของ framework และจะถูกทำงานหลังจากที่ Database Transaction ถูก commit ทั้งหมดแล้วเท่านั้น

อีกตัวอย่างหนึ่งที่อาจจะได้พบบ่อย ๆ คือการ Dispatch Queue job, Mail, Notification, Broadcast Event หรือ Listener จากภายใน Database Transaction บางครั้ง Worker อาจจะทำงานก่อนที่ Transaction จะทำการ commit ก็เป็นได้ถ้าหลังจากที่ dispatch job ไปแล้วยังมี logic ที่ต้องประมวลผลเยอะโดยที่ข้อมูลอาจอยู่ในสถานะที่ยังไม่ควรนำไป run job ได้ เช่น

DB::transaction(function(){
    $user = User::create([...]);

    SendWelcomeEmail::dispatch($user);

    Team::create([
        'owner_id' => $user->id,
        ...
    ]);

    // logic อื่น ๆ ที่มีอาจมีการประมวลผลนาน
    sleep(10);
});

กรณีนี้ Job SendWelcomeEmail อาจจะถูก dispatch ลง Queue และอาจจะถูกทำงานก่อนที่ transaction จะทำการ commit ทำให้ยังไม่มี User ถูกสร้างขึ้นจริงใน Database ถ้า Worker ทำงาน Jobe นี้ก่อนก็จะทำให้เกิด Exception ModelNotFound ได้

เพิ่ม config after_commit ใน Queue Connection

การเซ็ต property $afterCommit ในคลาส Job ทำให้เรามั่นใจว่า Job จะทำงานหลังจาก Commit Database Transaction แล้วเท่านั้น เรายังสามารถเซ้ต 'after_commit' = true ไว้ในไฟล์ config queue.php ในส่วนของ queue connection ได้เช่นกัน

'redis' => [
    'driver' => 'redis',
    'connection' => 'default',
    // ...
    'after_commit' => true
],

ทีนี้ Job ทั้งหมดที่ทำงานผ่าน queue redis จะรอจนกว่า transaction ทั้งหมดถูก commit จึงจะเริ่มทำงานได้

เรียก afterCommit() หลัง Dispatch Job

นอกจากนี้แล้วเรายังสามารถเรียกใช้ afterCommit() ตอนที่ Dispatch Job ได้เช่นกัน

SendWelcomeEmail::dispatch($user)->afterCommit();
// หรือ
SendWelcomeEmail::dispatch($user)->beforeCommit();

Property $afterCommit ถูกประกาศไว้ใน Trait Queueable ดังนั้นเราจึงสามารถใช้ $afterCommit ได้ในคลาส Mailable, Notification, Job, Listener, Model Observer, และ Broadcast Event

อ้างอิง

สำหรับผู้ที่สนใจเกี่ยวกับ Database Transaction และ Queue ดูเพิ่มเติมได้จาก

LINE Store 500 Internal Server Error Sticker
สนับสนุน phattarachai.dev
หากบทความใน phattarachai.dev มีประโยชน์กับคุณ โปรดสนับสนุน Sticker และ Theme LINE ที่ผมทำขึ้นได้ทาง เพื่อเป็นกำลังใจให้ผมนำเนื้อหาสาระดี ๆ และ Open Source Library ให้แก่ Laravel Developer ชาวไทยมากขึ้นนะครับ

เรื่องล่าสุด

เคล็ดลับ HTML ที่คุณอาจไม่เคยรู้
12 กุมภาพันธ์ 2564
เคล็ดลับ HTML ที่คุณอาจไม่เคยรู้
เลือกรหัสสีบนหน้าจอได้ง่าย ๆ ด้วย Color Picker บน Windows
12 กุมภาพันธ์ 2564
เลือกรหัสสีบนหน้าจอได้ง่าย ๆ ด้วย Color Picker บน Windows
Validation Rule สำหรับตรวจสอบรหัสบัตรประชาชน
12 กุมภาพันธ์ 2564
Validation Rule สำหรับตรวจสอบรหัสบัตรประชาชน