🔁 ทวนก่อนเริ่ม — บทนี้ใช้คำพวกนี้
- job · คิว · worker (บท 142) — ใบงานหลังร้าน · กระดานแขวนใบงาน · พนักงานหลังร้านที่หยิบใบงานไปทำ · และ at-least-once = งานไม่หาย แต่อาจถูกทำซ้ำ
PublishPostJob(บท 143) — รับpost_id· ล็อกแถวด้วยwith_lock· เผยแพร่เฉพาะเมื่อscheduled?และถึงเวลาแล้ว (guard) · รันซ้ำแล้วไม่พัง · คิวคือตารางsolid_queue_*ใน Postgres · worker คือbin/jobs- unique index (Day 2 บท 111) — สารบัญห้ามซ้ำของตู้ · ใส่ค่าซ้ำแล้ว Ruby ได้ error ชื่อ
ActiveRecord::RecordNotUnique·validates uniquenessอย่างเดียวกันไม่ได้ถ้าสองคนมาพร้อมกัน normalizes(บท 112) — ทำค่าให้เป็นรูปเดียวกันก่อนเก็บ เช่น อีเมลตัวเล็ก ·dependent: :delete_all(บท 116) — ลบแฟ้มแม่แล้วโกยแฟ้มลูกทิ้งด้วย SQL คำสั่งเดียว- SIGTERM (Go บท 25) — สัญญาณที่ระบบส่งมาบอกโปรแกรมว่า "ขอให้ปิดตัวเองนะ"
🧰 เวอร์ชันที่ใช้ตรวจ: 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 — เรื่องในบทนี้ไม่ต่างกัน)
📍 โค้ดเริ่มจาก: ตอนจบบท 143 ที่ commit แล้ว (มี status/scheduled_at/published_at · Solid Queue · PublishPostJob · ปุ่ม "เผยแพร่ทันที" · บรรทัด jobs: bin/jobs ใน Procfile.dev) — git status --short ต้องว่าง ·
บทนี้เขียนโค้ดถาวร แล้ว commit ตอนท้าย
🖥 เปิดค้างไว้: docker compose up -d (ฐานข้อมูล + MinIO) · หน้าต่าง ① bin/dev (เว็บ + CSS + worker) · หน้าต่าง ② ไว้พิมพ์คำสั่ง ·
หน้าต่าง ③ ไว้ดู log ของ job — worker ไม่พิมพ์ log ออกหน้าต่าง bin/dev (บท 143) ให้เปิดไฟล์ log แล้วกรองเฉพาะบรรทัดสำคัญ:
tail -f log/development.log | grep -E "Enqueued|Performing|Performed|Delivered|Retrying|Discarded|Error performing"
tail -f = แสดงท้ายไฟล์แล้วรอดูบรรทัดใหม่ไปเรื่อย ๆ · | = ส่งต่อให้คำสั่งถัดไป · grep -E "ก|ข" = เอาเฉพาะบรรทัดที่มีคำใดคำหนึ่ง ·
ผลรันทุกกล่องมาจากเครื่องจริง · log ตัดบรรทัดที่ไม่เกี่ยวเป็น … · ตัดคอมเมนต์ /*application=…*/ ท้าย SQL และชื่อเครื่องในเลขอีเมลออก ·
เวลา · Job ID · เลข id · ชื่อไฟล์ migration ของคุณจะต่าง · เวลาใน log ของ Rails ที่ลงท้าย Z/UTC และเวลาใน psql = เวลาสากล (ช้ากว่าไทย 7 ชั่วโมง)
เป้าหมายของบท — ตั้งเวลาไว้แล้วไปนอนได้
ตอนนี้แอปมีสถานะ "ตั้งเวลาเผยแพร่" แล้ว แต่ยังมีสองช่องโหว่ · ① ถึงเวลาแล้วไม่มีใครมาทำ — PublishPostJob ทำงานเมื่อมีคนกด "เผยแพร่ทันที" เท่านั้น ·
② ไม่มีใครรู้ว่ามีบทความใหม่ — ยังไม่มีผู้ติดตาม ยังไม่มีอีเมล
บทนี้เติมทีละชิ้นตามลำดับนี้ (ทุกขั้นยังรันซ้ำแล้วไม่พังเหมือนบท 143):
| ขั้น | ทำอะไร | ไฟล์หลัก |
|---|---|---|
| 1 | รายชื่อผู้ติดตาม | followers (ตาราง + model + seeds) |
| 2 | หน้าตาอีเมล + ส่งอีเมลลงไฟล์ในเครื่อง | PostMailer + view 2 ไฟล์ + development.rb |
| 3 | สมุดจดว่าส่งใครไปแล้ว (กันส่งซ้ำ) | post_notifications |
| 4 | ให้ job ส่งอีเมลหลังเผยแพร่ + ลองใหม่ / ทิ้ง เมื่อพัง | publish_post_job.rb |
| 5 | ตัวเดินตรวจทุกนาที | publish_scheduled_posts_job.rb + config/recurring.yml |
ขั้น 1 — ผู้ติดตาม: ตาราง followers
ผู้ติดตาม = คนที่อยากได้อีเมลเมื่อมีบทความใหม่ · เก็บแค่อีเมลพอ · บทนี้ไม่ทำหน้าสมัคร (หัวใจของบทคือ job) — ใส่รายชื่อ 3 คนผ่าน seeds · ชื่อตรงกับผู้ใช้ที่จะมาในบล็อก G (somchai · malee · boss)
หน้าต่าง ② พิมพ์:
$ bin/rails generate model Follower email:string:uniq
invoke active_record
create db/migrate/20261110034734_create_followers.rb
create app/models/follower.rb
invoke test_unit
create test/models/follower_test.rb
create test/fixtures/followers.yml
email:string:uniq= ช่องemailเป็นข้อความ และสร้างสารบัญห้ามซ้ำให้ด้วย (:uniq)- ไฟล์ใน
test/สองไฟล์ ปล่อยไว้ตามที่ generator สร้าง ยังไม่แตะ (เรื่อง test เป็นของบล็อก J)
เปิด migration ที่เพิ่งได้ (db/migrate/…_create_followers.rb) แก้ให้เป็นแบบนี้ — เติม , null: false กับคอมเมนต์หนึ่งบรรทัด:
class CreateFollowers < ActiveRecord::Migration[8.1]
def change
create_table :followers do |t|
t.string :email, null: false
t.timestamps
end
# อีเมลซ้ำไม่ได้ (คู่กับ validates uniqueness ใน model)
add_index :followers, :email, unique: true
end
end
null: false— ตู้ไม่ยอมเก็บแถวที่ไม่มีอีเมล (ชั้น DB · บท 111)add_index … unique: true— generator เขียนให้แล้วจาก:uniq· เราแค่เติมคอมเมนต์ข้างบน
เปิด app/models/follower.rb แทนทั้งไฟล์:
# ผู้ติดตาม: ได้อีเมลเมื่อมีบทความเผยแพร่ใหม่ (เพิ่มผ่าน db/seeds.rb หรือ console)
class Follower < ApplicationRecord
# เลิกติดตาม = ประวัติการส่งของคนนี้ไม่มีความหมายแล้ว ลบด้วย SQL คำสั่งเดียว
has_many :post_notifications, dependent: :delete_all
# unique index เทียบตรงตัวอักษร → ทำเป็นตัวเล็กก่อนเก็บ (A@x.com กับ a@x.com คือคนเดียวกัน)
normalizes :email, with: ->(email) { email.strip.downcase }
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
end
has_many :post_notifications— ประกาศไว้ก่อนว่าคนหนึ่งมี "บันทึกการส่ง" ได้หลายแถว · ตารางนี้จะสร้างในขั้น 3 (ตอนนี้ยังไม่มีใครเรียกใช้ จึงยังไม่พัง)normalizes :email, with: ->(email) { … }—stripตัดช่องว่างหัวท้าย ·downcaseทำเป็นตัวเล็ก · ทำก่อนเก็บและก่อนค้นทุกครั้งformat: { with: URI::MailTo::EMAIL_REGEXP }— รูปแบบอีเมลที่ Ruby มีมาให้ (มาตรฐานเดียวกับช่อง<input type="email">ของเบราว์เซอร์)
เปิด db/seeds.rb ต่อท้ายไฟล์ (ใต้บรรทัด puts "categories: …" เดิม):
# ผู้ติดตาม 3 คน — ได้อีเมลเมื่อมีบทความเผยแพร่ใหม่
%w[ somchai@example.com malee@example.com boss@example.com ].each do |email|
Follower.find_or_create_by!(email: email)
end
puts "followers: #{Follower.count}"
%w[ a b c ]= เขียนรายการคำแบบย่อ ได้["a", "b", "c"]·find_or_create_by!= มีแล้วไม่สร้างซ้ำ (seeds รันซ้ำได้ · บท 120)
สร้างตารางแล้วใส่รายชื่อ:
$ bin/rails db:migrate
== 20261110034734 CreateFollowers: migrating ==================================
-- create_table(:followers)
-> 0.0026s
-- add_index(:followers, :email, {unique: true})
-> 0.0007s
== 20261110034734 CreateFollowers: migrated (0.0051s) =========================
$ bin/rails db:seed
categories: 5 · posts: 30
followers: 3
เปิด bin/rails console แล้วลองเพิ่ม malee ซ้ำ แต่พิมพ์มีช่องว่างและตัวใหญ่ปน:
wongnok-blog-all-in-one(dev):001> Follower.create!(email: " Malee@Example.COM ")
คิดว่าได้ผู้ติดตามคนที่ 4 ไหม?
wongnok-blog-all-in-one(dev):001> Follower.pluck(:email)
Follower Pluck (0.4ms) SELECT "followers"."email" FROM "followers"
=> ["somchai@example.com", "malee@example.com", "boss@example.com"]
wongnok-blog-all-in-one(dev):002> Follower.create!(email: " Malee@Example.COM ")
TRANSACTION (0.3ms) BEGIN
Follower Exists? (0.8ms) SELECT 1 AS one FROM "followers" WHERE "followers"."email" = 'malee@example.com' LIMIT 1
TRANSACTION (0.3ms) ROLLBACK
(wongnok-blog-all-in-one):2:in '<main>': Validation failed: Email has already been taken (ActiveRecord::RecordInvalid)
ไม่ได้ — บรรทัด Follower Exists? ค้นด้วย 'malee@example.com' (ตัดช่องว่าง ตัวเล็กแล้ว) จึงเจอคนเดิม · ถ้าไม่มี normalizes ตู้จะเห็นเป็นคนละคน แล้ว malee ได้อีเมลสองฉบับต่อบทความ
ขั้น 2 — อีเมลใน Rails: ActionMailer
ActionMailer คือส่วนของ Rails ที่ทำอีเมล · หน้าตาเหมือน controller + view ที่คุ้นแล้ว: มี method (เหมือน action) เตรียมข้อมูล แล้วมี view วาดเนื้ออีเมล · ต่างกันตรงที่ผลลัพธ์ไม่ได้กลับไปที่เบราว์เซอร์ แต่ถูกส่งออกไปเป็นจดหมาย
ปกติคนจัดจาน (view) วางอาหารบนจานแล้วเสิร์ฟที่โต๊ะลูกค้าที่สั่ง · mailer คือคนจัดจานชุดเดิม แต่ใส่กล่องส่งไปรษณีย์ไปบ้านคนที่ไม่ได้มานั่งในร้าน · แม่พิมพ์ (ERB) เขียนแบบเดียวกัน · คนสั่งให้ทำคือพนักงานหลังร้าน (job) ไม่ใช่ลูกค้าที่โต๊ะ
ที่ไม่เหมือนกัน: ส่งออกไปแล้วเรียกคืนไม่ได้ (ส่งซ้ำ = ลูกค้าได้สองกล่องจริง ๆ) ·
และที่อยู่บนกล่องต้องเขียนเต็ม — ลิงก์ในอีเมลต้องเป็น post_url (มีชื่อโดเมน) ไม่ใช่ post_path เพราะอีเมลไม่ได้เปิดจากในร้าน ไม่รู้ว่า "ร้านเรา" อยู่ที่ไหน
เทียบของที่รู้: ใน Go ต้องประกอบเองด้วย html/template + ไลบรารีส่งเมล · ใน Next.js มักเรียก service ภายนอก ·
Rails มีมาในกล่อง และใช้ ERB/helper ชุดเดียวกับหน้าเว็บ (Day 3)
สร้าง mailer ชื่อ PostMailer ที่มีอีเมลหนึ่งแบบชื่อ published:
$ bin/rails generate mailer PostMailer published
create app/mailers/post_mailer.rb
invoke tailwindcss
create app/views/post_mailer
create app/views/post_mailer/published.text.erb
create app/views/post_mailer/published.html.erb
invoke test_unit
create test/mailers/post_mailer_test.rb
create test/mailers/previews/post_mailer_preview.rb
- ได้ view สองไฟล์:
.text.erb(ตัวหนังสือล้วน) กับ.html.erb(มีหัวข้อ ลิงก์) · อีเมลหนึ่งฉบับแนบทั้งสองแบบ โปรแกรมอ่านอีเมลเลือกโชว์แบบที่มันถนัด previews/post_mailer_preview.rb= หน้าดูตัวอย่างอีเมลในเบราว์เซอร์ (ไม่ได้ส่งจริง) ·post_mailer_test.rbปล่อยไว้ก่อน
แทนทั้งไฟล์ app/mailers/post_mailer.rb:
class PostMailer < ApplicationMailer
# อีเมลแจ้งผู้ติดตามว่ามีบทความใหม่ · เรียกจาก PublishPostJob เท่านั้น (ไม่ส่งใน callback ของ model)
# ลิงก์สร้างตอนส่ง ไม่รับ URL หรือ token ที่สร้างไว้ก่อนเข้าคิว
def published
@post = params[:post]
@follower = params[:follower]
mail to: @follower.email, subject: "บทความใหม่: #{@post.title}"
end
end
params[:post]— ของที่ยื่นมาตอนเรียกPostMailer.with(post: …, follower: …)(เห็นในขั้น 4) · ชื่อเหมือนparamsของ controller แต่ไม่ได้มาจากฟอร์ม@post@follower— ตัวแปร@ส่งต่อให้ view ได้ เหมือน controllermail to: …, subject: …— ประกอบซองจดหมาย: ผู้รับ หัวเรื่อง · ผู้ส่งมาจากApplicationMailer(from@example.comที่ Rails ใส่ไว้ — ในเครื่องเราไม่เป็นไร)
แทนทั้งไฟล์ app/views/post_mailer/published.text.erb:
มีบทความใหม่ใน Wongnok Blog
<%= @post.title %>
<%= truncate(@post.plain_text, length: 200, separator: " ") %>
อ่านต่อ: <%= post_url(@post) %>
ส่งถึง <%= @follower.email %> เพราะคุณติดตาม Wongnok Blog
และ app/views/post_mailer/published.html.erb:
<p>มีบทความใหม่ใน Wongnok Blog</p>
<h1><%= @post.title %></h1>
<p><%= truncate(@post.plain_text, length: 200, separator: " ") %></p>
<p><%= link_to "อ่านต่อ", post_url(@post) %></p>
<p><small>ส่งถึง <%= @follower.email %> เพราะคุณติดตาม Wongnok Blog</small></p>
@post.plain_text— เนื้อหาแบบตัวหนังสือล้วนที่เก็บไว้ตั้งแต่บท 137 ·truncate(…, length: 200, separator: " ")= ตัดให้ไม่เกิน 200 ตัวอักษร ตัดตรงช่องว่างpost_url(@post)— ลิงก์เต็มhttp://localhost:3000/posts/…· โดเมนมาจากdefault_url_optionsในdevelopment.rbที่ Rails ตั้งไว้ให้แล้ว
แก้หน้าดูตัวอย่าง test/mailers/previews/post_mailer_preview.rb — ของเดิมเรียก PostMailer.published เฉย ๆ (ไม่ยื่นบทความกับผู้รับ เปิดแล้ว error) · แทนทั้งไฟล์:
# Preview all emails at http://localhost:3000/rails/mailers/post_mailer
class PostMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/post_mailer/published
# ดูหน้าตาอีเมลในเบราว์เซอร์ (ไม่ได้ส่งจริง) ด้วยบทความล่าสุดกับผู้ติดตามคนแรก
def published
PostMailer.with(post: Post.latest_published.first, follower: Follower.first).published
end
end
อีเมลในเครื่องเราไปไหน — เขียนลงไฟล์
ในเครื่องเราไม่มีเซิร์ฟเวอร์อีเมล (สไลด์ใช้ Mailpit = กล่องจดหมายปลอมที่รันใน docker · คอร์สนี้ไม่ได้ติดตั้ง) ·
ทางที่ง่ายที่สุดคือสั่ง Rails เขียนอีเมลเป็นไฟล์ · เปิด config/environments/development.rb หาสองบรรทัดนี้:
# Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = false
แทนด้วย:
# ส่งไม่ผ่านต้อง error → PublishPostJob ยกเลิกแถวที่จองไว้ แล้ว retry ได้
config.action_mailer.raise_delivery_errors = true
# อีเมลไม่ได้ส่งออกจริง: เขียนเป็นไฟล์ใน tmp/mails/ (1 ไฟล์ต่อผู้รับ · ฉบับใหม่ต่อท้ายไฟล์เดิม)
# นับอีเมล: grep -c "^Subject:" tmp/mails/* (ไม่ต้องลง service เพิ่มแบบ Mailpit)
config.action_mailer.delivery_method = :file
config.action_mailer.file_settings = { location: Rails.root.join("tmp/mails") }
raise_delivery_errors = true— ส่งไม่ผ่านแล้วต้องโวยวาย (ค่าเดิมfalse= พังเงียบ) · ขั้น 4 จะเห็นว่าทำไมสำคัญdelivery_method = :file— "ส่ง" = เขียนไฟล์ ·tmp/ไม่ถูก commit อยู่แล้ว (อยู่ใน.gitignore)
แก้ไฟล์ config แล้วต้องปิดเปิด bin/dev ใหม่ (Ctrl+C แล้ว bin/dev) · ไฟล์ใน app/ ไม่ต้อง — ทั้งเว็บและ worker โหลดโค้ดใหม่ให้เองในเครื่องเรา
ลองประกอบอีเมลหนึ่งฉบับโดยยังไม่ส่ง (หน้าต่าง ②):
$ bin/rails runner 'm = PostMailer.with(post: Post.latest_published.first, follower: Follower.first).published; puts m.subject; puts m.to.inspect; puts m.from.inspect; puts "---"; puts m.text_part.decoded'
บทความใหม่: นมเย็น
["somchai@example.com"]
["from@example.com"]
---
มีบทความใหม่ใน Wongnok Blog
นมเย็น
นมสีชมพูจากน้ำแดง หวานหอม เด็ก ๆ ชอบที่สุด เติมไข่มุกได้
อ่านต่อ: http://localhost:3000/posts/30
ส่งถึง somchai@example.com เพราะคุณติดตาม Wongnok Blog
bin/rails runner '…'= รันโค้ด Ruby หนึ่งก้อนในแอปแล้วจบ (ไม่ต้องเปิด console)text_part.decoded= หยิบส่วนตัวหนังสือล้วนมาอ่าน ·นมเย็นคือบทความเผยแพร่ล่าสุดของ seeds- หรือเปิดเบราว์เซอร์ที่
localhost:3000/rails/mailers/post_mailer/published— หน้าดูตัวอย่างที่เพิ่งแก้ มีให้สลับดูแบบ HTML/ตัวหนังสือ
- mailer (ActionMailer)
- คลาสที่สร้างอีเมล · method หนึ่งตัว = อีเมลหนึ่งแบบ · พิมพ์บอก AI ได้ว่า "สร้าง PostMailer#published รับ post กับ follower ผ่าน
with" - multipart (text + html)
- อีเมลฉบับเดียวมีสองหน้าตา · ถาม senior ได้ว่า "ทีมเราต้องมีส่วน text ทุกฉบับไหมครับ"
- delivery method
- วิธีส่งจริง:
:smtp(เซิร์ฟเวอร์อีเมล) ·:file(ลงไฟล์) ·:test(เก็บในหน่วยความจำ ใช้ตอนเขียน test) · ตั้งแยกตาม environment - mailer preview
- หน้า
/rails/mailers/…ดูอีเมลในเบราว์เซอร์โดยไม่ส่ง · มีเฉพาะตอน development
ขั้น 3 — สมุดจดว่าส่งใครไปแล้ว: ตาราง post_notifications
job ของเราต้องรันซ้ำได้ (บท 142–143: คิวรับประกันแค่ "ไม่หาย แต่อาจซ้ำ") · การเปลี่ยนสถานะบทความซ้ำไม่เป็นไรเพราะมี guard · แต่อีเมลที่ส่งออกไปแล้วเรียกคืนไม่ได้ — ต้องมีที่จดว่า "บทความนี้ ส่งถึงคนนี้แล้ว" แล้วให้ตู้เป็นคนตัดสินว่าจดซ้ำไม่ได้
ท่าที่ใช้คือท่าที่ 3 ของบท 143: กุญแจกันซ้ำ + unique index · กุญแจในที่นี้คือคู่ (บทความ, ผู้รับ) · และใช้ลำดับ"จองก่อนส่ง": เขียนแถวลงสมุดก่อน → เขียนได้ = ได้สิทธิ์ส่ง · เขียนไม่ได้เพราะมีอยู่แล้ว = มีคนส่งไปแล้ว ข้าม
หลังร้านมีสมุดจดใบงานที่ทำแล้ววางข้างประตู · พนักงานส่งของต้องเขียน "จานนี้ → บ้านเลขที่นี้" ลงสมุดก่อนออกไปส่ง · สมุดเล่มนี้พิเศษ: ถ้ามีบรรทัดเดียวกันอยู่แล้ว ปากกาเขียนไม่ติด → พนักงานรู้ทันทีว่ามีคนส่งไปแล้ว ไม่ต้องออกไป · ถ้าออกไปแล้วส่งไม่สำเร็จ (บ้านปิด) กลับมาลบบรรทัดนั้นทิ้ง รอบหน้าจะได้มีคนส่งใหม่
ที่ไม่เหมือนกัน: "ปากกาเขียนไม่ติด" เป็นของจริงที่ตู้บังคับ แม้สองคนเขียนพร้อมกันวินาทีเดียวกัน คนที่สองก็ต้องรอแล้วเขียนไม่ติด (คนจริงอาจเขียนทับกันได้) · แต่ยังมีช่องเล็ก ๆ ที่ปิดไม่ได้: พนักงานส่งของถึงมือลูกค้าแล้ว เป็นลมก่อนกลับมายืนยันบรรทัดในสมุด → บรรทัดถูกลบเพราะไม่ได้ยืนยัน → คนใหม่ส่งซ้ำอีกหนึ่งกล่อง · เราเลือก "อาจซ้ำหนึ่งฉบับในกรณีหายาก" ดีกว่า "อาจหายเงียบ" (ประโยคข้อ ② ของวัน)
สร้าง model:
$ bin/rails generate model PostNotification post:references follower:references
invoke active_record
create db/migrate/20261110034739_create_post_notifications.rb
create app/models/post_notification.rb
invoke test_unit
create test/models/post_notification_test.rb
create test/fixtures/post_notifications.yml
แทนทั้งไฟล์ migration ที่ได้ (db/migrate/…_create_post_notifications.rb):
# หนึ่งแถว = "บทความนี้ส่งอีเมลแจ้งผู้ติดตามคนนี้แล้ว"
class CreatePostNotifications < ActiveRecord::Migration[8.1]
def change
create_table :post_notifications do |t|
# ไม่ต้องมี index แยกของ post_id: unique index ข้างล่างขึ้นต้นด้วย post_id อยู่แล้ว
t.references :post, null: false, foreign_key: true, index: false
t.references :follower, null: false, foreign_key: true
t.timestamps
end
# หัวใจของ "ไม่ส่งซ้ำ": บทความเดียวกัน + คนเดิม มีได้แถวเดียว
# job รันซ้ำ หรือสอง worker ทำพร้อมกัน → ตัวที่มาทีหลังใส่แถวไม่ได้ (DB ตัดสิน ไม่ใช่โค้ด Ruby)
add_index :post_notifications, [ :post_id, :follower_id ], unique: true
end
end
t.references :post— ช่องpost_idชี้ไปบทความ ·foreign_key: true= ตู้ไม่ยอมให้ชี้ไปบทความที่ไม่มีอยู่index: false— ปกติ references สร้าง index ให้เอง · แต่ index คู่ข้างล่างขึ้นต้นด้วยpost_idค้นด้วย post_id ได้อยู่แล้ว ไม่ต้องมีสองอันadd_index … [ :post_id, :follower_id ], unique: true— สารบัญห้ามซ้ำแบบคู่: บทความ 31 ส่งถึงคน 1 มีได้แถวเดียว · บทความ 31 ถึงคน 2 เป็นอีกแถว ได้
แทนทั้งไฟล์ app/models/post_notification.rb:
# บันทึกว่า "บทความนี้ส่งอีเมลแจ้งผู้ติดตามคนนี้แล้ว" — PublishPostJob ใส่แถวก่อนส่งเพื่อจองสิทธิ์
# ไม่มี validates uniqueness: ตัวกันซ้ำจริงคือ unique index ที่ DB (validation เช็กแล้วสอง worker ยังชนกันได้)
# อยากรู้ว่าแจ้งไปกี่คน → นับแถวในตารางนี้ ไม่เก็บตัวนับที่บวกเพิ่ม (job รันซ้ำแล้วเลขเพี้ยน)
class PostNotification < ApplicationRecord
belongs_to :post
belongs_to :follower
end
สองบรรทัด belongs_to generator เขียนให้แล้ว · เราเติมแค่คอมเมนต์ 3 บรรทัดบนสุด
เปิด app/models/post.rb เพิ่ม 2 บรรทัดใต้ has_many :comments, dependent: :destroy:
has_many :comments, dependent: :destroy
# ประวัติว่าส่งอีเมลแจ้งใครไปแล้ว · ลบบทความ = ไม่มีความหมายแล้ว ลบด้วย SQL คำสั่งเดียว
has_many :post_notifications, dependent: :delete_all
$ bin/rails db:migrate
== 20261110034739 CreatePostNotifications: migrating ==========================
-- create_table(:post_notifications)
-> 0.0029s
-- add_index(:post_notifications, [:post_id, :follower_id], {unique: true})
-> 0.0007s
== 20261110034739 CreatePostNotifications: migrated (0.0050s) =================
- composite unique index
- unique index หลายช่องรวมกัน · ถาม senior ได้ว่า "กันส่งอีเมลซ้ำด้วย unique index (post_id, follower_id) พอไหมครับ"
- reserve-then-send (จองก่อนทำ)
- ใส่แถวกันซ้ำก่อน แล้วค่อยทำงานที่ย้อนไม่ได้ · พิมพ์บอก AI ได้ว่า "insert แถว notification ก่อนส่ง ใน transaction เดียวกัน ถ้าชน RecordNotUnique ให้ข้าม"
- race condition
- ผลเพี้ยนเพราะสองงานทำพร้อมกัน ·
exists?แล้วค่อยcreateแพ้ race (เช็กพร้อมกันได้ "ยังไม่มี" ทั้งคู่) · unique index ไม่แพ้
ขั้น 4 — ให้ PublishPostJob ส่งอีเมลหลังเผยแพร่
เปิด app/jobs/publish_post_job.rb · ก) แก้คอมเมนต์สองบรรทัดบนสุดเป็น:
# เผยแพร่บทความที่ตั้งเวลาไว้ เมื่อถึงเวลาแล้ว แล้วส่งอีเมลแจ้งผู้ติดตาม
# ห้ามสมมติว่ารันครั้งเดียว: กดปุ่มซ้ำ · ตัวกวาดส่งซ้ำ · retry · สอง worker หยิบงานพร้อมกัน → รันกี่รอบผลต้องเหมือนรอบเดียว
ข) ใน perform ต่อจาก end ของบล็อก post.with_lock (ก่อน end ของ perform) เพิ่ม · แล้วค) ต่อท้ายคลาส (ก่อน end สุดท้ายของไฟล์) เพิ่ม private กับ method สองตัว:
end # ← end ของ post.with_lock (มีอยู่แล้ว)
# แจ้งทุกรอบที่บทความเผยแพร่แล้ว (ไม่ใช่แค่รอบที่เพิ่งเปลี่ยนสถานะ):
# รอบก่อนพังกลางทาง → retry รอบนี้ส่งต่อให้คนที่ยังไม่ได้ · คนที่ได้แล้วถูกกันด้วย unique index
notify_followers(post) if post.published?
end # ← end ของ perform (มีอยู่แล้ว)
private
# เฉพาะคนที่ติดตามก่อนเวลาเผยแพร่: รันซ้ำทีหลังจะไม่ส่งบทความเก่าไปหาคนที่เพิ่งมาติดตาม
def notify_followers(post)
Follower.where(created_at: ..post.published_at).find_each do |follower|
notify(post, follower)
end
end
def notify(post, follower)
PostNotification.transaction do
# จองก่อนส่ง: ใส่แถว (post, follower) ได้ = ได้สิทธิ์ส่ง
PostNotification.create!(post: post, follower: follower)
# ส่งพังอยู่ใน transaction → แถวที่จองถูกยกเลิกด้วย → retry รอบหน้าส่งใหม่ ไม่หายเงียบ
PostMailer.with(post: post, follower: follower).published.deliver_now
end
rescue ActiveRecord::RecordNotUnique
# มีแถวอยู่แล้ว = ส่งไปแล้ว (รอบก่อน หรือ worker อีกตัว) → ข้าม
end
end
อ่านทีละส่วน:
notify_followers(post) if post.published?— อยู่นอกบล็อกwith_lock· ทำทุกรอบที่บทความเผยแพร่แล้ว ไม่ใช่แค่รอบที่เพิ่งเปลี่ยนสถานะ · เพราะถ้ารอบแรกส่งได้ครึ่งเดียวแล้วพัง รอบ retry ต้องส่งต่อให้คนที่เหลือ (ถ้าเขียนreturn if post.published?แบบสไลด์หน้า 19–20 คนที่เหลือจะไม่ได้อีเมลเลย)private— method ข้างล่างเรียกได้จากในคลาสเท่านั้น · ย่อหน้าเข้าไปอีกขั้นตามสไตล์ของ Railswhere(created_at: ..post.published_at)—..x= "ไม่เกิน x" (SQL<=) · เอาเฉพาะคนที่ติดตามก่อนบทความออก · ไม่งั้นรันซ้ำพรุ่งนี้ คนที่เพิ่งมาติดตามจะได้อีเมลบทความเก่าfind_each— วนทีละแถว แต่ดึงจากตู้ทีละ 1,000 (ผู้ติดตามเป็นแสนก็ไม่กินหน่วยความจำ)PostNotification.transaction do … end— สองบรรทัดข้างในสำเร็จพร้อมกัน หรือยกเลิกพร้อมกันcreate!แล้วค่อยdeliver_now— จองก่อนส่ง ·deliver_now= ส่งเดี๋ยวนี้ (เราอยู่ใน job อยู่แล้ว ไม่ต้องเข้าคิวอีกรอบ)rescue ActiveRecord::RecordNotUnique— จองไม่ได้เพราะมีแถวอยู่แล้ว → จับ error นี้ไว้แล้วไม่ทำอะไร = ข้ามคนนี้
ล้างไฟล์อีเมลเก่า แล้วเปิด bin/rails console สร้างบทความที่ "ถึงเวลาแล้ว" หนึ่งบทความ จากนั้นสั่ง job สามรอบติดกัน:
$ rm -rf tmp/mails
$ bin/rails console
wongnok-blog-all-in-one(dev):001> post = Post.create!(title: "ทดลองรันซ้ำ", body: "ทดลองส่งอีเมลหาผู้ติดตาม", category: Category.first, status: :scheduled, scheduled_at: 1.minute.ago); nil
wongnok-blog-all-in-one(dev):002> 3.times { PublishPostJob.perform_now(post.id) }
; nilท้ายบรรทัดแรก = ให้ console ไม่พิมพ์บทความออกมายาว ๆ ·perform_now= ทำเดี๋ยวนี้ในหน้าต่างนี้ ไม่ผ่านคิว (บท 143)
ผู้ติดตาม 3 คน · job รัน 3 รอบ · คิดว่าในโฟลเดอร์ tmp/mails จะมีอีเมลรวมกี่ฉบับ? 3 หรือ 9?
3 ฉบับ — คนละฉบับพอดี · console พิมพ์ log ยาวมาก (รวมเนื้ออีเมลทั้งฉบับ) ตัดเหลือบรรทัดสำคัญ:
Performing PublishPostJob (Job ID: ff976d23-…) from SolidQueue(default) with arguments: 35
…
Post Update (0.3ms) UPDATE "posts" SET "updated_at" = '…', "status" = 'published', "published_at" = '2026-11-10 04:02:37.065475' WHERE "posts"."id" = 35
…
PostNotification Create (0.5ms) INSERT INTO "post_notifications" ("post_id", "follower_id", "created_at", "updated_at") VALUES (35, 1, …) RETURNING "id"
…
Delivered mail 6af2975d19675_4248af8-427@….mail (4.6ms)
… (คนที่ 2 และ 3 เหมือนกัน)
Performed PublishPostJob (Job ID: ff976d23-…) from SolidQueue(default) in 60.9ms
Performing PublishPostJob (Job ID: 2e394268-…) from SolidQueue(default) with arguments: 35
…
PostNotification Create (1.0ms) INSERT INTO "post_notifications" (…) VALUES (35, 1, …) RETURNING "id"
TRANSACTION (0.2ms) ROLLBACK
PostNotification Create (0.7ms) INSERT INTO "post_notifications" (…) VALUES (35, 2, …) RETURNING "id"
TRANSACTION (0.2ms) ROLLBACK
PostNotification Create (0.6ms) INSERT INTO "post_notifications" (…) VALUES (35, 3, …) RETURNING "id"
TRANSACTION (0.2ms) ROLLBACK
Performed PublishPostJob (Job ID: 2e394268-…) from SolidQueue(default) in 6.25ms
… (รอบที่ 3 เหมือนรอบที่ 2)
=> 3
wongnok-blog-all-in-one(dev):003> post.reload.status
=> "published"
wongnok-blog-all-in-one(dev):004> post.post_notifications.count
=> 3
$ grep -c "^Subject:" tmp/mails/*
tmp/mails/boss@example.com:1
tmp/mails/somchai@example.com:1
tmp/mails/malee@example.com:1
- รอบ 1:
Post Updateเปลี่ยนเป็น published ·INSERTสำเร็จ 3 ครั้ง ·Delivered mail3 ครั้ง - รอบ 2–3: ไม่มี
Post Update(guard ของบท 143 เห็นว่าไม่ใช่ scheduled แล้ว) · แต่ยังเข้าnotify_followersเพราะ published ·INSERTชนสารบัญห้ามซ้ำ →ROLLBACK→rescueข้าม · ไม่มีDelivered mailเลย grep -c "^Subject:"= นับบรรทัดที่ขึ้นต้นด้วยSubject:ในแต่ละไฟล์ = นับอีเมล (ไฟล์ละผู้รับ)
ถ้าคุณได้ :3 ทุกไฟล์ = notify ไม่ได้จองก่อนส่ง (เช่นลืม create! หรือวาง deliver_now ไว้นอก transaction) · ถ้าได้ไฟล์เดียวหรือไม่มีโฟลเดอร์เลย = ยังไม่ได้ปิดเปิด bin/dev/console หลังแก้ development.rb
เปิดไฟล์อีเมลตรง ๆ จะอ่านไม่ออก เพราะภาษาไทยถูกแปลงเป็นรหัส (base64) ตามมาตรฐานอีเมล:
$ head -6 tmp/mails/malee@example.com
Date: Tue, 10 Nov 2026 11:02:37 +0700
From: from@example.com
To: malee@example.com
Message-ID: <6af2975d1d089_4248af8-2e8@….mail>
Subject: =?UTF-8?Q?=E0=B8=9A=E0=B8=97=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88:?=
=?UTF-8?Q?_=E0=B8=97=E0=B8=94=E0=B8=A5=E0=B8=AD=E0=B8=87=E0=B8=A3=E0=B8=B1=E0=B8=99=E0=B8=8B=E0=B9=89=E0=B8=B3?=
อ่านเป็นภาษาคนได้ด้วย bin/rails runner 'm = Mail.read("tmp/mails/malee@example.com"); puts m.subject; puts m.text_part.decoded' (อ่านฉบับแรกของไฟล์) — ภารกิจท้ายบทใช้คำสั่งนี้
เมื่อ job พัง — retry_on กับ discard_on
job ที่ส่งอีเมลพังได้สองแบบที่ต่างกันมาก · แบบที่ปัญหาอาจหายเอง — เซิร์ฟเวอร์อีเมลต่อไม่ติดชั่วคราว ยุ่งอยู่ · รอแล้วลองใหม่ก็น่าจะผ่าน · แบบที่ลองใหม่ไปก็เท่านั้น — บทความถูกลบไปแล้วก่อนถึงคิว · ลองอีกร้อยรอบก็หาไม่เจอ · สไลด์หน้า 21 สรุปว่า retry กับ discard ต่างกันที่ความหวัง
และถ้าลองใหม่ ต้องเว้นระยะให้ห่างขึ้นเรื่อย ๆ · ถ้าเซิร์ฟเวอร์อีเมลป่วยอยู่แล้ว job เป็นพันตัวลองใหม่ทุกวินาที = รุมกระหน่ำคนป่วยจนไม่มีวันฟื้น
พนักงานหลังร้านโทรสั่งของจากซัพพลายเออร์ แล้วสายไม่ว่าง → วางสาย รอ 3 วินาทีโทรใหม่ · ยังไม่ว่าง รอ 18 วินาที · แล้ว 1 นาทีครึ่ง · แล้วราว 4 นาที (retry + backoff) · ครบ 5 ครั้งยังไม่ติด → แปะใบงานไว้ที่กระดาน "งานติดปัญหา" ให้ผู้จัดการมาดู · แต่ถ้าใบงานเขียนว่า "ส่งของให้โต๊ะ 12" แล้วลูกค้าโต๊ะ 12 ยกเลิกไปแล้ว → ฉีกใบงานทิ้งเลย ไม่ต้องโทรสักครั้ง (discard)
ที่ไม่เหมือนกัน: พนักงานจริงฟังเสียงแล้วรู้ว่า "สายไม่ว่าง" หรือ "เบอร์นี้ยกเลิกแล้ว" · job ไม่รู้เอง — เราต้องบอกล่วงหน้าเป็นชื่อ error ทีละชื่อ · error ที่เราไม่ได้ระบุ = ไม่ลองใหม่ ไม่ทิ้ง ไปกองที่กระดาน "งานติดปัญหา" ทันที
เปิด app/jobs/publish_post_job.rb เพิ่มใต้บรรทัด queue_as :default (เว้นบรรทัดว่างคั่นตามนี้):
queue_as :default
# ส่งอีเมลไม่ผ่านแบบชั่วคราว (เซิร์ฟเวอร์อีเมลต่อไม่ติด / ยุ่ง) → ลองใหม่ เว้นระยะถ่างออกเรื่อย ๆ
# (ประมาณ 3 วิ · 18 วิ · 83 วิ · 258 วิ) · retry ได้อย่างปลอดภัยเพราะ job นี้รันซ้ำได้
retry_on Net::OpenTimeout, Net::SMTPServerBusy, wait: :polynomially_longer, attempts: 5
# บทความถูกลบไปก่อนถึงคิว → ลองใหม่กี่รอบก็ไม่เจอ ทิ้งงานไปเลย
discard_on ActiveRecord::RecordNotFound
# รับแค่ id แล้วโหลดสดจาก DB: ได้สถานะ/เวลาล่าสุดเสมอ (ไม่ใช่ค่าเก่าตอนเข้าคิว)
Net::OpenTimeout— ต่อเซิร์ฟเวอร์ไม่ติดภายในเวลาที่รอ ·Net::SMTPServerBusy— เซิร์ฟเวอร์อีเมลตอบว่า "ยุ่งอยู่ ลองใหม่ทีหลัง" · ทั้งคู่เป็น error ที่ไลบรารีส่งอีเมลของ Ruby โยนออกมาwait: :polynomially_longer— สูตรเว้นระยะของ Rails:(ครั้งที่ลอง)⁴ + 2วินาที บวกสุ่มเพิ่มไม่เกิน 15% ให้ job ที่พังพร้อมกันไม่ลองพร้อมกันอีก (ตารางข้างล่าง)attempts: 5— รวมรอบแรกด้วย = ทำรอบแรก + ลองใหม่อีก 4 · ครั้งที่ 5 ยังพัง → ไปอยู่ตารางsolid_queue_failed_executionsdiscard_on ActiveRecord::RecordNotFound— error ที่Post.findโยนเมื่อหาไม่เจอ → ทิ้งงาน เขียน log หนึ่งบรรทัด จบ
| ครั้งที่พัง | สูตร | รอ (ประมาณ) |
|---|---|---|
| 1 | 1⁴ + 2 | 3 วินาที |
| 2 | 2⁴ + 2 | 18 วินาที |
| 3 | 3⁴ + 2 | 83 วินาที |
| 4 | 4⁴ + 2 | 258 วินาที (~4 นาที) |
| 5 | — | ไม่ลองแล้ว → failed |
ไฟล์ทั้งไฟล์ตอนนี้ต้องเป็นแบบนี้ (เทียบทีละบรรทัดได้):
# เผยแพร่บทความที่ตั้งเวลาไว้ เมื่อถึงเวลาแล้ว แล้วส่งอีเมลแจ้งผู้ติดตาม
# ห้ามสมมติว่ารันครั้งเดียว: กดปุ่มซ้ำ · ตัวกวาดส่งซ้ำ · retry · สอง worker หยิบงานพร้อมกัน → รันกี่รอบผลต้องเหมือนรอบเดียว
class PublishPostJob < ApplicationJob
queue_as :default
# ส่งอีเมลไม่ผ่านแบบชั่วคราว (เซิร์ฟเวอร์อีเมลต่อไม่ติด / ยุ่ง) → ลองใหม่ เว้นระยะถ่างออกเรื่อย ๆ
# (ประมาณ 3 วิ · 18 วิ · 83 วิ · 258 วิ) · retry ได้อย่างปลอดภัยเพราะ job นี้รันซ้ำได้
retry_on Net::OpenTimeout, Net::SMTPServerBusy, wait: :polynomially_longer, attempts: 5
# บทความถูกลบไปก่อนถึงคิว → ลองใหม่กี่รอบก็ไม่เจอ ทิ้งงานไปเลย
discard_on ActiveRecord::RecordNotFound
# รับแค่ 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
# แจ้งทุกรอบที่บทความเผยแพร่แล้ว (ไม่ใช่แค่รอบที่เพิ่งเปลี่ยนสถานะ):
# รอบก่อนพังกลางทาง → retry รอบนี้ส่งต่อให้คนที่ยังไม่ได้ · คนที่ได้แล้วถูกกันด้วย unique index
notify_followers(post) if post.published?
end
private
# เฉพาะคนที่ติดตามก่อนเวลาเผยแพร่: รันซ้ำทีหลังจะไม่ส่งบทความเก่าไปหาคนที่เพิ่งมาติดตาม
def notify_followers(post)
Follower.where(created_at: ..post.published_at).find_each do |follower|
notify(post, follower)
end
end
def notify(post, follower)
PostNotification.transaction do
# จองก่อนส่ง: ใส่แถว (post, follower) ได้ = ได้สิทธิ์ส่ง
PostNotification.create!(post: post, follower: follower)
# ส่งพังอยู่ใน transaction → แถวที่จองถูกยกเลิกด้วย → retry รอบหน้าส่งใหม่ ไม่หายเงียบ
PostMailer.with(post: post, follower: follower).published.deliver_now
end
rescue ActiveRecord::RecordNotUnique
# มีแถวอยู่แล้ว = ส่งไปแล้ว (รอบก่อน หรือ worker อีกตัว) → ข้าม
end
end
ของจริงตอน retry — ส่งได้คนแรก พังคนที่สอง
ในเครื่องเราเซิร์ฟเวอร์อีเมลไม่มีวันล่ม (เขียนไฟล์) · เพื่อให้เห็น retry ของจริง เครื่องที่ตรวจแทรกโค้ดทดลองชั่วคราวใน process ที่สั่ง perform_now
ให้การส่งฉบับที่ 2 ล้มด้วย Net::OpenTimeout (worker ที่เปิดอยู่ไม่โดนแทรก) · ไม่ต้องทำตาม — อ่าน log อย่างเดียว:
[ActiveJob] [PublishPostJob] [8d906f40-…] Performing PublishPostJob (Job ID: 8d906f40-…) from SolidQueue(default) with arguments: 32
[ActiveJob] [PublishPostJob] [8d906f40-…] PostNotification Create (0.5ms) INSERT INTO "post_notifications" (…) VALUES (32, 1, …)
[ActiveJob] [PublishPostJob] [8d906f40-…] Delivered mail 6af2944de176b_…mail (5.2ms)
[ActiveJob] [PublishPostJob] [8d906f40-…] PostNotification Create (1.0ms) INSERT INTO "post_notifications" (…) VALUES (32, 2, …)
[ActiveJob] [PublishPostJob] [8d906f40-…] Failed delivery of mail 6af2944de2f8b_…mail error_class=Net::OpenTimeout error_message="จำลองเซิร์ฟเวอร์อีเมลต่อไม่ติด"
[ActiveJob] [PublishPostJob] [8d906f40-…] TRANSACTION (0.4ms) ROLLBACK
[ActiveJob] [PublishPostJob] [8d906f40-…] Enqueued PublishPostJob (Job ID: 8d906f40-…) to SolidQueue(default) at 2026-11-10 03:49:36 UTC with arguments: 32
[ActiveJob] [PublishPostJob] [8d906f40-…] Retrying PublishPostJob (Job ID: 8d906f40-…) after 1 attempts in 3 seconds, due to a Net::OpenTimeout (จำลองเซิร์ฟเวอร์อีเมลต่อไม่ติด).
… 3 วินาทีต่อมา worker หยิบงานเดิมไปทำ …
[ActiveJob] [PublishPostJob] [8d906f40-…] Performing PublishPostJob (Job ID: 8d906f40-…) from SolidQueue(default) enqueued at 2026-11-10T03:49:33.938501000Z with arguments: 32
[ActiveJob] [PublishPostJob] [8d906f40-…] PostNotification Create (0.7ms) INSERT INTO "post_notifications" (…) VALUES (32, 1, …)
[ActiveJob] [PublishPostJob] [8d906f40-…] TRANSACTION (0.3ms) ROLLBACK
[ActiveJob] [PublishPostJob] [8d906f40-…] PostNotification Create (0.9ms) INSERT INTO "post_notifications" (…) VALUES (32, 2, …)
[ActiveJob] [PublishPostJob] [8d906f40-…] Delivered mail 6af29452eec0b_…mail (2.8ms)
[ActiveJob] [PublishPostJob] [8d906f40-…] PostNotification Create (0.9ms) INSERT INTO "post_notifications" (…) VALUES (32, 3, …)
[ActiveJob] [PublishPostJob] [8d906f40-…] Delivered mail 6af29452f00bc_…mail (0.9ms)
[ActiveJob] [PublishPostJob] [8d906f40-…] Performed PublishPostJob (Job ID: 8d906f40-…) from SolidQueue(default) in 104.94ms
- คน 1 ได้อีเมล · คน 2
Failed delivery→ROLLBACK= แถวที่จองให้คน 2 ถูกยกเลิก (เพราะอยู่ใน transaction เดียวกับการส่ง) · คน 3 ยังไม่ถึงคิว Enqueued … at 03:49:36 UTC+Retrying … in 3 seconds=retry_onจับ error ได้ แขวนใบงานใบเดิม (Job ID เดิม) กลับคิว นัดเวลา 3 วินาทีข้างหน้า- รอบ retry: คน 1
ROLLBACK(จองไม่ได้ ส่งไปแล้ว ข้าม) · คน 2 และ 3 ได้ · สรุปทุกคนได้หนึ่งฉบับพอดี (นับไฟล์แล้ว: เพิ่มคนละ 1) - ถ้า
raise_delivery_errorsยังเป็นfalse: คน 2 ส่งพังแบบเงียบ → แถวที่จองถูก commit → ระบบเชื่อว่าส่งแล้ว → คน 2 ไม่ได้อีเมลตลอดกาล · เหตุผลที่ต้องเปลี่ยนเป็นtrueในขั้น 2
discard — ลองเองได้ (ใช้เวลา 1 นาที)
สั่ง job กับบทความเลขที่ไม่มีอยู่จริง (หน้าต่าง ② · bin/dev ต้องเปิดอยู่ เพราะ perform_later แขวนใบงานให้ worker):
$ bin/rails runner 'PublishPostJob.perform_later(999_999)'
หน้าต่าง ③ (log) ขึ้น:
[ActiveJob] Enqueued PublishPostJob (Job ID: 28a2a556-…) to SolidQueue(default) with arguments: 999999
[ActiveJob] [PublishPostJob] [28a2a556-…] Performing PublishPostJob (Job ID: 28a2a556-…) from SolidQueue(default) enqueued at 2026-11-10T03:50:16.064812000Z with arguments: 999999
[ActiveJob] [PublishPostJob] [28a2a556-…] Discarded PublishPostJob (Job ID: 28a2a556-…) due to a ActiveRecord::RecordNotFound (Couldn't find Post with 'id'=999999).
999_999 = เขียนเลข 999999 แบบมีขีดคั่นให้อ่านง่าย · Discarded … due to a ActiveRecord::RecordNotFound = ทิ้งใบงานตามที่สั่งไว้ ไม่มีอะไรค้าง
ถ้าใส่ # หน้าบรรทัด discard_on (ปิดไว้ชั่วคราว) แล้วสั่งคำสั่งเดิมอีกครั้ง
ใบงานไปอยู่ไหน? ทิ้งเอง? ลองใหม่ 5 รอบ? หรืออย่างอื่น?
ที่เครื่องตรวจลองแล้ว · log:
[ActiveJob] [PublishPostJob] [36188c8f-…] Performing PublishPostJob (Job ID: 36188c8f-…) from SolidQueue(default) enqueued at 2026-11-10T03:50:45.776026000Z with arguments: 999999
[ActiveJob] [PublishPostJob] [36188c8f-…] Error performing PublishPostJob (Job ID: 36188c8f-…) from SolidQueue(default) in 116.29ms: ActiveRecord::RecordNotFound (Couldn't find Post with 'id'=999999):
ไม่ทิ้ง ไม่ลองใหม่ — RecordNotFound ไม่อยู่ในรายชื่อของ retry_on จึงพังทันทีรอบเดียว แล้ว Solid Queue เก็บไว้ในตารางงานที่ล้มเหลว (solid_queue_failed_executions) รอคนมาดู ·
หัวข้อ "มองคิวด้วย SQL" จะเปิดตารางนี้ให้ดู · ถ้าคุณลองตาม อย่าลืมเอา # ออก (worker โหลดโค้ดใหม่เอง ไม่ต้องรีสตาร์ต)
เทียบกับ Go: ถ้าเขียน worker เอง (บท 26) การลองใหม่คือ for + time.Sleep ที่ต้องคูณระยะเอง และต้องเก็บใบงานที่ล้มเหลวไว้ที่ไหนสักแห่งเอง ·
Active Job ให้มาเป็นสองบรรทัดประกาศบนหัวคลาส
- transient error vs permanent error
- error ชั่วคราว (ลองใหม่มีหวัง) กับ error ถาวร (ลองใหม่ไร้ประโยชน์) · ถาม senior ได้ว่า "error ตัวนี้นับเป็น transient ไหมครับ ควร retry_on หรือ discard_on"
- exponential / polynomial backoff
- เว้นระยะลองใหม่ให้ห่างขึ้นเรื่อย ๆ · Rails ใช้
:polynomially_longer· พิมพ์บอก AI ได้ว่า "retry_on Net::OpenTimeout ด้วย polynomially_longer ไม่เกิน 5 ครั้ง" - jitter
- ระยะรอที่สุ่มเพิ่มเล็กน้อย ให้ job ที่พังพร้อมกันไม่ลองใหม่พร้อมกันจนรุมเซิร์ฟเวอร์ซ้ำ (thundering herd)
- failed execution / dead letter
- ใบงานที่ลองครบแล้วยังพัง เก็บไว้ให้คนตรวจ · ระบบคิวอื่นเรียก dead letter queue
deploy ตอน job กำลังรัน — SIGTERM และใบงานที่กลับเข้าคิว
deploy (เอาโค้ดเวอร์ชันใหม่ขึ้นเครื่องจริง) ต้องปิดโปรแกรมเก่าก่อนเสมอ · ระบบจะส่ง SIGTERM ไปบอก worker ว่า "เก็บของ กำลังจะปิด" (เหมือน Go บท 25) · worker ที่ดีหยุดรับงานใหม่ แล้วพยายามทำงานในมือให้จบ · ถ้าไม่จบในเวลาที่ให้ งานนั้นถูกแขวนกลับเข้าคิว → ตัวใหม่หยิบไปทำใหม่ตั้งแต่ต้น
กลับเข้าคิว = ถูกทำซ้ำ = idempotency ต้องทำงานตรงนี้แหละ (สไลด์หน้า 21)
ผู้จัดการประกาศ "อีก 5 วินาทีปิดครัวรีโนเวท" · พนักงานหลังร้านหยุดหยิบใบงานใหม่ · คนที่ถือใบงานอยู่รีบทำให้จบ · ครบ 5 วินาทียังไม่เสร็จ → แขวนใบงานกลับกระดานแล้วออกจากร้าน · ครัวเปิดใหม่ พนักงานกะใหม่หยิบใบเดิมไปทำ
ที่ไม่เหมือนกัน: พนักงานจริงจำได้ว่าทำไปถึงไหนแล้ว · job เริ่มใหม่ตั้งแต่บรรทัดแรก ไม่รู้ว่ารอบก่อนทำอะไรไปบ้าง (เว้นแต่ใช้ Continuations ข้างล่าง) ·
และถ้าโดนดึงปลั๊กกะทันหัน (เครื่องดับ · kill -9) ไม่มีใครได้แขวนใบงานคืน — Solid Queue จะย้ายใบนั้นไปกองงานล้มเหลว ไม่ทำใหม่เอง (README ของ Solid Queue · หัวข้อ "Jobs interrupted by non-graceful process death")
เครื่องที่ตรวจลองให้ดู (ไม่ต้องทำตาม) ด้วย job ทดลองที่ทำงาน 20 วินาที (ไฟล์ชั่วคราว ลบทิ้งหลังทดลอง):
# ทดลองเท่านั้น: งานยาว 20 วินาที
class SlowDemoJob < ApplicationJob
def perform
logger.info "งานยาว: เริ่ม"
20.times { |i| sleep 1 }
logger.info "งานยาว: เสร็จ"
end
end
ส่งเข้าคิว · รอ 4 วินาที · ส่ง SIGTERM ให้ bin/jobs (kill -TERM <เลข process>) · log:
[ActiveJob] [SlowDemoJob] [c01f4042-…] Performing SlowDemoJob (Job ID: c01f4042-…) from SolidQueue(default) enqueued at 2026-11-10T03:53:42.465542000Z
[ActiveJob] [SlowDemoJob] [c01f4042-…] งานยาว: เริ่ม
SolidQueue-1.7.0 Deregister Dispatcher (9.0ms) …
SolidQueue-1.7.0 Deregister Scheduler (8.7ms) …
…
SolidQueue-1.7.0 Supervisor terminated immediately (0.1ms) process_id: 11, supervisor_pid: 14276, supervised_processes: [14312]
SolidQueue-1.7.0 Supervisor wasn't terminated gracefully - shutdown timeout exceeded (5005.2ms) …
SolidQueue-1.7.0 Release claimed job (11.6ms) job_id: 7, process_id: 12
SolidQueue-1.7.0 Release claimed jobs (28.1ms) size: 1
SolidQueue-1.7.0 Deregister Worker (33.8ms) …
shutdown timeout exceeded (5005.2ms)— รอให้งานจบ 5 วินาที (ค่าเริ่มต้นของ Solid Queue) งานยังเหลืออีก ~11 วินาที ·terminated immediately= สั่งปิดทันทีRelease claimed job … job_id: 7— คืนใบงานเข้าคิว ("claimed" = มีคนถือไว้ · "release" = ปล่อยคืน) · ไม่มีบรรทัด "งานยาว: เสร็จ"
เปิด bin/jobs ใหม่ · worker หยิบใบเดิม (Job ID เดิม) ไปทำตั้งแต่ต้น:
[ActiveJob] [SlowDemoJob] [c01f4042-…] Performing SlowDemoJob (Job ID: c01f4042-…) from SolidQueue(default) enqueued at 2026-11-10T03:53:42.465542000Z
[ActiveJob] [SlowDemoJob] [c01f4042-…] งานยาว: เริ่ม
[ActiveJob] [SlowDemoJob] [c01f4042-…] งานยาว: เสร็จ
[ActiveJob] [SlowDemoJob] [c01f4042-…] Performed SlowDemoJob (Job ID: c01f4042-…) from SolidQueue(default) in 20130.57ms
"งานยาว: เริ่ม" ขึ้นสองครั้ง = บรรทัดก่อนจุดที่โดนตัด ถูกทำซ้ำ · ถ้าบรรทัดนั้นคือ "ส่งอีเมล" ลูกค้าได้สองฉบับ — เว้นแต่ job ออกแบบให้ทนแบบ PublishPostJob ของเรา
งานยาวมาก — Active Job Continuations (แค่รู้จัก)
Rails 8.1 เพิ่มทางเลือกให้ job ยาว ๆ (เช่น ไล่แก้บทความแสนแถว) แบ่งเป็นขั้น และจดว่าทำถึงแถวไหนแล้ว · โดนปิดกลางทาง กลับมาทำต่อจากจุดเดิม ไม่ต้องเริ่มใหม่ ·
ตัวอย่างของสไลด์หน้า 21 (ไม่ต้องพิมพ์ — แอปเราไม่มีคอลัมน์ indexed และไม่มี method send_report · ตรวจแล้วว่า ActiveJob::Continuable, step, step.cursor, step.advance!(from:) มีจริงใน Rails 8.1.4):
class BackfillJob < ApplicationJob
include ActiveJob::Continuable
def perform
step :mark_posts do |step|
Post.find_each(start: step.cursor) do |p|
p.update!(indexed: true)
step.advance!(from: p.id)
end
end
step :send_report
end
end
step :mark_posts do |step|— ขั้นที่ 1 ·step.cursor= "ทำถึงแถวไหนแล้ว" (รอบแรกว่าง = เริ่มต้น) ·step.advance!(from: p.id)= จดว่าทำถึงแถวนี้แล้ว และเป็นจุดที่ยอมให้หยุดได้step :send_report— ขั้นที่ 2 · เรียก method ชื่อเดียวกัน · ทำเมื่อขั้นที่ 1 จบ- แอปเรายังไม่มีงานยาวขนาดนั้น ·
PublishPostJobทำเสร็จในไม่ถึงหนึ่งวินาที ใช้ "ทนการทำซ้ำ" ก็พอ
- graceful shutdown
- ปิดอย่างสุภาพ: หยุดรับงานใหม่ ทำงานในมือให้จบ แล้วค่อยปิด · ถาม senior ได้ว่า "deploy ของเราให้เวลา worker graceful shutdown กี่วินาทีครับ"
- shutdown timeout
- เวลาที่ยอมรอก่อนบังคับปิด · Solid Queue ค่าเริ่มต้น 5 วินาที (
SolidQueue.shutdown_timeout) - claimed / released job
- ใบงานที่ worker ถืออยู่ / ถูกปล่อยคืนเข้าคิว · ตาราง
solid_queue_claimed_executions - job continuation
- job ที่แบ่งขั้นและจดความคืบหน้า กลับมาทำต่อได้ · พิมพ์บอก AI ได้ว่า "ทำ backfill เป็น ActiveJob::Continuable ใช้ step กับ cursor"
กฎเขียน job 5 ข้อ — เทียบกับโค้ดของเรา
สไลด์หน้า 21 ให้กฎ 5 ข้อ · ใช้เป็น checklist ตรวจ job ทุกตัว รวมถึงที่ AI เขียนให้ (Lab บท 145 จะใช้):
| กฎ | เพราะ | ในโค้ดเรา |
|---|---|---|
| ① job ต้องสั้น — งานยาวแตกเป็นหลาย job หรือใช้ step | สั้น = โดนตัดกลางคันน้อย · ทำซ้ำก็เสียไม่มาก | ตัวกวาด (ขั้นถัดไป) ไม่เผยแพร่เอง แตกเป็น PublishPostJob หนึ่งตัวต่อบทความ |
| ② ส่ง id อย่าส่งก้อนข้อมูลใหญ่หรือไฟล์ | ข้อมูลในใบงานเก่าตั้งแต่ตอนเข้าคิว · id แล้วโหลดสดได้ค่าล่าสุด | perform_later(post.id) · Post.find(post_id) |
| ③ อย่าส่งของที่หมดอายุได้ เช่น signed URL หรือ token | รอคิวนานหรือ retry ทีหลัง ของหมดอายุไปแล้ว (บท 139) | mailer สร้าง post_url ตอนส่ง ไม่รับลิงก์ที่ทำไว้ก่อน |
| ④ สมมติเสมอว่าจะรันซ้ำ และรันช้ากว่าที่คิด | at-least-once · retry · deploy | post_notifications + unique index · guard เช็ก "ถึงเวลาหรือยัง" ณ ตอนที่รันจริง |
| ⑤ job ที่เขียนลง DB ต้องมี guard ทุกตัว | รอบซ้ำต้องไม่เขียนทับของที่ถูกแล้ว | if post.scheduled? && post.scheduled_at <= Time.current |
งานตามเวลา — ตั้งนาฬิกาปลุก หรือ เดินตรวจทุกนาที
ถึงตอนนี้ job ส่งอีเมลได้แล้ว แต่ใครจะสั่ง job ตอนถึงเวลา? สไลด์หน้า 22 ให้สองท่า ·
ท่า A: ตอนบันทึกบทความ แขวนใบงานพร้อมเวลานัด PublishPostJob.set(wait_until: post.scheduled_at).perform_later(post.id) — ใบงานนอนรอในคิวจนถึงเวลา ·
ท่า B: มี job อีกตัวทำงานทุกนาที ค้นในตู้ว่าบทความไหนถึงเวลาแล้ว แล้วส่งต่อให้ PublishPostJob
ท่า B ทนกว่า · ท่า A ถ้าผู้ใช้เลื่อนเวลา ใบงานเก่ายังนัดเวลาเดิม (ไปถึงแล้ว guard บอก "ยังไม่ถึง" → ไม่ทำ → เวลาใหม่ไม่มีใครมา) · ถ้าคิวถูกล้าง ใบงานหายพร้อมกัน · ท่า B อ่านเวลาล่าสุดจากตู้ทุกรอบ รอบนี้พลาด รอบหน้าก็เจอ
ท่า A = ตอนรับออเดอร์จัดเลี้ยง ติดนาฬิกาปลุกไว้ที่ใบงานแต่ละใบ "ดังตอนบ่ายสอง" · ลูกค้าโทรมาเลื่อนเป็นบ่ายสาม — นาฬิกาเดิมยังดังบ่ายสอง ไม่มีใครตั้งใหม่ · ท่า B = มีพนักงานเดินดูสมุดออเดอร์ทุกนาที "ออเดอร์ไหนถึงเวลาแล้วบ้าง" · ลูกค้าเลื่อนเวลา ก็แค่แก้ในสมุด รอบหน้าพนักงานเห็นเวลาใหม่เอง
ที่ไม่เหมือนกัน: พนักงานเดินตรวจไม่มีวันเบื่อ แต่ก็ละเอียดได้แค่นาที (ตั้ง 10:30 จะออกช่วง 10:30:00–10:30:01 ไม่ใช่วินาทีที่ 30) · และถ้าพนักงานคนนี้ไม่ได้มาทำงาน (worker ปิด) ช่วงนั้นไม่มีใครเดินตรวจย้อนหลังให้ — แต่ออเดอร์ยังอยู่ในสมุด พอเขากลับมารอบแรกก็เจอเอง (ภารกิจท้ายบทพิสูจน์)
เทียบของที่รู้: ใน Go ต้องเขียน goroutine ที่วนรอบ + time.Sleep(time.Minute) เอง (บท 21) หรือพึ่ง cron ของเครื่อง · Solid Queue มีตัว Scheduler (คนเดินตรวจตามเวลา) อยู่ใน bin/jobs แล้ว แค่บอกในไฟล์ว่าจะให้ทำอะไรเมื่อไร
ตัวกวาด — PublishScheduledPostsJob
$ bin/rails generate job PublishScheduledPosts
invoke test_unit
create test/jobs/publish_scheduled_posts_job_test.rb
create app/jobs/publish_scheduled_posts_job.rb
แทนทั้งไฟล์ app/jobs/publish_scheduled_posts_job.rb:
# ตัวกวาด: config/recurring.yml สั่งให้รันทุกนาที · หาบทความที่ถึงเวลาแล้ว ส่งต่อให้ PublishPostJob ทีละบทความ
# ไม่ใช้ PublishPostJob.set(wait_until: scheduled_at) ตอนบันทึก เพราะผู้ใช้แก้เวลาได้
# (งานที่ตั้งไว้กับเวลาเก่าจะค้างในคิว) ตัวกวาดอ่านเวลาล่าสุดจาก DB ทุกรอบ
# รอบนี้พลาด รอบหน้าก็กวาดเจออีก · ส่งซ้ำก็ไม่เป็นไร เพราะ PublishPostJob รันซ้ำได้
class PublishScheduledPostsJob < ApplicationJob
queue_as :default
def perform
Post.scheduled.where(scheduled_at: ..Time.current).find_each do |post|
# แยกงานต่อบทความ + ส่งแค่ id: บทความหนึ่งพังไม่ลากบทความอื่น retry เฉพาะตัวที่พัง
PublishPostJob.perform_later(post.id)
end
end
end
Post.scheduled— scope ที่ enum สร้างให้ (บท 143) =WHERE status = 'scheduled'·where(scheduled_at: ..Time.current)= เวลานัดไม่เกินตอนนี้performไม่รับอะไรเลย — ตัวกวาดไม่ต้องรู้ล่วงหน้าว่าจะเจอบทความไหน- บทความเดียวกันอาจถูกส่งเข้าคิวสองรอบ (เช่นรอบที่แล้วยังทำไม่เสร็จ) — ไม่เป็นไร เพราะ
PublishPostJobรันซ้ำได้
config/recurring.yml — บอก Scheduler ว่าให้ทำอะไรเมื่อไร
ไฟล์นี้ Rails สร้างมาให้แล้ว · เปิดดูจะเห็นตัวอย่างที่ comment ไว้ และงานหนึ่งตัวใต้ production: · ลองทำตามสไลด์หน้า 22 ก่อน — เพิ่มงานของเราใต้ production::
production:
publish_scheduled_posts:
class: PublishScheduledPostsJob
schedule: every minute
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12
publish_scheduled_posts:— ชื่องาน (ตั้งเอง) ·class:— job ที่จะสั่ง ·schedule: every minute— ทุกนาที (ต้นนาที · เขียนเป็นภาษาอังกฤษแบบนี้ได้ หรือรูปแบบ cron)clear_solid_queue_finished_jobs— งานที่ Rails ใส่มาให้: ลบประวัติใบงานที่เสร็จแล้วทุกชั่วโมง (นาทีที่ 12) ไม่ให้ตารางโตไม่หยุด ·command:= สั่งโค้ด Ruby บรรทัดเดียวแทน job
Scheduler อ่านไฟล์นี้ตอนเริ่มเท่านั้น → ปิด bin/dev (Ctrl+C) แล้วเปิดใหม่ · จากนั้นถามว่ามีพนักงานกี่แบบทำงานอยู่:
$ bin/rails runner 'p SolidQueue::Process.pluck(:kind); p SolidQueue::RecurringTask.pluck(:key)'
คิดว่าจะเห็น "Scheduler" ในรายการไหม และงาน publish_scheduled_posts ถูกลงทะเบียนไหม?
["Supervisor(fork)", "Dispatcher", "Worker"]
[]
ไม่มี Scheduler · ไม่มีงานตามเวลาเลยสักตัว · และไม่มี error — เพราะเครื่องเรารันเป็น development แต่ไฟล์มีแค่หัวข้อ production: ·
ไม่มีชื่อ environment ไหนในไฟล์ = environment นั้นไม่มีงานตามเวลา · อาการที่ผู้ใช้เห็น: ตั้งเวลาไว้แล้วรอเท่าไรก็ไม่เผยแพร่ ·
(Supervisor = หัวหน้าที่คอยดูแลพนักงานคนอื่น · Dispatcher = คนย้ายใบงานที่ถึงเวลานัดขึ้นกระดาน · Worker = คนทำงาน)
แก้ให้มีหัวข้อ development: ด้วย · แทนทั้งไฟล์ config/recurring.yml (ลบตัวอย่างที่ comment ไว้ทิ้ง · เขียนซ้ำสองชุดตรง ๆ อ่านง่ายกว่าใช้ท่าย่อของ YAML):
# งานที่ bin/jobs สั่งรันเองตามเวลา (ตัว scheduler ของ Solid Queue)
# แยกตาม environment: ไม่มีชื่อ environment ไหนในไฟล์ = environment นั้นไม่มีงานตามเวลาเลย
development:
# กวาดบทความที่ถึงเวลาเผยแพร่ทุกนาที
publish_scheduled_posts:
class: PublishScheduledPostsJob
schedule: every minute
production:
publish_scheduled_posts:
class: PublishScheduledPostsJob
schedule: every minute
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12
- development ไม่ใส่งานลบประวัติ — ใบงานที่เสร็จแล้วสะสมนาทีละแถว ไม่เป็นไรในเครื่อง และได้เปิดดูประวัติด้วย SQL
ปิดเปิด bin/dev อีกครั้ง แล้วถามซ้ำ:
$ bin/rails runner 'p SolidQueue::Process.pluck(:kind); p SolidQueue::RecurringTask.pluck(:key, :schedule, :class_name)'
["Supervisor(fork)", "Worker", "Dispatcher", "Scheduler"]
[["publish_scheduled_posts", "every minute", "PublishScheduledPostsJob"]]
มี "Scheduler" แล้ว (ลำดับในรายการของคุณอาจสลับกัน) — คนเดินตรวจมาทำงานแล้ว
อย่าก็อป cleanup_orphan_blobs: class: CleanupBlobsJob จากสไลด์หน้า 22 มาด้วย — แอปเราไม่มีคลาสนี้ · ลองแล้ว bin/jobs ไม่ยอมเริ่ม และ bin/dev ปิดทั้งชุด (เว็บด้วย):
11:00:58 jobs.1 | Invalid recurring tasks:
11:00:58 jobs.1 | - cleanup_orphan_blobs: Class name doesn't correspond to an existing class
11:00:58 jobs.1 | Exiting...
11:00:59 jobs.1 | exited with code 1
11:00:59 system | sending SIGTERM to all processes
- recurring task (cron job)
- งานที่ระบบสั่งเองตามรอบเวลา · ถาม senior ได้ว่า "งานนี้ควรเป็น recurring sweep หรือ schedule ทีละตัวด้วย wait_until ครับ"
- sweeper / poller
- job ที่วนตรวจหาของที่ถึงเวลา · พิมพ์บอก AI ได้ว่า "ทำ PublishScheduledPostsJob กวาดทุกนาที ส่งต่อทีละ id ห้ามเผยแพร่ในตัวกวาดเอง"
- delayed job (
wait_until) - ใบงานที่นัดเวลาไว้ล่วงหน้า (ท่า A) · เหมาะกับนัดที่ไม่มีวันเปลี่ยน เช่น "อีก 10 นาทีส่งอีเมลยืนยัน"
- Scheduler · Dispatcher · Supervisor
- สามบทบาทใน
bin/jobsนอกจาก Worker: สั่งงานตามเวลา · ย้ายใบงานที่ถึงเวลานัดขึ้นกระดาน · ดูแลคนอื่น
ลองของจริง — ตั้งเวลาแล้วรอดูมันเผยแพร่เอง
- ล้างอีเมลเก่า:
rm -rf tmp/mails - เปิด
localhost:3000/posts/new· หัวข้อตั้งเวลา 1 นาที· เนื้อหาอะไรก็ได้ · เลือกหมวด · ช่อง "การเผยแพร่" เลือก ตั้งเวลาเผยแพร่ · "เวลาเผยแพร่" ใส่นาฬิกาตอนนี้ + 2 นาที (เผื่อเวลากรอก) · กดบันทึก - หน้าบทความขึ้นกล่องสีฟ้า "ตั้งเวลาเผยแพร่ไว้ … · ยังไม่โชว์ในหน้ารวม" · ดูหน้าต่าง ③ (log) แล้วรอ
ของจริงที่เครื่องตรวจ (ตั้งไว้ 10:53 เวลาไทย = 03:53 เวลาสากล):
[ActiveJob] Enqueued PublishScheduledPostsJob (Job ID: 8ba7bb31-…) to SolidQueue(default)
SolidQueue-1.7.0 Enqueued recurring task (55.3ms) task: "publish_scheduled_posts", active_job_id: "8ba7bb31-…", at: "2026-11-10T03:52:00Z"
[ActiveJob] [PublishScheduledPostsJob] [8ba7bb31-…] Performing PublishScheduledPostsJob (Job ID: 8ba7bb31-…) from SolidQueue(default) enqueued at 2026-11-10T03:52:00.046772000Z
[ActiveJob] [PublishScheduledPostsJob] [8ba7bb31-…] Performed PublishScheduledPostsJob (Job ID: 8ba7bb31-…) from SolidQueue(default) in 110.12ms
[ActiveJob] Enqueued PublishScheduledPostsJob (Job ID: afb4b25c-…) to SolidQueue(default)
SolidQueue-1.7.0 Enqueued recurring task (29.3ms) task: "publish_scheduled_posts", active_job_id: "afb4b25c-…", at: "2026-11-10T03:53:00Z"
[ActiveJob] [PublishScheduledPostsJob] [afb4b25c-…] Performing PublishScheduledPostsJob (Job ID: afb4b25c-…) from SolidQueue(default) enqueued at 2026-11-10T03:53:00.015784000Z
[ActiveJob] [PublishScheduledPostsJob] [afb4b25c-…] Enqueued PublishPostJob (Job ID: 13ed3d23-…) to SolidQueue(default) with arguments: 33
[ActiveJob] [PublishScheduledPostsJob] [afb4b25c-…] Performed PublishScheduledPostsJob (Job ID: afb4b25c-…) from SolidQueue(default) in 13.67ms
[ActiveJob] [PublishPostJob] [13ed3d23-…] Performing PublishPostJob (Job ID: 13ed3d23-…) from SolidQueue(default) enqueued at 2026-11-10T03:53:00.755667000Z with arguments: 33
[ActiveJob] [PublishPostJob] [13ed3d23-…] Delivered mail 6af2951ccb3f9_…mail (5.9ms)
[ActiveJob] [PublishPostJob] [13ed3d23-…] Delivered mail 6af2951cccbeb_…mail (0.9ms)
[ActiveJob] [PublishPostJob] [13ed3d23-…] Delivered mail 6af2951ccdbb8_…mail (0.9ms)
[ActiveJob] [PublishPostJob] [13ed3d23-…] Performed PublishPostJob (Job ID: 13ed3d23-…) from SolidQueue(default) in 74.17ms
- รอบ 03:52 — Scheduler สั่งตัวกวาด (
Enqueued recurring task … at: …03:52:00Z) · ตัวกวาดทำงานแล้วไม่ส่งต่ออะไร เพราะบทความยังไม่ถึงเวลา - รอบ 03:53 — ตัวกวาดเจอบทความ 33 →
Enqueued PublishPostJob … with arguments: 33(ส่งแค่ id) · ไม่ถึงวินาทีต่อมาPublishPostJobเผยแพร่และส่งอีเมล 3 ฉบับ
$ grep -c "^Subject:" tmp/mails/*
tmp/mails/boss@example.com:1
tmp/mails/malee@example.com:1
tmp/mails/somchai@example.com:1
กลับไปหน้า localhost:3000/posts — บทความ "ตั้งเวลา 1 นาที" อยู่บนสุดของแท็บ "เผยแพร่แล้ว" · ไม่มีใครกดอะไรเลย
มองคิวด้วย SQL — คิวคือตารางธรรมดา
ข้อดีของ Solid Queue คือคิวอยู่ในตาราง Postgres · อยากรู้อะไรก็ถามด้วย SQL ได้เลย · สไลด์หน้า 22 เตือนว่า "ถ้าไม่มีใครดูคิว งาน fail เงียบ ๆ เป็นเดือนได้" · เปิด psql ในกล่อง Postgres ของ compose (หน้าต่าง ②):
docker compose exec postgres psql -U postgres -d wongnok_blog_all_in_one_development -c "SELECT key, schedule, class_name FROM solid_queue_recurring_tasks;"
คำสั่งยาวเหมือนกันทุกครั้ง เปลี่ยนแค่ SQL ในเครื่องหมายคำพูด · ผลจริง (เวลาเป็นเวลาสากล):
key | schedule | class_name
-------------------------+--------------+--------------------------
publish_scheduled_posts | every minute | PublishScheduledPostsJob
(1 row)
| อยากรู้ | SQL |
|---|---|
| ใครทำงานอยู่บ้าง (ไม่มีแถว = worker ปิด) | SELECT kind, pid, last_heartbeat_at FROM solid_queue_processes; |
| ใบงานล่าสุด เสร็จหรือยัง | SELECT id, class_name, finished_at FROM solid_queue_jobs ORDER BY id DESC LIMIT 2; |
| ตัวกวาดทำงานนาทีไหนบ้าง | SELECT task_key, run_at, job_id FROM solid_queue_recurring_executions ORDER BY run_at DESC LIMIT 3; |
| ใบงานรอบนกระดาน (ควรเป็น 0 เกือบตลอด) | SELECT COUNT(*) FROM solid_queue_ready_executions; |
| งานที่ล้มเหลว (สไลด์บอกให้ตั้งเตือน) | SELECT COUNT(*) FROM solid_queue_failed_executions; |
ผลจริงของสองแถวแรก (หลังรอบ 03:57):
kind | pid | last_heartbeat_at
------------------+-------+----------------------------
Supervisor(fork) | 15008 | 2026-11-10 03:57:05.18314
Worker | 15016 | 2026-11-10 03:57:05.220258
Dispatcher | 15015 | 2026-11-10 03:57:05.220286
Scheduler | 15017 | 2026-11-10 03:57:05.220494
(4 rows)
id | class_name | finished_at
----+--------------------------+----------------------------
9 | PublishPostJob | 2026-11-10 03:57:00.869085
8 | PublishScheduledPostsJob | 2026-11-10 03:57:00.798291
(2 rows)
last_heartbeat_at= "เสียงหัวใจ" ล่าสุด · พนักงานทุกคนรายงานตัวเป็นระยะ ถ้าเวลานี้เก่ามาก = คนนั้นตายไปแล้วfinished_atมีค่า = เสร็จแล้ว · ว่าง = ยังรอหรือกำลังทำ
และงานที่ล้มเหลวจากการทดลองปิด discard_on เมื่อกี้ (ถ้าคุณไม่ได้ลอง จะได้ 0):
count
-------
1
(1 row)
ดูว่าพังเพราะอะไร แล้วทิ้งใบนั้น — ทำใน bin/rails console (คำสั่งจาก README ของ Solid Queue · .retry = ส่งกลับเข้าคิวใหม่ · .discard = ทิ้ง):
wongnok-blog-all-in-one(dev):001> SolidQueue::FailedExecution.count
=> 1
wongnok-blog-all-in-one(dev):002> SolidQueue::FailedExecution.last.error["message"]
=> "Couldn't find Post with 'id'=999999"
wongnok-blog-all-in-one(dev):003> SolidQueue::FailedExecution.last.discard
…
wongnok-blog-all-in-one(dev):004> SolidQueue::FailedExecution.count
=> 0
ทีมจริงไม่นั่งพิมพ์ SQL ทุกวัน · มี gem ชื่อ mission_control-jobs ทำหน้าเว็บดูคิว กด retry / ลบงานได้ (สไลด์หน้า 22 · คอร์สนี้ไม่ได้ติดตั้ง) · และควรตั้งสัญญาณเตือนสองอย่าง: งานค้างบนกระดานเกิน N นาที · จำนวนงานที่ fail มากกว่า 0
commit
$ git add .
$ git commit -m "ผู้ติดตาม + อีเมลแจ้งบทความใหม่ (ไม่ส่งซ้ำ) + ตัวกวาดทุกนาที + retry/discard"
[my-day4 c978426] ผู้ติดตาม + อีเมลแจ้งบทความใหม่ (ไม่ส่งซ้ำ) + ตัวกวาดทุกนาที + retry/discard
21 files changed, 218 insertions(+), 15 deletions(-)
ต้องได้ 21 ไฟล์ · +218 · −15 พอดี (ชื่อ branch และเลข commit ของคุณจะต่าง) · ถ้าตัวเลขต่าง ให้ git diff HEAD~1 --stat แล้วเทียบทีละไฟล์กับบทนี้ ·
8 ไฟล์ใน test/ เป็นของที่ generator สร้าง (แก้แค่ preview) — ใช้ในบล็อก J
🎯 ภารกิจ — ระบบดับตอนถึงเวลา แล้วยังเผยแพร่ได้ไหม (~6 นาที)
พิสูจน์ประโยค "ท่า B ทนกว่า" ด้วยมือตัวเอง: ให้เวลานัดผ่านไปตอนที่ worker ไม่ได้ทำงาน · ต้อง commit หัวข้อก่อนหน้าแล้ว
- หน้าต่าง ① กด Ctrl+C ปิด
bin/dev(ปิดทั้งเว็บและ worker) · รอ ~5 วินาที แล้วเช็ก:bin/rails runner 'p SolidQueue::Process.count'ต้องได้0 - สร้างบทความที่เวลานัดผ่านไปแล้ว 2 นาที:
bin/rails runner 'p Post.create!(title: "ระบบดับตอนถึงเวลา", body: "ทดสอบตัวกวาด", category: Category.first, status: :scheduled, scheduled_at: 2.minutes.ago).id' rm -rf tmp/mailsแล้วรอ 1 นาทีเต็ม · เช็กสถานะ:bin/rails runner 'p Post.find_by!(title: "ระบบดับตอนถึงเวลา").status'- เปิด
bin/devใหม่ · รอไม่เกิน 1 นาที แล้วเช็กสถานะอีกครั้ง +grep -c "^Subject:" tmp/mails/* - อ่านอีเมลของ malee:
bin/rails runner 'm = Mail.read("tmp/mails/malee@example.com"); puts m.subject; puts m.text_part.decoded'
ก่อนกดเฉลย: ข้อ 3 สถานะเป็นอะไร? ข้อ 4 หลังเปิดใหม่ บทความที่ "เลยเวลามาแล้วหลายนาที" ถูกเผยแพร่ไหม หรือหายไปเพราะพลาดรอบ?
ผลจริงที่เครื่องตรวจ:
$ bin/rails runner 'p SolidQueue::Process.count'
0
$ bin/rails runner 'p Post.create!(title: "ระบบดับตอนถึงเวลา", …, scheduled_at: 2.minutes.ago).id'
34
… รอ 70 วินาที (worker ปิด) …
$ bin/rails runner 'p Post.find_by!(title: "ระบบดับตอนถึงเวลา").status'
"scheduled"
… เปิด bin/jobs ตอน 10:56:04 · รอ 62 วินาที …
$ bin/rails runner 'p Post.find_by!(title: "ระบบดับตอนถึงเวลา").status'
"published"
$ grep -c "^Subject:" tmp/mails/*
tmp/mails/somchai@example.com:1
tmp/mails/boss@example.com:1
tmp/mails/malee@example.com:1
$ bin/rails runner 'm = Mail.read("tmp/mails/malee@example.com"); puts m.subject; puts m.text_part.decoded'
บทความใหม่: ระบบดับตอนถึงเวลา
มีบทความใหม่ใน Wongnok Blog
ระบบดับตอนถึงเวลา
ทดสอบตัวกวาด
อ่านต่อ: http://localhost:3000/posts/34
ส่งถึง malee@example.com เพราะคุณติดตาม Wongnok Blog
ข้อ 3 ยัง "scheduled" — ไม่มีคนเดินตรวจ ไม่มีอะไรเกิด · ข้อ 4 เผยแพร่ในรอบแรกหลังเปิด (ต้นนาที 10:57) พร้อมอีเมลคนละฉบับ ·
ตัวกวาดไม่ได้ "ย้อนกวาดนาทีที่พลาด" — มันแค่ถามตู้ว่า "อะไรถึงเวลาแล้วบ้าง" ซึ่งรวมของที่เลยเวลามาด้วย · นี่คือเหตุผลที่ท่า B ทนกว่า
ตาราง solid_queue_recurring_executions ยืนยันว่านาทีที่ worker ปิดไม่มีการกวาดเลย (ไม่มีแถว 03:54–03:56 · ต่อจาก 03:53 ก็ 03:57 เลย):
task_key | run_at | job_id
-------------------------+---------------------+--------
publish_scheduled_posts | 2026-11-10 03:57:00 | 8
publish_scheduled_posts | 2026-11-10 03:53:00 | 5
publish_scheduled_posts | 2026-11-10 03:52:00 | 4
ถ้าเจออาการนี้ แปลว่า…
- ข้อ 4 รอเกิน 2 นาทีแล้วยัง
"scheduled"— ไม่มีคนเดินตรวจ · รันbin/rails runner 'p SolidQueue::Process.pluck(:kind)'· ไม่มี"Scheduler"=recurring.ymlไม่มีหัวข้อdevelopment:(หรือสะกดผิด / ย่อหน้าผิด) · ได้[]= worker ไม่ได้เปิด (เช็กProcfile.devว่ามีjobs: bin/jobs) - สถานะ published แต่ไม่มีโฟลเดอร์
tmp/mails—development.rbยังไม่มีdelivery_method = :fileหรือแก้แล้วยังไม่ได้ปิดเปิดbin/dev· หรือ seeds ยังไม่ได้รัน (Follower.countเป็น 0) bin/devปิดเองทั้งชุดทันทีหลังเปิด — เลื่อนขึ้นดูบรรทัดjobs.1 |· ถ้าเห็นInvalid recurring tasksแปลว่ามีชื่อคลาสในrecurring.ymlที่ไม่มีอยู่จริง (กล่องเตือนข้างบน)- ได้
:2ในบางไฟล์ — ลืมrm -rf tmp/mailsในข้อ 3 (อีเมลจากหัวข้อก่อนยังอยู่) · ดูให้แน่ด้วยbin/rails runner 'p Post.find_by!(title: "ระบบดับตอนถึงเวลา").post_notifications.count'ต้องได้3
บทความทดลองเก็บไว้ได้ (ไม่มีไฟล์โค้ดเปลี่ยน · git status ยังว่าง)
ทดสอบความเข้าใจ
job สี่ตัวนี้ ถ้าถูกรันซ้ำ 3 รอบ ตัวไหนได้ผลเหมือนรันรอบเดียว (idempotent)?
ตัวเลือกที่ 2 ถูก — ตั้งค่าให้เท่ากับจำนวนที่นับจากตู้ · รันกี่รอบก็ได้เลขเดียวกัน (เหมือน "ตั้งป้ายเป็นเปิดร้าน")
ตัวเลือกที่ 1 ผิด — บวกเพิ่มจากค่าเดิม รัน 3 รอบได้ +3 · ตัวเลือกที่ 3 ผิด — ส่งจริงทุกรอบ ลูกค้าได้ 3 ฉบับ (ต้องมีสมุดจดแบบ post_notifications) ·
ตัวเลือกที่ 4 ผิด — หักเงินซ้ำ 3 รอบ = เสียหายจริง ·
สไลด์หน้า 23 มีข้อ ง. user.update(welcomed_at: Time.current) ด้วย — สถานะ "ต้อนรับแล้ว" เหมือนเดิม แต่เวลาเลื่อนไปทุกรอบ · ถ้าอยากให้เป๊ะ เติม guard if user.welcomed_at.nil?
จุดที่ต้องจำ: "ตั้งค่าให้เป็น X" ซ้ำได้ · "บวก/ลบเพิ่มจากค่าเดิม" และ "ส่งของออกนอกระบบ" ซ้ำไม่ได้ ต้องมี guard หรือสมุดจด
สถานการณ์ไหนควรใช้ discard_on (ทิ้งงาน) แทน retry_on?
ตัวเลือกที่ 3 ถูก — ลองใหม่กี่รอบบทความก็ไม่กลับมา · ใน job เรา discard_on ActiveRecord::RecordNotFound (ลองแล้ว: log ขึ้น Discarded ไม่มีอะไรค้าง)
ตัวเลือกที่ 1, 2, 4 ผิด — ทั้งสามเป็นปัญหาชั่วคราว รอแล้วมีหวังหาย → retry_on พร้อม backoff · ข้อ 4 คือ Net::SMTPServerBusy ที่เราใส่ไว้จริง ·
สไลด์หน้า 23 มีอีกข้อ "ส่งอีเมลไปที่อีเมลที่ไม่มีอยู่จริง" — ลองใหม่ก็ไม่มีวันมีจริง จึงเป็นฝั่ง discard
จุดที่ต้องจำ: ถามว่า "ถ้ารออีกสักพัก ปัญหานี้หายเองได้ไหม" · ได้ = retry (เว้นระยะห่างขึ้น) · ไม่ได้ = discard · ไม่ได้ระบุ = ไปกองงานล้มเหลวรอคนดู
config/recurring.yml มีงานกวาดอยู่ใต้ production: อย่างเดียว · เปิด bin/dev ในเครื่องแล้วตั้งเวลาบทความ · เกิดอะไรขึ้น?
ตัวเลือกที่ 1 ถูก — ลองแล้ว: ["Supervisor(fork)", "Dispatcher", "Worker"] ไม่มี "Scheduler" · RecurringTask เป็น [] · ไม่มีบรรทัด error สักบรรทัด
ตัวเลือกที่ 2 ผิด — ไม่มีหัวข้อ = ไม่มีงาน ไม่ใช่ error (คนละเรื่องกับใส่ชื่อคลาสที่ไม่มีจริง ซึ่ง bin/jobs ไม่ยอมเริ่ม) ·
ตัวเลือกที่ 3 ผิด — ไม่มีการยืมหัวข้อของ environment อื่น · ตัวเลือกที่ 4 ผิด — การบันทึกไม่เกี่ยวกับ recurring เลย บทความเป็น scheduled ตามที่เลือก
จุดที่ต้องจำ: ตั้งเวลาแล้ว "ไม่เกิดอะไรขึ้น" → เช็ก SolidQueue::Process.pluck(:kind) ก่อนอย่างอื่น · แก้ recurring.yml แล้วต้องปิดเปิด bin/jobs
ระหว่าง PublishPostJob ส่งอีเมลถึงผู้ติดตามคนที่ 1–2 เสร็จแล้ว มีการ deploy · worker รอ 5 วินาทีไม่ทัน ใบงานกลับเข้าคิวแล้วถูกทำใหม่ · ผลที่ผู้ติดตามได้คืออะไร?
ตัวเลือกที่ 4 ถูก — job เริ่มใหม่จริง แต่คน 1–2 มีแถวใน post_notifications แล้ว → INSERT ชน → ROLLBACK → ข้าม · คน 3 จองได้ → ส่ง (เหมือนรอบ retry ใน log ข้างบน)
ตัวเลือกที่ 1 ผิด — เริ่มใหม่ตั้งแต่บรรทัดแรกจริง แต่การจองกันไว้ · ตัวเลือกที่ 2 ผิด — นั่นคือโค้ดแบบ return if post.published? ของสไลด์ · ของเราแจ้งทุกรอบที่ published ·
ตัวเลือกที่ 3 ผิด — SIGTERM แล้วไม่ทันคือคืนเข้าคิว (log Release claimed job) ไม่ใช่ทิ้ง · กรณีหายาก: ถ้าถูกตัดตรงจังหวะที่คน 3 ส่งแล้วแต่ยังไม่ COMMIT คน 3 จะได้สองฉบับ — ซ้ำหนึ่งฉบับดีกว่าหาย
จุดที่ต้องจำ: deploy · retry · สอง worker = ทางเข้าของ "การทำซ้ำ" ทั้งหมด · ป้องกันที่จุดเดียวคือทำ job ให้ซ้ำแล้วไม่เสียหาย
ถามกลับได้เลย
ติดตรงไหนถามได้ทุกเรื่อง เช่น "ถ้าผู้ติดตามเป็นหมื่นคน job เดียวส่งไหวไหม" · "ทำไมไม่ใช้ deliver_later" · "ถ้าจะเพิ่มหน้าสมัครติดตามต้องแตะไฟล์ไหนบ้าง" ·
หรือวาง log จากหน้าต่าง ③ มาให้ดูด้วยกัน · บทถัดไป (Lab 145) จะให้ AI ทำงานแบบนี้ แล้วเราใช้กฎ 5 ข้อกับ perform_now ×3 ตรวจรับ
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง (Rails 8.1.4 · solid_queue 1.7.0 · แอปของคอร์ส) | ต้องสนใจไหม |
|---|---|---|---|
| 22 | config/recurring.yml มีแค่หัวข้อ production: | เครื่องเราเป็น development → ไม่มี Scheduler ไม่กวาด ไม่มี error · ต้องเพิ่มหัวข้อ development: | สนใจ |
| 22 | cleanup_orphan_blobs: class: CleanupBlobsJob | แอปเราไม่มีคลาสนี้ · ก็อปมาแล้ว bin/jobs ขึ้น Invalid recurring tasks และ bin/dev ปิดทั้งชุด | สนใจ — อย่าก็อป |
| 22 | Post.where(published: false) · PublishPostJob.perform_later(p) | ไม่มีคอลัมน์ published (ได้ PG::UndefinedColumn: ERROR: column posts.published does not exist) · ของเราใช้ Post.scheduled และส่ง post.id (ส่งทั้งแฟ้มแล้ว Post.find โยน ArgumentError) | ใช้โค้ดของบทนี้ |
| 21 | "จบไม่ทันในเวลาที่ให้ มันจะโดนฆ่า แล้วงานนั้นกลับเข้าคิว" | จริงสำหรับ SIGTERM: Solid Queue รอ 5 วินาทีแล้วคืนงานเข้าคิว (ลองแล้ว) · แต่ถ้าโดนฆ่าแบบกระชาก (kill -9 · เครื่องดับ) งานไปอยู่ failed_executions ไม่ทำใหม่เอง (README) | รู้ไว้ |
| 21 | Continuations: p.update!(indexed: true) · step :send_report | API มีจริงใน 8.1.4 · แอปเราไม่มีคอลัมน์ indexed และ method send_report | ไม่ต้อง — แค่รู้จัก |
| 22 | worker: bin/jobs ใน Procfile.dev · ใช้ Mailpit ดูอีเมล | ของเรา jobs: bin/jobs (บท 143 · ชื่อหน้าบรรทัดเป็นแค่ป้าย) · อีเมลเขียนลง tmp/mails/ แทน Mailpit | ไม่ต้อง |
อ้างอิง
- Rails Guides — Active Job Basics § 8.3 Retrying or Discarding Failed Jobs —
ตัวอย่างสั้น ๆ ของ
retry_onกับdiscard_on· แล้วอ่าน § 8.4 Missing Records ต่อ (job ที่หาแฟ้มไม่เจอ) และ § 8.1 Mission Control (หน้าเว็บดูคิว) - Rails API — ActiveJob::Exceptions::ClassMethods —
หัวข้อ
retry_on: สูตร:polynomially_longer(~3s · ~18s · ~83s) ·attemptsนับรวมรอบแรก ·jitter· และdiscard_on - Rails Guides — Active Job Basics § Recurring Tasks —
รูปแบบ
config/recurring.yml(class/command/schedule) · สังเกตว่าตัวอย่างก็มีแค่production: - rails/solid_queue (README) —
อ่าน 3 หัวข้อ: Threads, processes, and signals (TERM รอ
shutdown_timeoutแล้วคืนงานเข้าคิว) · Failed jobs and retries (FailedExecution#retry/#discard) · Recurring tasks - Rails Guides — Active Job Basics § Job Continuations —
อ่านตัวอย่างแรก:
include ActiveJob::Continuable·stepแบบ block และแบบชื่อ method · ทำต่อจาก step ที่ค้าง - Rails Guides — Action Mailer Basics § Generate the Mailer —
แล้วกระโดดไป § Generating URLs in Action Mailer Views (ทำไมต้อง
*_url) และ § Previewing Emails - The Twelve-Factor App — IX. Disposability — ย่อหน้า "For a worker process": ปิดอย่างสุภาพต้องคืนงานเข้าคิว · ทุก job จึงต้อง idempotent