rage-rb 1.26.1 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,273 +6,618 @@ require "zlib"
6
6
  # `Rage::Deferred::Backends` implements a storage layer to persist deferred tasks.
7
7
  # A storage should implement the following instance methods:
8
8
  #
9
- # * `add` - called when a task has to be added to the storage;
10
- # * `remove` - called when a task has to be removed from the storage;
9
+ # * `add_task` - called when a task has to be added to the storage;
10
+ # * `remove_task` - called when a task has to be removed from the storage;
11
11
  # * `pending_tasks` - the method should iterate over the underlying storage and return a list of tasks to replay;
12
+ # * `add_dead_task` - called when a task has exhausted its retries or aborted them;
13
+ # * `list_dead_tasks` - return a list of dead tasks, newest first;
14
+ # * `find_dead_task` - return a single dead task;
15
+ # * `remove_dead_tasks` - permanently delete dead tasks;
12
16
  #
13
17
  class Rage::Deferred::Backends::Disk
14
- STORAGE_VERSION = "0"
15
- STORAGE_SIZE_INCREASE_RATIO = 1.5
18
+ def initialize(path:, prefix:, fsync_frequency:)
19
+ @tasks_storage = TasksStorage.new(path:, prefix:, fsync_frequency:)
20
+ @dead_tasks_storage = DeadTasksStorage.new(path:, prefix:)
21
+ end
16
22
 
17
- DEFAULT_PUBLISH_AT = "0"
18
- DEFAULT_STORAGE_SIZE_LIMIT = 2_000_000
23
+ # Add a record to the log representing a new task.
24
+ # @param task [Rage::Deferred::Task]
25
+ # @param publish_at [Integer, nil]
26
+ # @param task_id [String, nil]
27
+ # @return [String]
28
+ def add_task(task, publish_at: nil, task_id: nil)
29
+ @tasks_storage.add(task, publish_at:, task_id:)
30
+ end
19
31
 
20
- def initialize(path:, prefix:, fsync_frequency:)
21
- @storage_path = path
22
- @storage_prefix = "#{prefix}#{STORAGE_VERSION}"
23
- @fsync_frequency = fsync_frequency
24
-
25
- @storage_path.mkpath
26
-
27
- # try to open and take ownership of all storage files in the storage directory
28
- storage_files = @storage_path.glob("#{@storage_prefix}-*").filter_map do |file_path|
29
- file = file_path.open("a+b")
30
- if file.flock(File::LOCK_EX | File::LOCK_NB)
31
- sleep 0.01 # reduce contention between workers
32
- file
32
+ # Add a record to the log representing a task removal.
33
+ # @param task_id [String]
34
+ def remove_task(task_id)
35
+ @tasks_storage.remove(task_id)
36
+ end
37
+
38
+ # Return a list of pending tasks in the storage.
39
+ # @return [Array<(String, Rage::Deferred::Task, Integer, Integer)>
40
+ def pending_tasks
41
+ @tasks_storage.pending_tasks
42
+ end
43
+
44
+ # Add a task to the dead-tasks store.
45
+ # @param task_id [String] the id the task was persisted with
46
+ # @param context [Array] the serializable execution context of the task
47
+ # @param exception [Exception] the exception raised during the last attempt
48
+ # @param task_class [Class, String] the class of the task
49
+ # @param attempts [Integer] the number of attempts made to process the task
50
+ # @return [String] the id of the dead-task record
51
+ # @raise [Rage::Deferred::DeadTasksLockTimeout] if the dead-tasks store cannot be locked
52
+ def add_dead_task(task_id, context, exception, task_class:, attempts:)
53
+ @dead_tasks_storage.add(task_id, context, exception, task_class:, attempts:)
54
+ end
55
+
56
+ # Return a list of dead-lettered tasks, newest first.
57
+ # @param limit [Integer, nil] the maximum number of records to return
58
+ # @param offset [Integer] the number of records to skip
59
+ # @return [Array<Hash>]
60
+ def list_dead_tasks(limit: nil, offset: 0)
61
+ @dead_tasks_storage.list(limit:, offset:)
62
+ end
63
+
64
+ # Return a single dead-lettered task.
65
+ # @param id [String] the id of the dead-task record
66
+ # @return [Hash, nil]
67
+ def find_dead_task(id)
68
+ @dead_tasks_storage.find(id)
69
+ end
70
+
71
+ # Permanently delete dead-lettered tasks.
72
+ # @param ids [String, Array<String>] the ids of the dead-task records
73
+ # @return [Integer] the number of deleted records
74
+ # @raise [Rage::Deferred::DeadTasksLockTimeout] if the dead-tasks store cannot be locked
75
+ def remove_dead_tasks(ids)
76
+ @dead_tasks_storage.remove(ids)
77
+ end
78
+
79
+ ##
80
+ # The write-ahead log holding tasks that are yet to be processed. Every worker owns its own
81
+ # storage file and never reads the files owned by the other workers, except during recovery.
82
+ #
83
+ # @private
84
+ class TasksStorage
85
+ STORAGE_VERSION = "0"
86
+ STORAGE_SIZE_INCREASE_RATIO = 1.5
87
+
88
+ DEFAULT_PUBLISH_AT = "0"
89
+ DEFAULT_STORAGE_SIZE_LIMIT = 2_000_000
90
+
91
+ def initialize(path:, prefix:, fsync_frequency:)
92
+ @storage_path = path
93
+ @storage_prefix = "#{prefix}#{STORAGE_VERSION}"
94
+ @fsync_frequency = fsync_frequency
95
+
96
+ @storage_path.mkpath
97
+
98
+ # try to open and take ownership of all storage files in the storage directory
99
+ storage_files = @storage_path.glob("#{@storage_prefix}-*").filter_map do |file_path|
100
+ file = file_path.open("a+b")
101
+ if file.flock(File::LOCK_EX | File::LOCK_NB)
102
+ sleep 0.01 # reduce contention between workers
103
+ file
104
+ else
105
+ file.close
106
+ end
107
+ end
108
+
109
+ # if there are no storage files - create one;
110
+ # otherwise the first one is used as the main storage; the rest will be merged into the main storage
111
+ if storage_files.empty?
112
+ @storage = create_storage
33
113
  else
34
- file.close
114
+ @storage = storage_files[0]
115
+ @recovered_storages = storage_files[1..] if storage_files.length > 1
35
116
  end
36
- end
37
117
 
38
- # if there are no storage files - create one;
39
- # otherwise the first one is used as the main storage; the rest will be merged into the main storage
40
- if storage_files.empty?
41
- @storage = create_storage
42
- else
43
- @storage = storage_files[0]
44
- @recovered_storages = storage_files[1..] if storage_files.length > 1
45
- end
118
+ # include recovered storages from crashed/previous workers
119
+ all_storages = [@storage, *@recovered_storages].compact
120
+
121
+ # find the highest task timestamp across all storage files
122
+ storage_file_max_timestamp = all_storages.map do |storage|
123
+ max_timestamp = 0
124
+ storage.tap(&:rewind).each_line(chomp: true) do |entry|
125
+ next unless entry[9...12] == "add"
126
+ timestamp = entry[13..].split("-").first.to_i
127
+ max_timestamp = timestamp if timestamp > max_timestamp
128
+ end
129
+ max_timestamp
130
+ end.max.to_i
131
+
132
+ # apply Lamport IR2(b) From time, clocks and the ordering of
133
+ # events in a distributed system to guard against clock skew
134
+ task_id_seed = [Time.now.to_i, storage_file_max_timestamp].max + 1
46
135
 
47
- # include recovered storages from crashed/previous workers
48
- all_storages = [@storage, *@recovered_storages].compact
136
+ @task_id_base, @task_id_i = "#{task_id_seed}-#{Process.pid}", 0
137
+ Iodine.run_every(1_000) do
138
+ task_id_seed += 1
139
+ @task_id_base, @task_id_i = "#{task_id_seed}-#{Process.pid}", 0
140
+ end
49
141
 
50
- # find the highest task timestamp across all storage files
51
- storage_file_max_timestamp = all_storages.map do |storage|
52
- max_timestamp = 0
53
- storage.tap(&:rewind).each_line(chomp: true) do |entry|
54
- next unless entry[9...12] == "add"
55
- timestamp = entry[13..].split("-").first.to_i
56
- max_timestamp = timestamp if timestamp > max_timestamp
142
+ @storage_size_limit = DEFAULT_STORAGE_SIZE_LIMIT
143
+ @storage_size = @storage.size
144
+ @fsync_scheduled = false
145
+ @should_rotate = false
146
+
147
+ # we use different counters for different tasks:
148
+ # delayed tasks are stored in the hash; for regular tasks we only maintain a counter;
149
+ # this information is only used during storage rotation
150
+ @immediate_tasks_in_queue = 0
151
+ @delayed_tasks = {}
152
+
153
+ # ensure data is written to disk
154
+ @storage_has_changes = false
155
+ Iodine.run_every(@fsync_frequency) do
156
+ if @storage_has_changes
157
+ @storage_has_changes = false
158
+ @storage.fsync
159
+ end
57
160
  end
58
- max_timestamp
59
- end.max.to_i
161
+ end
60
162
 
61
- # apply Lamport IR2(b) From time, clocks and the ordering of
62
- # events in a distributed system to guard against clock skew
63
- task_id_seed = [Time.now.to_i, storage_file_max_timestamp].max + 1
163
+ # Add a record to the log representing a new task.
164
+ # @param task [Rage::Deferred::Task]
165
+ # @param publish_at [Integer, nil]
166
+ # @param task_id [String, nil]
167
+ # @return [String]
168
+ def add(task, publish_at: nil, task_id: nil)
169
+ serialized_task = Marshal.dump(task).dump
64
170
 
65
- @task_id_base, @task_id_i = "#{task_id_seed}-#{Process.pid}", 0
66
- Iodine.run_every(1_000) do
67
- task_id_seed += 1
68
- @task_id_base, @task_id_i = "#{task_id_seed}-#{Process.pid}", 0
171
+ persisted_task_id = task_id || generate_task_id
172
+
173
+ entry = build_add_entry(persisted_task_id, serialized_task, publish_at)
174
+ write_to_storage(entry)
175
+
176
+ if publish_at
177
+ @delayed_tasks[persisted_task_id] = [serialized_task, publish_at]
178
+ else
179
+ @immediate_tasks_in_queue += 1
180
+ end
181
+
182
+ persisted_task_id
69
183
  end
70
184
 
71
- @storage_size_limit = DEFAULT_STORAGE_SIZE_LIMIT
72
- @storage_size = @storage.size
73
- @fsync_scheduled = false
74
- @should_rotate = false
75
-
76
- # we use different counters for different tasks:
77
- # delayed tasks are stored in the hash; for regular tasks we only maintain a counter;
78
- # this information is only used during storage rotation
79
- @immediate_tasks_in_queue = 0
80
- @delayed_tasks = {}
81
-
82
- # ensure data is written to disk
83
- @storage_has_changes = false
84
- Iodine.run_every(@fsync_frequency) do
85
- if @storage_has_changes
86
- @storage_has_changes = false
87
- @storage.fsync
185
+ # Add a record to the log representing a task removal.
186
+ # @param task_id [String]
187
+ def remove(task_id)
188
+ write_to_storage(build_remove_entry(task_id))
189
+
190
+ if @delayed_tasks.has_key?(task_id)
191
+ @delayed_tasks.delete(task_id)
192
+ else
193
+ @immediate_tasks_in_queue -= 1
88
194
  end
195
+
196
+ # rotate the storage once the size is over the limit and all non-delayed tasks are processed
197
+ rotate_storage if @should_rotate && @immediate_tasks_in_queue == 0
89
198
  end
90
- end
91
199
 
92
- # Add a record to the log representing a new task.
93
- # @param task [Rage::Deferred::Task]
94
- # @param publish_at [Integer, nil]
95
- # @param task_id [String, nil]
96
- # @return [String]
97
- def add(task, publish_at: nil, task_id: nil)
98
- serialized_task = Marshal.dump(task).dump
200
+ # Return a list of pending tasks in the storage.
201
+ # @return [Array<(String, Rage::Deferred::Task, Integer, Integer)>
202
+ def pending_tasks
203
+ if @recovered_storages
204
+ # `@recovered_storages` will only be present if the server has previously crashed and left
205
+ # some storage files behind, or if the new cluster is started with fewer workers than before;
206
+ # TLDR: this code is expected to execute very rarely
207
+ @recovered_storages.each { |storage| recover_tasks(storage.tap(&:rewind)) }
208
+ end
209
+
210
+ tasks = {}
211
+ corrupted_tasks_count = 0
212
+
213
+ # find pending tasks in the storage
214
+ @storage.tap(&:rewind).each_line(chomp: true) do |entry|
215
+ signature, op, payload = entry[0...8], entry[9...12], entry[9..]
216
+ next if signature&.empty? || payload&.empty? || op&.empty?
217
+
218
+ unless signature == Zlib.crc32(payload).to_s(16).rjust(8, "0")
219
+ corrupted_tasks_count += 1
220
+ next
221
+ end
222
+
223
+ if op == "add"
224
+ task_id = entry[13...entry.index(":", 13).to_i]
225
+ tasks[task_id] = entry
226
+ elsif op == "rem"
227
+ task_id = entry[13..]
228
+ tasks.delete(task_id)
229
+ end
230
+ end
231
+
232
+ if corrupted_tasks_count != 0
233
+ puts "WARNING: Detected #{corrupted_tasks_count} corrupted deferred task(s)"
234
+ end
99
235
 
100
- persisted_task_id = task_id || generate_task_id
236
+ tasks.filter_map do |task_id, entry|
237
+ _, _, _, serialized_publish_at, serialized_task = entry.split(":", 5)
101
238
 
102
- entry = build_add_entry(persisted_task_id, serialized_task, publish_at)
103
- write_to_storage(entry)
239
+ task = Marshal.load(serialized_task.undump)
104
240
 
105
- if publish_at
106
- @delayed_tasks[persisted_task_id] = [serialized_task, publish_at]
107
- else
108
- @immediate_tasks_in_queue += 1
241
+ publish_at = (serialized_publish_at == DEFAULT_PUBLISH_AT ? nil : serialized_publish_at.to_i)
242
+
243
+ if publish_at
244
+ @delayed_tasks[task_id] = [serialized_task, publish_at]
245
+ else
246
+ @immediate_tasks_in_queue += 1
247
+ end
248
+
249
+ [task_id, task, publish_at]
250
+
251
+ rescue ArgumentError, NameError => e
252
+ puts "ERROR: Can't deserialize the task with id #{task_id}: (#{e.class}) #{e.message}"
253
+ nil
254
+ end
109
255
  end
110
256
 
111
- persisted_task_id
112
- end
257
+ private
113
258
 
114
- # Add a record to the log representing a task removal.
115
- # @param task_id [String]
116
- def remove(task_id)
117
- write_to_storage(build_remove_entry(task_id))
259
+ def generate_task_id
260
+ @task_id_i += 1
261
+ "#{@task_id_base}-#{@task_id_i}"
262
+ end
118
263
 
119
- if @delayed_tasks.has_key?(task_id)
120
- @delayed_tasks.delete(task_id)
121
- else
122
- @immediate_tasks_in_queue -= 1
264
+ def create_storage
265
+ file = @storage_path.join("#{@storage_prefix}-#{Time.now.strftime("%Y%m%d")}-#{Process.pid}-#{rand(0x100000000).to_s(36)}")
266
+
267
+ file.open("a+b").tap { |f| f.flock(File::LOCK_EX) }
123
268
  end
124
269
 
125
- # rotate the storage once the size is over the limit and all non-delayed tasks are processed
126
- rotate_storage if @should_rotate && @immediate_tasks_in_queue == 0
127
- end
270
+ def write_to_storage(content, adjust_size_limit: false)
271
+ @storage.write(content)
272
+ @storage_has_changes = true
128
273
 
129
- # Return a list of pending tasks in the storage.
130
- # @return [Array<(String, Rage::Deferred::Task, Integer, Integer)>
131
- def pending_tasks
132
- if @recovered_storages
133
- # `@recovered_storages` will only be present if the server has previously crashed and left
134
- # some storage files behind, or if the new cluster is started with fewer workers than before;
135
- # TLDR: this code is expected to execute very rarely
136
- @recovered_storages.each { |storage| recover_tasks(storage.tap(&:rewind)) }
274
+ @storage_size += content.bytesize
275
+ @should_rotate = true if @storage_size >= @storage_size_limit
276
+
277
+ if adjust_size_limit
278
+ # if the data copied from recovered storages or during the rotation takes up most of the storage, we might
279
+ # end up in an infinite rotation loop; instead, we dynamically increase the storage size limit
280
+ if @storage_size * STORAGE_SIZE_INCREASE_RATIO >= @storage_size_limit
281
+ @storage_size_limit *= STORAGE_SIZE_INCREASE_RATIO
282
+ @should_rotate = false
283
+ end
284
+ end
137
285
  end
138
286
 
139
- tasks = {}
140
- corrupted_tasks_count = 0
287
+ def rotate_storage
288
+ old_storage = @storage
289
+ @storage = nil # in case `create_storage` ends up blocking the fiber
141
290
 
142
- # find pending tasks in the storage
143
- @storage.tap(&:rewind).each_line(chomp: true) do |entry|
144
- signature, op, payload = entry[0...8], entry[9...12], entry[9..]
145
- next if signature&.empty? || payload&.empty? || op&.empty?
291
+ # create a new storage and update internal state;
292
+ # after this point all new tasks will be written to the new storage
293
+ @should_rotate = false
294
+ @storage_size = 0
295
+ @storage_size_limit = DEFAULT_STORAGE_SIZE_LIMIT
296
+ @storage = create_storage
297
+
298
+ # copy delayed tasks to the new storage in batches
299
+ @delayed_tasks.keys.each_slice(100) do |task_ids|
300
+ entries = task_ids.filter_map do |task_id|
301
+ # don't copy the task if it has already been processed during the rotation
302
+ next unless @delayed_tasks.has_key?(task_id)
303
+
304
+ serialized_task, publish_at = @delayed_tasks[task_id]
305
+ build_add_entry(task_id, serialized_task, publish_at)
306
+ end
307
+
308
+ write_to_storage(entries.join, adjust_size_limit: true)
146
309
 
147
- unless signature == Zlib.crc32(payload).to_s(16).rjust(8, "0")
148
- corrupted_tasks_count += 1
149
- next
310
+ Fiber.pause
150
311
  end
151
312
 
152
- if op == "add"
153
- task_id = entry[13...entry.index(":", 13).to_i]
154
- tasks[task_id] = entry
155
- elsif op == "rem"
156
- task_id = entry[13..]
157
- tasks.delete(task_id)
313
+ # delete the old storage ensuring the copied data has already been written to disk
314
+ Iodine.run_after(@fsync_frequency) do
315
+ cleanup_storage(old_storage)
158
316
  end
159
317
  end
160
318
 
161
- if corrupted_tasks_count != 0
162
- puts "WARNING: Detected #{corrupted_tasks_count} corrupted deferred task(s)"
163
- end
319
+ def build_add_entry(task_id, serialized_task, publish_at)
320
+ entry = "add:#{task_id}:#{publish_at || DEFAULT_PUBLISH_AT}:#{serialized_task}"
321
+ crc = Zlib.crc32(entry).to_s(16).rjust(8, "0")
164
322
 
165
- tasks.filter_map do |task_id, entry|
166
- _, _, _, serialized_publish_at, serialized_task = entry.split(":", 5)
323
+ "#{crc}:#{entry}\n"
324
+ end
167
325
 
168
- task = Marshal.load(serialized_task.undump)
326
+ def build_remove_entry(task_id)
327
+ entry = "rem:#{task_id}"
328
+ crc = Zlib.crc32(entry).to_s(16).rjust(8, "0")
169
329
 
170
- publish_at = (serialized_publish_at == DEFAULT_PUBLISH_AT ? nil : serialized_publish_at.to_i)
330
+ "#{crc}:#{entry}\n"
331
+ end
171
332
 
172
- if publish_at
173
- @delayed_tasks[task_id] = [serialized_task, publish_at]
174
- else
175
- @immediate_tasks_in_queue += 1
333
+ def recover_tasks(storage)
334
+ # copy records to the main storage
335
+ while (content = storage.read(262_144))
336
+ write_to_storage(content, adjust_size_limit: true)
176
337
  end
177
338
 
178
- [task_id, task, publish_at]
339
+ Iodine.run_after(@fsync_frequency) do
340
+ cleanup_storage(storage)
341
+ end
342
+ end
179
343
 
180
- rescue ArgumentError, NameError => e
181
- puts "ERROR: Can't deserialize the task with id #{task_id}: (#{e.class}) #{e.message}"
182
- nil
344
+ def cleanup_storage(storage)
345
+ path = storage.path
346
+ storage.close
347
+ File.unlink(path) if File.exist?(path)
183
348
  end
184
349
  end
185
350
 
186
- private
351
+ ##
352
+ # Stores tasks that exhausted or aborted their retries so they can be inspected or replayed.
353
+ #
354
+ # All workers and processes share one append-only file. An exclusive, non-blocking file lock
355
+ # serializes access; lock acquisition is retried briefly before an operation fails. Deletions
356
+ # replace the file atomically, so a crash cannot leave a partially rewritten store.
357
+ #
358
+ # @private
359
+ class DeadTasksStorage
360
+ STORAGE_VERSION = "0"
361
+
362
+ LOCK_MAX_ATTEMPTS = 20
363
+ LOCK_RETRY_INTERVAL = 0.01
364
+ LOCK_MAX_RETRY_INTERVAL = 0.1
365
+
366
+ BACKTRACE_LIMIT = 20
367
+
368
+ ENTRY_OP = "dead_task"
369
+ ENTRY_CRC_HEX_WIDTH = 8
370
+ TAIL_SCAN_CHUNK_SIZE = 8_192
371
+
372
+ # Open or create the shared dead-tasks store and its stable lock file.
373
+ # @param path [Pathname] directory in which storage files are kept
374
+ # @param prefix [String] prefix used for storage file names
375
+ def initialize(path:, prefix:)
376
+ path.mkpath
377
+
378
+ @storage_path = path.join("#{prefix}dead_tasks-#{STORAGE_VERSION}")
379
+ @tmp_storage_path = Pathname("#{@storage_path}.tmp")
380
+ @lock_file = File.open(path.join("#{prefix}dead_tasks.lock"), File::WRONLY | File::CREAT, 0o644)
381
+ @locked = false
382
+
383
+ File.open(@storage_path, File::WRONLY | File::CREAT | File::BINARY, 0o644) {}
384
+ sync_storage_directory
385
+ end
187
386
 
188
- def generate_task_id
189
- @task_id_i += 1
190
- "#{@task_id_base}-#{@task_id_i}"
191
- end
387
+ # Persist a failed task and its final error details.
388
+ # @param task_id [String] id assigned when the task was enqueued
389
+ # @param context [Object] serializable context needed to replay the task
390
+ # @param exception [Exception] error raised by the final attempt
391
+ # @param task_class [Class, String] task class or its name
392
+ # @param attempts [Integer] number of processing attempts made
393
+ # @return [String] the persisted task id
394
+ # @raise [Rage::Deferred::DeadTasksLockTimeout] if the store cannot be locked
395
+ def add(task_id, context, exception, task_class:, attempts:)
396
+ record = {
397
+ id: task_id,
398
+ task_class: task_class.to_s,
399
+ attempts: attempts.to_i,
400
+ # the timestamp the task was originally enqueued at is a part of its id
401
+ enqueued_at: task_id.to_s.split("-").first.to_i,
402
+ failed_at: Time.now.to_i,
403
+ exception_class: exception.class.name,
404
+ exception_message: exception.message.to_s,
405
+ backtrace: exception.backtrace&.first(BACKTRACE_LIMIT) || [],
406
+ # the context is stored as an opaque blob so that reading the record never depends
407
+ # on the task class being loadable in the process that reads it
408
+ context: Marshal.dump(context)
409
+ }
410
+
411
+ entry = build_entry(task_id, record)
412
+
413
+ with_lock("add a task to") do
414
+ File.open(@storage_path, File::RDWR | File::APPEND | File::BINARY) do |storage|
415
+ repair_torn_tail(storage)
416
+ storage.write(entry)
417
+ storage.fsync
418
+ end
419
+
420
+ task_id
421
+ end
422
+ end
192
423
 
193
- def create_storage
194
- file = @storage_path.join("#{@storage_prefix}-#{Time.now.strftime("%Y%m%d")}-#{Process.pid}-#{rand(0x100000000).to_s(36)}")
424
+ # Return dead task records, newest first.
425
+ # @param limit [Integer, nil] maximum number of records to return, or all records if nil
426
+ # @param offset [Integer] number of newest records to skip
427
+ # @return [Array<Hash>] task records that could be read successfully
428
+ # @raise [Rage::Deferred::DeadTasksLockTimeout] if the store cannot be locked
429
+ def list(limit: nil, offset: 0)
430
+ records = read_records.reverse
431
+ records = records.drop(offset) if offset > 0
432
+ records = records.first(limit) if limit
433
+
434
+ records
435
+ end
195
436
 
196
- file.open("a+b").tap { |f| f.flock(File::LOCK_EX) }
197
- end
437
+ # Find a dead task by its id.
438
+ # @param id [String] persisted task id
439
+ # @return [Hash, nil] the task record, or nil when no record matches
440
+ # @raise [Rage::Deferred::DeadTasksLockTimeout] if the store cannot be locked
441
+ def find(id)
442
+ read_records.find { |record| record[:id] == id }
443
+ end
198
444
 
199
- def write_to_storage(content, adjust_size_limit: false)
200
- @storage.write(content)
201
- @storage_has_changes = true
445
+ # Permanently delete the records with the given ids.
446
+ # @param ids [String, Array<String>] one or more persisted task ids
447
+ # @return [Integer] number of distinct records deleted
448
+ # @raise [Rage::Deferred::DeadTasksLockTimeout] if the store cannot be locked
449
+ def remove(ids)
450
+ ids = Array(ids)
451
+ return 0 if ids.empty?
452
+
453
+ index = {}
454
+ ids.each { |id| index[id] = true }
455
+
456
+ with_lock("delete tasks from") do
457
+ removed_ids = {}
458
+
459
+ File.open(@tmp_storage_path, File::WRONLY | File::CREAT | File::TRUNC | File::BINARY, 0o644) do |tmp|
460
+ File.open(@storage_path, File::RDONLY | File::BINARY) do |storage|
461
+ storage.each_line do |entry|
462
+ id = entry_id(entry)
463
+
464
+ if id.nil?
465
+ next # drop corrupted records; they can neither be listed nor deleted otherwise
466
+ elsif index[id]
467
+ removed_ids[id] = true
468
+ else
469
+ tmp.write(entry)
470
+ end
471
+ end
472
+ end
473
+
474
+ tmp.fsync unless removed_ids.empty?
475
+ end
476
+
477
+ removed_count = removed_ids.length
478
+
479
+ if removed_count > 0
480
+ File.rename(@tmp_storage_path, @storage_path)
481
+ sync_storage_directory
482
+ else
483
+ File.unlink(@tmp_storage_path)
484
+ end
485
+
486
+ removed_count
487
+ end
488
+ end
202
489
 
203
- @storage_size += content.bytesize
204
- @should_rotate = true if @storage_size >= @storage_size_limit
490
+ private
205
491
 
206
- if adjust_size_limit
207
- # if the data copied from recovered storages or during the rotation takes up most of the storage, we might
208
- # end up in an infinite rotation loop; instead, we dynamically increase the storage size limit
209
- if @storage_size * STORAGE_SIZE_INCREASE_RATIO >= @storage_size_limit
210
- @storage_size_limit *= STORAGE_SIZE_INCREASE_RATIO
211
- @should_rotate = false
492
+ # Persist changes to the live file's directory entry, including file creation and replacement.
493
+ # @return [void]
494
+ def sync_storage_directory
495
+ File.open(@storage_path.dirname, File::RDONLY, &:fsync)
496
+ end
497
+
498
+ # Remove an incomplete final entry left by an interrupted append.
499
+ # @param storage [File] store opened for reading and writing
500
+ # @return [void]
501
+ def repair_torn_tail(storage)
502
+ storage.seek(0, IO::SEEK_END)
503
+ end_position = storage.pos
504
+ return if end_position == 0
505
+
506
+ storage.seek(-1, IO::SEEK_END)
507
+ return if storage.read(1) == "\n"
508
+
509
+ position = end_position
510
+ truncate_at = 0
511
+
512
+ while position > 0
513
+ chunk_start = [position - TAIL_SCAN_CHUNK_SIZE, 0].max
514
+ storage.seek(chunk_start, IO::SEEK_SET)
515
+ chunk = storage.read(position - chunk_start)
516
+
517
+ if (newline_index = chunk.rindex("\n"))
518
+ truncate_at = chunk_start + newline_index + 1
519
+ break
520
+ end
521
+
522
+ position = chunk_start
212
523
  end
524
+
525
+ storage.truncate(truncate_at)
213
526
  end
214
- end
215
527
 
216
- def rotate_storage
217
- old_storage = @storage
218
- @storage = nil # in case `create_storage` ends up blocking the fiber
219
-
220
- # create a new storage and update internal state;
221
- # after this point all new tasks will be written to the new storage
222
- @should_rotate = false
223
- @storage_size = 0
224
- @storage_size_limit = DEFAULT_STORAGE_SIZE_LIMIT
225
- @storage = create_storage
226
-
227
- # copy delayed tasks to the new storage in batches
228
- @delayed_tasks.keys.each_slice(100) do |task_ids|
229
- entries = task_ids.filter_map do |task_id|
230
- # don't copy the task if it has already been processed during the rotation
231
- next unless @delayed_tasks.has_key?(task_id)
232
-
233
- serialized_task, publish_at = @delayed_tasks[task_id]
234
- build_add_entry(task_id, serialized_task, publish_at)
528
+ # Read valid records, keeping only the latest entry for each task id.
529
+ # Corrupted or unreadable records are reported and skipped.
530
+ # @return [Array<Hash>]
531
+ # @raise [Rage::Deferred::DeadTasksLockTimeout] if the store cannot be locked
532
+ def read_records
533
+ entries = with_lock("read tasks from") do
534
+ result, corrupted_count = {}, 0
535
+
536
+ File.open(@storage_path, File::RDONLY | File::BINARY) do |storage|
537
+ storage.each_line(chomp: true) do |entry|
538
+ id = entry_id(entry)
539
+
540
+ if id.nil?
541
+ corrupted_count += 1
542
+ next
543
+ end
544
+
545
+ # the same task can be dead-lettered more than once if the worker crashed
546
+ # before the task was removed from the write-ahead log
547
+ result.delete(id)
548
+ result[id] = entry
549
+ end
550
+ end
551
+
552
+ if corrupted_count != 0
553
+ puts "WARNING: Detected #{corrupted_count} corrupted dead-lettered task(s)"
554
+ end
555
+
556
+ result
557
+ end
558
+
559
+ entries.filter_map do |id, entry|
560
+ _, _, _, serialized_record = entry.split(":", 4)
561
+ Marshal.load(serialized_record.undump)
562
+ rescue ArgumentError, NameError, TypeError => e
563
+ puts "ERROR: Can't deserialize the dead-lettered task with id #{id}: (#{e.class}) #{e.message}"
564
+ nil
235
565
  end
566
+ end
236
567
 
237
- write_to_storage(entries.join, adjust_size_limit: true)
568
+ # Validate a stored entry and extract its task id.
569
+ # @param entry [String] serialized entry, optionally ending with a newline
570
+ # @return [String, nil] task id, or nil if the entry is malformed or corrupted
571
+ def entry_id(entry)
572
+ entry = entry.chomp
238
573
 
239
- Fiber.pause
574
+ signature, payload = entry[0...ENTRY_CRC_HEX_WIDTH], entry[(ENTRY_CRC_HEX_WIDTH + 1)..]
575
+ return if signature.nil? || payload.nil? || !payload.start_with?("#{ENTRY_OP}:")
576
+ return unless signature == Zlib.crc32(payload).to_s(16).rjust(ENTRY_CRC_HEX_WIDTH, "0")
577
+
578
+ id_start = ENTRY_CRC_HEX_WIDTH + 1 + ENTRY_OP.length + 1
579
+ separator_index = entry.index(":", id_start)
580
+ entry[id_start...separator_index] if separator_index
240
581
  end
241
582
 
242
- # delete the old storage ensuring the copied data has already been written to disk
243
- Iodine.run_after(@fsync_frequency) do
244
- cleanup_storage(old_storage)
583
+ # Serialize a task record as a checksummed, newline-delimited entry.
584
+ # @param id [String] persisted task id
585
+ # @param record [Hash] task data to serialize
586
+ # @return [String] encoded storage entry
587
+ def build_entry(id, record)
588
+ entry = "#{ENTRY_OP}:#{id}:#{Marshal.dump(record).dump}"
589
+ crc = Zlib.crc32(entry).to_s(16).rjust(ENTRY_CRC_HEX_WIDTH, "0")
590
+
591
+ "#{crc}:#{entry}\n"
245
592
  end
246
- end
247
593
 
248
- def build_add_entry(task_id, serialized_task, publish_at)
249
- entry = "add:#{task_id}:#{publish_at || DEFAULT_PUBLISH_AT}:#{serialized_task}"
250
- crc = Zlib.crc32(entry).to_s(16).rjust(8, "0")
594
+ # Run an operation while holding the process-wide and file-system locks.
595
+ # @param operation [String] action used to describe a lock timeout
596
+ # @yieldreturn [Object] result returned by the protected operation
597
+ # @return [Object] the block result
598
+ # @raise [Rage::Deferred::DeadTasksLockTimeout] if the store cannot be locked
599
+ def with_lock(operation)
600
+ attempts = 0
251
601
 
252
- "#{crc}:#{entry}\n"
253
- end
602
+ until !@locked && @lock_file.flock(File::LOCK_EX | File::LOCK_NB)
603
+ attempts += 1
254
604
 
255
- def build_remove_entry(task_id)
256
- entry = "rem:#{task_id}"
257
- crc = Zlib.crc32(entry).to_s(16).rjust(8, "0")
605
+ if attempts == LOCK_MAX_ATTEMPTS
606
+ raise Rage::Deferred::DeadTasksLockTimeout, "Could not lock the dead tasks store to #{operation} it"
607
+ end
258
608
 
259
- "#{crc}:#{entry}\n"
260
- end
609
+ # `sleep` is handled by the fiber scheduler and yields the fiber instead of the worker
610
+ sleep [LOCK_RETRY_INTERVAL * attempts, LOCK_MAX_RETRY_INTERVAL].min
611
+ end
261
612
 
262
- def recover_tasks(storage)
263
- # copy records to the main storage
264
- while (content = storage.read(262_144))
265
- write_to_storage(content, adjust_size_limit: true)
266
- end
613
+ @locked = true
267
614
 
268
- Iodine.run_after(@fsync_frequency) do
269
- cleanup_storage(storage)
615
+ begin
616
+ yield
617
+ ensure
618
+ @lock_file.flock(File::LOCK_UN)
619
+ @locked = false
620
+ end
270
621
  end
271
622
  end
272
-
273
- def cleanup_storage(storage)
274
- path = storage.path
275
- storage.close
276
- File.unlink(path) if File.exist?(path)
277
- end
278
623
  end