แก้ Bug “ค้างตาย” ใน WordPress Plugin – เมื่อเลือกหนัง 10 เรื่อง แต่ทำได้แค่ 6

เมื่อวานผมสร้าง WordPress Plugin สำหรับ rewrite เนื้อหาหนังกว่า 5,900 เรื่องด้วย Gemini AI

วันนี้มันค้าง

ไม่ใช่ค้างแบบธรรมดานะ แต่ค้างแบบ “Completed: 6” แล้วหยุดนิ่งอยู่งั้น

ผมเลือกหนังไป 10 เรื่อง กด Generate แล้วรอ… รอ… รอ…

ค้างที่ 6 เรื่องตลอดเลย


ปัญหาคืออะไร?

Plugin ผมทำงานแบบนี้:

  1. User เลือกหนังที่ต้องการ 10 เรื่อง
  2. กด Generate
  3. Plugin ดึงรายการเข้า Queue
  4. Background Process ค่อยๆ ทำทีละเรื่อง
  5. เสร็จแล้ว User มา Preview และ Save

ฟังดูดีใช่ไหม?

แต่มันมี time limit

PHP บน Cloudways มี max_execution_time = 60 วินาที

ผมตั้งให้ตัวเอง break ที่ 55 วินาที เพื่อความปลอดภัย

$max_exec_time = 55;

foreach ($items as $item) {
    if ((time() - $start_time) > $max_exec_time) {
        break; // หยุด! หมดเวลาแล้ว
    }

    $this->process_item($item);
}

ปัญหาคือ:

ก่อน break ผมได้ “claim” รายการไว้แล้ว 10 รายการ

แต่ทำได้จริงแค่ 5-6 เรื่อง

อีก 4-5 เรื่องที่เหลือ… ค้างอยู่ใน status “processing” ตลอดกาล


Bug #1: รายการหนังติดค้างใน “Processing”

สถานการณ์

  • เลือกหนัง 10 เรื่อง กด Generate
  • Queue claim รายการทั้ง 10 → status = ‘processing’
  • ประมวลผลได้ 5 เรื่อง → status = ‘preview’
  • หมดเวลา 55 วินาที → break
  • อีก 5 เรื่องที่เหลือ? → ยังคง status = ‘processing’

Batch ถัดไปมาทำงาน มันหาแต่ status = 'pending' เท่านั้น

แต่ 5 เรื่องที่เหลือเป็น 'processing' ไม่ใช่ 'pending'

มันถูกข้ามไป ตลอดไป

ความพยายามแรก (v1.0.13): Reset ตาม Time

ผมคิดว่า “งั้นก็ reset รายการที่ค้างนานเกิน 2 นาทีสิ!”

// Reset รายการที่ค้างอยู่ใน 'processing' นานเกิน 2 นาที
private function reset_stale_processing_items($batch_id, $threshold_sec = 120) {
    global $wpdb;

    $cutoff_time = gmdate('Y-m-d H:i:s', time() - $threshold_sec);

    $wpdb->query($wpdb->prepare(
        "UPDATE {$this->table_name}
         SET status = 'pending'
         WHERE batch_id = %s
         AND status = 'processing'
         AND updated_at < %s",
        $batch_id,
        $cutoff_time
    ));
}

ดูดีใช่ไหม?

Deploy v1.0.13 แล้วทดสอบ

ยังค้างอยู่ที่ 6 เรื่องเหมือนเดิม…

ทำไม v1.0.13 ไม่ work?

นั่งคิดอยู่นาน แล้วก็เข้าใจ

Threshold 2 นาที ไม่มีทางถูก trigger

เพราะอะไร?

  • Batch แรกรัน → ค้าง 5 เรื่อง
  • รอ 5 วินาที → Batch ถัดไปรัน
  • Batch ถัดไปเช็ค: “มีรายการที่ค้างเกิน 2 นาทีไหม?”
  • คำตอบ: ไม่มี (มันค้างแค่ประมาณ 1 นาที)
  • Batch ถัดไปข้ามรายการนั้นไป
  • รอ 5 วินาที → Batch ถัดไปรัน
  • เช็คอีก: ค้างเกิน 2 นาทีไหม? ยังไม่เกิน
  • รายการค้างตลอดไป เพราะไม่มี batch ไหนรออยู่ถึง 2 นาที

บทเรียน: Time-based threshold ไม่ work ถ้า batch interval สั้นกว่า threshold


Bug #2: Race Condition ซ้อนทับ

ระหว่าง debug ผมเจออีกปัญหานึง

Debug log แสดงว่า หนังเรื่องเดียวถูก process ซ้ำ 2-3 รอบ

[19:49:18] Processing item 45 (post 21408)
[19:49:18] Processing item 45 (post 21408)  // อีกแล้ว!

ปัญหา:

เมื่อ progress ดูเหมือน “ค้าง” frontend จะ trigger “manual processing”

แต่ background cron ก็ยังรันอยู่

ทั้งสองตัวแย่งรายการกัน

วิธีแก้: เพิ่ม lock mechanism

$lock_key = 'gmr_processing_' . $batch_id;
$lock_acquired = get_transient($lock_key);

if ($lock_acquired) {
    $this->debug_log('มี process อื่นกำลังทำอยู่ ข้ามไปก่อน');
    return;
}

// ล็อคไว้
set_transient($lock_key, time(), 120);

บทเรียน: Background jobs + Frontend triggers = ต้องมี lock เสมอ


วิธีแก้ที่ Work จริงๆ (v1.0.14): Reset ทันที

หลังจากลองผิดลองถูก ผมเข้าใจแล้วว่า:

ปัญหาคือ timing

ผมรู้ว่ารายการไหนถูก claim แต่ยังไม่ได้ process

ทำไมต้องรอ 2 นาทีถึงจะ reset ล่ะ?

Reset ทันทีหลัง loop break สิ!

$processed_ids = array();  // เก็บรายการที่ process จริง
$all_claimed_ids = wp_list_pluck($items, 'id');  // ทั้งหมดที่ claim ไว้

foreach ($items as $item) {
    if ((time() - $start_time) > $max_exec_time) {
        $this->debug_log('หมดเวลา ออกจาก loop');
        break;
    }

    $this->process_item($item);
    $processed_ids[] = $item->id;  // เพิ่มเข้า list ที่ทำแล้ว
}

// หลัง loop break → reset ทันที!
$this->reset_unprocessed_items($all_claimed_ids, $processed_ids);

Function reset_unprocessed_items():

private function reset_unprocessed_items($all_claimed_ids, $processed_ids) {
    global $wpdb;

    // หารายการที่ claim ไว้แต่ยังไม่ได้ process
    $unprocessed_ids = array_diff($all_claimed_ids, $processed_ids);

    if (empty($unprocessed_ids)) {
        return 0;
    }

    $this->debug_log('รายการที่ยังไม่ได้ทำ: ' . implode(', ', $unprocessed_ids));

    // Reset กลับเป็น 'pending' ทันที
    $placeholders = implode(', ', array_fill(0, count($unprocessed_ids), '%d'));

    $wpdb->query($wpdb->prepare(
        "UPDATE {$this->table_name}
         SET status = 'pending'
         WHERE id IN ({$placeholders})
         AND status = 'processing'",
        ...$unprocessed_ids
    ));

    return count($unprocessed_ids);
}

ผลลัพธ์ใน Debug Log

[19:49:57] Max exec time reached, breaking
[19:49:57] Unprocessed item IDs: 48, 49, 50, 51, 52
[19:49:57] Reset 5 unprocessed items back to pending  ← ทำงานแล้ว!
[19:49:57] Remaining items: 5, scheduling next batch
[19:50:01] Claimed 5 items with token: 376a1995...
[19:50:01] Found 5 items to process
...
[19:51:13] All items processed for this batch

หนังทั้ง 10 เรื่องผ่านหมด ไม่ค้างแม้แต่เรื่องเดียว


ความแตกต่างระหว่าง v1.0.13 กับ v1.0.14

เรื่องv1.0.13v1.0.14
วิธีตรวจจับTime-based (ค้างเกิน 2 นาที)เทียบ array (claimed vs processed)
Timingรอ batch ถัดไปมาเช็คReset ทันทีหลัง loop break
ความน่าเชื่อถือ❌ ไม่ work ถ้า interval < threshold✅ Work เสมอ
แก้ที่ไหนแก้ที่ปลายเหตุแก้ที่ต้นเหตุ

สิ่งที่เรียนรู้

1. Debug Log คือเพื่อนแท้

ถ้าไม่มี debug log ผมจะไม่รู้เลยว่า:

  • รายการไหนถูก claim ไปแล้ว
  • รายการไหนถูก process จริง
  • Loop break ตรงไหน
  • Batch ถัดไปเห็นรายการอะไรบ้าง
$this->debug_log("กำลัง process รายการ {$item->id} (post {$item->post_id})");
$this->debug_log("รายการ {$item->id} เสร็จแล้ว รวม: {$processed} รายการ");
$this->debug_log("หมดเวลา ออกจาก loop");

บทเรียน: เขียน log ให้เยอะตอน debug แล้วค่อยลบทีหลัง

2. Time-based Solutions มีจุดอ่อน

“รอ X วินาทีแล้ว reset” ฟังดูง่าย

แต่ถ้า system ไม่ได้รอนานขนาดนั้น มันไม่มีทาง trigger

บทเรียน: ถ้าเป็นไปได้ ใช้ event-based แทน time-based

3. Track State ให้ชัดเจน

v1.0.13 ไม่ได้ track ว่ารายการไหน process แล้ว รายการไหนยัง

v1.0.14 track อย่างชัดเจน:

  • $all_claimed_ids = ทุกรายการที่ claim ไว้
  • $processed_ids = เฉพาะที่ process จริง
  • array_diff() = หาส่วนต่าง

บทเรียน: State ที่ชัดเจน = Debug ง่าย = แก้ง่าย

4. ทดสอบด้วย Load จริง

Bug นี้ไม่เคยเกิดตอนทดสอบด้วยหนังแค่ 2-3 เรื่อง

เพราะมัน process ทันภายใน 55 วินาที

แต่พอทดสอบด้วยหนัง 10 เรื่อง (ที่แต่ละเรื่องใช้เวลานาน) ถึงเจอปัญหา

บทเรียน: ทดสอบด้วย load ที่ใกล้เคียงกับของจริง


ผลลัพธ์สุดท้าย

  • v1.0.14 deploy แล้วใช้งานได้
  • ทดสอบหนัง 10 เรื่อง → ผ่านหมดไม่มีค้าง
  • พร้อม process หนังกว่า 5,900 เรื่องจริง
  • Token ที่ใช้: ประมาณ 21,000 tokens (~$0.06 USD)

สำหรับคนที่กำลังสร้าง Background Processing

  1. อย่าเชื่อ time-based thresholds มากเกินไป – คิดให้ดีว่า system มี timing เป็นยังไง
  2. Track state ให้ชัดเจน – รู้ว่าอะไร claim ไปแล้ว อะไร process จริงแล้ว
  3. Reset ทันทีที่รู้ว่ามีปัญหา – อย่ารอให้ batch ถัดไปมาแก้ให้
  4. เขียน log ให้เยอะตอน debug – แล้วค่อยลดลงทีหลัง
  5. ทดสอบด้วย scenario จริง – อย่าทดสอบแค่ happy path

Bug นี้สอนผมว่า…

วิธีแก้ที่ดูสมเหตุสมผล อาจไม่ work ในโลกจริง

และบางที solution ที่ดีที่สุดก็คือ วิธีที่ง่ายที่สุด

ไม่ต้อง fancy time-based recovery อะไรเลย

แค่ track ว่าทำอะไรไปแล้ว แล้ว reset ที่เหลือทันที


ถ้าคุณเจอ bug ที่ดูเหมือน “ค้าง” โดยไม่รู้สาเหตุ

ลองถามตัวเองว่า: “มันติดค้างอยู่ที่ state ไหน? และทำไมมันไม่หลุดออกมา?”

คำตอบมักจะซ่อนอยู่ใน timing

Scroll to Top