บทเรียน 145 · Rails · Day 4 Part 1

Lab Vibe 5 — ตั้งเวลาเผยแพร่โพสต์ และพิสูจน์ว่ารันซ้ำได้

~30 นาทีอ่าน + รอเวลาจริงในเครื่องอีก ~10 นาที (เกณฑ์ 2 ข้อต้องรอนาฬิกาเดิน) · ควรผ่าน บท 142–144 (job · idempotent · Solid Queue · ตัวกวาดทุกนาที · อีเมล) มาก่อน

บท 142–144 คุณสร้างระบบ "ตั้งเวลาเผยแพร่ แล้วส่งอีเมลบอกผู้ติดตาม" ครบแล้ว · บทนี้ไม่มีโค้ดแอปใหม่ แต่จะสวมบท "คนตรวจรับงาน" ไล่เกณฑ์ 5 ข้อของสไลด์ด้วยของจริง — และทำให้มันพังโดยตั้งใจ เพื่อดูว่าตัวกันซ้ำแต่ละตัวกันอะไรอยู่
อ่านจบแล้วคุณจะ พิสูจน์ได้ด้วยการรัน (ไม่ใช่ด้วยการอ่านโค้ด) ว่า job ของใครก็ตาม รวมถึงของ AI รันซ้ำแล้วไม่ส่งอีเมลซ้ำ · มีใบสั่งงาน prompts/05-jobs.md กับกฎข้อ 16–18 ไว้ใช้ครั้งหน้า · และปิดบล็อก E ได้ด้วยประโยคข้อ ② ของวัน

🔁 ทวนก่อนเริ่ม — ทั้งบทใช้คำพวกนี้ ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน

📍 โค้ดเริ่มจาก: ตอนจบบท 144 ที่ commit แล้ว (git status --short ว่าง) — ฟอร์ม /posts/new มีตัวเลือก "ตั้งเวลาเผยแพร่" · หน้า show ของบทความที่ยังไม่เผยแพร่มีปุ่ม "เผยแพร่ทันที" · บทนี้เพิ่มแค่สองไฟล์ใน prompts/ · จบบทโค้ดของคุณคือจุดเช็คพอยต์ E (ชื่อที่สไลด์ใช้เรียกจุดพักท้ายบล็อก E = จบเรื่อง background jobs)

🖥 เปิดค้างไว้: docker compose up -d (Postgres + MinIO) · bin/dev หนึ่งหน้าต่าง — ต้องเห็น 3 สีขึ้นต้นบรรทัด web · css · jobs (บรรทัด jobs: bin/jobs ใน Procfile.dev จากบท 143) · อีกหน้าต่างไว้พิมพ์คำสั่ง · Chrome ที่ localhost:3000

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · solid_queue 1.7.0 · Chrome (สั่งด้วยสคริปต์ ไม่ได้กดเอง) — สไลด์กับ repo ของอาจารย์เขียน Ruby 4.0.5–4.0.6 · Rails 8.1.3 เรื่องในบทนี้ไม่ต่างกัน

ผลรันทุกก้อนรันจริงแล้วก็อปมา · ค่าที่เปลี่ยนทุกครั้ง เช่น เวลา · เลข id · Job ID · เลข commit · เวลา ms ของคุณจะต่าง · เวลาในผล SQL เป็นเวลาสากล (UTC) ช้ากว่าเวลาไทย 7 ชั่วโมง (Rails เก็บเป็น UTC แล้วแปลงเป็นเวลาไทยตอนแสดงในหน้าเว็บ) · ผมรัน SQL กับ Postgres ตัวทดลองที่ชื่อ container/ฐานข้อมูลต่างจากของคุณ — คำสั่งในบทเขียนเป็นแบบของคุณ ส่วนผลข้างใต้เป็นของจริง · log ตัดรหัสสีและ comment ท้าย SQL ออกแล้ว

Lab นี้คือวันตรวจรับ ไม่ใช่วันสร้าง

สไลด์หน้า 24 ให้ Lab นี้เป็น "เขียน prompt ให้ AI ทำทั้งบล็อก E แล้วตรวจ" · เว็บนี้เลือกให้คุณทำเองเป็นก่อน — ข้อกำหนดทุกข้อในสไลด์คุณทำไปแล้วในบท 143–144 · ที่เหลือคือส่วนที่สำคัญที่สุดของการทำงานกับ AI: ตรวจรับให้เป็น

ข้อกำหนดในสไลด์หน้า 24ทำไปแล้วในบทไฟล์หลักที่ได้
status (draft/scheduled/published) + scheduled_at + published_at + index143migration ของ posts · enum :status ใน models/post.rb
Solid Queue · bin/jobs ใน Procfile.dev143ตาราง solid_queue_* · Procfile.dev
PublishPostJob idempotent ด้วย guard สถานะ + unique constraint ที่ DB143 (guard) · 144 (unique index)jobs/publish_post_job.rb · ตาราง post_notifications
recurring job กวาดทุกนาที ไม่ใช่ wait_until144jobs/publish_scheduled_posts_job.rb · config/recurring.yml
retry_on + backoff · discard_on RecordNotFound144บรรทัดบน ๆ ของ publish_post_job.rb
ส่งเฉพาะ id เข้า job143perform_later(post.id) ทั้งสองที่

Lab นี้มี 4 ขั้น:

ขั้น 1 เขียนใบสั่งงาน prompts/05-jobs.md (ทุกบรรทัดมาจากบท 143–144) │ ขั้น 2 ตรวจรับ 5 ข้อ Chrome · console · psql · grep │ └ ข้อ 2 = หัวใจ: ทำให้พังโดยตั้งใจ 2 แบบ แล้วนับอีเมลจริง │ ขั้น 3 เติมกฎ 16–18 prompts/00-context.md (15 ข้อ → 18 ข้อ) │ ขั้น 4 เก็บเข้าโปรเจกต์ git commit
🍽 เทียบให้เห็นภาพ — ซ้อมเหตุการณ์ร้ายหลังร้าน

ระบบใบงานหลังร้านเพิ่งติดตั้งเสร็จ · วันตรวจรับ เจ้าของร้านไม่ได้แค่ดูว่า "ส่งของได้" แต่ซ้อมเรื่องร้ายที่จะเกิดจริง: ยื่นใบงานเดิมซ้ำ 3 ใบ (พนักงานเป็นลมแล้วคนใหม่ทำซ้ำ) · เปลี่ยนเวลาส่งกลางทาง · ไล่พนักงานหลังร้านกลับบ้านแล้วดูว่าใบงานยังแขวนอยู่ไหม · ถ้าซ้อมแล้วลูกค้าได้ของซ้ำ 3 ชุด = ยังเซ็นรับไม่ได้

ที่ไม่เหมือนกัน: ในร้านจริง ซ้อมแล้วเสียของจริง · ของเรา "ลูกค้า" คือไฟล์ใน tmp/mails/ ลบทิ้งแล้วซ้อมใหม่ได้ฟรีกี่รอบก็ได้ — แต่ระบบจริงที่ส่งอีเมลออกไปแล้วดึงกลับไม่ได้ จึงต้องซ้อมให้จบในเครื่องตัวเองก่อน

🗣 ศัพท์ของขั้นนี้
idempotency test (ทดสอบการรันซ้ำ)
รันงานเดิมหลายรอบแล้วเช็คว่าผลเหมือนรอบเดียว · เกณฑ์ข้อ 2 ของ Lab นี้คือ idempotency test ด้วยมือ (ของจริงจะเขียนเป็น test อัตโนมัติ) · พิมพ์บอก AI ได้ว่า "Prove this job is idempotent: run it 3 times with perform_now and show me the email count before and after."
fault injection (ใส่ความพังเข้าไปเอง)
ตั้งใจทำให้โค้ดพังทีละจุดเพื่อดูว่า "ตัวกัน" ตัวนั้นกันอะไรอยู่จริง · ถ้าลบตัวกันแล้วไม่มีอะไรเปลี่ยน แปลว่าการตรวจของเราไม่ได้ตรวจสิ่งที่คิด · ถาม senior ได้ว่า "ถ้าลบ guard ตัวนี้ออก test ตัวไหนจะแดงครับ"

ขั้น 1 — ใบสั่งงาน prompts/05-jobs.md: ทุกบรรทัดมาจากบทไหน

ไฟล์นี้คือใบสั่งงานที่จะส่งให้ AI ทำบล็อก E ทั้งหมดในครั้งเดียว · หน้าตาเหมือน 04-content.md (บท 141): เป้าหมาย · ข้อกำหนด · ห้าม · เกณฑ์ตรวจรับ — สี่ส่วนของบท 121

สร้างไฟล์ prompts/05-jobs.md แล้วพิมพ์ตามนี้ทั้งไฟล์ (ก็อปมาจากสไลด์หน้า 24 · บรรทัดในวงเล็บคือที่เราเพิ่มจากของจริง และแก้ 2 คำ ดูกล่องเตือนข้างล่าง):

## เป้าหมาย

ตั้งเวลาเผยแพร่โพสต์ล่วงหน้าได้
พอถึงเวลา ระบบเผยแพร่เองและแจ้งคนติดตาม

## ข้อกำหนด

- เพิ่ม status (draft/scheduled/published) + scheduled_at
  + published_at พร้อม index ที่ scheduled_at
  (โพสต์เดิมทั้งหมดเป็น published · เติม published_at ด้วย rake task ที่รันซ้ำได้ ไม่ใช่ใน migration)
- ใช้ Solid Queue · เพิ่ม jobs: bin/jobs ใน Procfile.dev
  (development ใช้ DB เดียวกับข้อมูล: ตาราง solid_queue_* มาจาก migration ไม่ต้องตั้ง connects_to)
- PublishPostJob ต้อง idempotent ด้วย guard สถานะ
  และต้องมี unique constraint ที่ DB กันสอง worker ชนกัน
  (guard สถานะอยู่ใน with_lock · unique index (post_id, follower_id) ของตารางที่จดว่าส่งอีเมลหาใครแล้ว)
- ใช้ท่า recurring job กวาดทุกนาที ไม่ใช่ wait_until
  (เพราะผู้ใช้แก้เวลาได้)
  (config/recurring.yml ต้องมี key development: ไม่งั้น bin/jobs ไม่กวาดในเครื่องเรา)
- retry_on พร้อม backoff แบบถ่างออก
- discard_on RecordNotFound
- ส่งเฉพาะ id เข้า job ห้ามส่งก้อนข้อมูล
- อีเมลใน development เขียนลงไฟล์ tmp/mails (delivery_method :file) ไม่ต้องลง service เพิ่ม

## ห้าม

- ส่งอีเมลใน callback ของ model
- ใช้ after_save (ต้อง after_commit)
- job ที่บวกเลขสะสมแทนการคำนวณใหม่
- ส่ง signed URL หรือ token เข้า job
- สมมติว่า job รันครั้งเดียว

## เกณฑ์ตรวจรับ

- [ ] ตั้งเวลา 1 นาทีข้างหน้า แล้วรอดู มันเผยแพร่เอง
- [ ] รัน PublishPostJob.perform_now(post.id) ซ้ำ 3 ครั้ง
      -> สถานะไม่เปลี่ยน และไม่มีอีเมลเพิ่ม (นับด้วย grep -c "^Subject:" tmp/mails/*)
- [ ] แก้เวลาที่ตั้งไว้ แล้วมันเผยแพร่ตามเวลาใหม่
- [ ] ปิด worker แล้วกด -> งานค้างในคิว ไม่หาย
      เปิด worker กลับมา -> งานถูกทำ
- [ ] เปิดตาราง solid_queue_* ดูด้วย SQL ได้

## = หัวข้อของ Markdown · - [ ] = ช่องติ๊กที่ยังไม่ติ๊ก · -> = "แล้วต้องได้ผลแบบนี้" · ใบสั่งนี้มีวงเล็บเยอะกว่าใบสั่ง 04 เพราะบล็อก E มีกับดักเงียบหลายจุด (ไม่มี error ให้เห็น งานแค่ไม่เกิด) — ถ้าไม่เขียนบอก AI จะตกหลุมเดียวกับที่สไลด์ตก:

บรรทัดในวงเล็บเรียนในบทถ้าไม่เขียน AI มักทำแบบไหน
โพสต์เดิมเป็น published · rake task ที่รันซ้ำได้143 (ทวน 109)ไม่คิดถึง 30 บทความเดิม → หน้า /posts ว่างเปล่าหลัง migrate เพราะทุกบทความกลายเป็นฉบับร่าง
DB เดียวกับข้อมูล ไม่ต้อง connects_to143ตั้ง DB คิวแยกแบบ production (แบบ repo อาจารย์) → bin/jobs หาตารางไม่เจอ
guard ใน with_lock · unique index (post_id, follower_id)143 · 144สไลด์บอกให้มี unique constraint แต่ไม่บอกว่าที่ไหน → AI ใส่ unique ที่คอลัมน์ที่ไม่ได้กันอีเมลซ้ำ
recurring.yml ต้องมี key development:144เขียนแค่ production: ตามไฟล์ที่ Rails สร้าง → ในเครื่องไม่มีตัวกวาดเลย และไม่มี error
อีเมลลงไฟล์ tmp/mails144เสนอให้ลง Mailpit หรือ letter_opener เพิ่ม (service/gem ใหม่)

⚠️ สองคำที่แก้จากสไลด์: (1) สไลด์เขียน PublishPostJob.perform_now(post) — job ของเรารับ id (ข้อกำหนด "ส่งเฉพาะ id") ส่งบทความทั้งก้อนเข้าไปจะได้ ArgumentError: You are passing an instance of ActiveRecord::Base to `find`. Please pass the id of the object by calling `.id`. (ผลจริง) จึงเขียนเป็น post.id · (2) สไลด์เขียน worker: bin/jobs — ของเราตั้งชื่อป้ายว่า jobs: ตามบท 143 (เป็นแค่ชื่อสีหน้าบรรทัดใน bin/dev ทำงานเหมือนกัน)

ขั้น 2 — ตรวจรับ 5 ข้อของสไลด์หน้า 24 ด้วยของจริง

เตรียมโต๊ะตรวจ

เช็คก่อนว่าพนักงานหลังร้านมาครบ 4 ตำแหน่ง (หน้าต่างที่สอง ระหว่างที่ bin/dev เปิดอยู่):

$ bin/rails runner 'p SolidQueue::Process.pluck(:kind)'
["Supervisor(fork)", "Dispatcher", "Worker", "Scheduler"]

SolidQueue::Process = ตารางรายชื่อพนักงานที่กำลังเข้ากะ · pluck(:kind) = ขอแค่คอลัมน์ "ตำแหน่ง" · Worker หยิบใบงานไปทำ · Scheduler คือคนที่เดินตรวจกระดานทุกนาที (บท 144) · ถ้าไม่มี "Scheduler" = recurring.yml ไม่มี key development: หรือแก้ไฟล์แล้วยังไม่ได้ปิด-เปิด bin/dev ใหม่

แล้วล้างกล่องจดหมายทดลองให้เริ่มนับจากศูนย์ (อีเมลจากบท 144 ยังอยู่ในนั้น):

$ rm -rf tmp/mails

tmp/mails/ มีไฟล์ละผู้รับ (อีเมลใหม่ต่อท้ายไฟล์เดิม) · ลบทิ้งได้ไม่เสียอะไร · Rails สร้างโฟลเดอร์ใหม่เองตอนส่งฉบับถัดไป

ข้อ 1 — ตั้งเวลา 1 นาทีข้างหน้า แล้วรอดู มันเผยแพร่เอง

  1. Chrome ไปที่ localhost:3000/posts/new · หัวข้อ ตั้งเวลา 1 นาที · เนื้อหาอะไรก็ได้ · เลือกหมวด
  2. เลือก ตั้งเวลาเผยแพร่ · ช่องเวลาใส่นาทีถัดไป (ตอนนี้ 10:50 → ใส่ 10:51) · กดบันทึก

ผลจริงของผม (กดบันทึกตอน 10:50:23) — หน้า show ขึ้นกล่องสีฟ้า:

ตั้งเวลาเผยแพร่ไว้ 10/11/2026 10:51 · ยังไม่โชว์ในหน้ารวม
[เผยแพร่ทันที]

จด id จาก URL ไว้ (ของผมคือ /posts/31) · ไม่ต้องกดอะไรอีก — แค่รอ แล้วรีเฟรชหน้านี้หลังขึ้นนาทีใหม่ · ถ้าอยากเห็นในตู้แฟ้มตรง ๆ ใช้ psql (โปรแกรมคุยกับ Postgres ด้วย SQL ที่อยู่ใน container) แทน id เป็นของคุณ:

$ docker compose exec postgres psql -U postgres -d wongnok_blog_all_in_one_development \
    -c "SELECT id, status, scheduled_at, published_at FROM posts WHERE id = 31;"
# ตอน 10:50:54 (เวลาไทย)
 id |  status   |    scheduled_at     | published_at
----+-----------+---------------------+--------------
 31 | scheduled | 2026-11-10 03:51:00 |
(1 row)

# ตอน 10:51:06
 id |  status   |    scheduled_at     |        published_at
----+-----------+---------------------+----------------------------
 31 | published | 2026-11-10 03:51:00 | 2026-11-10 03:51:00.733052
(1 row)

ใครเป็นคนเผยแพร่? ดูใน log/development.log (log ของ job อยู่ที่นี่ ไม่ได้อยู่ในหน้าต่าง bin/dev ทั้งหมด · ตัดมาเฉพาะบรรทัดสำคัญ):

SolidQueue-1.7.0 Enqueued recurring task (56.7ms)  task: "publish_scheduled_posts", active_job_id: "7392816a-…", at: "2026-11-10T03:51:00Z"
[ActiveJob] [PublishScheduledPostsJob] [7392816a-…] Performing PublishScheduledPostsJob (Job ID: 7392816a-…) from SolidQueue(default) enqueued at 2026-11-10T03:51:00.048767000Z
[ActiveJob] [PublishScheduledPostsJob] [7392816a-…] Enqueued PublishPostJob (Job ID: 9191a520-…) to SolidQueue(default) with arguments: 31
[ActiveJob] [PublishScheduledPostsJob] [7392816a-…] Performed PublishScheduledPostsJob (Job ID: 7392816a-…) from SolidQueue(default) in 107.73ms
[ActiveJob] [PublishPostJob] [9191a520-…] Performing PublishPostJob (Job ID: 9191a520-…) from SolidQueue(default) enqueued at 2026-11-10T03:51:00.716798000Z with arguments: 31
[ActiveJob] [PublishPostJob] [9191a520-…] Performed PublishPostJob (Job ID: 9191a520-…) from SolidQueue(default) in 78.19ms

นับอีเมล — คำสั่งเดียวกับที่เขียนในใบสั่ง:

$ grep -c "^Subject:" tmp/mails/*
tmp/mails/boss@example.com:1
tmp/mails/malee@example.com:1
tmp/mails/somchai@example.com:1

grep -c = นับบรรทัดที่ตรงเงื่อนไข · "^Subject:" = บรรทัดที่ขึ้นต้นด้วย Subject: (อีเมลหนึ่งฉบับมีหนึ่งบรรทัด) · tmp/mails/* = ทุกไฟล์ในโฟลเดอร์ → ได้ผลแยกรายคน · เนื้ออีเมลในไฟล์เป็นรหัส base64 (เพราะมีภาษาไทย) เปิดอ่านตรง ๆ ไม่ออก · อยากอ่านให้เปิด localhost:3000/rails/mailers/post_mailer/published หรือ:

$ bin/rails runner 'm = Mail.read("tmp/mails/malee@example.com"); puts m.subject; puts m.text_part.decoded'
บทความใหม่: ตั้งเวลา 1 นาที
มีบทความใหม่ใน Wongnok Blog

ตั้งเวลา 1 นาที

ทดลองตั้งเวลาเผยแพร่ หนึ่งนาที

อ่านต่อ: http://localhost:3000/posts/31

ส่งถึง malee@example.com เพราะคุณติดตาม Wongnok Blog

Mail.read อ่านได้เฉพาะฉบับแรกของไฟล์ · ✅ ข้อ 1 ผ่าน

ข้อ 2 — perform_now ซ้ำ 3 ครั้ง สถานะไม่เปลี่ยน อีเมลไม่เพิ่ม (หัวใจของบล็อก E)

สไลด์หน้า 24 เขียนกำกับข้อนี้ว่าเป็นเกณฑ์ที่ AI โกงไม่ได้เลย — AI เขียนได้ว่า "job นี้ idempotent แล้วครับ" แต่มันทำให้ grep นับได้ 1 ด้วยคำพูดไม่ได้ · บทความ 31 เผยแพร่ไปแล้วจากข้อ 1 → เหมือนสถานการณ์ "worker ตัวแรกทำเสร็จ แล้วใบงานเดิมโผล่มาอีก 3 รอบ"

เปิด console (bin/rails console) แล้วพิมพ์ทีละบรรทัด:

wongnok-blog-all-in-one(dev):001> post = Post.find(31); post.status
=> "published"
wongnok-blog-all-in-one(dev):002> post.published_at
=> 2026-11-10 10:51:00.733052000 +07 +07:00
wongnok-blog-all-in-one(dev):003> 3.times { PublishPostJob.perform_now(post.id) }
=> 3
wongnok-blog-all-in-one(dev):004> post.reload.status
=> "published"
wongnok-blog-all-in-one(dev):005> post.published_at
=> 2026-11-10 10:51:00.733052000 +07 +07:00
wongnok-blog-all-in-one(dev):006> post.post_notifications.count
=> 3

ระหว่างนั้น console พิมพ์ SQL ออกมาด้วย · หนึ่งรอบหน้าตาแบบนี้ (ตัดคำสั่ง SELECT ออก):

Performing PublishPostJob (Job ID: b71ea82b-…) from SolidQueue(default) with arguments: 31
  Post Load (1.2ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 31 LIMIT 1 FOR UPDATE
  TRANSACTION (1.0ms)  COMMIT
  TRANSACTION (0.3ms)  BEGIN
  PostNotification Create (0.7ms)  INSERT INTO "post_notifications" ("post_id", "follower_id", "created_at", "updated_at") VALUES (31, 1, …) RETURNING "id"
  TRANSACTION (0.3ms)  ROLLBACK
  … (follower 2 และ 3 เหมือนกัน: INSERT แล้ว ROLLBACK)
Performed PublishPostJob (Job ID: b71ea82b-…) from SolidQueue(default) in 34.57ms

แล้วนับอีเมลจริงอีกรอบ:

$ grep -c "^Subject:" tmp/mails/*
tmp/mails/boss@example.com:1
tmp/mails/malee@example.com:1
tmp/mails/somchai@example.com:1

ยังคนละ 1 ฉบับ · ✅ ข้อ 2 ผ่าน — แต่ "ผ่าน" ยังไม่พอ ต้องรู้ด้วยว่าตัวไหนทำให้ผ่าน

ข้อ 2 ภาคต่อ — ทำให้พังโดยตั้งใจ ทีละตัวกัน

job ของเรามีตัวกันซ้ำ 2 ชั้น (บท 143–144): ชั้นแรก guard สถานะ (if post.scheduled? …) · ชั้นที่สอง จองแถวก่อนส่ง (PostNotification.create! + unique index) · สไลด์บอกว่า "ถ้า job ไม่ idempotent จะเห็นอีเมล 3 ฉบับทันที" — ลองดูว่าถอดชั้นไหนแล้วเห็นจริง

การทดลอง ก — ถอด guard สถานะ · เปิด app/jobs/publish_post_job.rb ใส่ # หน้าบรรทัด if กับ end ที่ครอบ update! (ผลจาก git diff):

     post.with_lock do
       # guard สถานะ: เผยแพร่แล้ว เป็นฉบับร่าง หรือยังไม่ถึงเวลา (ผู้ใช้เลื่อนเวลาออกไป) → ไม่ทำอะไร
-      if post.scheduled? && post.scheduled_at <= Time.current
+      # if post.scheduled? && post.scheduled_at <= Time.current
         post.update!(status: :published, published_at: Time.current)
-      end
+      # end
     end

บรรทัด - = ของเดิม · + = ของใหม่ · ตอนนี้ update! ทำทุกรอบไม่ว่าสถานะอะไร · ล้างกล่องจดหมาย (rm -rf tmp/mails) แล้วเปิด console ใหม่:

wongnok-blog-all-in-one(dev):001> post = Post.find(31); post.published_at.strftime("%T.%L")
=> "10:51:00.733"
wongnok-blog-all-in-one(dev):002> 3.times { PublishPostJob.perform_now(post.id); puts post.reload.published_at.strftime("%T.%L") }

strftime("%T.%L") = แสดงเวลาแบบ ชั่วโมง:นาที:วินาที.มิลลิวินาที · คิดว่า 3 บรรทัดที่พิมพ์ออกมาเป็นเวลาอะไร? และ grep -c "^Subject:" tmp/mails/* หลังรันจะได้กี่ฉบับต่อคน?

10:54:02.512
10:54:02.636
10:54:02.649
=> 3
wongnok-blog-all-in-one(dev):003> post.status
=> "published"
$ grep -c "^Subject:" tmp/mails/*
grep: tmp/mails/*: No such file or directory
  • เวลาเผยแพร่ถูกเขียนทับทุกรอบ — จาก 10:51:00 กลายเป็นเวลาที่รัน (log มี UPDATE "posts" SET … "published_at" = … 3 บรรทัด) · บทความที่ออกไปแล้วดูเหมือน "เพิ่งออกใหม่" และหน้า /posts เรียงตาม published_at → บทความเก่าเด้งขึ้นบนสุดได้
  • อีเมลไม่ออกเลยสักฉบับ — โฟลเดอร์ไม่ถูกสร้างด้วยซ้ำ (grep ฟ้องว่าไม่มีไฟล์) เพราะชั้นที่สอง (จองแถว) ยังกันอยู่
  • สรุป: guard สถานะกันข้อมูลในตู้แฟ้มเพี้ยน ไม่ได้กันอีเมล · ถ้าคุณตรวจแค่ "สถานะยัง published ไหม" (เกณฑ์ครึ่งแรก) จะผ่านทั้งที่ published_at พังแล้ว

เอา guard กลับก่อนไปต่อ (และคืนเวลาเผยแพร่ด้วย ไม่งั้นบทความ 31 จะจำเวลาที่เพี้ยนไว้):

$ git restore app/jobs/publish_post_job.rb

git restore <ไฟล์> = ทิ้งทุกอย่างที่แก้ในไฟล์นั้นตั้งแต่ commit ล่าสุด · published_at ของบทความ 31 ไม่ต้องสนก็ได้ (เป็นบทความทดลอง) — แต่ถ้าอยากคืน ใน console: Post.find(31).update_columns(published_at: Time.zone.parse("2026-11-10 10:51")) (แก้วันเวลาให้ตรงของคุณ · update_columns = เขียนลงตู้ตรง ๆ ไม่ผ่าน validation/callback)

การทดลอง ข — ถอดการจองแถว · ในไฟล์เดียวกัน เมธอด notify ใส่ # หน้าบรรทัด create!:

       PostNotification.transaction do
         # จองก่อนส่ง: ใส่แถว (post, follower) ได้ = ได้สิทธิ์ส่ง
-        PostNotification.create!(post: post, follower: follower)
+        # PostNotification.create!(post: post, follower: follower)
         PostMailer.with(post: post, follower: follower).published.deliver_now
       end
$ rm -rf tmp/mails
wongnok-blog-all-in-one(dev):001> post = Post.find(31); post.status
=> "published"
wongnok-blog-all-in-one(dev):002> 3.times { PublishPostJob.perform_now(post.id) }
wongnok-blog-all-in-one(dev):003> post.reload.status
wongnok-blog-all-in-one(dev):004> post.post_notifications.count

บรรทัด 003 กับ 004 ได้อะไร? แล้ว grep -c ได้กี่ฉบับต่อคน? — คิดก่อนว่า "ตัวเลขที่ console บอก" กับ "อีเมลที่ออกไปจริง" จะตรงกันไหม

wongnok-blog-all-in-one(dev):002> 3.times { PublishPostJob.perform_now(post.id) }
PostMailer#published: processed outbound mail in 24.3ms
Delivered mail 6af29574789f2_…@….local.mail (4.6ms)
Date: Tue, 10 Nov 2026 10:54:28 +0700
From: from@example.com
To: somchai@example.com
… (console พิมพ์อีเมลทั้งฉบับ 9 รอบ ยาวหลายร้อยบรรทัด — ตัดออก)
=> 3
wongnok-blog-all-in-one(dev):003> post.reload.status
=> "published"
wongnok-blog-all-in-one(dev):004> post.post_notifications.count
=> 3
$ grep -c "^Subject:" tmp/mails/*
tmp/mails/boss@example.com:3
tmp/mails/malee@example.com:3
tmp/mails/somchai@example.com:3
  • คนละ 3 ฉบับ รวม 9 — นี่คือภาพที่สไลด์บอกว่า "จะเห็นอีเมล 3 ฉบับทันที"
  • แต่ตัวเลขใน console ดูปกติหมด: สถานะ "published" · แถว post_notifications ยัง 3 (แถวเก่าจากข้อ 1 · รอบนี้ไม่ได้ใส่แถวใหม่เลย) — ถ้าตรวจด้วยการนับแถวแทนการนับอีเมลจริง จะคิดว่าผ่าน
  • นี่คือเหตุผลที่เกณฑ์ข้อ 2 เขียนว่านับอีเมล ไม่ใช่นับแถว: ต้องวัดที่ผลที่ลูกค้าได้รับ ไม่ใช่ที่สมุดจดของร้าน
$ git restore app/jobs/publish_post_job.rb
$ git status --short

git status --short ต้องว่าง (ไม่มีบรรทัดไหนออกมา) ก่อนไปข้อ 3 · ไฟล์ใน tmp/ ไม่โผล่เพราะอยู่ใน .gitignore

🍽 เทียบให้เห็นภาพ — ป้ายหน้าร้าน กับ สมุดจดเลขใบงาน

ใบงาน "เปิดร้านแล้วโทรบอกลูกค้าประจำ 3 คน" ถูกยื่นซ้ำ 3 ใบ · พนักงานมีตัวกันสองอย่าง: (1) ดูป้ายหน้าร้านก่อน — ป้ายเขียน "เปิดแล้ว" ก็ไม่ต้องไปพลิกป้ายใหม่ (= guard สถานะ) · (2) สมุดจดเลขใบงาน — ก่อนโทรหาใคร ต้องเขียนชื่อคนนั้นลงสมุดก่อน ถ้าชื่อมีอยู่แล้วเขียนซ้ำไม่ได้ ก็ไม่ต้องโทร (= จองแถว + unique index)

การทดลอง ก = ไม่ดูป้าย พลิกป้ายใหม่ทุกครั้ง → เวลาเปิดร้านบนป้ายเปลี่ยนไปเรื่อย ๆ แต่ลูกค้ายังไม่โดนโทรซ้ำ (สมุดยังกัน) · การทดลอง ข = เลิกเขียนสมุด → ลูกค้าโดนโทร 3 รอบ ทั้งที่สมุดยังมีชื่อครบ 3 คนจากรอบแรก ดูจากสมุดเลยเหมือนไม่มีอะไรผิด

ที่ไม่เหมือนกัน: สมุดของร้านจริงสองคนเขียนพร้อมกันได้ · "สมุด" ของเราคือ unique index ใน Postgres ที่ให้เขียนชื่อเดียวกันได้คนเดียวเสมอ แม้ worker สองตัวมาพร้อมกันในเสี้ยววินาทีเดียว — ตัวที่สองต้องรอ แล้วได้ error ว่าซ้ำ (บท 144) · และการ "โทร" (ส่งอีเมล) อยู่ในก้อนเดียวกับการเขียนสมุด: ส่งพัง = ลบชื่อออกจากสมุดให้ด้วย

🗣 ศัพท์ของการทดลองนี้
defense in depth (กันหลายชั้น)
มีตัวกันมากกว่าหนึ่งชั้น แต่ละชั้นกันคนละเรื่อง · ชั้นหนึ่งหลุด อีกชั้นยังรับไว้ (การทดลอง ก) · พิมพ์บอก AI ได้ว่า "What does each guard in this job protect against? Show me what breaks if I remove each one."
observable outcome (ผลที่สังเกตได้จากข้างนอก)
ผลที่ผู้ใช้ได้รับจริง (อีเมลในกล่องจดหมาย) ต่างจาก "สถานะภายใน" (แถวในตาราง) · ตรวจที่ผลภายนอกเสมอเมื่อทำได้ — หลักนี้เรียกว่า "ทดสอบพฤติกรรม ไม่ใช่การทำงานภายใน" · ถาม senior ได้ว่า "ตรงนี้เรา assert ที่ผลลัพธ์ที่ user เห็น หรือที่ state ภายในครับ"

ข้อ 3 — แก้เวลาที่ตั้งไว้ แล้วมันเผยแพร่ตามเวลาใหม่

ข้อนี้ต้องรอ ~3 นาที · ตั้งเวลาเผื่อไว้ 2 นาที จะได้มีเวลากดแก้:

  1. สร้างบทความใหม่ "แก้เวลาเผยแพร่" ตั้งเวลาอีก 2 นาทีข้างหน้า (ของผม: สร้างตอน 10:54:49 ตั้ง 10:56)
  2. กด "แก้ไข" → ฟอร์มขึ้นเวลาเดิม 10:56 และตัวเลือก "ตั้งเวลาเผยแพร่" ติ๊กอยู่ → เปลี่ยนเป็น 10:58 → บันทึก
  3. กล่องสีฟ้าต้องเปลี่ยนเป็น ตั้งเวลาเผยแพร่ไว้ 10/11/2026 10:58 · ยังไม่โชว์ในหน้ารวม
  4. รอ แล้วดู psql (หรือรีเฟรชหน้า) เป็นระยะ
# ผลจริง เช็คทุก ~15 วินาที (เวลาซ้ายมือ = เวลาไทยตอนเช็ค · ในตาราง = UTC)
10:55:43  32|scheduled|2026-11-10 03:58:00|
10:56:13  32|scheduled|2026-11-10 03:58:00|     ← ผ่าน 10:56 (เวลาเก่า) ไปแล้ว ยังไม่เผยแพร่
10:57:44  32|scheduled|2026-11-10 03:58:00|
10:57:59  32|scheduled|2026-11-10 03:58:00|
10:58:14  32|published|2026-11-10 03:58:00|2026-11-10 03:58:00.398734

(ตัดบางบรรทัดที่ซ้ำกันออก · แบบ | คั่นได้จากการเติม -At ท้ายคำสั่ง psql = ไม่เอาหัวตาราง) ดูในตารางใบงานว่ามีใบงานเผยแพร่บทความ 32 ตอนไหนบ้าง:

$ docker compose exec postgres psql -U postgres -d wongnok_blog_all_in_one_development \
    -c "SELECT id, class_name, arguments::json->'arguments' AS args, created_at FROM solid_queue_jobs WHERE class_name = 'PublishPostJob' ORDER BY id;"
 id |   class_name   | args |         created_at
----+----------------+------+----------------------------
  2 | PublishPostJob | [31] | 2026-11-10 03:51:00.717452
 10 | PublishPostJob | [32] | 2026-11-10 03:58:00.378617
(2 rows)

ข้อ 4 — ปิด worker แล้วกด → งานค้างในคิว ไม่หาย · เปิดกลับ → งานถูกทำ

  1. ปิด bin/dev (Ctrl+C) แล้วเปิดแค่เว็บ: bin/rails server — ตอนนี้ไม่มีพนักงานหลังร้าน (เช็ค: bin/rails runner 'p SolidQueue::Process.pluck(:kind)' → ผลจริง [])
  2. สร้างบทความใหม่แบบ "เก็บเป็นฉบับร่าง" (ของผม: "ปิด worker แล้วกด" id 33) → หน้า show กด เผยแพร่ทันที
  3. รอสัก 1 นาที แล้วดูตาราง

ผลจริง: กดตอน 10:58:39 · หน้าขึ้น ส่งเข้าคิวเผยแพร่แล้ว และกล่องสีฟ้าเปลี่ยนเป็น ตั้งเวลาเผยแพร่ไว้ 10/11/2026 10:58 (ปุ่มนี้ตั้งเวลาเป็น "ตอนนี้" แล้วแขวนใบงาน · บท 143) · เช็คตอน 11:00:03:

$ docker compose exec postgres psql -U postgres -d wongnok_blog_all_in_one_development \
    -c "SELECT id, class_name, arguments::json->'arguments' AS args, created_at, finished_at FROM solid_queue_jobs WHERE class_name = 'PublishPostJob' ORDER BY id DESC LIMIT 1;" \
    -c "SELECT id, job_id, queue_name, created_at FROM solid_queue_ready_executions;" \
    -c "SELECT id, status, published_at FROM posts WHERE id = 33;"
 id |   class_name   | args |         created_at         | finished_at
----+----------------+------+----------------------------+-------------
 11 | PublishPostJob | [33] | 2026-11-10 03:58:39.473611 |
(1 row)

 id | job_id | queue_name |         created_at
----+--------+------------+----------------------------
 11 |     11 | default    | 2026-11-10 03:58:39.483583
(1 row)

 id |  status   | published_at
----+-----------+--------------
 33 | scheduled |
(1 row)

เปิดพนักงานกลับมา: ปิด bin/rails server แล้วเปิด bin/dev ตามเดิม · ผลจริง (เปิด worker ตอน 11:00:08 · ใบงานหมดกระดานตอน 11:00:10):

 id |   class_name   |        finished_at
----+----------------+----------------------------
 11 | PublishPostJob | 2026-11-10 04:00:09.860676
(1 row)

 count
-------
     0
(1 row)

 id |  status   |        published_at
----+-----------+----------------------------
 33 | published | 2026-11-10 04:00:09.797648
(1 row)

ใบงาน 11 เสร็จภายใน 2 วินาทีหลังเปิด worker · กระดานว่าง (count 0) · และสังเกต published_at = 11:00:09 ไม่ใช่ 10:58:39 ที่กดปุ่ม — job ตั้งเวลาเผยแพร่ตอนที่ทำจริง (Time.current ใน job) · grep -c ได้ +1 ฉบับต่อคน · ✅ ข้อ 4 ผ่าน

🍽 เทียบให้เห็นภาพ — กระดานใบงานที่ตอกติดผนัง

พนักงานหลังร้านกลับบ้านหมด · พนักงานหน้าร้านยังรับออเดอร์ "ส่งของให้โต๊ะ 33" ได้ตามปกติ แล้วแขวนใบงานไว้บนกระดาน · ใบงานแขวนอยู่อย่างนั้นทั้งคืน ไม่มีใครทำ แต่ก็ไม่มีใครทิ้ง · เช้ามาพนักงานเข้ากะ เห็นใบงานก็หยิบไปทำทันที

ที่ไม่เหมือนกัน: ร้านจริงมีคนเห็นว่าพนักงานหลังร้านไม่มา · ของเราไม่มีอะไรเตือนเลย — หน้าเว็บบอก "ส่งเข้าคิวเผยแพร่แล้ว" เหมือนทุกอย่างปกติ และนาทีที่ worker ปิดอยู่ ตัวกวาดก็ไม่เดินด้วย (ดูข้อ 5) · นี่คือเหตุผลของกฎข้อ 18 ข้างล่าง: "ลืมเปิด bin/jobs = งานนอนในคิวเงียบ ๆ"

🗣 ศัพท์ของข้อ 3–4
durable queue (คิวที่ทนทาน)
คิวที่เก็บใบงานในที่ที่ไม่หายเมื่อ process ตาย (ของเราคือตารางใน Postgres) · ตรงข้ามกับคิวในหน่วยความจำ · ถาม senior ได้ว่า "คิวของระบบนี้ durable ไหมครับ ถ้า worker ตายตอน deploy งานหายไหม"
polling (กวาดเป็นรอบ) vs delayed job (ตั้งเวลาล่วงหน้า)
polling = ตัวกวาดถาม DB ทุกนาทีว่ามีอะไรถึงเวลา · delayed job = แขวนใบงานไว้กับเวลาที่ระบุตอนบันทึก (wait_until) · polling ทนการแก้เวลากว่า แลกกับช้าได้ถึง 1 นาที · พิมพ์บอก AI ได้ว่า "Use a recurring job that polls the DB every minute instead of wait_until, because users can change the scheduled time."

ข้อ 5 — เปิดตาราง solid_queue_* ดูด้วย SQL

ข้อนี้คือ "รู้ว่ากระดานอยู่ไหน และอ่านเองได้โดยไม่ต้องพึ่งหน้าจอสวย ๆ" · \dt = คำสั่งพิเศษของ psql "ขอรายชื่อตาราง":

$ docker compose exec postgres psql -U postgres -d wongnok_blog_all_in_one_development -c '\dt solid_queue_*'
                        List of tables
 Schema |               Name               | Type  |  Owner
--------+----------------------------------+-------+----------
 public | solid_queue_batch_executions     | table | postgres
 public | solid_queue_batches              | table | postgres
 public | solid_queue_blocked_executions   | table | postgres
 public | solid_queue_claimed_executions   | table | postgres
 public | solid_queue_failed_executions    | table | postgres
 public | solid_queue_jobs                 | table | postgres
 public | solid_queue_pauses               | table | postgres
 public | solid_queue_processes            | table | postgres
 public | solid_queue_ready_executions     | table | postgres
 public | solid_queue_recurring_executions | table | postgres
 public | solid_queue_recurring_tasks      | table | postgres
 public | solid_queue_scheduled_executions | table | postgres
 public | solid_queue_semaphores           | table | postgres
(13 rows)

13 ตาราง แต่ที่ต้องรู้จักมีแค่ 5 · รันทีละคำสั่ง (ใส่ใน -c "…" แบบข้างบน) ผลจริงหลังข้อ 4:

-- งานตามเวลาที่ลงทะเบียนไว้ (มาจาก config/recurring.yml)
SELECT key, schedule, class_name FROM solid_queue_recurring_tasks;
           key           |   schedule   |        class_name
-------------------------+--------------+--------------------------
 publish_scheduled_posts | every minute | PublishScheduledPostsJob

-- ตัวกวาดรันนาทีไหนบ้าง (3 รอบล่าสุด)
SELECT task_key, run_at, job_id FROM solid_queue_recurring_executions ORDER BY run_at DESC LIMIT 3;
        task_key         |       run_at        | job_id
-------------------------+---------------------+--------
 publish_scheduled_posts | 2026-11-10 03:58:00 |      9
 publish_scheduled_posts | 2026-11-10 03:57:00 |      8
 publish_scheduled_posts | 2026-11-10 03:56:00 |      7

-- พนักงานที่เข้ากะอยู่
SELECT kind, pid, last_heartbeat_at FROM solid_queue_processes ORDER BY id;
       kind       |  pid  |     last_heartbeat_at
------------------+-------+----------------------------
 Supervisor(fork) | 16274 | 2026-11-10 04:00:09.640604
 Dispatcher       | 16279 | 2026-11-10 04:00:09.66135
 Worker           | 16280 | 2026-11-10 04:00:09.663729
 Scheduler        | 16281 | 2026-11-10 04:00:09.665981

-- งานที่พังจน retry หมดแล้ว
SELECT COUNT(*) FROM solid_queue_failed_executions;
 count
-------
     0

-- ใบงานทั้งหมดที่เคยมี แยกตามชนิด
SELECT class_name, COUNT(*) FROM solid_queue_jobs GROUP BY class_name;
        class_name        | count
--------------------------+-------
 PublishPostJob           |     3
 PublishScheduledPostsJob |     8

⚠️ อ่านอย่างเดียว: ห้าม DELETE / UPDATE ตาราง solid_queue_* เองตอน worker เปิดอยู่ — แก้ผิดแถวแล้วใบงานหายหรือถูกทำซ้ำแบบที่ไม่มีใครตาม log ได้ · จะลบงานค้างให้ใช้คำสั่งของ Solid Queue (README หัวข้อ "Failed jobs and retries")

ที่ AI พลาดประจำในบล็อกนี้ — ข้อห้าม 5 ข้อของหน้า 24

เกณฑ์ตรวจรับบอกว่า "ทำได้" · ข้อห้ามบอกว่า "ไม่ได้แถมของอันตรายมา" — แต่ละข้อมีอาการ และมีวิธีตรวจด้วยคำสั่ง:

ข้อห้ามถ้า AI ทำ จะเกิดอะไรตรวจยังไงของเราถูกเพราะ
ส่งอีเมลใน callback ของ modelทุกครั้งที่บันทึก (รวมตอนแก้ชื่อเรื่อง · ตอน seed · ตอน console) อีเมลออก · และอีเมลออกก่อนข้อมูลถูกบันทึกจริง — ผมลองใส่ after_save ที่ deliver_now แล้วสั่งยกเลิก transaction: จำนวนบทความ 33 -> 33 (ไม่ได้บันทึก) แต่อีเมลออกไปแล้ว 1 ฉบับgrep -rn "deliver_" appเจอบรรทัดเดียวใน jobs/publish_post_job.rb
ใช้ after_save (ต้อง after_commit)สั่ง job ก่อนข้อมูลถูกบันทึกจริง → job ไปหาบทความแล้วเจอค่าเก่าหรือไม่เจอเลย (บท 133 เจอเรื่องเดียวกันกับ broadcast)grep -rn "after_save" appไม่มีผลเลย · ปุ่มสั่ง job จาก controller ตัวกวาดอ่านจาก DB — ไม่ต้องใช้ callback
job ที่บวกเลขสะสมเก็บ "แจ้งไปแล้ว N คน" แล้ว + 1 ทุกครั้งที่ส่ง → job รันซ้ำ 3 รอบ = เลขเป็น 3 เท่า (โจทย์ ก. กับ จ. ในสไลด์หน้า 23)grep -rn "increment\|+= 1" app/models app/jobsไม่มีผล · นับจากจำนวนแถว post.post_notifications.count ทุกครั้ง
ส่ง signed URL หรือ token เข้า jobURL ของรูปหมดอายุใน 5 นาที (บท 139) แต่ job อาจรอคิวนานกว่านั้น (worker ปิด · retry รอบที่ 4 รอ ~258 วินาที) → ลิงก์ในอีเมลเปิดไม่ได้ · และ token ไปนอนในตาราง solid_queue_jobs ให้ใครเปิดอ่านก็ได้grep -rn "perform_later" appทั้ง 2 ที่ส่ง post.id · ลิงก์ในอีเมลสร้างตอนส่ง (post_url ใน mailer)
สมมติว่า job รันครั้งเดียวอีเมลซ้ำ (การทดลอง ข) · ข้อมูลเพี้ยน (การทดลอง ก) — อ่านโค้ดแล้วมักดูไม่ออกเกณฑ์ข้อ 2 · ทั้งสองการทดลองมีตัวกัน 2 ชั้น และพิสูจน์ด้วยการรันแล้ว

-rn = ค้นทุกไฟล์ในโฟลเดอร์ย่อย (r) และบอกเลขบรรทัด (n) · \| = "หรือ" ใน grep · ระวัง: grep -rn "after_" app/models จะเจอ after_create_commit ใน comment.rb จากบท 133 — อันนั้นถูก (ลงท้าย _commit) ไม่ใช่สิ่งที่ข้อห้ามพูดถึง

ขั้น 3 — เติมกฎ 16–18 ลง 00-context.md: คู่มือโตจาก 15 ข้อเป็น 18 ข้อ

ใบสั่ง 05-jobs.md ใช้ครั้งเดียวต่อบล็อก · ส่วน 00-context.md คือกฎถาวรของร้านที่ AI อ่านทุกครั้ง · เปิด prompts/00-context.md หาหัวข้อ ## รูปแบบการตอบ แล้วแทรกก้อนนี้ไว้ข้างบน (ต่อจากกฎข้อ 15 · มีบรรทัดว่างคั่นหัวข้อละบรรทัดเหมือนเดิม):

## กฎเพิ่มจากบล็อค Background jobs

16. สมมติเสมอว่า job จะถูกรันซ้ำ (retry · เข้าคิวซ้ำ · สอง worker หยิบพร้อมกัน)
    เปลี่ยนสถานะ: guard สถานะใน with_lock · งานที่ทำซ้ำไม่ได้ (อีเมล): unique index ที่ DB แล้วจองแถวก่อนทำ
    ห้ามนับด้วยการบวกเพิ่ม ให้นับจากจำนวนแถว
17. ส่งเฉพาะ id เข้า job แล้วโหลดสดตอน perform · ห้ามส่ง signed URL หรือ token
18. ตั้งเวลา = recurring job กวาดจาก DB (config/recurring.yml ต้องมี key ของ environment นั้น)
    ไม่ใช้ wait_until · ลืมเปิด bin/jobs = งานนอนในคิวเงียบ ๆ
กฎมาจากทำไมเป็นกฎถาวร ไม่ใช่แค่ในใบสั่ง
16เกณฑ์ข้อ 2 + การทดลอง ก/ข · ห้ามข้อ 3 และ 5ทุก job ในอนาคต (ส่งใบเสร็จ · ย่อรูป · เก็บกวาดไฟล์กำพร้า) เจอการรันซ้ำแน่นอน · บรรทัดที่สองบอก "ท่า" ให้ครบทั้งสองชั้นที่คุณเพิ่งเห็นว่ากันคนละเรื่อง
17ข้อกำหนด "ส่งเฉพาะ id" + ห้ามข้อ 4รวมสองบรรทัดของใบสั่งเป็นกฎเดียว · "โหลดสดตอน perform" = เหตุผลที่ข้อ 3 เผยแพร่ตามเวลาใหม่ได้
18เกณฑ์ข้อ 3–4 + กับดัก recurring.yml (บท 144)สองกับดักที่ไม่มี error: Scheduler ไม่ขึ้น · worker ไม่ได้เปิด — ใส่ไว้ AI จะเตือนคุณเองตอนงานไม่เกิด

ข้อห้าม "ส่งอีเมลใน callback" กับ "after_save" ไม่ย้ายมาเป็นกฎใหม่ — กฎข้อ 16–17 ครอบไว้แล้ว (ไม่มี callback = ไม่มีปัญหา after_save) · เลขกฎต้องตรงกับบทเรียน ถ้าเติมเกินมาจะเทียบกับบทหลัง ๆ ยาก

ขั้น 4 — เก็บเข้าโปรเจกต์

ดูก่อนว่าจะเก็บอะไร (ผลจริง — ต้องมีแค่สองบรรทัดนี้):

$ git status --short
 M prompts/00-context.md
?? prompts/05-jobs.md

ถ้ามี app/jobs/publish_post_job.rb โผล่มาด้วย = ยังไม่ได้ git restore หลังการทดลองในข้อ 2 → git diff ดูก่อน แล้ว restore

$ git add .
$ git commit -m "prompts: ใบสั่งงาน job ตั้งเวลา และกฎ 16–18"
[my-day3 ea11ef8] prompts: ใบสั่งงาน job ตั้งเวลา และกฎ 16–18
 2 files changed, 49 insertions(+)
 create mode 100644 prompts/05-jobs.md

49 insertions = 9 บรรทัดใน 00-context (หัวข้อ · บรรทัดว่าง · กฎ 6 บรรทัด · บรรทัดว่าง) + 40 บรรทัดของไฟล์ใหม่ · เลข commit ของคุณจะต่าง

✅ จบ Lab: โค้ดของคุณตอนนี้คือจุดเช็คพอยต์ E — ตั้งเวลาเผยแพร่ได้ ระบบเผยแพร่เองและส่งอีเมลถึงผู้ติดตาม รันซ้ำกี่รอบอีเมลก็ไม่ซ้ำ · สไลด์บอกให้คนตามไม่ทันใช้ branch ของอาจารย์ (day3-e-done) — ใช้ได้ แต่ไฟล์ข้างในต่างจากบทเรียนนี้ (ใช้ i18n · Mailpit · DB คิวแยก · เลขกฎใน 00-context ไม่ตรงกัน) · แนะนำให้ไล่บท 143–144 มากกว่า

ทางเลือก: ลอง vibe จริง — ให้ AI ทำบล็อก E ใหม่ แล้วตรวจด้วย checklist ชุดนี้

ทำหลัง commit ข้างบน · ไม่บังคับ · ขั้นตอนเหมือนบท 141 หัวข้อ "ทางเลือก" ต่างแค่จุดเริ่ม (commit prompts: ใบสั่งงาน rich text + รูปปก และกฎ 13–15) กับชื่อใบสั่ง (@prompts/05-jobs.md) · บทนี้ไม่โชว์ว่า AI ตอบอะไร (ของแต่ละคนต่างกันทุกครั้ง) · จ้องเป็นพิเศษ: ทำการทดลอง ข กับโค้ดของ AI ได้ไหม (มันมี "สมุด" หรือเปล่า) · recurring.yml มี development: ไหม · AI ใช้ bundle add แล้วได้ gem เวอร์ชันใหม่ที่ต้องดาวน์โหลดไหม

สรุปบล็อก E — ได้อะไรไปบ้าง

บทภาพร้านอาหารประโยคที่ควรอธิบายให้เพื่อนฟังได้
142ใบงานหลังร้าน · พนักงานเป็นลมก่อนติ๊กกระดานงานที่ผู้ใช้ไม่ต้องรอผลให้ทำเบื้องหลัง · exactly-once ไม่มีจริง เลือกได้แค่ "อาจหาย" หรือ "อาจซ้ำ"
143ดูป้ายก่อนทำ · กระดานอยู่ในตู้แฟ้มเลือก "อาจซ้ำ" แล้วทำให้ซ้ำได้ (idempotent) · คิวของ Rails 8 อยู่ในตาราง Postgres · ส่งแค่ id
144สมุดจดเลขใบงาน · โทรไม่ติดเว้นนานขึ้น · เดินตรวจกระดานทุกนาทีงานที่ซ้ำไม่ได้ (อีเมล) จองแถวก่อนทำ · retry ปลอดภัยเพราะซ้ำได้ · ตั้งเวลาด้วยตัวกวาด ไม่ใช่ wait_until
145 (บทนี้)ซ้อมเหตุร้ายหลังร้านพิสูจน์ด้วยการรัน + นับผลที่ลูกค้าได้จริง · ถอดตัวกันทีละตัวเพื่อรู้ว่าแต่ละตัวกันอะไร

ประโยคข้อ ② ของวัน (สไลด์หน้า 32): exactly-once ไม่มีอยู่จริง — ออกแบบให้ทำซ้ำได้แทน · ตอนนี้คุณไม่ได้แค่จำประโยคนี้ แต่เห็นด้วยตาแล้วว่า "ทำซ้ำได้" หน้าตาเป็นยังไง (3 รอบ อีเมล 1) และ "ทำซ้ำไม่ได้" หน้าตาเป็นยังไง (3 รอบ อีเมล 3) · ประโยคข้อ ③ (cache) อยู่ในสไลด์ส่วนถัดไป ซึ่งเว็บนี้ยังไม่ได้ทำเป็นบทเรียน · บทนี้คือบทสุดท้ายของเว็บตอนนี้

ภารกิจ: พิสูจน์ซ้ำกับบทความใหม่ และตรวจข้อห้ามด้วยคำสั่ง

🎯 ภารกิจ Lab Vibe 5 (~10 นาที)

ข้อ 2 ข้างบนทำกับบทความที่ "ตัวกวาด" เผยแพร่ · รอบนี้ลองกับบทความที่เผยแพร่ด้วยปุ่ม และนับก่อน–หลังเอง · bin/dev เปิดค้างไว้ · git status --short ว่าง

  1. rm -rf tmp/mails
  2. สร้างบทความใหม่แบบ "เก็บเป็นฉบับร่าง" → หน้า show กด เผยแพร่ทันที → รอ 2–3 วินาทีแล้วรีเฟรช (กล่องสีฟ้าต้องหาย) · จด id
  3. grep -c "^Subject:" tmp/mails/* → จดตัวเลข
  4. console: 3.times { PublishPostJob.perform_now(<id ของคุณ>) } แล้ว Post.find(<id>).published_at ก่อนและหลัง
  5. grep -c "^Subject:" tmp/mails/* อีกรอบ
  6. ตรวจข้อห้ามทีละบรรทัด:
    grep -rn "deliver_" app
    grep -rn "after_save" app
    grep -rn "increment\|+= 1" app/models app/jobs
    grep -rn "perform_later" app

ควรเห็น: ขั้น 3 กับ 5 ได้ตัวเลขเท่ากัน คือคนละ 1 · published_at ก่อน–หลังเหมือนกันทุกหลัก · grep บรรทัดแรกเจอบรรทัดเดียวใน app/jobs/publish_post_job.rb · บรรทัด 2 และ 3 ไม่มีผล · บรรทัด 4 เจอ 2 บรรทัด ลงท้าย (post.id) และ (@post.id)

ถ้าเจออาการนี้ แปลว่า…

ผลจริงของคำสั่ง grep ทั้ง 4 บรรทัดบนจุดเช็คพอยต์ E:

$ grep -rn "deliver_" app
app/jobs/publish_post_job.rb:43:        PostMailer.with(post: post, follower: follower).published.deliver_now
$ grep -rn "after_save" app
$ grep -rn "increment\|+= 1" app/models app/jobs
$ grep -rn "perform_later" app
app/jobs/publish_scheduled_posts_job.rb:11:      PublishPostJob.perform_later(post.id)
app/controllers/posts_controller.rb:72:      PublishPostJob.perform_later(@post.id)
  • อีเมลส่งที่เดียวคือใน job ใต้การจองแถว — ไม่อยู่ใน model · ไม่มี callback แบบ after_save · ไม่มีตัวนับที่บวกเพิ่ม · ส่งแค่ id ทั้งสองที่
  • ขั้น 3–5 ของผม (ทำกับบทความ 31 ในข้อ 2): ก่อน 1/1/1 → หลัง perform_now ×3 1/1/1 · published_at 10:51:00.733052 ทั้งก่อนและหลัง
  • ถ้าคุณกดปุ่มตอน worker เปิดอยู่ published_at จะห่างจากเวลากดไม่ถึงวินาที · ถ้าปิดอยู่ จะเป็นเวลาที่เปิด worker กลับมา (ข้อ 4)

Quiz

1. สไลด์หน้า 23 ถามว่า job ไหน idempotent (รัน 3 รอบผลเหมือน 1 รอบ) — ข้อไหนเป็นแบบนั้นจริง?

ตัวเลือกที่ 2 ถูก — นับจำนวนแถวที่มีอยู่จริงทุกรอบ รัน 3 รอบได้เลขเดียวกัน (ท่าเดียวกับ post_notifications.count ของเรา)

  • ตัวเลือกที่ 1 ผิด: รัน 3 รอบได้ +3 = "บวกเลขสะสม" ข้อห้ามข้อ 3
  • ตัวเลือกที่ 3 ผิดแบบที่หลอกตา: ดูเหมือน "ตั้งค่า" แต่ค่าที่ตั้งคือเวลาตอนรัน → รันทีหลังได้เวลาใหม่ทุกรอบ = การทดลอง ก ของเราเป๊ะ (published_at ถูกเขียนทับ) · ต้องมี guard เช่น unless user.welcomed_at
  • ตัวเลือกที่ 4 ผิด: ส่ง 3 รอบได้ 3 ฉบับ = การทดลอง ข

จุดที่ต้องจำ: "ตั้งค่า" จะ idempotent ก็ต่อเมื่อค่าที่ตั้งไม่ขึ้นกับตอนที่รัน — Time.current ต้องมี guard คุมเสมอ

2. AI ส่งงานมาบอกว่า "job idempotent แล้วครับ" คุณรัน perform_now ×3 แล้ว console บอก status "published" และ post_notifications.count => 3 เหมือนก่อนรัน — สรุปได้หรือยังว่าผ่าน?

ตัวเลือกที่ 4 ถูก — การทดลอง ข ได้ตัวเลขใน console ชุดนี้เป๊ะ ("published" · 3) ทั้งที่อีเมลออกไป 9 ฉบับ · แถวที่เห็นเป็นของเก่าจากรอบแรก

  • ตัวเลือกที่ 1 ผิด: สถานะคือสิ่งที่ guard ชั้นแรกกัน แต่อีเมลเป็นอีกชั้น
  • ตัวเลือกที่ 2 ผิด: แถวไม่เพิ่มได้ทั้งเพราะ "กันได้" และเพราะ "ไม่ได้จดเลย"
  • ตัวเลือกที่ 3 ผิด: อ่านโค้ดช่วยได้แต่ไม่ใช่หลักฐาน · สไลด์หน้า 24 ย้ำว่าเกณฑ์นี้พิสูจน์ด้วยการรัน ไม่ใช่ด้วยการอ่านโค้ด

จุดที่ต้องจำ: วัดที่ผลที่ลูกค้าได้รับ (อีเมลจริง) ไม่ใช่ที่สมุดจดของร้าน (แถวในตาราง)

3. ข้อ 3 ตั้งเวลา 10:56 แล้วแก้เป็น 10:58 · บทความเผยแพร่ตอน 10:58 พอดี และตาราง solid_queue_jobs ไม่มีใบงานของบทความนี้ตอน 10:56 เลย — เป็นเพราะอะไร?

ตัวเลือกที่ 1 ถูก — เราไม่ได้แขวนใบงานไว้ล่วงหน้าเลย · ตัวกวาด (ทุกนาที) ถาม DB ว่า "มีอะไรถึงเวลาแล้ว" ด้วยเวลาล่าสุด → แก้เวลาในตู้แฟ้มที่เดียวพอ

  • ตัวเลือกที่ 2 ผิด: ไม่มีโค้ดไหนลบใบงาน · ถ้าใช้ wait_until ใบงานเก่าจะค้างอยู่จริงและต้องเขียนโค้ดยกเลิกเอง
  • ตัวเลือกที่ 3 ผิด: guard ทำให้ job "ไม่ทำอะไร" ได้ แต่ไม่ลบแถวใบงาน · และในกรณีนี้ไม่มีใบงานให้ guard ทำงานด้วยซ้ำ
  • ตัวเลือกที่ 4 ผิด: Solid Queue ไม่รู้จักฟอร์มของเรา · scheduled_executions ใช้กับ wait_until ซึ่งเราไม่ได้ใช้

จุดที่ต้องจำ: ตั้งเวลาด้วยตัวกวาด = ความจริงอยู่ใน DB ที่เดียว · ผู้ใช้แก้กี่ครั้งก็ไม่มีใบงานเก่าค้าง

4. ข้อ 4 ปิด worker แล้วกด "เผยแพร่ทันที" ตอน 10:58:39 · เปิด worker กลับตอน 11:00:08 — published_at ของบทความคือเวลาไหน และใบงานรอดมาได้ยังไง?

ตัวเลือกที่ 3 ถูก — ผลจริง published_at 04:00:09.797648 (UTC = 11:00:09) · ใบงาน id 11 เป็นใบเดิมที่สร้างตอน 03:58:39 และแขวนอยู่ใน solid_queue_ready_executions ตลอดช่วงที่ worker ปิด

  • ตัวเลือกที่ 1 ผิด: คิวในหน่วยความจำคือ Async ของบท 142 ซึ่งหายทันทีที่ปิด · Solid Queue อยู่ในตู้แฟ้ม
  • ตัวเลือกที่ 2 ผิด: ปุ่มตั้งแค่ scheduled_at แล้วแขวนใบงาน · published_at มีทางเดียวคือ job (หน้าตอนนั้นยังเป็น scheduled)
  • ตัวเลือกที่ 4 ผิด: ใบงานไม่หาย (id 11 ใบเดิม finished_at ตอน 04:00:09) · ตัวกวาดรอบ 03:59 กับ 04:00 ไม่ได้รันด้วยซ้ำเพราะ Scheduler ปิดอยู่

จุดที่ต้องจำ: ปิด worker = งานรอ ไม่ใช่งานหาย · แต่ก็ไม่มีอะไรเตือน → ต้องเช็ค SolidQueue::Process เป็นนิสัย

ถามกลับได้เลย

ติดข้อไหน ก็อปผลจริงจากเครื่องคุณ (log · ผล psql · ผล grep) ไปถามได้เลย · ตัวอย่างคำถามที่ดี: "ผมทำการทดลอง ข แล้ว grep ได้คนละ 3 แต่ post_notifications.count ยัง 3 — ทำไมตัวเลขสองตัวนี้ไม่ไปด้วยกัน" · "ถ้ามีผู้ติดตาม 10,000 คน job นี้ถือ lock ระหว่างส่งอีเมลนานไหม ควรแยกยังไง"

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริงต้องสนใจไหม
24PublishPostJob.perform_now(post)job ของเรารับ id → ต้องเป็น perform_now(post.id) · ส่ง record ได้ ArgumentErrorสนใจ — พิมพ์ตามแล้วพัง
24"ถ้า job ไม่ idempotent จะเห็นอีเมล 3 ฉบับทันที"ของเรามีตัวกัน 2 ชั้น · ถอด guard สถานะอย่างเดียวไม่เห็นอีเมลซ้ำ (แต่ published_at เพี้ยน) · ต้องถอดการจองแถวถึงเห็นคนละ 3 ฉบับสนใจ — ข้อ 2 ภาคต่อ
24"นับอีเมลใน log"ไม่มี Mailpit · นับจากไฟล์ grep -c "^Subject:" tmp/mails/* (log ก็นับได้ด้วย Delivered mail แต่ปนรอบเก่า)แค่รู้ไว้
24"unique constraint ที่ DB" (ไม่บอกตารางไหน)unique index (post_id, follower_id) ของตาราง post_notificationsแค่รู้ไว้
24 (อ้างหน้า 22)"recurring job กวาดทุกนาที" · ตัวอย่างหน้า 22 มีแค่ production:ในเครื่องต้องมี development: ไม่งั้นไม่มี Scheduler และไม่มี errorสนใจ — กฎข้อ 18
24worker: bin/jobs · ป้าย "LAB"ของเราชื่อป้าย jobs: (ทำงานเหมือนกัน) · lab นี้ต้องรอนาฬิกาจริง ~10 นาทีแค่รู้ไว้

อ้างอิง