[GSoC Proposal Draft] - Digvijay Rawat - SQL Adapter for Background Jobs #240
Replies: 2 comments 1 reply
-
|
Hi @Digvijay-x1 Overall, you have a very strong grasp of the core problem. I appreciate that you explicitly noted this project is about adding a data durability layer for stateless environments, rather than trying to build a distributed queue. Your approach to handling graceful shutdowns with SIGTERM and mitigating thundering herds on boot using There're a few edge cases I'd love for you to clarify or rethink for your next revision: 1. Database Connections & Fiber Concurrency There is a bit of a contradiction in the proposal regarding Active Record. In the Solution section, you mention "no new dependencies beyond activerecord", but your code snippets use the raw pg gem, and your timeline mentions building an Active Record adapter later. We need to align on exactly which approach you are proposing as the primary deliverable. 2. Dead Letter Queue Your current DB cleanup strategy relies on hard deletes upon completion, which is great for keeping the table lean. However, the proposal is currently missing a strategy for poison pills. For example, in your 3. SQLite Support Your proposal relies on Looking forward to your updated version! Let me know if you have any questions about this feedback. |
Beta Was this translation helpful? Give feedback.
-
|
Hi @Digvijay-x1 I love this! Added a couple of suggestions, but this is already very strong! Make sure to submit your proposal before March 31!
I can see the appeal here - we could use the SQL backend to remove one of the trafe-offs This is also very different from how
I think you do need a separate table. The limit for
Great thinking, but this will be problematic in real-world deployments. This approach essentially hides some of the tables from the schema file. Additionally, it assumes the DB credentials given to the app process have the DDL permissions, which is not necessarily the case. If an app process only has DML permissions, this line will crash the app on boot. Instead, there will need to be a command to generate the migrations for the SQL backend.
I have the same suggestion here as with the polling loop - think through the abstrations and try to avoid the two-way dependency where the backend schedules the tasks in the queue. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
SQL Adapter for Background Jobs
mentored by @rsamoilov & @cuneyter
Introduction
Rage is a Ruby web framework built for speed, using fibers and the Iodine HTTP server to achieve high performance. Its built-in background job system,
Rage::Deferred, allows developers to offload work like sending emails or calling external APIs without adding heavyweight dependencies like Sidekiq or Redis.This proposal introduces a SQL adapter for
Rage::Deferred, enabling tasks to survive container restarts and be distributed across multiple pods in cloud-native deployments.Problem Understanding
The Current Architecture
Rage's background job system (
Rage::Deferred) works entirely in-process. When a task is enqueued, it's added to a fiber-based in-memory queue and executed by the same server process that handles HTTP requests.To protect against data loss, Rage uses a Disk Backend (
Rage::Deferred::Backends::Disk) as a Write-Ahead Log (WAL):The disk backend uses OS-level file locking (
flock) to ensure that only one Iodine worker process owns each WAL file. This prevents duplicate execution within a single server, and periodic file rotation keeps storage manageable.The Problem in Cloud Environments
This architecture breaks in Kubernetes and similar cloud platforms:
flockdoesn't work across pods: Disk-level file locks are local to a single node. When two pods on different nodes run the same Rage app, they cannot coordinate through file locks.These aren't edge cases. They are the default behavior in any containerized deployment. Any Rage application running on Kubernetes with background jobs is at risk of silent task loss.
The Solution
Replace the local WAL with a shared PostgreSQL/MySQL database that:
FOR UPDATE SKIP LOCKED).activerecord, no external job queues, no Redis.The execution model stays the same. Tasks are processed in-memory by Iodine workers. The database acts purely as the durability and coordination layer.
Technical Approach
The implementation prioritizes simplicity to minimize edge cases and race conditions. It is broken into two core challenges:
Database Schema
Design decisions:
Setting an individual
Iodine.run_aftertimer for each delayed task would create thousands of timers under load and doesn't survive restarts. A simple polling loop is stateless, crash-resilient, and handles clock skew gracefully.create_tablesmethod is safe to call on every boot. This lets applications auto-create tables inafter_initializewithout migration tooling, which is critical for ephemeral containers that may be the first to touch the database.publish_attimestamp guarantees execution will not begin before the specified time, though it may start slightly after (within the 5-second claim polling interval). This makes the system suitable for operations where dropping a task is unacceptable (e.g., sending notifications, syncing external APIs) but where exact-once or precise timing is not required.owner_idis nullable. ANULLowner_idmeans the task is unclaimed and available for any worker to pick up. This is the central coordination signal, i.e., graceful shutdown and crash recovery both work by settingowner_id = NULL.owner_id, it is claimed by a worker (eitherpendingorcurrentlyrunning we don't distinguish because both cases are handled identically). If a task exists withowner_id = NULL, it is unclaimed and waiting for a worker to pick it up. If the task row doesn't exist at all, it has been completed and deleted. This means we never need to update a status; we only INSERT on enqueue and DELETE on completion. Theowner_idcolumn alone encodes all the coordination state we need, eliminating the risk of status becoming inconsistent with reality (e.g., a task marked "running" whose worker is actually dead).Rage::Deferred::Task. This keeps concerns cleanly separated.failed_execution_countfor Dead Letter Queue. Instead of silently deleting tasks that fail to deserialize, the backend increments afailed_execution_countcounter. Tasks exceeding a configurable threshold (default: 3) are automatically excluded from claims, acting as an in-table dead letter queue.1. Normal Operation (Task Lifecycle)
Immediate tasks: When a worker enqueues a task without a delay, it inserts the task into the database with
owner_idset to its own worker ID. Since Rage executes tasks in the same process that enqueues them, the task is immediately scheduled in-memory. The database row exists only to ensure durability; if the process crashes before completion, the task can be recovered.Delayed tasks: Tasks with a
publish_attimestamp are inserted withowner_id = NULL. They sit in the database until a periodic background loop (claim_delayed_tasks, running every 5 seconds) finds eligible tasks and claims them.Task completion: A simple
DELETEby primary key:Worker heartbeats: Each worker updates its
worker_heartbeattimestamp every 30 seconds:2. Server Boot and Crash Recovery
Task distribution on startup: When a worker boots (or reboots after a crash), it calls
pending_tasksto claim orphaned work. To prevent thundering herd (multiple pods racing to claim all tasks), each worker limits how many tasks it claims using a configurablebatch_sizeandFOR UPDATE SKIP LOCKED:This atomically claims and locks up to
batch_sizetasks. If two pods boot simultaneously,SKIP LOCKEDensures they claim different tasks with zero contention.Graceful shutdown — When a pod receives
SIGTERM(normal K8s scaling), each Iodine worker:owner_id = NULLon all its tasks (orphaning them for other workers).rage_active_workers.Other pods can immediately claim the orphaned tasks.
Hard crash — If a pod is killed without running shutdown hooks (OOM kill, node failure), the worker's heartbeat goes stale. A periodic sweeper (running every 60 seconds) on surviving workers detects stale heartbeats and reclaims tasks:
The
stale_threshold(default: 90 seconds) is configurable. This introduces a recovery delay, but guarantees no duplicate execution. A task is only reclaimed after we're confident the original worker is dead.Code Snippets
1. Initialize
2. Add
3. Remove
4. Pending Tasks (claim_tasks)
3. Dead Letter Queue (DLQ)
The DLQ is implemented via the
failed_execution_countcolumn on the existingrage_deferred_taskstable. No separate table is needed.claim_tasksfails to deserialize a task, it incrementsfailed_execution_countand releases the task (owner_id = NULL).AND failed_execution_count < @max_failures(default: 5), so tasks that have failed too many times are automatically excluded.Rage::Deferred::Task. The DLQ handles infrastructure failures.Performance Analysis
All critical operations are indexed. Here is the time complexity for each:
rage_deferred_tasksrage_active_workerstableidx_rage_tasks_claim+ lock B rowsidx_rage_tasks_owner, update T tasksWhere: N = total tasks, W = total workers, B = batch size, T = tasks per worker, K = stale workers.
Note: log₂(20,000,000) ≈ 24, so the architecture can handle very large loads
The batch claim uses
idx_rage_tasks_claim(owner_id, publish_at)which makes finding unclaimed tasks O(log N) regardless of table size. The sweeper operates only on the smallrage_active_workerstable, keeping crash recovery fast.API Integration
The adapter integrates into Rage's existing configuration system:
The adapter uses
ActiveRecord::Base.connection_pool.with_connectionfor all database operations.Milestones & Timeline
sql.rbusing ActiveRecord for all DB operations. Address maintainer review feedback.BEGIN IMMEDIATE+UPDATE RETURNING).Deliverables
Rage::Deferred::Backends::Sql— Production-ready SQL adapter using ActiveRecord, with full test coverage.:sqlbackend option inRage.configurewith documented parameters.create_tables.Validation
A working E2E test application built and deployed to Kubernetes:
Rage::Deferredbackground tasks.For formal testing, a Docker Compose-based integration test suite will be developed that covers:
delay: 10executes after the delay.FOR UPDATE SKIP LOCKEDdistributes tasks fairly.About Me
I'm an undergraduate student who first started learning Ruby while preparing for my college’s technical society. The society is responsible for maintaining much of our campus infrastructure, including the ERP system, event websites, administrative portals, and other internal IT services. Since the ERP was built with Ruby on Rails, learning Ruby felt like the most natural place to start. In my second year, I began contributing to open-source projects, mostly in Ruby. Through that experience, I became interested in working on infrastructure-level problems rather than just application code. I chose the SQL adapter project for Rage because it addresses a real limitation in the framework today. Currently, when a Rage application is deployed on Kubernetes, background tasks can be lost if pods restart or crash. This makes it difficult to rely on Rage for production workloads in cloud environments. Building a durable, database-backed task backend would solve this problem by ensuring tasks persist across restarts. It would allow developers to confidently run Rage applications in serverless and containerized environments, fully leveraging the performance and speed the framework was designed for.
The problem itself is technically interesting and challenging involving distributed coordination, crash recovery, and race-condition handling. Working on it has already pushed me to explore concepts and systems that go well beyond what I’ve encountered in coursework, which is exactly the kind of learning experience I’m looking for through open-source development.
1. How much time would you be able to devote to the project?
I am having a Summer break from May to July, I will have approximately 8 weeks starting from 21st May to 19th July. During this period, I will be able to dedicate around ~40 hrs/week. (40 * 8 → 320 hrs.)
From 27th July until 15th September, which is approximately 7 weeks, I will only be able to devote around ~20 hrs/week due to my college semester being in session. (20 * 7 → 140 hrs.)
The allocated time frame is adequate for completing the project; however, I can adjust my work hours to meet the project's needs if necessary.
2. What other obligations might you need to work around during the summer?
3. How often, and through which channel(s), do you plan on communicating with your mentor?
I aim to provide daily updates to my mentor to maintain proper communication and ensure the project runs smoothly. For such discussions and addressing my small queries, my preferred mode of communication would be Discord.
Meetings with mentor: 2 times a week (flexible) - on Google Meet or any other platform.
“If accepted to contribute in Rage as a GSoC student this year, I decide to put in my best efforts to submit quality work (code) and have regular communication with my mentor and the broader community during these 12 weeks. I also plan to sustain my involvement in this project even after GSoC ends. Should it be that I can't make it this time, I will continue to contribute to the Rage project to the best of my ability and will reapply for GSoC at it, next year.”
Excitedly looking forward to joining the Rage developer team this GSoC!
Thanks and Regards,
Digvijay Rawat ( Digvijay-x1 )
Beta Was this translation helpful? Give feedback.
All reactions