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

idempotency, Active Job และ Solid Queue — job ที่รันซ้ำแล้วไม่พัง

~25 นาทีอ่าน + พิมพ์โค้ดตามอีก ~20 นาที · ควรผ่าน บท 142 (job · exactly-once) · Day 2 บท 109 (migration ปลอดภัย) · บท 111 (ด่านของตู้ · unique index) · และ Go บท 36 มาก่อน

บท 142 พิสูจน์ว่างานในคิวถูกทำซ้ำได้เสมอ · บทนี้ตอบคำถามที่ค้างไว้ว่า "ถ้าซ้ำแล้ว จะไม่เป็นไรได้ยังไง" แล้วสร้างของจริงชิ้นแรกของบล็อก E: บทความมีสถานะ ร่าง · ตั้งเวลา · เผยแพร่แล้ว · ปุ่ม "เผยแพร่ทันที" ที่ส่งงานเข้าคิวซึ่งเก็บในฐานข้อมูล (ปิดโปรแกรมแล้วไม่หาย) · และ PublishPostJob ที่รัน 3 ครั้งผลเท่ากับรันครั้งเดียว
อ่านจบแล้วคุณจะ ดูออกว่า job ไหนรันซ้ำได้ job ไหนรันซ้ำแล้วพัง · เลือกใช้ 3 ท่าทำให้รันซ้ำได้ · ตั้ง Solid Queue ในฐานข้อมูลก้อนเดิมแล้วเปิด worker เป็น · และพิสูจน์ด้วยการรัน ไม่ใช่ด้วยการอ่านโค้ด

🔁 ทวนก่อนเริ่ม — บทนี้ใช้คำพวกนี้

🧰 เวอร์ชันที่ใช้ตรวจ: 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
เทียบกับร้านอาหาร

ท่า 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
$ 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

ถ้าลืม 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):

$ 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

อย่าใช้ 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
  1. เปิด db/queue_schema.rb · ก็อปทุกบรรทัดระหว่าง บรรทัดแรก ActiveRecord::Schema[7.1].define(version: 1) do กับ end บรรทัดสุดท้าย (ไม่เอาสองบรรทัดนั้น)
  2. วางในไฟล์ migration ใหม่ ระหว่าง def change กับ end · เลือกที่วางทั้งก้อนแล้วกด Tab หนึ่งครั้งให้เยื้องเข้าอีก 2 ช่อง (บรรทัดว่างไม่ต้องมีช่องว่าง)
  3. 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 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

ถ้าเอา 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

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 %>

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)

ต่อจากนั้นพิมพ์คู่นี้ 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 ครบแล้ว

  1. ปิด worker: หน้าต่าง bin/dev กด Ctrl+C แล้วเปิดแค่เว็บด้วย bin/rails server (CSS สร้างไว้แล้ว ไม่ต้องเปิดตัวสร้าง CSS)
  2. เบราว์เซอร์: localhost:3000/posts/new · กรอกหัวข้อ ทดลองปิด worker · เนื้อหาอะไรก็ได้ · เลือกหมวด · ส่วน "การเผยแพร่" ปล่อยไว้ที่ "เก็บเป็นฉบับร่าง" · กดบันทึก
  3. ในหน้าบทความกด เผยแพร่ทันที · ดู log ในหน้าต่าง server
  4. รอ 20 วินาที แล้วเปิดคิวดูด้วย SQL (หน้าต่างที่สอง):
    $ 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;"
    (คำสั่งแบบเดียวกับตอนดูรายชื่อตารางในขั้น 3 · ใส่ -c สองตัว = รัน SQL สองคำสั่งต่อกัน: ดูใบงานทุกใบ แล้วดูใบที่ยังแขวนรอ)
  5. เปิด worker (หน้าต่างที่สาม): bin/jobs · รอ 5 วินาที แล้วรันคำสั่ง SQL ในข้อ 4 อีกรอบ · และ
    $ grep "PublishPostJob" log/development.log | grep "Enqueued\|Perform"
    (ค้นใน log เฉพาะบรรทัดที่มีคำว่า PublishPostJob แล้วกรองต่อเหลือบรรทัดที่มี Enqueued หรือ Perform · \| = "หรือ")
  6. รีเฟรชหน้าบทความ · เปิดหน้ารวม localhost:3000/posts
  7. เก็บกวาดและ 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–20post.update!(published: true, …) (คอลัมน์ boolean published)หน้า 24 สั่งให้ใช้ status 3 ค่า → status: :published · ไม่มีคอลัมน์ publishedไม่ต้อง — อย่าพิมพ์ตาม
20 · 24def perform(post) · perform_now(post)job เรารับ id → perform_now(post) ได้ ArgumentError (ผลจริงในบท) · พิมพ์ post.idสนใจ
19–20guard return if post.published?ของเราตรวจ "scheduled และถึงเวลา" + with_lock — กันร่างและการเลื่อนเวลาด้วยไม่ต้อง
24"unique constraint ที่ DB กันสอง worker ชนกัน"บทนี้ใช้ row lock (วัดแล้วกันได้) · unique index มากับอีเมลในบท 144ยังไม่ต้อง
22 · 24worker: bin/jobs ใน Procfile.devเราใช้ jobs: — ชื่อหน้า : เป็นแค่ป้ายใน log ได้ผลเหมือนกันไม่ต้อง
20retry_on · discard_on · NotifyFollowersJob · set(wait_until:)ยังไม่มีในบทนี้ · มาในบท 144ยังไม่ต้อง

อ้างอิง