Bài viết trước trong series này đã khẳng định Solid Queue là lựa chọn tuyệt vời nếu bạn cần hệ thống xử lý background job. Nó giảm thiểu các dependency bên ngoài — không cần Redis! — bằng cách lưu trữ tất cả job trong database. Mặc dù vậy, nó vẫn cực kỳ hiệu suất.
Nhưng chỉ có hiệu suất là chưa đủ cho một hệ thống background job sẵn sàng production. Các nhà phát triển Rails đã kỳ vọng rất nhiều qua nhiều năm. Chúng ta không chỉ muốn enqueue job để chạy trong background. Chúng ta muốn lên lịch job, chạy chúng theo lịch định kỳ, và thậm chí muốn giới hạn số job có thể chạy đồng thời. Chúng ta muốn thêm tính năng!
Kỳ diệu thay, Solid Queue cung cấp tất cả các tính năng đó ngay out of the box. Hãy đi sâu vào Solid Queue và tìm hiểu làm sao điều đó có thể!
Lên lịch Job với Solid Queue cho Ruby on Rails
Trước tiên, hãy ôn lại một chút. Solid Queue sử dụng database của bạn — và chỉ database — để lưu trữ dữ liệu job. Mọi thứ nó làm đều được hỗ trợ bởi bảng database này hay bảng khác. Lên lịch job — tức là chỉ định job chạy tại một thời điểm cụ thể trong tương lai — không khác biệt. Bất kỳ job được lên lịch nào đều được lưu trong solid_queue_scheduled_executions.
Bảng này gần như giống hệt bảng solid_queue_ready_executions. Sự khác biệt duy nhất là thêm cột scheduled_at, cho biết job được lên lịch khi nào. Hãy xác nhận điều đó bằng cách xem điều gì xảy ra khi chúng ta lên lịch một job.
Không có gì bất ngờ. Solid Queue thêm một dòng mới vào bảng solid_queue_scheduled_executions, chứa dữ liệu chúng ta mong đợi. Nhưng làm sao để từ việc bản ghi đó tồn tại mà thực sự chạy job đúng lúc?
Chúng ta cần một process liên tục poll bảng solid_queue_scheduled_executions. Process đó gọi là Dispatcher, và nó chịu trách nhiệm thực thi job được lên lịch đúng giờ. Nó bắt đầu khi Solid Queue khởi động — không cần cấu hình thêm. Tuy nhiên, nếu cần, bạn có thể chỉ khởi động process dispatcher bằng cách chạy Solid Queue với cấu hình cụ thể.
Nếu bạn thắc mắc process Dispatcher được giám sát như thế nào, đó là trách nhiệm của Supervisor — tên gọi rất phù hợp. Nó theo dõi bất kỳ process đang chạy nào trong Solid Queue, bao gồm cả worker process và Dispatchers.
Vậy Dispatcher thực sự hoạt động như thế nào? Nó định nghĩa phương thức poll được gọi trong một vòng lặp để liên tục kiểm tra job được lên lịch. Code polling được phân tán trên nhiều class và module, nhưng dưới dạng đơn giản hóa nặng nề, nó trông như sau:
Truy vấn để lấy 'ready' scheduled executions rất trực tiếp.
Vì vậy, bất kỳ job được lên lịch nào với scheduled_at trong quá khứ đều sẵn sàng để dispatch. Như chúng ta đã đề cập trong phần một của series này, khi Solid Queue dispatch một job, nó tạo bản ghi ReadyExecution và xóa bản ghi ScheduledExecution tương ứng. Bản ghi ReadyExecution sau đó được worker process thông thường nhặt lên và job tương ứng chạy.
Cho đến nay, mọi thứ đều tốt. Job được lên lịch thực sự không phức tạp! Hãy xem thứ phức tạp hơn: recurring tasks.
Recurring Tasks (Nhiệm vụ định kỳ)
Recurring tasks là tính năng thường được yêu cầu cho background job processor. Nói đơn giản, đó là background job nên chạy theo lịch định kỳ. Chúng tương tự như Cron job: bạn định nghĩa lịch (ví dụ: mỗi 5 phút, mỗi ngày trưa, v.v.) cho khi nào công việc nên diễn ra.
Trong Solid Queue, bạn cấu hình recurring jobs bằng file config/recurring.yml. Ví dụ, nếu muốn chạy job CleanupData mỗi ngày trưa, bạn làm như sau:
Solid Queue sử dụng Fugit để parse schedule expressions, đó là lý do tại sao lịch đọc được như 'every day at noon' được phép. Khi dùng scheduled tasks, bạn định nghĩa class của job sẽ chạy và các tham số job. README xuất sắc của SolidQueue recurring tasks cung cấp thêm chi tiết. Chúng ta ở đây để học cách nó hoạt động, nên hãy nhìn vào bên trong.
Recurring tasks được biểu diễn bởi model RecurringTask, được hỗ trợ bởi bảng solid_queue_recurring_tasks tương ứng. Các cột trong đó tương ứng với các trường trong file cấu hình.
Khi bạn khởi động SolidQueue, các bản ghi recurring task được tạo theo file cấu hình recurring tasks của bạn. Để tạo job đúng lúc, chúng ta lại cần một process mới — lần này gọi là Scheduler. Scheduler là anh em của Dispatcher mà chúng ta đã biết. Nó hoạt động gần như giống hệt: một process mới được spin up khi Solid Queue khởi động, và process này chạy vòng lặp vô tận. Khác biệt giữa Scheduler và Dispatcher là những gì xảy ra trong vòng lặp đó. Nơi Dispatcher query bảng solid_queue_scheduled_executions, Scheduler query solid_queue_recurring_tasks — và lên lịch job đúng lúc. Vậy Scheduler biết lúc nào là đúng và khi nào lên lịch job như thế nào?
Để trả lời câu hỏi đó, chúng ta phải xem xét implementation kỹ. Class scheduler tạo một object RecurringSchedule mới định nghĩa phương thức schedule. Phương thức đó được gọi liên tục cho mỗi scheduled task. Đây là phiên bản đơn giản hóa:
Hãy tách mã này ra. Solid Queue sử dụng Concurrent::ScheduledTask (từ thư viện concurrent-ruby) để spawn một thread mới. Thread đó được lên lịch chạy tại thời điểm được chỉ định bởi lịch của recurring task. Khi thread đó chạy, nó trước tiên đệ quy spawn thread khác để lên lịch recurring task tiếp theo. Sau đó, nó enqueue 'current' scheduled job.
Hãy xem ví dụ một recurring task đơn giản để nắm bắt mọi thứ.
Nếu chúng ta khởi động Solid Queue lúc 8:30, các biến trong phương thức schedule được gán giá trị sau. Không phải nguyên văn, chúng ta đơn giản hóa rất nhiều ở đây.
Vì vậy, background thread của chúng ta được lên lịch chạy 30 phút từ bây giờ, tức 9:00. Khi thời điểm đó đến, background thread được thực thi. Nó chạy thread_task.enqueue(at: 9:00) — nên một instance của CleanupData được xếp hàng để thực thi. Nó cũng gọi đệ quy chính nó qua thread_schedule.schedule. Vì bây giờ là 9:00, các biến cho lần gọi này đã thay đổi.
Vì vậy, background thread được lên lịch chạy lại lúc 10:00, và chu trình tiếp tục. Bạn có thể thắc mắc điều gì xảy ra nếu scheduling thread bị kill, ví dụ khi re-deploy hoặc system crash. Liệu đó có làm hỏng lịch của bạn không? May mắn thay, câu trả lời là không. Cron schedule là tĩnh. Biểu thức như 'Every Hour' luôn resolve thành 10:00, 11:00, 12:00, v.v., bất kể khi nào Solid Queue khởi động. Bất kỳ gián đoạn nào của scheduling thread không thay đổi điều đó.
Dưới đây là một số chi tiết implementation khác cần lưu ý. Thứ nhất, pattern lên lịch lần xuất hiện tiếp theo của recurring task trước khi thực thi nó được lấy cảm hứng từ GoodJob. Thứ hai, RecurringTask.enqueue không tạo Job và ReadyExecution mới như bạn có thể mong đợi. Thay vào đó, nó tạo thêm một bản ghi nữa, là RecurringExecution.
Bản ghi này chỉ để tránh thực thi recurring job nhiều lần. Nó có index trên task_key và run_at với ràng buộc unique để phục vụ mục đích đó. Một RecurringTask chỉ được enqueue nếu không có RecurringExecution trước đó cho cùng thời điểm và cùng job.
Độc giả chú ý sẽ nhận ra đoạn code này chỉ ra một hạn chế của Solid Queue. Đó là, nếu bạn không dùng Solid Queue làm backend để chạy cron-style tasks — đúng, bạn có thể làm điều đó — Solid Queue không thể đảm bảo recurring jobs chỉ được enqueue một lần. Nếu bạn rơi vào tình huống đó, bạn nên nhận thức được điều đó.
Bạn cũng có thể thắc mắc điều gì xảy ra nếu process Scheduler chết hoặc bị kill — ví dụ khi deploy. Vì recurrences được quản lý bởi thread, việc kill thread có làm hỏng lịch không? May mắn thay, câu trả lời là không.
Concurrency Controls (Điều khiển đồng thời)
Hãy xem tính năng cuối cùng của Solid Queue, là concurrency controls. Đôi khi, bạn muốn giới hạn số job nhất định có thể chạy đồng thời. Bạn có thể làm điều đó với Solid Queue dùng limits_concurrency.
Ở đây, chúng ta nói với SolidQueue chạy tối đa một instance của MyJob cho mỗi user. Hãy xem cấu hình chi tiết hơn.
to: Số lượng job tối đa bạn muốn chạy đồng thời.key: Tham số bắt buộc để chỉ định job nào nên bị giới hạn cùng nhau. Trong ví dụ, job với cùng User ID bị giới hạn một lần thực thi đồng thời. Bạn có thể dùng bất kỳ tham số job nào làmkey, nhưng hằng số như string hoặc symbol cũng được phép.duration: Thời gian tối đa Solid Queue có thể đảm bảo concurrency sau khi job được enqueue. Nếu job chạy lâu hơn, concurrency controls sẽ không áp dụng và job có thể overlap. Chúng ta sẽ hiểu tại sao sau!group: Bạn có thể dùng tùy chọn này để giới hạn concurrency across các job class khác nhau.
Nếu muốn tìm hiểu thêm, tôi giới thiệu tài liệu concurrency control. Concurrency controls dễ dàng là tính năng tinh vi nhất của Solid Queue. Nếu scheduled tasks chưa làm bạn choáng, học cách tính năng này hoạt động chắc chắn sẽ.
Hãy bắt đầu từ cơ bản. Giống như các tính năng Solid Queue khác, concurrency controls được hỗ trợ bởi các model và bảng database tương ứng. Hai model bạn cần đặc biệt chú ý là Semaphore và BlockedExecution.
Hãy xem Semaphore trước. Như tên gợi ý, đây là implementation của pattern counting semaphore. Bất cứ khi nào Solid Queue enqueue job với limits_concurrency, nó trước tiên thử acquire semaphore lock dựa trên concurrency key. Concurrency key này dựa trên các tham số truyền cho limits_concurrency, là job class, key, và group name — nếu có. Nếu semaphore available, job được enqueue. Nếu không, bản ghi BlockedExecution được tạo thay thế.
Semaphores có value để hỗ trợ nhiều job đồng thời. Bạn có thể coi nó như dung lượng còn lại của semaphore. Acquire semaphore nghĩa là giảm value đó, và release nghĩa là tăng nó. Semaphore coi là unavailable khi value bằng 0. Hãy xem ví dụ cơ chế locking cho job đơn giản.
Hãy xem điều gì xảy ra nếu thử enqueue job này nhiều lần liên tiếp.
- Instance đầu tiên của
MyJobđược enqueue. Chưa có semaphore, nên tạo mới. Giá trị ban đầu làlimit - 1. Vì limit là 3, giá trị ban đầu của semaphore là 2. - Instance thứ hai của
MyJobđược enqueue. Solid Queue thử acquire lock cho job đó. Job có thể enqueue vì value là 2, lớn hơn 0. Value của semaphore giảm thành 1. - Instance thứ ba của job được enqueue. Lặp lại thủ tục. Value của semaphore giờ là 0.
- Instance thứ tư của
MyJobđược enqueue. Acquire semaphore thất bại vì value bằng 0. Bản ghiBlockedExecutionđược tạo cho job. - Instance đầu tiên kết thúc. Khi kết thúc, nó release semaphore, nên value lại thành 1.
- Khi kết thúc, instance đầu tiên cũng gọi method để release các blocked job.
- Instance thứ tư của
MyJobđược release và lại thử acquire lock. Value semaphore là 1, nên lock có thể acquire và blocked job được enqueue. Value semaphore giờ là 0.
Code release semaphore khi job kết thúc rất trực tiếp.
Còn một chi tiết chưa nói đến. Tại sao semaphores có expiry date, và tại sao cần set duration khi dùng limits_concurrency?
Hãy xem điều gì xảy ra khi job crash mà không release semaphore — ví dụ worker xử lý job đó chết. Trừ khi có cơ chế dọn dẹp semaphore, lock do job đó giữ sẽ được giữ mãi. Trong أسوأ trường hợp, điều này sẽ chặn vĩnh viễn các job khác được xử lý.
Semaphores có expiry tương ứng với duration cho trong định nghĩa job để tránh tình huống đó. Nếu semaphore hết hạn — xảy ra khi không có job nào được enqueue — semaphore sẽ bị destroy. Chúng ta đã biết process chịu trách nhiệm — đó là người bạn cũ, Dispatcher. Nó khởi tạo class ConcurrencyMaintenance, làm hai việc:
- Thứ nhất, xóa bất kỳ semaphore hết hạn nào.
- Thứ hai, kiểm tra xem có blocked job nào không và release chúng.
Job được release từng cái một, nên concurrency limits vẫn giữ. Tuy nhiên, xem điều gì xảy ra nếu job chạy lâu hơn duration cho trước. Trong trường hợp đó, semaphore sẽ bị dọn dẹp, mặc dù job vẫn chạy. Nếu job khác sau đó được enqueue, các job đó sẽ overlap.
Giám sát Solid Queue cho Rails với AppSignal
Như chúng ta đã xác lập, Solid Queue làm được nhiều việc. Tuy nhiên, với nhiều thành phần chuyển động này, monitoring trở nên quan trọng. May mắn thay, AppSignal cung cấp hỗ trợ built-in cho Solid Queue, với dashboard sẵn có cho thời gian thực thi job, throughput và tỷ lệ thất bại. Chỉ cần cài AppSignal vào ứng dụng Rails, và bạn đã sẵn sàng.
AppSignal sẽ tự động phát hiện việc bạn dùng Solid Queue và tạo active job dashboard chứa biểu đồ cho các metric quan trọng, như error rate và throughput.

Nếu bạn bao giờ thấy job hành xử sai — vì chạy chậm hoặc quá nhiều lỗi — hãy gán status và assignee để giải quyết hiệu quả.

Rõ ràng, bạn không nên phải nhìn dashboard cả ngày để biết có vấn đề. AppSignal Alerts lo phần đó. Chỉ cần tạo alert mới cho job metrics, như failure rate và job duration, và bạn đã xong.

Solid Queue tuyệt vời cho việc thêm xử lý job mạnh mẽ vào ứng dụng phiền toái. AppSignal làm tương tự khi nói đến monitoring!
Kết thúc
Chúng ta đã đi qua nhiều địa điểm trong khám phá các tính năng nâng cao của Solid Queue. Từ scheduled jobs đến dependency chains phức tạp, mỗi tính năng xây dựng trên nền tảng vững chắc chúng ta thảo luận ở phần một. Như đã thấy, xây dựng job processing backend không dễ. Nhưng bằng cách đi sâu vào source code và cách hoạt động của Solid Queue, chúng ta đã hiểu và trân trọng những thách thức liên quan.
Dù sao, Solid Queue là một bổ sung tuyệt vời cho hệ sinh thái Rails, nhờ thiết kế database xuất sắc và điều phối process. Nó cung cấp công cụ bạn cần trong khi giữ vững lời hứa cốt lõi: đơn giản và đáng tin cậy, không dependency bên ngoài.
Happy coding!