[Deferred] Durable dead-letter storage for deferred tasks - #383
Conversation
82e1ab4 to
e144296
Compare
Deferred Backends Approach (for discussion, not final)Class dependenciesclassDiagram
class Queue {
-backend
+initialize(backend)
+enqueue(...)
+schedule(...)
}
class Nil {
+TasksBackend tasks
+DeadTasksBackend dead_tasks
+initialize(**)
}
class Disk {
+TasksBackend tasks
+DeadTasksBackend dead_tasks
+initialize(tasks_options:, dead_tasks_options:)
}
class NilTasksBackend["Nil::TasksBackend"] {
+add(...)
+remove(...)
+pending_tasks()
}
class NilDeadTasksBackend["Nil::DeadTasksBackend"] {
+add(...)
+remove(...)
+retry(...)
}
class DiskTasksBackend["Disk::TasksBackend"] {
- path
- prefix
- fsync_frequency
+add(context, publish_at:, task_id:)
+remove(task_id)
+pending_tasks()
}
class DiskDeadTasksBackend["Disk::DeadTasksBackend"] {
- path
- prefix
+add(context, exception:, task_id:)
+remove(task_id)
+retry(...)
}
Queue --> Nil : backend
Queue --> Disk : backend
Nil *-- NilTasksBackend : tasks
Nil *-- NilDeadTasksBackend : dead_tasks
Disk *-- DiskTasksBackend : tasks
Disk *-- DiskDeadTasksBackend : dead_tasks
Runtime call shape: backend.tasks.add(...)
backend.tasks.remove(...)
backend.tasks.pending_tasks
backend.dead_tasks.add(...)
backend.dead_tasks.remove(...)File interfaces
class Rage::Deferred::Backends::Disk
attr_reader :tasks, :dead_tasks
def initialize(tasks_options: {}, dead_tasks_options: {})
@tasks = TasksBackend.new(**tasks_options)
@dead_tasks = DeadTasksBackend.new(**dead_tasks_options)
end
class TasksBackend
def initialize(path:, prefix:, fsync_frequency:)
# WAL setup (current Disk body)
end
def add(context, publish_at: nil, task_id: nil) end
def remove(task_id) end
def pending_tasks end
end
class DeadTasksBackend
def initialize(path:, prefix:)
...
end
def add(context, exception:, task_id:) end
def remove(task_id) end
def retry(...) end
end
end
class Rage::Deferred::Backends::Nil
attr_reader :tasks, :dead_tasks
def initialize(**)
@tasks = TasksBackend.new
@dead_tasks = DeadTasksBackend.new
end
class TasksBackend
def add(_, **) end
def remove(_) end
def pending_tasks = []
end
class DeadTasksBackend
def add(_, **) end
def remove(_) end
def retry(_, **) end
end
endConfiguration
Rage.configure do
config.deferred.backend = :disk
config.deferred.tasks = { path: "storage", prefix: "deferred-", fsync_frequency: 0.5 }
config.deferred.dead_tasks = { path: "storage", prefix: "dead_tasks-" }
end
@backend_class.new(tasks_options: @tasks_options, dead_tasks_options: @dead_tasks_options)For |
|
@rsamoilov @serhii-sadovskyi please take a look at the proposed approach above for the deferred backends implementation. I'd like to hear your thoughts! |
|
Hey @alex-rogachev , This looks great. Love the diagram! Several thoughts:
This would be a breaking change, which we would ideally want to avoid.
This exposes the fact that dead tasks are stored in another file, which is an internal implementation detail that shouldn't be exposed. Consider a hypothetical Redis backend - would it need a separate The DLQ backend should be reusing the same
If this method is supposed to reenqueue the task and remove it from the DLQ, then it should be part of another class, potentially |
All these points make sense. We'll consider them if we select this approach! |
2eac16d to
4fdf7b2
Compare
78cff11 to
616d0ac
Compare
Write the dead-task record before WAL remove. Shared store uses a lock file, a process-local lock flag, and rename-based compaction. Co-authored-by: Oleksandr Rohachev <sasha.rogachev.workmail@gmail.com> Co-authored-by: Serhii Sadovskyi <sadovsky.sb@gmail.com>
616d0ac to
6c808fc
Compare
Truncate an incomplete final DLQ entry under the shared lock before appending a new record, preventing a torn tail from corrupting the next durable entry. Add regression coverage for partial-only and valid-prefix stores. Co-authored-by: Serhii Sadovskyi <sadovsky.sb@gmail.com>
268da30 to
0520f36
Compare
Fsync the storage directory after creating the live dead-tasks file and after rename-based compaction. This ensures filename changes survive host crashes once an operation returns. Add coverage for directory syncing and clarify the disk backend and dead-task storage documentation. Co-authored-by: Serhii Sadovskyi <sadovsky.sb@gmail.com>
0520f36 to
d8047d8
Compare
8d3c837 to
98275b9
Compare
Co-authored-by: Oleksandr Rohachev <sasha.rogachev.workmail@gmail.com>
919b65e to
7fa0c62
Compare
This reverts commit 54f2b28.
|
Removed the changelog entry - https://github.com/rage-rb/rage/blob/main/CONTRIBUTING.md#changelog. |
Deferred Backends Approach
Class dependencies
classDiagram class Queue { -backend +initialize(backend) +enqueue(...) +schedule(...) } class Nil { +initialize(**) +add_task(...) +remove_task(...) +pending_tasks() +add_dead_task(...) +list_dead_tasks(...) +find_dead_task(...) +remove_dead_tasks(...) } class Disk { -TasksStorage tasks_storage -DeadTasksStorage dead_tasks_storage +initialize(path:, prefix:, fsync_frequency:) +add_task(...) +remove_task(...) +pending_tasks() +add_dead_task(...) +list_dead_tasks(...) +find_dead_task(...) +remove_dead_tasks(...) } class DiskTasksStorage["Disk::TasksStorage"] { - path - prefix - fsync_frequency +add(task, publish_at:, task_id:) +remove(task_id) +pending_tasks() } class DiskDeadTasksStorage["Disk::DeadTasksStorage"] { - path - prefix +add(task_id, context, exception, task_class:, attempts:) +list(limit:, offset:) +find(id) +remove(ids) } Queue --> Nil : backend Queue --> Disk : backend Disk *-- DiskTasksStorage : tasks_storage Disk *-- DiskDeadTasksStorage : dead_tasks_storageRuntime call shape:
File interfaces
**[lib/rage/deferred/backends/disk.rb](lib/rage/deferred/backends/disk.rb)****[lib/rage/deferred/backends/nil.rb](lib/rage/deferred/backends/nil.rb)**Configuration
Keep the existing public API. Do not add
tasks=/dead_tasks=knobs — that would be a breaking change.**[lib/rage/configuration.rb](lib/rage/configuration.rb)**(Deferred) stays as today:**backend=**—:disk(with optional:path,:prefix,:fsync_frequency) ornil**backend**getter —@backend_class.new(**@backend_options)(one facade instance)The Disk facade receives that same options hash. It passes
path/prefix/fsync_frequencytoTasksStorage(WAL).DeadTasksStoragereusespathandprefixonly — no separate DLQ knobs, nofsync_frequency.WAL init globs
#{prefix}#{STORAGE_VERSION}-*(e.g.deferred-0-*). The DLQ file must not match that pattern. Use a constant basename (dead_tasks) plus its ownSTORAGE_VERSIONin the filename (same idea as WAL: bump the constant when the on-disk format changes, leave old files alone):deferred-dead_tasks-0does not matchdeferred-0-*, so WAL will not claim the DLQ file on boot. Do not put the WAL version immediately after the configured prefix (e.g.deferred-0-dead_tasks) — that would match the WAL glob.prefixstill comes frombackend=so a custom prefix applies to both stores.For
backend = nil, the facade methods remain no-ops.Nilhas no nested storage classes.Writing to the dead-tasks store
Give-up in
[Queue#schedule](lib/rage/deferred/queue.rb)writes the dead task before removing it from the tasks WAL. Ifadd_dead_taskraises, the WAL entry stays (at-least-once). Success path: durable DLQ write, thenremove_task.One shared data file (
{path}/{prefix}dead_tasks-{STORAGE_VERSION}) so listing all dead tasks is a single scan.Race conditions and other issues
The design must account for three failure categories across writes and locking. Each one is paired with its resolution below.
Mid-write crash
During record add
A crash during
addcan leave the final record only partially written and without its terminating newline. CRC validation makes that fragment unreadable, but it does not make the next append safe. Appending a new record directly joins it to the fragment, producing one corrupted line. The new record can then be lost even though its write andfsyncsucceed and Queue removes its WAL entry.Resolution. Repair the tail under the lock file before every append. Open the data file for read/write append, check its final byte, and do nothing when the file is empty or already ends with
\n. Otherwise, scan backward in bounded-memory chunks for the previous newline and truncate immediately after it; truncate to zero when the file contains no complete line. Then append the new record andfsync. The samefsyncpersists both the truncation and the new entry, while every earlier complete record remains untouched.During rewrite on removal
Truncating and rewriting the live file in place can leave it empty or half-written if the process dies. The dead tasks are then gone for good: they already left the WAL.
Resolution. Write the survivors to a temp file,
fsyncit, thenrenamethe temp over the live data path. A crash during the temp write leaves the live file unchanged, and an orphan.tmpis harmless.Multi-worker race
flockis tied to an inode, not to a path. A worker that keeps a long-lived fd on the data file and thenrenames a new file onto that path leaves the other workers holding the old, now unlinked, inode. Their writes go to a ghost file, and a laterremovecanrenamethat ghost back over the live path:There are two ways out.
Resolution: lock file (selected). Flock a file that is never renamed (
{prefix}dead_tasks.lock). Open the data file by path only while holding that lock. Afterrename, waiters still serialize on the same lock inode; the next dataopen(path)sees the new image. Never cache a data fd — keep only@lockfrom init, and let everyadd/list/find/removeopen the data path under that lock.renameof data cannot split workers;removeneeds no data-fd reopen dance; hard to regress by caching a data fd.Alternative: temp as journal, in-place live write (no live
rename). Keep the live inode so long-livedflockon the data file still works. Write the new image to.tmp,fsync, then overwrite the live file (truncate + write +fsync), then unlink.tmp. Crash during temp write: live DLQ intact. Crash during live overwrite: live file may be torn until recovery copies a complete temp back onto live.flockstill serializes workers; no lock file; crash during tmp write leaves live DLQ intact.Does not mean “always trust DLQ after any crash” unless recovery ran.
Same-worker fibers
Rage runs one OS thread per worker with many fibers on it, so a blocking
LOCK_EXis out: it parks the thread and stalls every fiber in the process. Contenders takeLOCK_EX | LOCK_NBandsleepto retry instead. That serializes other processes, but not the other fibers in this one:flockon a shared fd does not exclude them, and a secondLOCK_EXon that fd succeeds as a re-acquire. Two give-up fibers can then overlap, and oneensurecan unlock while the other is still mid-compaction.Resolution. A process-local flag in the same retry loop as
flock(until !@locked && @lock.flock(...)): set it on acquire, clear it inensure.Non-blocking lock and temp-file rewrite are complementary:
LOCK_NB+sleepavoids freezing the worker while waiting for the lock; temp →fsync→renameavoids wiping the store if a crash happens mid-rewrite.Add algorithm
Append one record under the lock file. Serialize the record before acquiring the lock so serialization does not extend the lock hold time. If lock acquisition retries, the fiber-aware
sleepyields execution while preserving the localentryvalue. Open the data file by path only while the lock is held; do not keep a long-lived data fd.Initialization creates the live data file when needed and
fsyncs its parent directory so the file's directory entry is durable before any dead task can be added and removed from the WAL.LOCK_EX | LOCK_NBon{prefix}dead_tasks.lockand set the process-local locked flag; raiseDeadTasksLockTimeoutif the retry cap is hit\n, scan backward to the previous newline and truncate the incomplete suffix; truncate to zero if there is no complete linefsyncensureflockwithLOCK_NBreturns false immediately when another worker holds the lock. The lock helper then yields the fiber with sleep before retrying. If the lock is not acquired within the fixed retry limit, it raises DeadTasksLockTimeout, leaving the task in the WAL for recovery.A crash mid-write can still leave a partial last line. Reads drop it as corrupted, and the next
addremoves that incomplete suffix under the lock before appending. Every earlier complete line remains intact. There is no temp file on this path: repairing only the invalid tail and thenfsyncing the repair with the new entry is sufficient.Remove algorithm
For a non-empty list of IDs, scan and compact the store under the lock file. Open the live data file by path while holding the lock and write valid records that should remain into a temporary file. Replace the live file only when at least one requested ID was found. Do not keep a long-lived data fd.
0immediately when it is emptyLOCK_EX | LOCK_NBon{prefix}dead_tasks.lockand set the process-local locked flag; raiseDeadTasksLockTimeoutif the retry cap is hit{prefix}dead_tasks-{STORAGE_VERSION}.tmp, then open the live data pathfsync, leave the live file unchanged, and return0fsyncthe temporary filerenamethe temporary file over the live data pathfsyncthe parent directory so the replacement directory entry is durableensureIf the process dies mid-temp write, the live data file is unchanged; an orphan temp is harmless. Malformed or CRC-invalid records are removed only when at least one requested ID was found and the temporary file replaces the live file. The parent-directory
fsyncmakes the replacement durable before a later add can rely on the new live inode.Lock contention during compaction.
removeholds the shared lock while it scans the live file, writes andfsyncs the temporary file, renames it, andfsyncs the parent directory. The lock duration therefore grows with the size of the dead-tasks store. Other operations use bounded non-blocking retries and raiseDeadTasksLockTimeoutif they cannot acquire the lock. Ifaddtimes out, Queue leaves the task in the WAL so it can be recovered on the next start. This favors at-least-once recovery over waiting indefinitely for the lock.Durability and crash consistency summary
Only one operation uses the store at a time. The permanent lock file ensures that only one process or worker accesses the dead-tasks store at a time. The local
@lockedflag provides the same protection between fibers inside one worker. After taking the lock, every operation opens the live data file by its current path, so it sees the result of the latest completed removal.What this guarantees: two operations cannot run at the same time and overwrite each other's changes. If the lock cannot be acquired before the timeout, the operation does not change the dead-tasks store. A timed-out add leaves the task in the WAL.
The dead-tasks storage file is safely created. Initialization creates the live data file when it does not exist and then
fsyncs the storage directory. Creating a file changes its directory, and a laterfsyncof the file itself does not guarantee that its filename will survive a host crash. Without the directoryfsync, the first dead-task record could be durably written to the file while the file is no longer reachable by its expected name after recovery.What this guarantees: after initialization returns, a host crash cannot lose the live filename from an existing storage directory. If initialization is interrupted, the file may or may not exist, but no dead-task write has succeeded yet and the next initialization can safely create or open it again.
An unfinished final write is discarded before the next add. Before appending a record,
addchecks whether the file ends with a complete line. If it does not,addtruncates the unfinished trailing fragment back to the last complete line. It does not recover the interrupted record. It then writes the new checksummed record andfsyncs the live file before returning.What this guarantees: after
addreturns, the new record and all earlier complete records are durable. If a crash interrupts the add, the file may end with an incomplete record. Reads ignore that fragment, the next add discards it before writing, and Queue keeps the interrupted task in the WAL because its DLQ add did not succeed. A complete newline-terminated record that fails its CRC is not removed byadd; reads skip it and a later compaction currently drops it.A removal never rewrites the live file in place.
removewrites all records that should remain to a temporary file andfsyncs it. It then renames the temporary file over the live file andfsyncs the storage directory before returning.What this guarantees: after
removereturns, both the remaining records and the replacement of the live file are durable. The removed records cannot reappear after a crash when the filesystem provides the expectedfsyncand rename guarantees.A crash before the rename leaves the old live file unchanged and may leave an unused temporary file. A crash after the rename but before the directory
fsyncfinishes may recover either the old or the new version of the live file. In that case the removal never returned successfully, so its result was not acknowledged.A task is written to the dead-tasks store before it leaves the WAL. Queue calls
add_dead_taskand waits for it to succeed before recording the corresponding removal in the tasks WAL.What this guarantees: a task cannot be successfully removed from the WAL before it has been durably written to the dead-tasks store. A crash between these two operations can leave the task in both stores and cause it to be replayed. The handoff therefore provides at-least-once rather than exactly-once behavior. It can create a duplicate record, but it does not silently lose the task.
These guarantees assume a local filesystem that supports file locks, atomic renames within one directory, and reliable
fsyncfor files and directories. They cover worker termination, process crashes, and host or OS crashes. They do not cover damaged storage, storage that reports a successfulfsyncwithout saving the data, or network filesystems with weaker guarantees.If a filesystem operation raises an error, the operation is not considered successful even though part of it may already be visible. In particular, the rename happens before the directory
fsync. If thatfsyncfails, callers cannot know whether the removal will survive a later host crash. Retrying the removal and finding no matching record does not prove that the earlier rename is durable.