🔁 ทวนก่อนเริ่ม — บทนี้ใช้คำพวกนี้
- at-least-once (บท 142) — คิวที่ไม่ยอมให้งานหาย ต้องยอมให้งานซ้ำ (worker ทำเสร็จแต่ตายก่อนบอกคิว → คิวแจกใบงานเดิมอีกรอบ)
perform_later· worker ·async(บท 142) — แขวนใบงานแล้วกลับทันที · worker คือโปรแกรมที่หยิบใบงานไปทำ ·asyncเก็บใบงานในหน่วยความจำ ปิดโปรแกรมแล้วหาย- idempotency (Go บท 36) —
PUTยิงซ้ำได้ผลเดิม ·POSTยิงซ้ำได้ของเบิ้ล - migration ปลอดภัย (บท 109) —
add_columnพร้อมdefaultไม่ต้องเขียนทุกแผ่น · index ใช้algorithm: :concurrentlyคู่กับdisable_ddl_transaction!· strong_migrations ดักท่าอันตรายก่อนถึงตู้ · การเติมข้อมูลแถวเดิม (backfill) อยู่ใน rake task ไม่ใช่ใน migration - ช่องว่างระหว่าง "ตรวจ" กับ "เก็บ" (บท 111) — สองโปรแกรมตรวจพร้อมกัน ผ่านทั้งคู่ แล้วเก็บซ้ำ · unique index ที่ตัวตู้คือด่านสุดท้ายที่กันได้จริง
🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · solid_queue 1.7.0 (สไลด์และ repo ของอาจารย์เขียน Ruby 4.0.5–4.0.6 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
📍 โค้ดเริ่มจาก: ตอนจบบท 141 (rich text + รูปปก + prompts/04-content.md) ที่ commit แล้ว — git status --short ต้องว่าง
(ถ้าบท 142 ยังเก็บกวาดไม่ครบ ทำภารกิจท้ายบท 142 ก่อน) · บทนี้เป็นโค้ดถาวร จบด้วย commit
🖥 เปิดค้างไว้: docker compose up -d (ฐานข้อมูล + MinIO) · bin/dev หนึ่งหน้าต่าง · อีกหน้าต่างไว้พิมพ์คำสั่ง · เบราว์เซอร์ที่ localhost:3000 ·
worker ยังไม่ต้องเปิด — ขั้น 3 จะเพิ่มมันเข้า bin/dev แล้วบอกให้ปิดเปิดใหม่
ผลรันทุกกล่องมาจากเครื่องจริง · SQL ตัดคอมเมนต์ /*application=…*/ ท้ายบรรทัดออก · บรรทัดที่ไม่เกี่ยวย่อเป็น … ·
เวลา · เลข ms · Job ID · เลข id ของบทความ · ตัวเลขในชื่อไฟล์ migration ของคุณจะต่าง
แผนที่บทนี้ — คิดก่อน แล้วสร้าง 5 ขั้น แล้วพิสูจน์
เป้าหมายของบล็อก E คือ "ตั้งเวลาเผยแพร่บทความ แล้วให้ระบบเผยแพร่เอง" · บทนี้สร้างครึ่งแรก: สถานะของบทความ คิวที่ไม่หาย และ job ที่ทำการเผยแพร่ · ส่วน "ระบบมาตรวจทุกนาที" กับอีเมลถึงผู้ติดตามรออยู่บท 144
| ส่วน | เรื่อง |
|---|---|
| ① ความคิด | idempotent คืออะไร · 3 ท่าทำให้รันซ้ำได้ · Active Job กับ Solid Queue |
| ② สร้าง | ขั้น 1 สถานะของบทความ · ขั้น 2 model + เติมข้อมูลเดิม · ขั้น 3 ติดตั้ง Solid Queue · ขั้น 4 PublishPostJob · ขั้น 5 ปุ่มและหน้าจอ |
| ③ พิสูจน์ | รัน job 3 ครั้งติด ผลต้องไม่เปลี่ยน · ปิด worker แล้วกดปุ่ม งานต้องรอในตาราง ไม่หาย |
idempotent — ทำ 1 ครั้งกับทำ 10 ครั้ง ได้ผลเท่ากัน
งานที่ idempotent คืองานที่ทำซ้ำกี่รอบ ผลสุดท้ายก็เหมือนทำรอบเดียว (สไลด์หน้า 19 ให้นิยามสั้น ๆ ว่า "ทำ 1 ครั้งกับทำ 10 ครั้ง ผลลัพธ์เหมือนกัน")
ทำไมต้องสน: บท 142 พิสูจน์แล้วว่าคิวที่ไม่ยอมให้งานหายจะส่งงานซ้ำได้เสมอ · เราห้ามการซ้ำไม่ได้ · ทางเดียวที่เหลือคือเขียน job ให้ "ซ้ำแล้วไม่เป็นไร" — นี่คือประโยคข้อ ② ของวัน: exactly-once ไม่มีอยู่จริง ออกแบบให้ทำซ้ำได้แทน
ใบงาน "ตั้งป้ายหน้าร้านเป็น 'เปิด'" — พนักงานทำรอบเดียวหรือเผลอทำสามรอบ ป้ายก็ยังเป็น "เปิด" · ซ้ำได้ ไม่มีใครเดือดร้อน
ใบงาน "บวกยอดขายวันนี้อีก 100 บาท" — ทำรอบเดียว +100 · ทำซ้ำเป็น +200 +300 · ยอดในสมุดผิดทุกครั้งที่ซ้ำ
ที่ไม่เหมือนกัน: พนักงานในร้านเดินไปถึงป้ายแล้วเห็นด้วยตาว่าเปิดอยู่แล้ว เลยไม่พลิกซ้ำเอง · worker ไม่เห็นอะไรเลยนอกจากที่โค้ดสั่งให้ "ดู" — ถ้าเราไม่เขียนบรรทัดที่ตรวจสถานะก่อน มันจะทำตามใบงานตรง ๆ ทุกรอบ
เทียบกับ Go (บท 36): PUT /posts/1 ส่งข้อมูลทั้งก้อน "ให้บทความ 1 เป็นแบบนี้" ยิงซ้ำได้ผลเดิม ·
POST /posts "สร้างบทความใหม่" ยิงซ้ำได้บทความสองชิ้น · job ก็แบ่งได้แบบเดียวกัน: "ทำให้เป็น" ซ้ำได้ · "เพิ่มอีก" ซ้ำแล้วพัง
- idempotent / idempotency
- ทำซ้ำแล้วผลเท่าเดิม · ถาม senior ได้ว่า "job ตัวนี้ idempotent ไหมครับ ถ้า retry แล้วจะเกิดอะไร"
- side effect (ผลข้างเคียง)
- สิ่งที่งานไปเปลี่ยนโลกข้างนอก — เขียนลงตู้ · ส่งอีเมล · เรียกระบบอื่น · งานที่ไม่มี side effect ซ้ำได้เสมอ ปัญหาอยู่ที่งานที่มี
3 ท่าที่ใช้จริง — ทำให้ job รันซ้ำแล้วไม่พัง
สไลด์หน้า 19 สรุปไว้ 3 ท่า · เรียงจาก "ดีที่สุดถ้าทำได้" ไปจนถึง "ท่าสุดท้ายเมื่อไม่มีอะไรให้ดู":
| ท่า | ทำยังไง | เหมาะกับ |
|---|---|---|
| 1 · ธรรมชาติของงาน | เขียนงานเป็น "ตั้งค่าให้เป็น" หรือ "คำนวณใหม่จากของจริง" แทน "บวกเพิ่มจากค่าเดิม" | ดีที่สุด ถ้าออกแบบได้ — ไม่ต้องตรวจอะไรเลย |
| 2 · guard สถานะ | ดูสถานะในตารางก่อน · ทำไปแล้วก็ไม่ทำ | งานที่มีสถานะให้ดู · สไลด์อ้างว่าใช้ได้ 90% ของงาน · บทนี้ใช้ท่านี้ |
| 3 · idempotency key + unique index | ตั้งชื่อเฉพาะให้งานแต่ละชิ้น จดลงตาราง · ตู้ไม่ยอมให้ชื่อซ้ำ | งานที่ไม่มีสถานะให้ดู เช่น ส่งอีเมล · ใช้จริงในบท 144 |
ตัวอย่างสั้น ๆ ของแต่ละท่า — ยังไม่ต้องพิมพ์ เป็นตัวอย่างสมมติทั้งก้อน (คอลัมน์ comments_count · ตาราง JobRun · method send_the_email ยังไม่มีในแอป ตั้งชื่อขึ้นมาให้อ่านง่าย):
# ท่า 1 — ซ้ำได้: นับใหม่จากของจริงทุกครั้ง (รันกี่รอบก็ได้เลขเท่าจำนวนความเห็นจริง)
post.update!(comments_count: post.comments.count)
# ท่า 1 — ซ้ำแล้วพัง: บวกเพิ่มจากค่าเดิม (รันสามรอบ = +3)
post.update!(comments_count: post.comments_count + 1)
# ท่า 2 — guard: ดูสถานะก่อน ทำไปแล้วก็ออกเลย
return if post.published?
post.update!(status: :published, published_at: Time.current)
# ท่า 3 — key: จดชื่อเฉพาะของงานลงตาราง ที่คอลัมน์ key มี unique index
key = "notify-#{post.id}-#{follower.id}"
JobRun.create!(key: key) # ชื่อนี้มีแล้ว → ตู้ปฏิเสธ (error) → ไม่ไปส่งซ้ำ
send_the_email
- ท่า 1 — คุณเจอมาแล้วโดยไม่รู้ตัว:
find_or_create_by!ใน seeds (บท 120) "หาก่อน ไม่มีค่อยสร้าง" รันซ้ำแล้วไม่เบิ้ล - ท่า 2 —
return if …คือ "ถ้า … ก็ออกจาก method เลย" · บรรทัดเดียวที่เปลี่ยน job ธรรมดาให้ซ้ำได้ - ท่า 3 — ใช้เมื่องานไม่ทิ้งร่องรอยให้ดู · อีเมลส่งออกไปแล้วไม่มีช่องไหนในตาราง posts บอกได้ว่า "ส่งให้คนนี้แล้ว" จึงต้องสร้างตารางจดเอง ·
และต้องเป็น unique index (ด่าน ④ ของบท 111) ไม่ใช่แค่
exists?ในโค้ด เพราะสอง worker อาจตรวจพร้อมกันแล้วผ่านทั้งคู่ — ช่องว่างเดียวกับที่บท 111 เจอ
ท่า 2 = ดูป้ายก่อนทำ · ใบงาน "เปิดร้าน" เขียนกำกับว่า "ถ้าป้ายเป็น 'เปิด' อยู่แล้ว ไม่ต้องทำอะไร" · คนที่หยิบใบงานซ้ำมาเห็นป้ายก็วางใบงานลง
ท่า 3 = สมุดจดเลขใบงานที่ทำแล้ว · งาน "ส่งข้าวกล่องให้คุณสมชาย" ไม่มีป้ายให้ดู · ร้านเลยมีสมุดเล่มหนึ่ง ใครจะออกไปส่งต้องเขียนเลขใบงานลงสมุดก่อน ถ้าเลขนี้มีอยู่แล้ว = มีคนส่งไปแล้ว ไม่ต้องไป
ที่ไม่เหมือนกัน: สมุดกระดาษ สองคนเขียนเลขเดียวกันพร้อมกันได้ (ต่างคนต่างเปิดดูแล้วไม่เห็น) · unique index ของตู้ไม่ยอมให้แผ่นที่สองเข้าลิ้นชักเลย — คนที่ช้ากว่าเสี้ยววินาทีได้ error กลับไป นี่คือเหตุที่ท่า 3 ต้องพึ่งตู้ ไม่ใช่โค้ด
- guard (guard clause)
- บรรทัดตรวจเงื่อนไขต้น method ที่ "ออกก่อน" ถ้าไม่ควรทำ · พิมพ์บอก AI ได้ว่า "add a state guard so the job is a no-op when the post is already published"
- idempotency key
- ชื่อเฉพาะของงานหนึ่งชิ้น ใช้ตรวจว่าเคยทำแล้วหรือยัง · คำเดียวกับหัว
Idempotency-Keyที่ client ส่งมากับPOSTใน Go บท 36 - unique constraint
- กฎที่ตัวตู้ "ห้ามซ้ำ" (สร้างด้วย unique index) · ใน Rails ชนแล้วได้
ActiveRecord::RecordNotUnique· ถาม senior ได้ว่า "อันนี้กันซ้ำที่ DB หรือแค่ในโค้ดครับ"
Active Job คือหน้ากาก · Solid Queue คือคิวที่อยู่ในตาราง Postgres
Active Job คือชุดเครื่องมือเขียน job ที่มากับ Rails — ApplicationJob · perform · perform_later ที่ใช้ในบท 142 ทั้งหมดมาจากมัน ·
สไลด์หน้า 20 เรียกมันว่า "หน้ากาก": โค้ด job ของเราหน้าตาเหมือนเดิม ไม่ว่าข้างหลังจะเก็บใบงานไว้ที่ไหน
ตัวที่อยู่ข้างหลังคือ adapter (ตัวเสียบ — บท 142) · บท 142 ใช้ async (ใบงานอยู่ในหน่วยความจำ ปิดโปรแกรมแล้วหาย) ·
บทนี้เปลี่ยนเป็น Solid Queue = adapter ที่เก็บใบงานเป็นแถวในตาราง Postgres · เปลี่ยน adapter ด้วย config บรรทัดเดียว ไม่ต้องแก้ job
ทำไมเป็น Solid Queue: เป็น adapter ค่าเริ่มต้นของ Rails 8 (production ของเราตั้ง :solid_queue ไว้แล้ว) · ไม่ต้องติดตั้งโปรแกรมคิวเพิ่ม (เช่น Redis — บท 32 ของ Go) ·
แบบฐานข้อมูลก้อนเดียวที่เราจะตั้งในขั้น 3 สำรองข้อมูล (backup) ครั้งเดียวได้ทั้งข้อมูลและใบงานที่ค้าง · และเปิดดูคิวด้วย SQL ได้
ที่แลกมา: สไลด์อ้างว่ารับงานต่อวินาทีได้น้อยกว่าคิวบน Redis แต่พอสำหรับงานส่วนใหญ่
Active Job = แบบฟอร์มใบงานมาตรฐานของร้าน · เชฟกรอกแบบเดียวกันเสมอ (ชื่องาน + เลขแฟ้ม) · adapter = ที่แขวนใบงาน — บท 142 คือโน้ตในกระเป๋าเสื้อเชฟ · บทนี้คือลิ้นชักหนึ่งในตู้แฟ้มเดิม ที่ร้านกันไว้เป็นกระดานแขวนใบงาน
ปิดครัว (ปิด server) แล้วตู้แฟ้มยังอยู่ · ใบงานในลิ้นชักก็ยังอยู่ · พนักงานหลังร้าน (โปรแกรม bin/jobs) มาถึงเมื่อไรก็หยิบไปทำต่อได้
ที่ไม่เหมือนกัน: พนักงานจริงเห็นใบงานใหม่ทันทีที่แขวน · bin/jobs ต้องคอยเปิดลิ้นชักดูเป็นรอบ ๆ
(ไฟล์ config/queue.yml ของเราตั้งไว้ทุก 1 วินาที) · และถ้าไม่มีพนักงานมาเลย ใบงานก็นอนในลิ้นชักเงียบ ๆ ไม่มีใครเตือน
เทียบกับ Go (บท 26 worker pool): channel jobs = คิว แต่อยู่ในหน่วยความจำของโปรแกรมเดียวกัน — ตรงกับ async ·
Solid Queue ย้าย "channel" ไปเป็นตารางในฐานข้อมูล worker จึงเป็นอีกโปรแกรมหนึ่ง และอยู่คนละเครื่องกับเว็บก็ได้
- Active Job
- ชั้นกลางของ Rails ที่ให้เขียน job แบบเดียวกันกับทุก adapter · คู่มือ Rails เขียนว่าเปลี่ยนไป Sidekiq หรือ Resque (โปรแกรมคิวยอดนิยมที่เก็บใบงานใน Redis) "requires only a configuration change"
- Solid Queue
- adapter ค่าเริ่มต้นของ Rails 8 ที่ใช้ฐานข้อมูลเป็นคิว · ถาม senior ได้ว่า "คิวเราอยู่ DB เดียวกับข้อมูลหรือแยกก้อนครับ"
- bin/jobs
- คำสั่งเปิด Solid Queue · มันเปิดโปรแกรมย่อย 3 ตัว: supervisor (หัวหน้าที่คอยดูว่าลูกทีมยังอยู่) · worker (หยิบใบงานไปทำ) · dispatcher (ย้ายใบงานที่ตั้งเวลาไว้มาให้ worker เมื่อถึงเวลา)
ขั้น 1 · บทความมีสถานะ — migration สองไฟล์
ตอนนี้บทความทุกชิ้นโชว์ทันทีที่บันทึก · เราต้องการ 3 สถานะ: ร่าง (ยังไม่โชว์) · ตั้งเวลา (รอถึงเวลา) · เผยแพร่แล้ว ·
และเวลาอีกสองช่อง: scheduled_at = เวลาที่อยากให้ออก · published_at = เวลาที่ออกจริง
1.1 สร้างไฟล์ migration (หน้าต่างที่สอง):
$ bin/rails generate migration AddPublishingToPosts status:string scheduled_at:datetime published_at:datetime
invoke active_record
create db/migrate/20261110034656_add_publishing_to_posts.rb
Rails เขียน add_column มาให้ 3 บรรทัด · เปิดไฟล์นั้นแล้วแก้ทั้งไฟล์เป็น:
class AddPublishingToPosts < ActiveRecord::Migration[8.1]
# expand: เพิ่มคอลัมน์อย่างเดียว ไม่ UPDATE แถวเดิมทีละแถว (Postgres 11+ เก็บ default ไว้ที่ตัวตาราง ไม่เขียนทับทุกแถว)
def change
# บทความเดิมทุกแถวถือว่าเผยแพร่แล้ว → ใส่ default "published" ตอนเพิ่มคอลัมน์
add_column :posts, :status, :string, null: false, default: "published"
# จากนี้ไปบทความใหม่เริ่มเป็นฉบับร่าง
change_column_default :posts, :status, from: "published", to: "draft"
add_column :posts, :scheduled_at, :datetime
# published_at ของบทความเดิม เติมด้วย rake task (bin/rails posts:backfill_published_at)
add_column :posts, :published_at, :datetime
end
end
status, :string— เก็บสถานะเป็นคำ ("draft") ไม่ใช่ตัวเลข · เปิดตารางดูด้วย SQL แล้วอ่านรู้เรื่องทันทีnull: false, default: "published"— ห้ามว่าง · และแถวเดิม 30 แถวได้ค่า "published" ทันที โดยไม่ต้องเขียนทีละแถว (บท 109: Postgres จดค่าเริ่มต้นไว้ที่ตัวตาราง)change_column_default … from: "published", to: "draft"— เปลี่ยนค่าเริ่มต้นทันทีหลังเพิ่ม · แถวเดิมยังเป็น published · แถวที่สร้างหลังจากนี้ได้ draft · ใส่from:ไว้ให้ Rails รู้วิธีย้อนกลับ (rollback)- ทำไมไม่ตั้ง default เป็น
"draft"แล้วเขียนงานเปลี่ยน "draft → published" ทีหลัง: งานนั้นรันซ้ำไม่ได้ — รันอีกรอบวันหน้าจะไปเผยแพร่ร่างจริงของผู้ใช้ด้วย · ท่าสองจังหวะนี้ไม่มีงานแบบนั้นเลย scheduled_at·published_at—:datetime= วันที่ + เวลา · ว่างได้ (ร่างไม่มีเวลา)
$ bin/rails db:migrate
== 20261110034656 AddPublishingToPosts: migrating =============================
-- add_column(:posts, :status, :string, {null: false, default: "published"})
-> 0.0013s
-- change_column_default(:posts, :status, {from: "published", to: "draft"})
-> 0.0020s
-- add_column(:posts, :scheduled_at, :datetime)
-> 0.0006s
-- add_column(:posts, :published_at, :datetime)
-> 0.0006s
== 20261110034656 AddPublishingToPosts: migrated (0.0064s) ====================
1.2 index สองตัว — ไฟล์แยก เพราะต้องปิด transaction (บท 109):
$ bin/rails generate migration AddPublishingIndexesToPosts
invoke active_record
create db/migrate/20261110034707_add_publishing_indexes_to_posts.rb
แก้ทั้งไฟล์เป็น:
class AddPublishingIndexesToPosts < ActiveRecord::Migration[8.1]
# concurrently = สร้าง index โดยไม่ล็อกการเขียนตาราง posts (ต้องอยู่นอก transaction)
disable_ddl_transaction!
def change
# ตัวกวาดทุกนาทีค้น "ถึงเวลาแล้วหรือยัง" ด้วย scheduled_at
add_index :posts, :scheduled_at, algorithm: :concurrently
# หน้ารวมเรียงบทความตามเวลาเผยแพร่
add_index :posts, :published_at, algorithm: :concurrently
end
end
- index ของ
scheduled_at— บท 144 จะมีตัวกวาดที่ถามตู้ทุกนาทีว่า "ใครถึงเวลาแล้วบ้าง" · index = ป้ายแท็บข้างลิ้นชัก หาได้โดยไม่ต้องเปิดทุกแฟ้ม - index ของ
published_at— หน้ารวมจะเปลี่ยนไปเรียงตามเวลาเผยแพร่ (ขั้น 2)
ถ้าลืม disable_ddl_transaction! กับ algorithm: :concurrently strong_migrations จะหยุดไว้ก่อน (ผลจริง ตัดส่วนท้าย):
=== Dangerous operation detected #strong_migrations ===
Adding an index non-concurrently blocks writes. Instead, use:
$ bin/rails db:migrate
== 20261110034707 AddPublishingIndexesToPosts: migrating ======================
-- add_index(:posts, :scheduled_at, {algorithm: :concurrently})
-> 0.0025s
-- add_index(:posts, :published_at, {algorithm: :concurrently})
-> 0.0011s
== 20261110034707 AddPublishingIndexesToPosts: migrated (0.0045s) =============
ขั้น 2 · model รู้จักสถานะ และเติม published_at ให้บทความเดิม
ตู้มีคอลัมน์แล้ว แต่ model ยังไม่รู้ว่า status มีได้แค่ 3 คำ · Rails มีเครื่องมือชื่อ enum (ย่อจาก enumeration = "รายการค่าที่เลือกได้")
ประกาศครั้งเดียว ได้ทั้งการตรวจค่า และคำถามสำเร็จรูปอย่าง post.draft? · Post.published
2.1 เปิด app/models/post.rb · เพิ่มสามจุด · บรรทัดของเดิม (has_one_attached · validates :title · validates :body · scope :recent) ใส่ไว้ให้ดูตำแหน่ง ที่เหลือคือของใหม่:
has_one_attached :cover
# สถานะเก็บเป็นคำ (string) อ่านใน SQL ได้ตรง ๆ · ได้ scope ฟรี: Post.draft / Post.scheduled / Post.published
# validate: true = ส่งค่าแปลก ๆ มาจากฟอร์ม → ได้ error ในฟอร์ม ไม่ใช่หน้า error
enum :status, { draft: "draft", scheduled: "scheduled", published: "published" }, validate: true
validates :title, presence: true, length: { maximum: 120 }
validates :body, presence: true
validates :scheduled_at, presence: true, if: :scheduled?
# published ได้ทางเดียวคือ PublishPostJob (ตั้ง published_at ให้) · ฟอร์มส่ง status=published มาเองจะติดข้อนี้
validates :published_at, presence: true, if: :published?
# ตรวจเฉพาะตอนแนบรูปใหม่ … (บรรทัดเดิม)
…
scope :recent, -> { order(created_at: :desc) }
# หน้ารวม: เฉพาะที่เผยแพร่แล้ว ใหม่สุดก่อน
scope :latest_published, -> { published.order(published_at: :desc) }
enum :status, { draft: "draft", … }— ชื่อในโค้ด (ซ้าย) กับคำที่เก็บในตู้ (ขวา) ตรงกัน · ได้ของฟรี:post.scheduled?(ถาม) ·Post.draft(ค้นเฉพาะร่าง) ·Post.not_published(ทุกอย่างที่ยังไม่เผยแพร่ — ขั้น 5 ใช้)validate: true— ค่าที่ไม่อยู่ในรายการกลายเป็นข้อความ error ในฟอร์ม · ไม่ใส่ = Rails โยน error ทั้งหน้าvalidates :scheduled_at, presence: true, if: :scheduled?— ถ้าเลือก "ตั้งเวลา" ต้องกรอกเวลาด้วย ·if:= กฎนี้ใช้เฉพาะบางกรณีvalidates :published_at, … if: :published?— เผยแพร่แล้วต้องมีเวลาเผยแพร่ · ทำให้ฟอร์มแอบส่งstatus=publishedมาเองไม่ได้ เพราะฟอร์มไม่มีช่อง published_atscope :latest_published— คำถามสำเร็จรูปของหน้ารวม แบบเดียวกับscope :recentที่สร้างในบท 126
ลองถาม (ผลจริง หลังเพิ่ม enum):
$ bin/rails runner 'puts Post.not_published.to_sql; p Post.new.status'
SELECT "posts".* FROM "posts" WHERE "posts"."status" != 'published'
"draft"
!= คือ "ไม่เท่ากับ" · บทความใหม่ได้ "draft" จากค่าเริ่มต้นของตู้ที่ตั้งในขั้น 1
2.2 บทความเดิม 30 ชิ้นเป็น published แล้ว แต่ published_at ยังว่าง · เติมด้วย rake task (บท 109: การเติมข้อมูลไม่อยู่ในไฟล์ migration) ·
เปิด lib/tasks/posts.rake ที่มี task backfill_body จากบท 137 อยู่แล้ว · พิมพ์ต่อท้าย ก่อน end บรรทัดสุดท้าย:
desc "เติม published_at ให้บทความที่เผยแพร่แล้วแต่ยังไม่มีเวลาเผยแพร่ (ใช้เวลาที่สร้าง) — รันซ้ำได้"
task backfill_published_at: :environment do
# เลือกเฉพาะแถวที่ยังว่าง → รันรอบสองไม่ทับของเดิม · ทีละ 1,000 แถว ไม่ล็อกทั้งตาราง
Post.published.where(published_at: nil).in_batches(of: 1000) do |posts|
posts.update_all("published_at = created_at")
end
puts "เผยแพร่แล้ว #{Post.published.count} · ยังไม่มี published_at #{Post.published.where(published_at: nil).count}"
end
Post.published.where(published_at: nil)— นี่คือท่า 2 (guard) ในรูปของคำค้น: เลือกเฉพาะแถวที่ยังไม่ได้ทำin_batches(of: 1000)— แบ่งทีละ 1,000 แถว (บท 109) ·update_all("published_at = created_at")— ให้ตู้ก็อปเวลาที่สร้างมาใส่เองในคำสั่งเดียว ไม่โหลดแฟ้มมาทีละแผ่น
อย่าใช้ bin/rails generate task posts … — ไฟล์ posts.rake มีอยู่แล้ว generator จะถามว่าจะเขียนทับไหม · ตอบ Y แล้ว task backfill_body หายทั้งก้อน
รัน task นี้สองรอบติดกัน:
$ bin/rails posts:backfill_published_at
$ bin/rails posts:backfill_published_at
รอบที่สองจะพิมพ์ต่างจากรอบแรกไหม · และรอบที่สองไปแก้ published_at ของใครหรือเปล่า?
ผลจริง:
$ bin/rails posts:backfill_published_at
เผยแพร่แล้ว 30 · ยังไม่มี published_at 0
$ bin/rails posts:backfill_published_at
เผยแพร่แล้ว 30 · ยังไม่มี published_at 0
- สองรอบพิมพ์เหมือนกัน · รอบแรกเติมครบ 30 · รอบสองคำค้น
where(published_at: nil)ไม่เจอใครเลย จึงไม่แก้อะไร - งานที่เปลี่ยนข้อมูลจริงแต่รันซ้ำได้ — task ชิ้นแรกของเราที่ idempotent แบบตั้งใจ ·
posts:backfill_bodyของบท 137 ก็ใช้ท่าเดียวกัน (where.missing)
ขั้น 3 · ติดตั้ง Solid Queue ในฐานข้อมูลก้อนเดิม
แอปที่สร้างด้วย Rails 8 มีไฟล์ของ Solid Queue ติดมาแล้ว: config/queue.yml (ตั้งค่า worker) · bin/jobs (คำสั่งเปิด worker) · db/queue_schema.rb (แบบของตารางคิว) ·
แต่ development ยังไม่ได้ใช้เลย
ส่วน production.rb มีบรรทัด connects_to ที่สั่งให้คิวไปอยู่ในฐานข้อมูลอีกก้อนหนึ่ง (ตู้แฟ้มใบที่สอง) ·
README ของ Solid Queue แนะนำแบบแยกก้อน แต่เราเลือกก้อนเดียวกับข้อมูล เพราะ compose ของเรามี Postgres ก้อนเดียว ตั้งค่าน้อยกว่า
README มีหัวข้อ "Single database configuration" รองรับแบบก้อนเดียว 3 ข้อ: ① ก็อป queue_schema.rb เป็น migration · ② ลบ connects_to จาก production · ③ migrate
3.1 สร้าง migration แล้วก็อปตารางมาใส่:
$ bin/rails generate migration CreateSolidQueueTables
invoke active_record
create db/migrate/20261110034727_create_solid_queue_tables.rb
- เปิด
db/queue_schema.rb· ก็อปทุกบรรทัดระหว่าง บรรทัดแรกActiveRecord::Schema[7.1].define(version: 1) doกับendบรรทัดสุดท้าย (ไม่เอาสองบรรทัดนั้น) - วางในไฟล์ migration ใหม่ ระหว่าง
def changeกับend· เลือกที่วางทั้งก้อนแล้วกด Tab หนึ่งครั้งให้เยื้องเข้าอีก 2 ช่อง (บรรทัดว่างไม่ต้องมีช่องว่าง) bin/rails db:migrate
พังทันที — strong_migrations ไม่ยอม (ผลจริง ตัดส่วนท้าย):
=== Dangerous operation detected #strong_migrations ===
The force option will destroy existing tables.
If this is intended, drop the existing table first.
In any case, remove the force option.
ทุก create_table ใน queue_schema.rb มี force: :cascade ต่อท้าย = "ถ้ามีตารางชื่อนี้อยู่แล้ว ลบทิ้งแล้วสร้างใหม่" — ไฟล์ schema ใช้ได้ แต่ใน migration อันตราย ·
ใช้ค้นหาและแทนที่ของ editor ลบข้อความ , force: :cascade ออกทั้ง 13 จุด แล้ว migrate อีกรอบ — พังอีกข้อ:
=== Dangerous operation detected #strong_migrations ===
Adding a foreign key blocks writes on both tables. Instead,
add the foreign key without validating existing rows,
then validate them in a separate migration.
(ไม่ต้องกลัวว่าพังกลางทางแล้วตารางสร้างไปครึ่งเดียว · migration ทั้งไฟล์อยู่ใน transaction เดียว พังแล้ว Postgres ย้อนทั้งหมดให้ (บท 109) · แก้ไฟล์แล้ว migrate ใหม่ได้เลย)
foreign key (เลขอ้างถึงแฟ้มอื่นต้องมีจริง — บท 111) ตอนเพิ่มต้องตรวจทุกแถวเดิม ระหว่างนั้นห้ามเขียน · strong_migrations อ่านแค่คำสั่ง ไม่รู้ว่าตารางเพิ่งสร้างในไฟล์เดียวกันและยังว่าง ·
เราบอกมันว่า "รู้แล้ว ปลอดภัย" ด้วย safety_assured do … end (คำสั่งของ strong_migrations) · หัวกับท้ายไฟล์สุดท้ายเป็นแบบนี้:
# ตารางของ Solid Queue — ก็อปจาก db/queue_schema.rb (README ของ solid_queue หัวข้อ "Single database configuration")
# คิวอยู่ใน DB เดียวกับข้อมูล → ตั้งค่าน้อยลง · backup ก้อนเดียวได้ทั้งข้อมูลและงานที่ค้างในคิว
# แก้จากของเดิม 2 จุด: ลบ force: :cascade (strong_migrations ห้าม) · ครอบ add_foreign_key ด้วย safety_assured
class CreateSolidQueueTables < ActiveRecord::Migration[8.1]
def change
create_table "solid_queue_blocked_executions" do |t|
t.bigint "job_id", null: false
… (ตาราง 13 ตารางจาก queue_schema.rb ไม่แก้อย่างอื่น)
end
# ตารางเพิ่งสร้างใน migration นี้ ยังไม่มีแถว → ตรวจ foreign key ไม่ล็อกนาน
safety_assured do
add_foreign_key "solid_queue_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade
… (add_foreign_key อีก 7 บรรทัดเดิม เยื้องเข้าอีก 2 ช่อง)
end
end
end
$ bin/rails db:migrate
== 20261110034727 CreateSolidQueueTables: migrating ===========================
-- create_table("solid_queue_blocked_executions")
…
== 20261110034727 CreateSolidQueueTables: migrated (0.0483s) ==================
$ rm db/queue_schema.rb
ลบ queue_schema.rb ตามข้อ 1 ของ README (ตารางย้ายไปอยู่ใน migration แล้ว) · แล้วเปิดดูตารางที่ได้ด้วยคำสั่งข้างล่าง — แยกเป็นท่อนได้แบบนี้:
docker compose exec postgres= สั่งโปรแกรมในกล่อง postgres ที่ compose เปิดไว้ (เครื่องเราไม่ต้องติดตั้ง psql เอง)psql= โปรแกรมพิมพ์ SQL ของ Postgres (บท 104) ·-U postgres= ล็อกอินเป็นผู้ใช้ postgres ·-d …= ชื่อฐานข้อมูล ·-c '…'= คำสั่งที่จะรันแล้วออกเลย\dt solid_queue*= คำสั่งพิเศษของ psql "แสดงรายชื่อตาราง (table) ที่ชื่อขึ้นต้นด้วย solid_queue"
$ 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 ตาราง แต่บทนี้ใช้แค่ 3 — ที่เหลือเป็นของงานตั้งเวลา งานพัง และงานกลุ่ม (บท 144 เปิดดูบางตัว):
| ตาราง | เก็บอะไร | ในร้านอาหาร |
|---|---|---|
solid_queue_jobs | ใบงานทุกใบ (ชื่อ job · argument · ทำเสร็จเมื่อไร) | สำเนาใบงานทุกใบ · ทำเสร็จแล้วประทับเวลาไว้ |
solid_queue_ready_executions | ใบงานที่พร้อมทำ รอ worker หยิบ | ใบงานที่ยังแขวนบนกระดาน |
solid_queue_processes | โปรแกรมของ Solid Queue ที่กำลังทำงาน | รายชื่อพนักงานหลังร้านที่เข้ากะอยู่ |
3.2 ตั้งค่า 4 ไฟล์
config/environments/development.rb — เพิ่มก่อนบรรทัด # Don't care if the mailer can't send.:
# งานเบื้องหลังเข้าคิว Solid Queue: ตาราง solid_queue_* อยู่ใน DB เดียวกับข้อมูล (ไม่ต้องตั้ง connects_to)
# ต้องเปิด bin/jobs (อยู่ใน Procfile.dev แล้ว) ไม่งั้นงานนอนรอในตารางเฉย ๆ
config.active_job.queue_adapter = :solid_queue
config/environments/production.rb — ลบบรรทัดนี้ (ข้อ 2 ของ README · production ของเราก็ใช้ฐานข้อมูลก้อนเดียว):
config.solid_queue.connects_to = { database: { writing: :queue } }
config/database.yml — ในก้อน default: ใต้บรรทัด password::
# macOS: libpq ลองเข้ารหัสแบบ GSSAPI (Kerberos) ก่อน แล้วพังใน process ที่ fork มา
# (bin/jobs fork worker ออกมา) → Segmentation fault ใน PG::Connection#connect_start
# เราล็อกอินด้วยรหัสผ่าน ไม่ได้ใช้ Kerberos → ปิดไปเลย
gssencmode: disable
บรรทัด gssencmode: disable ไม่ได้มาจากสไลด์ · บน Mac ที่ใช้ตรวจ ถ้าไม่มีมัน bin/jobs พังวนไม่หยุด (ผลจริง: 8 วินาทีขึ้น [BUG] Segmentation fault 16 ครั้ง):
…/gems/pg-1.7.0-arm64-darwin/lib/pg/connection.rb:1033: [BUG] Segmentation fault at 0x0000000124a2093d
ruby 4.0.9 (2026-10-23 revision 3e93b23afc) +PRISM [arm64-darwin25]
ภาษาคน: Segmentation fault = โปรแกรมตายกะทันหัน · ตัวที่ Ruby ใช้ต่อ Postgres (ชื่อ libpq) จะลอง "เข้ารหัสแบบ Kerberos" (ระบบล็อกอินขององค์กรใหญ่) ก่อนทุกครั้ง
แล้วพังในโปรแกรมที่ถูกแตกตัว (fork = ก็อปตัวเองเป็นโปรแกรมลูก — bin/jobs ทำแบบนี้ตอนเปิด worker)
เราล็อกอินด้วยรหัสผ่าน ไม่ได้ใช้ Kerberos จึงปิดทิ้งได้ · bin/rails server ไม่ fork เลยไม่เจอ · Linux/Windows อาจไม่เจอ แต่ใส่ไว้ไม่เสียหาย
Procfile.dev — เพิ่มบรรทัดที่สาม ให้ bin/dev เปิด worker ให้ด้วย:
web: bin/rails server
css: bin/rails tailwindcss:watch
jobs: bin/jobs
คำหน้า : เป็นแค่ป้ายชื่อใน log ของ bin/dev (สไลด์เขียน worker: ก็ทำงานเหมือนกัน) ·
⚠️ bin/jobs ไม่พิมพ์อะไรออกจอ — log ของ job ไปอยู่ใน log/development.log · ใน bin/dev จะไม่เห็นบรรทัด jobs.1 | ตอนทำงานปกติ ไม่ใช่อาการพัง
3.3 ปิด bin/dev (Ctrl+C) แล้วเปิดใหม่ ให้มันอ่าน Procfile.dev ใหม่ · ถามแอปว่าตอนนี้ใช้ adapter อะไร (บท 142 ได้ "async"):
$ bin/rails runner 'p ActiveJob::Base.queue_adapter_name'
"solid_queue"
ขั้น 4 · PublishPostJob ทีละบรรทัด
$ bin/rails generate job PublishPost
invoke test_unit
create test/jobs/publish_post_job_test.rb
create app/jobs/publish_post_job.rb
ไฟล์ test ปล่อยไว้ตามที่ generator สร้าง (เรื่อง test อยู่ใน Part 2 ของสไลด์) · เปิด app/jobs/publish_post_job.rb แก้ทั้งไฟล์เป็น:
# เผยแพร่บทความที่ตั้งเวลาไว้ เมื่อถึงเวลาแล้ว
# ห้ามสมมติว่ารันครั้งเดียว: กดปุ่มซ้ำ · retry · สอง worker หยิบงานพร้อมกัน → รันกี่รอบผลต้องเหมือนรอบเดียว
class PublishPostJob < ApplicationJob
queue_as :default
# รับแค่ id แล้วโหลดสดจาก DB: ได้สถานะ/เวลาล่าสุดเสมอ (ไม่ใช่ค่าเก่าตอนเข้าคิว)
def perform(post_id)
post = Post.find(post_id)
# with_lock = SELECT ... FOR UPDATE แล้วโหลดแถวใหม่: worker ตัวที่สองต้องรอตัวแรกเสร็จก่อน
# แล้วเห็นว่า published ไปแล้ว → ไม่ทำซ้ำ
post.with_lock do
# guard สถานะ: เผยแพร่แล้ว เป็นฉบับร่าง หรือยังไม่ถึงเวลา (ผู้ใช้เลื่อนเวลาออกไป) → ไม่ทำอะไร
if post.scheduled? && post.scheduled_at <= Time.current
post.update!(status: :published, published_at: Time.current)
end
end
end
end
queue_as :default— generator ใส่มาให้ = แขวนใบงานไว้ที่กระดานชื่อdefault· worker ของเราฟังทุกกระดานอยู่แล้ว (queues: "*"ในconfig/queue.yml) ไม่ต้องแก้def perform(post_id)+Post.find(post_id)— ใบงานจดแค่เลข id · worker ไปหยิบแฟ้มจากตู้เองตอนจะทำ · ได้ของล่าสุดเสมอ: ถ้าผู้ใช้เปลี่ยนใจกลับเป็นร่างหลังกดปุ่ม job จะเห็นว่าเป็นร่างแล้ว · ถ้าจดสถานะลงใบงานไปด้วย worker จะทำตามข้อมูลเก่าตอนแขวนif post.scheduled? && post.scheduled_at <= Time.current— guard (ท่า 2) ·&&= "และ" ·Time.current= เวลาตอนนี้ตามโซนเวลาของแอป (ใน Rails ใช้ตัวนี้แทนTime.now) ·<== "น้อยกว่าหรือเท่ากับ" คือเวลาที่ตั้งไว้มาถึงหรือเลยมาแล้ว · รวมกันได้ว่า ทำเฉพาะเมื่อ "ตั้งเวลาไว้ และ ถึงเวลาแล้ว" · กันได้ 3 กรณี: เผยแพร่ไปแล้ว (ใบงานซ้ำ) · เป็นร่าง · ผู้ใช้เลื่อนเวลาออกไปหลังแขวนใบงาน (สไลด์เขียนแค่return if post.published?ซึ่งกันได้กรณีแรกกรณีเดียว)post.update!(status: :published, published_at: Time.current)— ที่เดียวในแอปที่ตั้งpublished_at·!= บันทึกไม่ผ่านให้พังดัง ๆ (job พังแล้วบท 144 จะสอนให้ลองใหม่)post.with_lock do … end— ดูหัวข้อถัดไป
ถ้าเอา if ออก (และ with_lock ด้วย) เหลือ post.update!(status: :published, published_at: Time.current) บรรทัดเดียว ·
แล้วรันกับบทความที่ตั้งเวลาไว้ 3 รอบห่างกัน 1 วินาที · และรันกับบทความที่เป็นร่าง
status ได้อะไร · published_at สามรอบเท่ากันไหม · ร่างกลายเป็นอะไร?
ผลจริง (ผมแก้ไฟล์ชั่วคราวแล้วรันผ่าน bin/rails runner · แล้วคืนไฟล์เดิม — คุณไม่ต้องทำตาม):
รอบ 1: published 10:49:44.136
รอบ 2: published 10:49:45.153
รอบ 3: published 10:49:46.175
ร่าง → published
- status เป็น published ทุกรอบ — ดูเผิน ๆ เหมือน idempotent
- แต่ published_at ขยับทุกรอบ — "เวลาเผยแพร่" ของบทความเปลี่ยนไปเรื่อย ๆ ทั้งที่ออกไปตั้งแต่รอบแรก · ถ้า job นี้ส่งอีเมลด้วย = อีเมล 3 ฉบับ
- ร่างถูกเผยแพร่ — ใบงานค้างจากตอนที่ผู้ใช้ยังกดเผยแพร่อยู่ ไปเผยแพร่ของที่ผู้ใช้ถอนกลับแล้ว
- ใส่
ifกลับแล้วรันแบบเดียวกัน:รอบ 1–3: published 10:49:53.755(เท่ากันทุกรอบ) ·ร่าง → draft· ตั้งเวลาอีก 1 ชั่วโมง →scheduled
with_lock — สอง worker หยิบใบงานซ้ำพร้อมกัน
guard มีช่องโหว่แบบเดียวกับบท 111: ถ้าสอง worker ถือใบงานเดียวกันในเวลาเดียวกัน ทั้งคู่ตรวจแล้วเห็นว่า "ยัง scheduled" ทั้งคู่ แล้วเผยแพร่ทั้งคู่ ·
เกิดได้จริงเมื่อมีใบงานซ้ำในคิว (กดปุ่มสองที · retry = ลองทำใหม่หลังพัง) และ worker ทำหลายใบพร้อมกัน
(ของเราตั้งไว้ 3 ใบพร้อมกัน — threads: 3 ใน config/queue.yml · thread ≈ goroutine ของ Go)
with_lock คือการล็อกแถว: เปิด transaction (บท 111) แล้วขอแฟ้มแผ่นนี้แบบ "ห้ามคนอื่นแก้จนกว่าฉันจะเสร็จ" (SQL: SELECT … FOR UPDATE) พร้อมโหลดค่าล่าสุดมาใหม่ ·
ล็อกปล่อยตอน COMMIT (จบ transaction) · worker ตัวที่สองต้องรอจนถึงตอนนั้น · พอได้แฟ้มก็เห็นว่า published แล้ว guard จึงไม่ทำอะไร
พนักงานสองคนถือใบงาน "เปิดร้าน" ใบเดียวกันมาพร้อมกัน · ไม่มีล็อก = ทั้งคู่เหลือบมองป้ายพร้อมกัน เห็นว่า "ปิด" ทั้งคู่ แล้วเดินไปพลิกทั้งคู่ ·
มี with_lock = คนแรกหยิบแฟ้มของร้านออกจากตู้ไปวางบนโต๊ะตัวเอง · คนที่สองยืนรอหน้าตู้ · คนแรกพลิกป้าย จดลงแฟ้ม เก็บเข้าตู้ ·
คนที่สองได้แฟ้มแล้วอ่านเจอว่า "เปิดแล้ว" → วางใบงานลง
ที่ไม่เหมือนกัน: ล็อกนี้กันได้เฉพาะการแก้แฟ้มแผ่นนั้น · ถ้างานมี side effect นอกตู้ (ส่งอีเมล) ตู้ไม่รู้เรื่อง · งานแบบนั้นต้องจดลงตารางที่มี unique index ด้วย (ท่า 3 — บท 144) · และระหว่างรอ คนที่สองยืนเฉย ๆ ทำอย่างอื่นไม่ได้ จึงต้องทำงานในล็อกให้สั้นที่สุด
วัดจริง (สคริปต์เปิด 2 thread ยิง perform_now ใส่บทความเดียวกันพร้อมกัน 5 รอบ แล้วนับคำสั่ง UPDATE "posts" — ผลจริง):
| job | จำนวน UPDATE ใน 5 รอบ | แปลว่า |
|---|---|---|
guard + with_lock (ไฟล์จริง) | [1, 1, 1, 1, 1] | ทุกรอบเผยแพร่ครั้งเดียว |
guard อย่างเดียว ไม่มี with_lock | [1, 2, 2, 2, 2] | 4 ใน 5 รอบ เผยแพร่ซ้ำ |
ทำไมบทนี้ยังไม่ใช้ท่า 3 (unique index): job นี้เปลี่ยนแค่สถานะของแฟ้มแผ่นเดียว ซึ่ง guard + ล็อกแถวกันได้ครบ (ตารางบน) ·
ของที่ซ้ำแล้วเสียหายจริงคืออีเมล — บท 144 สร้างตาราง post_notifications ที่มี unique index คู่ (post_id, follower_id) = "บทความนี้ส่งให้คนนี้ได้ครั้งเดียว"
job รันใน worker ซึ่งเป็นอีกโปรแกรม ไม่มี request ไม่มีผู้ใช้ที่กดปุ่ม · ของที่ job ต้องรู้ต้องอยู่บนใบงาน (argument) เท่านั้น ·
Part 2 จะมี Current (ที่เก็บ "ใครล็อกอินอยู่" ระหว่าง request) · ห้ามหยิบมันใช้ใน job — ค่าของ request ไม่ตามไปถึง worker
- race condition
- ผลลัพธ์ขึ้นกับว่าใครทำก่อนเสี้ยววินาที · ตรวจผ่านพร้อมกันทั้งคู่แล้วทำซ้ำ · ถาม senior ได้ว่า "ถ้าสอง worker หยิบใบงานนี้พร้อมกันจะเกิด race ไหมครับ"
- row lock /
SELECT … FOR UPDATE/ pessimistic locking - ล็อกแถวไว้ระหว่างแก้ คนอื่นที่จะแก้แถวเดียวกันต้องรอ · Rails เรียกท่านี้ว่า pessimistic (มองโลกแง่ร้าย: "ต้องมีคนแย่งแน่ ล็อกก่อน") · method คือ
with_lock/lock! - argument ของ job
- ของที่จดบนใบงาน · กฎในสไลด์หน้า 21: ส่ง id อย่าส่งก้อนข้อมูลใหญ่ ไฟล์ หรือของที่หมดอายุได้ (signed URL · token) · พิมพ์บอก AI ได้ว่า "pass only the record id to the job and reload it inside perform"
ขั้น 5 · ปุ่ม "เผยแพร่ทันที" และหน้าจอ
job มีแล้ว แต่ยังไม่มีอะไรส่งงานเข้าคิว · ขั้นนี้เพิ่มปุ่มในหน้าบทความที่ยังไม่เผยแพร่ · และให้หน้ารวมโชว์เฉพาะที่เผยแพร่แล้ว · โค้ดเยอะแต่ไม่มีแนวคิดใหม่ — เป็นของ Day 2–3 ทั้งหมด
5.1 config/routes.rb — เพิ่มในบล็อก resources :posts do (เหนือบรรทัด comments):
resources :posts do
# ปุ่ม "เผยแพร่ทันที": POST /posts/:id/publish → posts#publish
post :publish, on: :member
resources :comments, only: :create
end
on: :member = เส้นทางของบทความแต่ละชิ้น (มี :id) · ได้ชื่อ publish_post_path(post) (ผลจริง):
$ bin/rails routes -g publish
Prefix Verb URI Pattern Controller#Action
publish_post POST /posts/:id/publish(.:format) posts#publish
5.2 app/controllers/posts_controller.rb — แก้ 4 จุด:
before_action :set_post, only: %i[ show edit update destroy publish ]
…
# GET /posts or /posts.json
# ปกติโชว์เฉพาะที่เผยแพร่แล้ว · ?view=pending = ฉบับร่าง + ที่ตั้งเวลาไว้ (ให้ผู้เขียนกลับไปแก้)
def index
@pending = params[:view] == "pending"
posts = @pending ? Post.not_published.recent : Post.latest_published
# with_rich_text_body / with_attached_cover = … (บรรทัดคอมเมนต์เดิม)
@pagy, @posts = pagy(posts.includes(:category).with_rich_text_body.with_attached_cover, limit: 10)
end
… (ใต้ action destroy · เหนือ private)
# POST /posts/1/publish — ปุ่ม "เผยแพร่ทันที"
# ไม่ได้เผยแพร่ใน request นี้: ตั้งเวลาเป็นตอนนี้ แล้วส่งเข้าคิวให้ PublishPostJob ทำ (ส่งแค่ id)
def publish
unless @post.published?
@post.update!(status: :scheduled, scheduled_at: Time.current)
PublishPostJob.perform_later(@post.id)
end
redirect_to @post, notice: "ส่งเข้าคิวเผยแพร่แล้ว", status: :see_other
end
…
def post_params
# ไม่รับ published_at: เผยแพร่ได้ทางเดียวคือ PublishPostJob
params.expect(post: [ :title, :body, :cover, :category_id, :status, :scheduled_at ])
end
index—เงื่อนไข ? ก : ขคือ if/else บรรทัดเดียว · ปกติได้latest_published· มี?view=pendingได้ร่างกับที่ตั้งเวลา · บรรทัดpagyเปลี่ยนแค่ต้นทางจากPostเป็นpostsและย้าย.recentออกpublish— ไม่ได้เผยแพร่เอง: ตั้งเป็น scheduled เวลา "ตอนนี้" แล้วแขวนใบงาน (ส่งแค่ id) · ให้ทุกการเผยแพร่ผ่านทางเดียวคือ job ·unless= "ถ้าไม่"post_params— รับ:statusกับ:scheduled_atจากฟอร์มเพิ่ม (strong params บท 117) · ไม่รับpublished_at
5.3 app/views/posts/_form.html.erb — ใส่ก่อน <div class="inline"> ที่มีปุ่มบันทึก:
<%# บทความที่เผยแพร่แล้วเปลี่ยนสถานะจากฟอร์มไม่ได้ · ปุ่ม "เผยแพร่ทันที" อยู่ในหน้า show %>
<% if post.published? && post.published_at? %>
<p class="my-5 text-sm text-base-content/70">เผยแพร่แล้วเมื่อ <%= post.published_at.strftime("%d/%m/%Y %H:%M") %></p>
<% else %>
<fieldset class="fieldset my-5">
<legend class="fieldset-legend">การเผยแพร่</legend>
<label class="label cursor-pointer gap-2">
<%= form.radio_button :status, "draft", class: "radio radio-sm" %>
เก็บเป็นฉบับร่าง
</label>
<label class="label cursor-pointer gap-2">
<%= form.radio_button :status, "scheduled", class: "radio radio-sm" %>
ตั้งเวลาเผยแพร่
</label>
<%= form.label :scheduled_at, "เวลาเผยแพร่", class: "label mt-2" %>
<%# datetime-local ไม่มีโซนเวลา → Rails อ่านเป็นเวลาไทยตาม config.time_zone = "Asia/Bangkok" %>
<%= form.datetime_field :scheduled_at, include_seconds: false,
class: ["input w-full sm:w-auto", {"input-error": post.errors[:scheduled_at].any?}] %>
<p class="text-sm text-base-content/60">ระบบตรวจทุกนาที ถึงเวลาแล้วเผยแพร่ให้เอง</p>
</fieldset>
<% end %>
post.published_at?—?ท้ายชื่อคอลัมน์ = "มีค่าไหม" ·strftime("%d/%m/%Y %H:%M")= จัดรูปเวลาเป็น10/11/2026 10:50form.radio_button :status, "draft"— ปุ่มเลือกข้อเดียว · ถูกติ๊กเองถ้าpost.statusเป็น "draft" (บทความใหม่จึงติ๊ก "ฉบับร่าง" ไว้ให้)form.datetime_field— ช่องเลือกวันเวลาของเบราว์เซอร์ ·include_seconds: false= เลือกแค่ถึงนาที ไม่มีช่องวินาที ·class: [ …, {"input-error": …}]= รูปแบบเดียวกับช่องอื่นใน Day 3 (ขอบแดงเมื่อมี error)- ข้อความ "ระบบตรวจทุกนาที" — ตอนนี้ยังไม่มีตัวตรวจ · บท 144 สร้าง · ตั้งเวลาล่วงหน้าตอนนี้บทความจะค้างเป็น scheduled จนถึงบทนั้น
5.4 app/views/posts/_post.html.erb — แทนบรรทัด <%= time_ago_in_words(post.created_at) %> ago ด้วย:
<% if post.published? %>
<%= time_ago_in_words(post.published_at) %> ago
<% elsif post.scheduled? %>
<span class="badge badge-info">ตั้งเวลา <%= post.scheduled_at.strftime("%d/%m/%Y %H:%M") %></span>
<% else %>
<span class="badge badge-ghost">ฉบับร่าง</span>
<% end %>
การ์ดบอกสถานะ · บทความที่เผยแพร่แล้วนับเวลาจาก published_at แทน created_at
5.5 app/views/posts/index.html.erb — ใส่เหนือ <div id="posts" …>:
<div role="tablist" class="tabs tabs-box mb-6">
<%= link_to "เผยแพร่แล้ว", posts_path, role: "tab", class: ["tab", {"tab-active": !@pending}] %>
<%= link_to "ร่างและตั้งเวลา", posts_path(view: "pending"), role: "tab", class: ["tab", {"tab-active": @pending}] %>
</div>
แท็บของ daisyUI (คลาส tabs · tab · tab-active) สองลิงก์ · !@pending = "ไม่ใช่ pending"
5.6 app/views/posts/show.html.erb — ใส่ใต้ </nav> ของ breadcrumbs เหนือ <%= render @post, full: true %>:
<% unless @post.published? %>
<div role="status" class="alert alert-info mb-5 flex flex-wrap items-center justify-between gap-3">
<span>
<% if @post.scheduled? %>
ตั้งเวลาเผยแพร่ไว้ <%= @post.scheduled_at.strftime("%d/%m/%Y %H:%M") %> · ยังไม่โชว์ในหน้ารวม
<% else %>
ฉบับร่าง · ยังไม่โชว์ในหน้ารวม
<% end %>
</span>
<%# ส่งเข้าคิว PublishPostJob (posts#publish) — ถ้าปิด bin/jobs อยู่ งานจะรอในคิว %>
<%= button_to "เผยแพร่ทันที", publish_post_path(@post), class: "btn btn-primary btn-sm" %>
</div>
<% end %>
button_to = ปุ่มที่ส่ง POST (เป็นฟอร์มเล็ก ๆ หนึ่งปุ่ม · มี token กัน CSRF ให้เอง) — ต่างจาก link_to ที่ส่ง GET
5.7 db/seeds.rb — ในบล็อก Post.find_or_create_by!(title: title) do |post| ใต้บรรทัด post.created_at = …:
# บทความตัวอย่างทั้งหมดเผยแพร่แล้ว (ฉบับร่างไม่โชว์ในหน้ารวม)
post.status = :published
post.published_at = post.created_at
คนที่ clone แอปไปแล้ว db:prepare ใหม่จะได้บทความตัวอย่างที่เผยแพร่แล้ว (ไม่ใช่ร่างทั้ง 30 ชิ้น) · ฐานข้อมูลของคุณเติมไปแล้วด้วย rake task ในขั้น 2
bin/dev สร้าง CSS ใหม่ให้เองเมื่อเห็นคลาสใหม่ (tabs · radio · badge-info) · ถ้าไม่ได้เปิด bin/dev ไว้ ให้รัน bin/rails tailwindcss:build
พิสูจน์ ① — รัน job 3 ครั้งติด สถานะต้องไม่เปลี่ยน
สไลด์หน้า 24 เรียกเกณฑ์ข้อนี้ว่า "หัวใจของบล็อก" — พิสูจน์ด้วยการรัน ไม่ใช่ด้วยการอ่านโค้ด · เปิด console (หน้าต่างที่สอง) แล้วพิมพ์ทีละบรรทัด:
$ bin/rails console
wongnok-blog-all-in-one(dev):001> post = Post.create!(title: "ทดลองเผยแพร่ซ้ำ 2", body: "ทดสอบ job", category: Category.first, status: :scheduled, scheduled_at: 1.minute.ago); post.id
…
=> 32
wongnok-blog-all-in-one(dev):002> PublishPostJob.perform_now(post.id)
- บรรทัด 001 — สร้างบทความที่ "ตั้งเวลาไว้เมื่อ 1 นาทีที่แล้ว" (ถึงเวลาแล้ว) ·
1.minute.ago= "1 นาทีก่อนตอนนี้" (Rails ให้ตัวเลขมี method บอกเวลาแบบนี้) ·; post.id= ให้ console โชว์แค่เลข id ไม่โชว์ทั้งแฟ้ม perform_now— "ทำเดี๋ยวนี้" ในโปรแกรมนี้เลย ไม่ผ่านคิว (ตรงข้ามกับperform_later) · ใช้ทดสอบ job ด้วยมือ ไม่ต้องเปิด worker
ต่อจากนั้นพิมพ์คู่นี้ 3 รอบ: PublishPostJob.perform_now(post.id) แล้ว post.reload.slice(:status, :published_at, :updated_at) (reload = อ่านแฟ้มจากตู้ใหม่ · slice = เลือกโชว์บางช่อง)
รอบ 2 และ 3: SQL ที่ขึ้นจะมี UPDATE ไหม · published_at กับ updated_at ขยับไหม · และ perform_now คืนค่าอะไรกลับมา?
ผลจริง (ตัด SQL ที่ไม่เกี่ยวเป็น … · Job ID ตัดสั้น):
wongnok-blog-all-in-one(dev):002> PublishPostJob.perform_now(post.id)
Performing PublishPostJob (Job ID: 1e62b04c-…) from SolidQueue(default) with arguments: 32
…
Post Load (1.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 32 LIMIT 1 FOR UPDATE
…
Post Update (0.5ms) UPDATE "posts" SET "updated_at" = '2026-11-10 03:49:13.861362', "status" = 'published', "published_at" = '2026-11-10 03:49:13.858956' WHERE "posts"."id" = 32
TRANSACTION (0.5ms) COMMIT
Performed PublishPostJob (Job ID: 1e62b04c-…) from SolidQueue(default) in 8.53ms
=> true
wongnok-blog-all-in-one(dev):003> post.reload.slice(:status, :published_at, :updated_at)
=>
{"status" => "published",
"published_at" => 2026-11-10 10:49:13.858956000 +07 +07:00,
"updated_at" => 2026-11-10 10:49:13.861362000 +07 +07:00}
wongnok-blog-all-in-one(dev):004> PublishPostJob.perform_now(post.id)
Performing PublishPostJob (Job ID: 524b09b7-…) from SolidQueue(default) with arguments: 32
…
Post Load (0.9ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 32 LIMIT 1 FOR UPDATE
TRANSACTION (0.7ms) COMMIT
Performed PublishPostJob (Job ID: 524b09b7-…) from SolidQueue(default) in 3.64ms
=> nil
wongnok-blog-all-in-one(dev):005> post.reload.slice(:status, :published_at, :updated_at)
=>
{"status" => "published",
"published_at" => 2026-11-10 10:49:13.858956000 +07 +07:00,
"updated_at" => 2026-11-10 10:49:13.861362000 +07 +07:00}
รอบที่ 3 (006–007) ได้เหมือนรอบ 2 ทุกตัวอักษร ยกเว้น Job ID และเวลา ms
- รอบ 1 มี
Post Update … "status" = 'published'— เผยแพร่จริง · คืนtrue(ค่าจากupdate!) - รอบ 2–3 ไม่มี UPDATE เลย — มีแค่
SELECT … FOR UPDATE(with_lock ขอแฟ้มแบบล็อก) แล้วCOMMITปล่อยล็อก · guard บอกว่าไม่ต้องทำ · คืนnil(= "ไม่มีอะไร" เพราะifไม่ได้ทำ) - published_at และ updated_at เท่ากันทุกรอบ ถึงหลักไมโครวินาที — หลักฐานว่ารอบ 2–3 ไม่ได้แตะแฟ้มเลย
from SolidQueue(default)—perform_nowไม่ได้ผ่านคิว แต่ log บอกชื่อ adapter ที่ตั้งไว้ · ส่วน+07 +07:00คือเวลาไทย ขณะที่ SQL เก็บเป็น UTC (03:49= 10:49 ไทย)
สไลด์หน้า 24 เขียน PublishPostJob.perform_now(post) (ส่งทั้งแฟ้ม) · กับ job ของเราได้ error จริง:
You are passing an instance of ActiveRecord::Base to `find`. Please pass the id of the object by calling `.id`. (ArgumentError) — พิมพ์ post.id
ออกจาก console ด้วย exit · บทความ "ทดลองเผยแพร่ซ้ำ 2" โผล่ในหน้ารวมแล้ว ลบทิ้งด้วยปุ่ม ลบบทความ ได้
🎯 ภารกิจ — ปิด worker แล้วกด งานต้องรอในตาราง ไม่หาย (~10 นาที)
พิสูจน์ ② — ไม่มี worker ใบงานก็ไม่หาย แค่รอ
บท 142 พิสูจน์ว่า async ปิดโปรแกรมแล้วใบงานหาย · ภารกิจนี้พิสูจน์ว่า Solid Queue ไม่หาย · ต้องทำขั้น 1–5 ครบแล้ว
- ปิด worker: หน้าต่าง
bin/devกด Ctrl+C แล้วเปิดแค่เว็บด้วยbin/rails server(CSS สร้างไว้แล้ว ไม่ต้องเปิดตัวสร้าง CSS) - เบราว์เซอร์:
localhost:3000/posts/new· กรอกหัวข้อทดลองปิด worker· เนื้อหาอะไรก็ได้ · เลือกหมวด · ส่วน "การเผยแพร่" ปล่อยไว้ที่ "เก็บเป็นฉบับร่าง" · กดบันทึก - ในหน้าบทความกด เผยแพร่ทันที · ดู log ในหน้าต่าง server
- รอ 20 วินาที แล้วเปิดคิวดูด้วย SQL (หน้าต่างที่สอง):
(คำสั่งแบบเดียวกับตอนดูรายชื่อตารางในขั้น 3 · ใส่$ docker compose exec postgres psql -U postgres -d wongnok_blog_all_in_one_development -c "SELECT id, class_name, finished_at FROM solid_queue_jobs;" -c "SELECT job_id, queue_name, created_at FROM solid_queue_ready_executions;"-cสองตัว = รัน SQL สองคำสั่งต่อกัน: ดูใบงานทุกใบ แล้วดูใบที่ยังแขวนรอ) - เปิด worker (หน้าต่างที่สาม):
bin/jobs· รอ 5 วินาที แล้วรันคำสั่ง SQL ในข้อ 4 อีกรอบ · และ
(ค้นใน log เฉพาะบรรทัดที่มีคำว่า PublishPostJob แล้วกรองต่อเหลือบรรทัดที่มี Enqueued หรือ Perform ·$ grep "PublishPostJob" log/development.log | grep "Enqueued\|Perform"\|= "หรือ") - รีเฟรชหน้าบทความ · เปิดหน้ารวม
localhost:3000/posts - เก็บกวาดและ commit: Ctrl+C ทั้ง
bin/jobsและ server → เปิดbin/devกลับมา · ลบบทความที่ขึ้นต้นด้วย "ทดลอง" · แล้ว$ git add -A $ git commit -m "สถานะบทความ + Solid Queue (DB เดียว) + PublishPostJob ที่รันซ้ำได้"
ก่อนกดเฉลย: ข้อ 3 หน้าเว็บจะขึ้น error ไหม · ข้อ 4 ใบงานอยู่ในตารางไหนบ้าง finished_at เป็นอะไร · ข้อ 5 worker ใช้เวลานานแค่ไหนกว่าจะหยิบ · ข้อ 6 บทความอยู่ในหน้ารวมหรือยัง?
ข้อ 3 — ไม่มี error · หน้าเว็บขึ้น "ส่งเข้าคิวเผยแพร่แล้ว" และกล่องสีฟ้าเปลี่ยนเป็น ตั้งเวลาเผยแพร่ไว้ 10/11/2026 10:50 · ยังไม่โชว์ในหน้ารวม · log ของ server (ผลจริง ย่อ):
Started POST "/posts/31/publish" for ::1 at 2026-11-10 10:50:57 +0700
Processing by PostsController#publish as TURBO_STREAM
…
Post Update (0.7ms) UPDATE "posts" SET "updated_at" = '2026-11-10 03:50:57.923412', "status" = 'scheduled', "scheduled_at" = '2026-11-10 03:50:57.917784' WHERE "posts"."id" = 31
…
[ActiveJob] SolidQueue::Job Create (2.2ms) INSERT INTO "solid_queue_jobs" ("queue_name", "class_name", "arguments", …) VALUES ('default', 'PublishPostJob', '{"job_class":"PublishPostJob", … "arguments":[31], …}', …) RETURNING "id"
…
[ActiveJob] SolidQueue::ReadyExecution Create (1.5ms) INSERT INTO "solid_queue_ready_executions" ("job_id", "queue_name", "priority", "created_at") VALUES (1, 'default', 0, '2026-11-10 03:50:57.968776') RETURNING "id"
…
[ActiveJob] Enqueued PublishPostJob (Job ID: ba201587-1edf-47b6-be71-2e42bd791812) to SolidQueue(default) with arguments: 31
Redirected to http://localhost:3000/posts/31
Completed 303 See Other in 58ms (ActiveRecord: 12.6ms (7 queries, 0 cached) | GC: 10.9ms)
- "แขวนใบงาน" ใน Solid Queue =
INSERTสองแถว: สำเนาใบงานลงsolid_queue_jobs(จด"arguments":[31]= แค่เลข id) · และป้าย "พร้อมทำ" ลงsolid_queue_ready_executions to SolidQueue(default)— บท 142 ตรงนี้เป็นAsync(default)as TURBO_STREAMไม่เกี่ยวกับ job — แค่ Turbo บอก server ว่ารับคำตอบแบบ Turbo Stream ได้ (บท 131) · action นี้ตอบด้วย redirect ตามปกติ
ข้อ 4 — ใบงานนอนรอ (ผลจริง 49 วินาทีหลังกด · ไม่มี worker):
id | class_name | finished_at
----+----------------+-------------
1 | PublishPostJob |
(1 row)
job_id | queue_name | created_at
--------+------------+----------------------------
1 | default | 2026-11-10 03:50:57.968776
(1 row)
finished_at ว่าง = ยังไม่มีใครทำ · ยังแขวนอยู่ใน ready_executions · เวลาใน psql เป็น UTC (03:50 = 10:50 ไทย) · นี่คืออาการ "กดแล้วไม่เกิดอะไรขึ้น" ที่สไลด์หน้า 22 เตือน — ไม่มี error ไม่มีอะไรบอก
ข้อ 5 — bin/jobs ไม่พิมพ์อะไรเลย (ปกติ) · แต่ภายใน 1 วินาทีหลังเปิด ใบงานก็ถูกทำ (ผลจริง):
id | class_name | finished_at
----+----------------+---------------------------
1 | PublishPostJob | 2026-11-10 03:51:47.85736
(1 row)
job_id | queue_name | created_at
--------+------------+------------
(0 rows)
$ grep "PublishPostJob" log/development.log | grep "Enqueued\|Perform"
[ActiveJob] Enqueued PublishPostJob (Job ID: ba201587-1edf-47b6-be71-2e42bd791812) to SolidQueue(default) with arguments: 31
[ActiveJob] [PublishPostJob] [ba201587-1edf-47b6-be71-2e42bd791812] Performing PublishPostJob (Job ID: ba201587-1edf-47b6-be71-2e42bd791812) from SolidQueue(default) enqueued at 2026-11-10T03:50:57.944730000Z with arguments: 31
[ActiveJob] [PublishPostJob] [ba201587-1edf-47b6-be71-2e42bd791812] Performed PublishPostJob (Job ID: ba201587-1edf-47b6-be71-2e42bd791812) from SolidQueue(default) in 104.95ms
- Job ID เดียวกันทั้งสามบรรทัด — ใบงานที่แขวนตอน 10:50:57 คือใบเดียวกับที่ทำตอน 10:51:47 · ไม่หาย แค่รอคน
finished_atมีเวลาแล้ว · ready_executions ว่าง (ใบงานถูกปลดจากกระดาน)- เช็คว่า worker เปิดอยู่จริง:
bin/rails runner 'p SolidQueue::Process.pluck(:kind)'→["Supervisor(fork)", "Dispatcher", "Worker"](ผลจริง) · ปิดแล้วได้[]
ข้อ 6 — กล่องสีฟ้าหายไป · หน้ารวมมี "ทดลองปิด worker" เป็นการ์ดแรก พร้อม less than a minute ago (นับจาก published_at)
ข้อ 7 — git status --short ก่อน git add ควรมี 14 บรรทัด M · D db/queue_schema.rb · และ ?? 5 รายการ (app/jobs/publish_post_job.rb · migration 3 ไฟล์ · test/jobs/)
ถ้าเจออาการนี้ แปลว่า…
- log ขึ้น
to Async(default)และบทความเผยแพร่ทันทีทั้งที่ไม่ได้เปิด worker — ยังไม่ได้ใส่config.active_job.queue_adapter = :solid_queueใน development.rb · Rails ใช้ async ที่ทำงานในโปรแกรมเว็บเอง (ลองแล้ว: ตาราง solid_queue_jobs ไม่มีใบงานใหม่) bin/jobsขึ้น[BUG] Segmentation faultซ้ำไม่หยุด — ยังไม่มีgssencmode: disableในdefault:ของ database.yml (ขั้น 3.2)- ข้อ 5 รอแล้ว
finished_atยังว่าง — worker ไม่ได้เปิดจริง · ดูหน้าต่างbin/jobsว่าค้างที่ prompt หรือมี error · ถามด้วยคำสั่งSolidQueue::Process.pluck(:kind)ข้างบน
ทดสอบความเข้าใจ
job ไหน idempotent (รันซ้ำ 3 รอบแล้วผลเท่ารันรอบเดียว)?
ตัวเลือกที่ 3 ถูก — guard (ท่า 2) · พิสูจน์ ① รันสามรอบ รอบ 2–3 ไม่มี UPDATE และ published_at เท่าเดิม
ตัวเลือกที่ 1 ผิด — "บวกเพิ่มจากค่าเดิม" สามรอบได้ +3 · ต้องเปลี่ยนเป็นคำนวณใหม่ (ท่า 1) ·
ตัวเลือกที่ 2 ผิด — สามรอบได้อีเมลสามฉบับ ต้องมีท่า 3 (บท 144) ·
ตัวเลือกที่ 4 ผิด — แบบเดียวกับ POST ของ Go บท 36 รันซ้ำได้แถวเบิ้ล
จุดที่ต้องจำ: "ทำให้เป็น" ซ้ำได้ · "เพิ่มอีก" ซ้ำแล้วพัง · งานที่ไม่มีสถานะให้ดูต้องจดเองลงตารางที่ห้ามซ้ำ
ถ้า PublishPostJob มีแค่ guard ไม่มี with_lock แล้วสอง worker หยิบใบงานของบทความเดียวกันพร้อมกันพอดี จะเกิดอะไรขึ้น?
ตัวเลือกที่ 2 ถูก — วัดจริง guard อย่างเดียวได้ [1, 2, 2, 2, 2] (4 ใน 5 รอบ UPDATE สองครั้ง) · ช่องว่างระหว่าง "ตรวจ" กับ "ทำ" แบบบท 111
ตัวเลือกที่ 1 ผิด — ทั้งคู่ตรวจในเสี้ยววินาทีเดียวกัน ตอนนั้นยังไม่มีใครเผยแพร่ · ตัวเลือกที่ 3 ผิด — การแก้แถวเดียวกันสองครั้งไม่ใช่ error ตัวที่สองแค่รอแล้วเขียนทับ · ตัวเลือกที่ 4 ผิด — ใบงานซ้ำในคิวเกิดได้จาก retry หรือกดปุ่มสองที (ลองแล้ว: กดสองทีขณะปิด worker ได้ใบงาน 2 ใบ worker ทำพร้อมกัน แต่มี UPDATE แค่ครั้งเดียวเพราะ with_lock)
จุดที่ต้องจำ: guard + with_lock = ตรวจกับทำในล็อกเดียวกัน · คนที่สองรอแล้วเห็นของล่าสุด
ลืมเปิด bin/jobs แล้วกด "เผยแพร่ทันที" · เกิดอะไรขึ้น?
ตัวเลือกที่ 4 ถูก — ภารกิจพิสูจน์: finished_at ว่าง ใบงานอยู่ใน ready_executions · เปิด bin/jobs แล้วถูกทำภายใน 1 วินาที Job ID เดิม
ตัวเลือกที่ 1 ผิด — หน้าเว็บขึ้น "ส่งเข้าคิวเผยแพร่แล้ว" ปกติ (สไลด์หน้า 22: "ไม่มี error ไม่มีอะไรบอก") · ตัวเลือกที่ 2 ผิด — ใบงานเป็นแถวในตาราง Postgres ไม่ได้อยู่ในหน่วยความจำ · ตัวเลือกที่ 3 ผิด — นั่นคืออาการตอนลืมตั้ง adapter (ได้ async) ไม่ใช่ตอนลืมเปิด worker
จุดที่ต้องจำ: Solid Queue = ใบงานไม่หาย แต่ไม่มีคนทำถ้าไม่เปิด worker · อาการคือ "กดแล้วเงียบ" → เปิดตาราง solid_queue_* ดู
ทำไม PublishPostJob รับแค่ post_id แล้วค่อย Post.find เอง แทนที่จะจดหัวข้อ สถานะ และเวลาลงใบงานไปด้วย?
ตัวเลือกที่ 1 ถูก — ระหว่างแขวนกับทำอาจห่างกันเป็นนาที (worker ปิดอยู่) · ผู้ใช้อาจถอนกลับเป็นร่าง หรือเลื่อนเวลา · guard ตรวจกับของล่าสุดถึงจะถูก
ตัวเลือกที่ 2 ผิด — log ในภารกิจเห็นแล้วว่า arguments เป็น JSON เก็บข้อความได้ · ตัวเลือกที่ 3 ผิด — perform รับกี่ตัวก็ได้ ·
ตัวเลือกที่ 4 ผิด — กลับกัน job ต้องอ่านตู้เพิ่มหนึ่งครั้ง (Post Load) แลกกับความถูกต้อง
จุดที่ต้องจำ: ใบงานจดแค่ "แฟ้มเลขที่เท่าไร" · worker ไปเปิดแฟ้มเองตอนทำ · ห้ามจดของที่เปลี่ยนหรือหมดอายุได้
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง (Rails 8.1.4 · แอปของคอร์ส) | ต้องสนใจไหม |
|---|---|---|---|
| 19–20 | post.update!(published: true, …) (คอลัมน์ boolean published) | หน้า 24 สั่งให้ใช้ status 3 ค่า → status: :published · ไม่มีคอลัมน์ published | ไม่ต้อง — อย่าพิมพ์ตาม |
| 20 · 24 | def perform(post) · perform_now(post) | job เรารับ id → perform_now(post) ได้ ArgumentError (ผลจริงในบท) · พิมพ์ post.id | สนใจ |
| 19–20 | guard return if post.published? | ของเราตรวจ "scheduled และถึงเวลา" + with_lock — กันร่างและการเลื่อนเวลาด้วย | ไม่ต้อง |
| 24 | "unique constraint ที่ DB กันสอง worker ชนกัน" | บทนี้ใช้ row lock (วัดแล้วกันได้) · unique index มากับอีเมลในบท 144 | ยังไม่ต้อง |
| 22 · 24 | worker: bin/jobs ใน Procfile.dev | เราใช้ jobs: — ชื่อหน้า : เป็นแค่ป้ายใน log ได้ผลเหมือนกัน | ไม่ต้อง |
| 20 | retry_on · discard_on · NotifyFollowersJob · set(wait_until:) | ยังไม่มีในบทนี้ · มาในบท 144 | ยังไม่ต้อง |
อ้างอิง
- Rails Guides — Active Job Basics § Default Backend: Solid Queue — อ่านย่อหน้าแรก: Solid Queue ใช้ฐานข้อมูลที่มีอยู่เก็บและแจกงาน ไม่ต้องมี Redis · แล้วอ่านหัวข้อย่อย "Workers, Dispatchers, Supervisors" ว่าโปรแกรมย่อยแต่ละตัวทำอะไร
- Rails Guides — Active Job Basics § Alternate Queuing Backends — ย่อหน้าเดียว: เปลี่ยน adapter "requires only a configuration change" โค้ด job ไม่ต้องแก้ — ที่มาของคำว่า "หน้ากาก"
- rails/solid_queue (README) § Single database configuration —
3 ข้อที่ขั้น 3 ทำตาม · อ่านต่อหัวข้อ "Workers, dispatchers, and scheduler" (ตารางไหนเป็นของใคร) และ "Usage in development" (ทำไม
bin/jobsไม่พิมพ์อะไรออกจอ) - Rails API — ActiveRecord::Locking::Pessimistic#with_lock —
อ่านคำอธิบายด้านบนของหน้า: ล็อกแถวด้วย
SELECT … FOR UPDATE·with_lock= เปิด transaction แล้วล็อก "in one go" · และlock!ที่อยู่ข้างในจะโหลดแฟ้มใหม่ ("Reloads the record") - Rails API — ActiveRecord::Enum —
ดูตัวอย่างที่เก็บค่าเป็น string · scope ที่ได้ฟรี (รวม
not_) · และตัวเลือกvalidate: true - MDN — Idempotent —
นิยามฝั่ง HTTP สั้น ๆ (ตรงกับ Go บท 36): ดูที่ผลต่อ server · และบอกตรง ๆ ว่าคำตอบแต่ละครั้งต่างกันได้ (
DELETEครั้งแรก 200 ครั้งต่อไป 404) — เหมือน job ของเรารอบแรกคืนtrueรอบหลังคืนnilแต่สถานะในตู้เท่าเดิม