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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -1
- data/CONTRIBUTING.md +10 -1
- data/README.md +1 -1
- data/exe/rage +1 -1
- data/lib/rage/cable/channel.rb +7 -11
- data/lib/rage/cli/base.rb +27 -0
- data/lib/rage/cli/code_generator.rb +58 -0
- data/lib/rage/cli/new_app_generator.rb +66 -0
- data/lib/rage/cli/openapi.rb +20 -0
- data/lib/rage/cli/skills.rb +222 -220
- data/lib/rage/cli.rb +12 -143
- data/lib/rage/code_loader.rb +5 -1
- data/lib/rage/configuration.rb +73 -2
- data/lib/rage/cookies.rb +11 -9
- data/lib/rage/daemon.rb +5 -2
- data/lib/rage/deferred/backends/disk.rb +544 -199
- data/lib/rage/deferred/backends/nil.rb +16 -2
- data/lib/rage/deferred/deferred.rb +5 -1
- data/lib/rage/deferred/queue.rb +8 -3
- data/lib/rage/deferred/task.rb +1 -0
- data/lib/rage/fiber_scheduler.rb +17 -13
- data/lib/rage/hooks.rb +6 -1
- data/lib/rage/logger/logger.rb +55 -4
- data/lib/rage/openapi/index.html.erb +1 -1
- data/lib/rage/openapi/openapi.rb +14 -5
- data/lib/rage/openapi/parsers/ext/blueprinter.rb +20 -12
- data/lib/rage/router/README.md +1 -1
- data/lib/rage/router/backend.rb +8 -4
- data/lib/rage/router/dsl.rb +5 -7
- data/lib/rage/tasks.rb +5 -0
- data/lib/rage/telemetry/capacity.rb +42 -0
- data/lib/rage/telemetry/telemetry.rb +19 -0
- data/lib/rage/version.rb +1 -1
- data/lib/rage-rb.rb +3 -0
- metadata +8 -3
|
@@ -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
|
-
# * `
|
|
10
|
-
# * `
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
114
|
+
@storage = storage_files[0]
|
|
115
|
+
@recovered_storages = storage_files[1..] if storage_files.length > 1
|
|
35
116
|
end
|
|
36
|
-
end
|
|
37
117
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
end.max.to_i
|
|
161
|
+
end
|
|
60
162
|
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
@
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
236
|
+
tasks.filter_map do |task_id, entry|
|
|
237
|
+
_, _, _, serialized_publish_at, serialized_task = entry.split(":", 5)
|
|
101
238
|
|
|
102
|
-
|
|
103
|
-
write_to_storage(entry)
|
|
239
|
+
task = Marshal.load(serialized_task.undump)
|
|
104
240
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
end
|
|
257
|
+
private
|
|
113
258
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
259
|
+
def generate_task_id
|
|
260
|
+
@task_id_i += 1
|
|
261
|
+
"#{@task_id_base}-#{@task_id_i}"
|
|
262
|
+
end
|
|
118
263
|
|
|
119
|
-
|
|
120
|
-
@
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
270
|
+
def write_to_storage(content, adjust_size_limit: false)
|
|
271
|
+
@storage.write(content)
|
|
272
|
+
@storage_has_changes = true
|
|
128
273
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
287
|
+
def rotate_storage
|
|
288
|
+
old_storage = @storage
|
|
289
|
+
@storage = nil # in case `create_storage` ends up blocking the fiber
|
|
141
290
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
corrupted_tasks_count += 1
|
|
149
|
-
next
|
|
310
|
+
Fiber.pause
|
|
150
311
|
end
|
|
151
312
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
323
|
+
"#{crc}:#{entry}\n"
|
|
324
|
+
end
|
|
167
325
|
|
|
168
|
-
|
|
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
|
-
|
|
330
|
+
"#{crc}:#{entry}\n"
|
|
331
|
+
end
|
|
171
332
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
339
|
+
Iodine.run_after(@fsync_frequency) do
|
|
340
|
+
cleanup_storage(storage)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
179
343
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
@
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
@
|
|
201
|
-
@
|
|
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
|
-
|
|
204
|
-
@should_rotate = true if @storage_size >= @storage_size_limit
|
|
490
|
+
private
|
|
205
491
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
602
|
+
until !@locked && @lock_file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
603
|
+
attempts += 1
|
|
254
604
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|