🔁 ทวนก่อนเริ่ม — ทั้งบทใช้คำพวกนี้ ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน
- background job = ใบงานหลังร้าน ที่ลูกค้าไม่ต้องยืนรอ · คิว = กระดานแขวนใบงาน · worker = พนักงานหลังร้านที่หยิบใบงานไปทำ (บท 142)
- exactly-once ไม่มีจริง — พนักงานส่งของถึงแล้วแต่เป็นลมก่อนติ๊กกระดาน คนใหม่เลยส่งซ้ำ · ทางออกคือทำให้งาน idempotent = รันกี่รอบผลเหมือนรอบเดียว (บท 142–143)
- Solid Queue ·
bin/jobs= กระดานแขวนใบงานอยู่ในตู้แฟ้ม (ตารางsolid_queue_*ใน Postgres) ·bin/jobsคือคำสั่งเรียกพนักงานหลังร้านมาทำงาน ·PublishPostJobรับแค่ id ของบทความ (บท 143) - ตัวกวาดทุกนาที · retry · อีเมล = พนักงานเดินตรวจกระดานทุกนาที (
config/recurring.yml) · โทรหาซัพพลายเออร์ไม่ติดก็เว้นนานขึ้นแล้วลองใหม่ · อีเมลถึงผู้ติดตาม 3 คน เขียนลงไฟล์ในtmp/mails/· ตารางpost_notificationsจดว่าส่งหาใครแล้ว (บท 144) - prompts/ = แฟ้มใบสั่งงานให้ AI ·
00-context.md= กฎถาวรของร้าน ตอนนี้มี 15 ข้อ (บท 121 · บท 141)
📍 โค้ดเริ่มจาก: ตอนจบบท 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 + index | 143 | migration ของ posts · enum :status ใน models/post.rb |
Solid Queue · bin/jobs ใน Procfile.dev | 143 | ตาราง solid_queue_* · Procfile.dev |
| PublishPostJob idempotent ด้วย guard สถานะ + unique constraint ที่ DB | 143 (guard) · 144 (unique index) | jobs/publish_post_job.rb · ตาราง post_notifications |
| recurring job กวาดทุกนาที ไม่ใช่ wait_until | 144 | jobs/publish_scheduled_posts_job.rb · config/recurring.yml |
| retry_on + backoff · discard_on RecordNotFound | 144 | บรรทัดบน ๆ ของ publish_post_job.rb |
| ส่งเฉพาะ id เข้า job | 143 | perform_later(post.id) ทั้งสองที่ |
Lab นี้มี 4 ขั้น:
ระบบใบงานหลังร้านเพิ่งติดตั้งเสร็จ · วันตรวจรับ เจ้าของร้านไม่ได้แค่ดูว่า "ส่งของได้" แต่ซ้อมเรื่องร้ายที่จะเกิดจริง: ยื่นใบงานเดิมซ้ำ 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_to | 143 | ตั้ง 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/mails | 144 | เสนอให้ลง 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 นาทีข้างหน้า แล้วรอดู มันเผยแพร่เอง
- Chrome ไปที่
localhost:3000/posts/new· หัวข้อตั้งเวลา 1 นาที· เนื้อหาอะไรก็ได้ · เลือกหมวด - เลือก ตั้งเวลาเผยแพร่ · ช่องเวลาใส่นาทีถัดไป (ตอนนี้ 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)
03:51:00= 10:51 เวลาไทย (UTC ช้ากว่า 7 ชั่วโมง) ·published_atห่างจากเวลาที่ตั้งไว้ไม่ถึง 1 วินาที — เพราะตัวกวาดรันต้นนาทีพอดี- รีเฟรช
/posts/31→ กล่องสีฟ้าหายไป · หน้า/postsมีบทความนี้อยู่บนสุด
ใครเป็นคนเผยแพร่? ดูใน 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
- บรรทัด 1 —
Schedulerแขวนใบงาน "กวาด" ตอน03:51:00Z(ต้นนาที ·Z= เวลา UTC) - บรรทัด 3 — ตัวกวาดเจอบทความ 31 ถึงเวลาแล้ว จึงแขวนใบงาน
PublishPostJobให้ ·with arguments: 31= ส่งแค่ id ตามข้อกำหนด - บรรทัด 5–6 — worker หยิบใบงานนั้นไปทำ: เปลี่ยนสถานะ + ส่งอีเมล 3 ฉบับ ใช้ 78 ms (ของคุณจะต่าง)
นับอีเมล — คำสั่งเดียวกับที่เขียนในใบสั่ง:
$ 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
perform_now= ทำใบงานทันทีในหน้าต่างนี้ ไม่ผ่านกระดาน (ต่างจากperform_laterที่แขวนให้ worker) — เหมาะกับการซ้อมเพราะเห็นผลทันที3.times { … }= ทำของในวงเล็บปีกกา 3 รอบ ·=> 3คือค่าที่timesคืนมา (จำนวนรอบ) ไม่ได้แปลว่าส่ง 3 ฉบับreload= โหลดค่าใหม่จากตู้แฟ้ม (ไม่งั้นได้ค่าเก่าที่ถืออยู่ในมือ) · สถานะกับpublished_atเหมือนก่อนรันทุกตัวpost_notifications.count= 3 แถว = ผู้ติดตาม 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
FOR UPDATE=with_lockล็อกแถวบทความระหว่างดูสถานะ (บท 143) · ไม่มีบรรทัดUPDATE "posts"เลย = guard สถานะเห็นว่าเผยแพร่แล้ว ไม่ทำซ้ำINSERTแล้วROLLBACK= พยายาม "จองแถว" (บทความ 31, ผู้ติดตาม 1) แต่ชน unique index เพราะมีแถวนี้แล้ว → ยกเลิกทั้งก้อน รวมถึงการส่งอีเมล → ข้ามไปคนถัดไป (บท 144)
แล้วนับอีเมลจริงอีกรอบ:
$ 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 นาที จะได้มีเวลากดแก้:
- สร้างบทความใหม่ "แก้เวลาเผยแพร่" ตั้งเวลาอีก 2 นาทีข้างหน้า (ของผม: สร้างตอน 10:54:49 ตั้ง 10:56)
- กด "แก้ไข" → ฟอร์มขึ้นเวลาเดิม
10:56และตัวเลือก "ตั้งเวลาเผยแพร่" ติ๊กอยู่ → เปลี่ยนเป็น 10:58 → บันทึก - กล่องสีฟ้าต้องเปลี่ยนเป็น
ตั้งเวลาเผยแพร่ไว้ 10/11/2026 10:58 · ยังไม่โชว์ในหน้ารวม - รอ แล้วดู 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)
arguments::json->'arguments'= ดึงเฉพาะค่าที่ส่งเข้า job ออกมาจากก้อน JSON ที่ Solid Queue เก็บไว้ (อ่านผ่าน ๆ ได้ ไม่ต้องจำ)- ใบงานของบทความ 32 มีใบเดียว สร้างตอน
03:58:00(10:58) — ตอน 10:56 ไม่มีใครแขวนใบงานเลย เพราะตัวกวาดอ่านเวลาล่าสุดจากตู้แฟ้มทุกนาที - ถ้าใช้ท่า
wait_until(แขวนใบงานล่วงหน้าตอนบันทึก) จะมีใบงานของ 10:56 ค้างอยู่ และต้องคิดเรื่องยกเลิกใบเก่าเอง (บท 144) · ✅ ข้อ 3 ผ่าน
ข้อ 4 — ปิด worker แล้วกด → งานค้างในคิว ไม่หาย · เปิดกลับ → งานถูกทำ
- ปิด
bin/dev(Ctrl+C) แล้วเปิดแค่เว็บ:bin/rails server— ตอนนี้ไม่มีพนักงานหลังร้าน (เช็ค:bin/rails runner 'p SolidQueue::Process.pluck(:kind)'→ ผลจริง[]) - สร้างบทความใหม่แบบ "เก็บเป็นฉบับร่าง" (ของผม: "ปิด worker แล้วกด" id 33) → หน้า show กด เผยแพร่ทันที
- รอสัก 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)
-cหลายตัว = สั่ง SQL หลายคำสั่งในครั้งเดียว ·\ท้ายบรรทัด = คำสั่งยังไม่จบ ต่อบรรทัดถัดไป- ใบงาน 11 ยังอยู่ ·
finished_atว่าง = ยังไม่มีใครทำ · ตารางsolid_queue_ready_executionsคือ "ใบงานที่พร้อมให้หยิบ" — แขวนรออยู่ 1 ใบ - ใบงานไม่หายเพราะกระดานอยู่ในตู้แฟ้ม (Postgres) ไม่ได้อยู่ในหัวของพนักงาน — ต่างจากคิวในหน่วยความจำ (
Async) ของบท 142 ที่ปิดเครื่องแล้วหายหมด
เปิดพนักงานกลับมา: ปิด 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 = งานนอนในคิวเงียบ ๆ"
- 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
- ตัวกวาดรอบล่าสุดคือ 03:58 แต่ตอนเช็คคือ 04:00 กว่า — นาที 03:59 กับ 04:00 หายไป เพราะ worker ปิดอยู่ช่วงข้อ 4 · ไม่เสียอะไร: รอบถัดไปกวาดจาก DB เจอเองทุกอย่างที่ถึงเวลา (ท่ากวาดทนกว่า · บท 144)
last_heartbeat_at= "ยังหายใจอยู่" ล่าสุดเมื่อไร · ตัวเลขนี้ค้างนาน = พนักงานตาย ·pid= เลขประจำ process ของเครื่อง (ของคุณต่าง)failed_executions= 0 = ไม่มีงานที่พังจนยอมแพ้ · ถ้ามี ต้องเปิดดู error ในแถวนั้นPublishPostJob 3= ข้อ 1 · 3 · 4 (การทดลองในข้อ 2 ใช้perform_nowไม่ผ่านกระดาน จึงไม่มีแถว) · ✅ ข้อ 5 ผ่าน
⚠️ อ่านอย่างเดียว: ห้าม 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 เข้า job | URL ของรูปหมดอายุใน 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 ว่าง
rm -rf tmp/mails- สร้างบทความใหม่แบบ "เก็บเป็นฉบับร่าง" → หน้า show กด เผยแพร่ทันที → รอ 2–3 วินาทีแล้วรีเฟรช (กล่องสีฟ้าต้องหาย) · จด id
grep -c "^Subject:" tmp/mails/*→ จดตัวเลข- console:
3.times { PublishPostJob.perform_now(<id ของคุณ>) }แล้วPost.find(<id>).published_atก่อนและหลัง grep -c "^Subject:" tmp/mails/*อีกรอบ- ตรวจข้อห้ามทีละบรรทัด:
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)
ถ้าเจออาการนี้ แปลว่า…
- กด "เผยแพร่ทันที" แล้วรีเฟรชกี่ทีกล่องสีฟ้าก็ไม่หาย ·
tmp/mailsไม่ถูกสร้าง → ไม่มี worker:bin/devไม่มีบรรทัดjobs(เช็คProcfile.dev) หรือSolidQueue::Process.pluck(:kind)ได้[]· ใบงานยังอยู่ในsolid_queue_ready_executionsไม่หาย เปิด worker แล้วจะถูกทำ (ข้อ 4) - ขั้น 5 ได้ตัวเลขมากกว่าขั้น 3 → การทดลอง ข ยังค้างอยู่ (
git diff app/jobsจะเห็นบรรทัด# PostNotification.create!) →git restore app/jobs/publish_post_job.rb published_atหลังรันเปลี่ยนเป็นเวลาปัจจุบัน → การทดลอง ก ยังค้างอยู่ (บรรทัด# if post.scheduled?) → restore เหมือนกัน- ข้อ 1 รอเกิน 2 นาทีแล้วยัง scheduled แต่ปุ่ม "เผยแพร่ทันที" ใช้ได้ → worker มี แต่ไม่มีตัวกวาด:
pluck(:kind)ไม่มี"Scheduler"→config/recurring.ymlไม่มีdevelopment:หรือแก้ไฟล์แล้วยังไม่ได้ปิด-เปิดbin/dev - หน้าต่าง
bin/devมี[BUG] Segmentation faultวนซ้ำที่บรรทัด jobs (บน macOS) →config/database.ymlไม่มีgssencmode: disable(บท 143)
ผลจริงของคำสั่ง 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 ×31/1/1·published_at10: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 ระหว่างส่งอีเมลนานไหม ควรแยกยังไง"
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 24 | PublishPostJob.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 |
| 24 | worker: bin/jobs · ป้าย "LAB" | ของเราชื่อป้าย jobs: (ทำงานเหมือนกัน) · lab นี้ต้องรอนาฬิกาจริง ~10 นาที | แค่รู้ไว้ |
อ้างอิง
- Rails Guides — Active Job: Retrying or Discarding Failed Jobs —
สั้นมาก · อ่านตัวอย่าง
retry_on/discard_onแล้วเทียบกับบรรทัดบน ๆ ของpublish_post_job.rb· retry ปลอดภัยเพราะ job รันซ้ำได้ (การทดลองในข้อ 2) - rails/solid_queue README — Recurring tasks —
ที่มาของ
config/recurring.ymlและตารางsolid_queue_recurring_tasks/_executionsในข้อ 5 · อ่านย่อหน้าที่บอกว่าไฟล์แยกตาม environment - rails/solid_queue README — Jobs and transactional integrity — ทำไมคิวที่อยู่ใน DB เดียวกับข้อมูลต้องระวังเรื่อง transaction · ปูพื้นข้อห้าม after_save
- Rails API — ActiveRecord::Locking::Pessimistic —
with_lock=SELECT … FOR UPDATEที่เห็นใน log ข้อ 2 · อ่านตัวอย่างแรกพอ - Rails Guides — Action Mailer: Previewing Emails —
หน้า
/rails/mailers/…ที่ใช้อ่านอีเมลภาษาไทยแทนการเปิดไฟล์ base64 - repo ของอาจารย์ — wongnok-blog-all-in-one —
เลือก branch
day3-e-doneถ้าอยากเทียบกับเฉลยของอาจารย์ · จำไว้ว่าไฟล์ต่างจากบทเรียนนี้ (Mailpit · DB คิวแยก)