lowkiq 1.0.0 → 1.0.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +19 -19
- data/LICENSE.md +13 -3
- data/README.md +378 -308
- data/README.ru.md +645 -0
- data/docker-compose.yml +1 -1
- data/lib/lowkiq.rb +6 -2
- data/lib/lowkiq/extend_tracker.rb +1 -1
- data/lib/lowkiq/queue/fetch.rb +2 -2
- data/lib/lowkiq/queue/keys.rb +16 -4
- data/lib/lowkiq/queue/queue.rb +103 -55
- data/lib/lowkiq/script.rb +42 -0
- data/lib/lowkiq/server.rb +4 -0
- data/lib/lowkiq/shard_handler.rb +3 -3
- data/lib/lowkiq/version.rb +1 -1
- data/lowkiq.gemspec +3 -2
- metadata +12 -10
- data/lib/lowkiq/queue/marshal.rb +0 -23
data/README.ru.md
ADDED
@@ -0,0 +1,645 @@
|
|
1
|
+
[![Gem Version](https://badge.fury.io/rb/lowkiq.svg)](https://badge.fury.io/rb/lowkiq)
|
2
|
+
|
3
|
+
# Lowkiq
|
4
|
+
|
5
|
+
Упорядоченная обработка фоновых задач.
|
6
|
+
|
7
|
+
![dashboard](doc/dashboard.png)
|
8
|
+
|
9
|
+
* [Rationale](#rationale)
|
10
|
+
* [Description](#description)
|
11
|
+
* [Sidekiq](#sidekiq)
|
12
|
+
* [Очередь](#%D0%BE%D1%87%D0%B5%D1%80%D0%B5%D0%B4%D1%8C)
|
13
|
+
+ [Алгоритм расчета retry_count и perform_in](#%D0%B0%D0%BB%D0%B3%D0%BE%D1%80%D0%B8%D1%82%D0%BC-%D1%80%D0%B0%D1%81%D1%87%D0%B5%D1%82%D0%B0-retry_count-%D0%B8-perform_in)
|
14
|
+
+ [Правило слияния задач](#%D0%BF%D1%80%D0%B0%D0%B2%D0%B8%D0%BB%D0%BE-%D1%81%D0%BB%D0%B8%D1%8F%D0%BD%D0%B8%D1%8F-%D0%B7%D0%B0%D0%B4%D0%B0%D1%87)
|
15
|
+
* [Install](#install)
|
16
|
+
* [Api](#api)
|
17
|
+
* [Ring app](#ring-app)
|
18
|
+
* [Настройка](#%D0%BD%D0%B0%D1%81%D1%82%D1%80%D0%BE%D0%B9%D0%BA%D0%B0)
|
19
|
+
* [Запуск](#%D0%B7%D0%B0%D0%BF%D1%83%D1%81%D0%BA)
|
20
|
+
* [Остановка](#%D0%BE%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BA%D0%B0)
|
21
|
+
* [Debug](#debug)
|
22
|
+
* [Development](#development)
|
23
|
+
* [Исключения](#%D0%B8%D1%81%D0%BA%D0%BB%D1%8E%D1%87%D0%B5%D0%BD%D0%B8%D1%8F)
|
24
|
+
* [Rails integration](#rails-integration)
|
25
|
+
* [Splitter](#splitter)
|
26
|
+
* [Scheduler](#scheduler)
|
27
|
+
* [Рекомендации по настройке](#%D1%80%D0%B5%D0%BA%D0%BE%D0%BC%D0%B5%D0%BD%D0%B4%D0%B0%D1%86%D0%B8%D0%B8-%D0%BF%D0%BE-%D0%BD%D0%B0%D1%81%D1%82%D1%80%D0%BE%D0%B9%D0%BA%D0%B5)
|
28
|
+
+ [`SomeWorker.shards_count`](#someworkershards_count)
|
29
|
+
+ [`SomeWorker.max_retry_count`](#someworkermax_retry_count)
|
30
|
+
* [Изменение количества шардов воркера](#%D0%B8%D0%B7%D0%BC%D0%B5%D0%BD%D0%B5%D0%BD%D0%B8%D0%B5-%D0%BA%D0%BE%D0%BB%D0%B8%D1%87%D0%B5%D1%81%D1%82%D0%B2%D0%B0-%D1%88%D0%B0%D1%80%D0%B4%D0%BE%D0%B2-%D0%B2%D0%BE%D1%80%D0%BA%D0%B5%D1%80%D0%B0)
|
31
|
+
|
32
|
+
## Rationale
|
33
|
+
|
34
|
+
При использовании Sidekiq мы столкнулись с проблемами при обработке сообщений от сторонней системы.
|
35
|
+
|
36
|
+
Скажем, сообщение представляет собой данные заказа в определенный момент времени.
|
37
|
+
При изменении атрибутов или статуса отправляется новое сообщение сторонней системой.
|
38
|
+
Заказы обновляются часто и в очереди рядом находятся сообщения, касающиеся одного и того же заказа.
|
39
|
+
|
40
|
+
Sidekiq не гарантирует строгого порядка сообщений, т.к. очередь обрабатывается в несколько потоков.
|
41
|
+
Например, пришло 2 сообщения: M1 и M2.
|
42
|
+
Sidekiq обработчики начинают обрабатывать их параллельно,
|
43
|
+
при этом M2 может обработаться раньше M1.
|
44
|
+
|
45
|
+
Параллельная обработка данных одного заказа приводит к:
|
46
|
+
|
47
|
+
+ dead locks
|
48
|
+
+ затиранию новых данных старыми
|
49
|
+
|
50
|
+
Lowkiq призван устранить эти проблемы, исключая параллельность обработки сообщений в рамках одной сущности.
|
51
|
+
|
52
|
+
## Description
|
53
|
+
|
54
|
+
Очереди надежны. Lowkiq сохраняет данные об обрабатываемой задаче и при запуске переносит
|
55
|
+
незавершенные задачи обратно в очередь.
|
56
|
+
|
57
|
+
Задачи в очереди отсортированы по заданному времени исполнения, т.е. это не FIFO очереди.
|
58
|
+
|
59
|
+
Каждая задача имеет идентификатор. Очереди гарантируют, что не может быть ситуации,
|
60
|
+
когда несколько потоков обрабатывают задачи с одинаковыми идентификаторами.
|
61
|
+
|
62
|
+
Каждая очередь разбивается на постоянный набор шардов.
|
63
|
+
На основе идентификатора задачи выбирается шард, в который попадет задача.
|
64
|
+
Таким образом задачи с одним идентификатором всегда попадают в один и тот же шард.
|
65
|
+
Задачи шарда всегда обрабатываются одним и тем же потоком.
|
66
|
+
Это гарантирует порядок обработки задач с одинаковым идентификатором и исключает возможность блокировок.
|
67
|
+
|
68
|
+
Кроме идентификатора задача имеет полезную нагрузку или данные задачи (payload).
|
69
|
+
Для задач с одинаковым идентификаторм происходит слияние полезных нагрузок.
|
70
|
+
Таким образом одновременно в обработку попадают все накопленные полезные нагрузки задачи.
|
71
|
+
Это полезно, если нужно обработать только последнее сообщение и отбросить все предыдущие.
|
72
|
+
|
73
|
+
Каждой очереди соответствует воркер, содержащий логику обработки задачи.
|
74
|
+
|
75
|
+
Для обработки всех задач всех очередей используется фиксированное количество тредов.
|
76
|
+
Добавление или удаление очередей или их шардов не приводит к изменению числа тредов.
|
77
|
+
|
78
|
+
## Sidekiq
|
79
|
+
|
80
|
+
Если для ваших задач подходит Sidekiq - используйте его.
|
81
|
+
|
82
|
+
Если вы используете плагины вроде
|
83
|
+
[sidekiq-grouping](https://github.com/gzigzigzeo/sidekiq-grouping),
|
84
|
+
[sidekiq-unique-jobs](https://github.com/mhenrixon/sidekiq-unique-jobs),
|
85
|
+
[sidekiq-merger](https://github.com/dtaniwaki/sidekiq-merger)
|
86
|
+
или реализуете собственный механизм блокировок, то стоит рассмотреть Lowkiq.
|
87
|
+
|
88
|
+
Например, sidekiq-grouping предварительно накапливает пачку задач, ставит ее в очередь и начинает накапливать следующую.
|
89
|
+
При таком подходе случается ситуация, когда в очереди находятся 2 пачки с данными одного заказа.
|
90
|
+
Эти пачки начинают обрабатываться одновременно разными тредами, что приводит к изначальной проблеме.
|
91
|
+
|
92
|
+
Lowkiq изначально проектировался так, чтобы не использовать любые блокировки.
|
93
|
+
|
94
|
+
Кроме того, в Lowkiq очереди изначально надежны. Только Sidekiq Pro или плагины добавляют такую функциональность.
|
95
|
+
|
96
|
+
Этот [бенчмарк](examples/benchmark) показывает накладные расходы на взаимодействие с redis.
|
97
|
+
Для 5 threads, 100'000 blank jobs получились результаты:
|
98
|
+
|
99
|
+
+ lowkiq: 214 sec или 2,14 мс на задачу
|
100
|
+
+ sidekiq: 29 sec или 0,29 мс на задачу
|
101
|
+
|
102
|
+
Эта разница связана с принципиально различным устройством очередей.
|
103
|
+
Sidekiq использует один список для всех воркеров и извлекает задачу целиком за O(1).
|
104
|
+
Lowkiq использует несколько типов данных, включая сортированные множества для хранения идентификаторов задач.
|
105
|
+
Таким образом только получение идентификатора задачи занимает O(log(N)).
|
106
|
+
|
107
|
+
## Очередь
|
108
|
+
|
109
|
+
Каждая задача в очереди имеет аттрибуты:
|
110
|
+
|
111
|
+
+ `id` - идентификатор задачи (строка)
|
112
|
+
+ `payloads` - сортированное множество payload'ов (объекты) по их score (вещественное число)
|
113
|
+
+ `perform_in` - запланированное время начала иполнения задачи (unix timestamp, вещественное число)
|
114
|
+
+ `retry_count` - количество совершённых повторов задачи (вещественное число)
|
115
|
+
|
116
|
+
`id` может быть, например, идентификатором реплицируемой сущности
|
117
|
+
`payloads` - множество,
|
118
|
+
получаемое в результате группировки полезной нагрузки задачи по `id` и отсортированное по ее `score`.
|
119
|
+
`payload` может быть ruby объектом, т.к. сериализуется с помощью `Marshal.dump`.
|
120
|
+
`score` может быть датой (unix timestamp) создания `payload`
|
121
|
+
или ее монотонно увеличивающимся номером версии.
|
122
|
+
По умолчанию - текущий unix timestamp.
|
123
|
+
По умолчанию `perform_in` - текущий unix timestamp.
|
124
|
+
`retry_count` для новой необработанной задачи равен `-1`, для упавшей один раз - `0`,
|
125
|
+
т.е. считаются не совершённые, а запланированные повторы.
|
126
|
+
|
127
|
+
Выполнение задачи может закончиться неудачей.
|
128
|
+
В этом случае ее `retry_count` инкрементируется и по заданной формуле вычисляется новый `perform_in`,
|
129
|
+
и она ставится обратно в очередь.
|
130
|
+
|
131
|
+
В случае, когда `retry_count` становится `>=` `max_retry_count`
|
132
|
+
элемент payloads с наименьшим(старейшим) score перемещается в морг,
|
133
|
+
а оставшиеся элементы помещаются обратно в очередь, при этом
|
134
|
+
`retry_count` и `perform_in` сбрасываются в `-1` и `now()` соответственно.
|
135
|
+
|
136
|
+
### Алгоритм расчета retry_count и perform_in
|
137
|
+
|
138
|
+
0. задача выполнилась и упала
|
139
|
+
1. `retry_count++`
|
140
|
+
2. `perform_in = now + retry_in(try_count)`
|
141
|
+
3. `if retry_count >= max_retry_count` задача перемещается в морг
|
142
|
+
|
143
|
+
| тип | `retry_count` | `perform_in` |
|
144
|
+
| --- | --- | --- |
|
145
|
+
| новая не выполнялась | -1 | задан или `now()` |
|
146
|
+
| новая упала | 0 | `now() + retry_in(0)` |
|
147
|
+
| повтор упал | 1 | `now() + retry_in(1)` |
|
148
|
+
|
149
|
+
Если `max_retry_count = 1`, то попытки прекращаются.
|
150
|
+
|
151
|
+
### Правило слияния задач
|
152
|
+
|
153
|
+
Когда применяется:
|
154
|
+
|
155
|
+
+ если в очереди была задача и добавляется еще одна с тем же id
|
156
|
+
+ если при обработке возникла ошибка, а в очередь успели добавили задачу с тем же id
|
157
|
+
+ если задачу из морга поставили в очередь, а в очереди уже есть задача с тем же id
|
158
|
+
|
159
|
+
Алгоритм:
|
160
|
+
|
161
|
+
+ payloads объединяются, при этом выбирается минимальный score,
|
162
|
+
т.е. для одинаковых payload выигрывает самая старая
|
163
|
+
+ если объединяется новая и задача из очереди,
|
164
|
+
то `perform_in` и `retry_count` берутся из задачи из очереди
|
165
|
+
+ если объединяется упавшая задача и задача из очереди,
|
166
|
+
то `perform_in` и `retry_count` берутся из упавшей
|
167
|
+
+ если объединяется задача из морга и задача из очереди,
|
168
|
+
то `perform_in = now()`, `retry_count = -1`
|
169
|
+
|
170
|
+
Пример:
|
171
|
+
|
172
|
+
```
|
173
|
+
# v1 - первая версия, v2 - вторая
|
174
|
+
# #{"v1": 1} - сортированное множество одного элемента, payload - "v1", score - 1
|
175
|
+
|
176
|
+
# задача в очереди
|
177
|
+
{ id: "1", payloads: #{"v1": 1, "v2": 2}, retry_count: 0, perform_in: 1536323288 }
|
178
|
+
# добавляемая задача
|
179
|
+
{ id: "1", payloads: #{"v2": 3, "v3": 4}, retry_count: -1, perform_in: 1536323290 }
|
180
|
+
|
181
|
+
# результат
|
182
|
+
{ id: "1", payloads: #{"v1": 1, "v2": 3, "v3": 4}, retry_count: 0, perform_in: 1536323288 }
|
183
|
+
```
|
184
|
+
|
185
|
+
Морг - часть очереди. Задачи в морге не обрабатываются.
|
186
|
+
Задача в морге имеет следующие атрибуты:
|
187
|
+
|
188
|
+
+ id - идентификатор задачи
|
189
|
+
+ payloads
|
190
|
+
|
191
|
+
Задачи в морге можно отсортировать по дате изменения или id.
|
192
|
+
|
193
|
+
Задачу из морга можно переместить в очередь. При этом для нее `retry_count = 0`, `perform_in = now()`.
|
194
|
+
|
195
|
+
## Install
|
196
|
+
|
197
|
+
```
|
198
|
+
# Gemfile
|
199
|
+
|
200
|
+
gem 'lowkiq'
|
201
|
+
```
|
202
|
+
|
203
|
+
Redis версии >= 3.2.
|
204
|
+
|
205
|
+
## Api
|
206
|
+
|
207
|
+
```ruby
|
208
|
+
module ATestWorker
|
209
|
+
extend Lowkiq::Worker
|
210
|
+
|
211
|
+
self.shards_count = 24
|
212
|
+
self.batch_size = 10
|
213
|
+
self.max_retry_count = 5
|
214
|
+
|
215
|
+
def self.retry_in(count)
|
216
|
+
10 * (count + 1) # (i.e. 10, 20, 30, 40, 50)
|
217
|
+
end
|
218
|
+
|
219
|
+
def self.perform(payloads_by_id)
|
220
|
+
# payloads_by_id - хеш
|
221
|
+
payloads_by_id.each do |id, payloads|
|
222
|
+
# id - идентификатор задачи
|
223
|
+
# payloads отсортированы по score, от старых к новым (от минимальных к максимальным)
|
224
|
+
payloads.each do |payload|
|
225
|
+
do_some_work(id, payload)
|
226
|
+
end
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
```
|
231
|
+
|
232
|
+
Значения по умолчанию:
|
233
|
+
|
234
|
+
```ruby
|
235
|
+
self.shards_count = 5
|
236
|
+
self.batch_size = 1
|
237
|
+
self.max_retry_count = 25
|
238
|
+
self.queue_name = self.name
|
239
|
+
|
240
|
+
# i.e. 15, 16, 31, 96, 271, ... seconds + a random amount of time
|
241
|
+
def retry_in(retry_count)
|
242
|
+
(retry_count ** 4) + 15 + (rand(30) * (retry_count + 1))
|
243
|
+
end
|
244
|
+
```
|
245
|
+
|
246
|
+
```ruby
|
247
|
+
ATestWorker.perform_async [
|
248
|
+
{ id: 0 },
|
249
|
+
{ id: 1, payload: { attr: 'v1' } },
|
250
|
+
{ id: 2, payload: { attr: 'v1' }, score: Time.now.to_i, perform_in: Time.now.to_i },
|
251
|
+
]
|
252
|
+
# payload по умолчанию равен ""
|
253
|
+
# score и perform_in по умолчанию равны Time.now.to_i
|
254
|
+
```
|
255
|
+
|
256
|
+
Вы можете переопределить `perform_async` и вычислять `id`, `score` и `perform_in` в воркере:
|
257
|
+
|
258
|
+
```ruby
|
259
|
+
module ATestWorker
|
260
|
+
extend Lowkiq::Worker
|
261
|
+
|
262
|
+
def self.perform_async(jobs)
|
263
|
+
jobs.each do |job|
|
264
|
+
job.merge! id: job[:payload][:id]
|
265
|
+
end
|
266
|
+
super
|
267
|
+
end
|
268
|
+
|
269
|
+
def self.perform(payloads_by_id)
|
270
|
+
#...
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
ATestWorker.perform_async 1000.times.map { |id| { payload: {id: id} } }
|
275
|
+
```
|
276
|
+
|
277
|
+
## Ring app
|
278
|
+
|
279
|
+
`Lowkiq::Web` - ring app.
|
280
|
+
|
281
|
+
+ `/` - dashboard
|
282
|
+
+ `/api/v1/stats` - длина очереди, длина морга, лаг для каждого воркера и суммарно
|
283
|
+
|
284
|
+
## Настройка
|
285
|
+
|
286
|
+
Опции и значения по умолчанию:
|
287
|
+
|
288
|
+
+ `Lowkiq.poll_interval = 1` - задержка в секундах между опросами очереди на предмет новых задач.
|
289
|
+
Используется только если на предыдущей итерации очередь оказалась пуста или случилась ошибка.
|
290
|
+
+ `Lowkiq.threads_per_node = 5` - кол-во тредов для каждой ноды.
|
291
|
+
+ `Lowkiq.redis = ->() { Redis.new url: ENV.fetch('REDIS_URL') }` - настройка redis.
|
292
|
+
+ `Lowkiq.client_pool_size = 5` - размер пула редиса для постановки задач в очередь.
|
293
|
+
+ `Lowkiq.pool_timeout = 5` - таймаут клиентского и серверного пула редиса
|
294
|
+
+ `Lowkiq.server_middlewares = []` - список middleware, оборачивающих воркер.
|
295
|
+
+ `Lowkiq.on_server_init = ->() {}` - выполнения кода при инициализации сервера.
|
296
|
+
+ `Lowkiq.build_scheduler = ->() { Lowkiq.build_lag_scheduler }` - планировщик.
|
297
|
+
+ `Lowkiq.build_splitter = ->() { Lowkiq.build_default_splitter }` - сплиттер.
|
298
|
+
+ `Lowkiq.last_words = ->(ex) {}` - обработчик исключений, потомков `StandardError`, вызвавших остановку процесса.
|
299
|
+
|
300
|
+
```ruby
|
301
|
+
$logger = Logger.new(STDOUT)
|
302
|
+
|
303
|
+
Lowkiq.server_middlewares << -> (worker, batch, &block) do
|
304
|
+
$logger.info "Started job for #{worker} #{batch}"
|
305
|
+
block.call
|
306
|
+
$logger.info "Finished job for #{worker} #{batch}"
|
307
|
+
end
|
308
|
+
|
309
|
+
Lowkiq.server_middlewares << -> (worker, batch, &block) do
|
310
|
+
begin
|
311
|
+
block.call
|
312
|
+
rescue => e
|
313
|
+
$logger.error "#{e.message} #{worker} #{batch}"
|
314
|
+
raise e
|
315
|
+
end
|
316
|
+
end
|
317
|
+
```
|
318
|
+
|
319
|
+
## Запуск
|
320
|
+
|
321
|
+
`lowkiq -r ./path_to_app`
|
322
|
+
|
323
|
+
`path_to_app.rb` должен загрузить приложение. [Пример](examples/dummy/lib/app.rb).
|
324
|
+
|
325
|
+
Ленивая загрузка модулей воркеров недопустима.
|
326
|
+
Используйте для предварительной загрузки модулей
|
327
|
+
`require` или [`require_dependency`](https://api.rubyonrails.org/classes/ActiveSupport/Dependencies/Loadable.html#method-i-require_dependency)
|
328
|
+
для Ruby on Rails.
|
329
|
+
|
330
|
+
## Остановка
|
331
|
+
|
332
|
+
Послать процессу TERM или INT (Ctrl-C).
|
333
|
+
Процесс будет ждать завершения всех задач.
|
334
|
+
|
335
|
+
Обратите внимание, если очередь пуста, процесс спит `poll_interval` секунд.
|
336
|
+
Таким образом завершится не позднее чем через `poll_interval` секунд.
|
337
|
+
|
338
|
+
## Debug
|
339
|
+
|
340
|
+
Получить trace всех тредов приложения:
|
341
|
+
|
342
|
+
```
|
343
|
+
kill -TTIN <pid>
|
344
|
+
cat /tmp/lowkiq_ttin.txt
|
345
|
+
```
|
346
|
+
|
347
|
+
## Development
|
348
|
+
|
349
|
+
```
|
350
|
+
docker-compose run --rm --service-port app bash
|
351
|
+
bundle
|
352
|
+
rspec
|
353
|
+
cd examples/dummy ; bundle exec ../../exe/lowkiq -r ./lib/app.rb
|
354
|
+
```
|
355
|
+
|
356
|
+
## Исключения
|
357
|
+
|
358
|
+
`StandardError` выброшенные воркером обрабатываются с помощью middleware.
|
359
|
+
Такие исключения не приводят к остановке процесса.
|
360
|
+
|
361
|
+
Все прочие исключения приводят к остановке процесса.
|
362
|
+
При этом Lowkiq дожидается выполнения задач другими тредами.
|
363
|
+
|
364
|
+
`StandardError` выброшенные вне воркера передаются в `Lowkiq.last_words`.
|
365
|
+
Например это происходит при потере соединения к Redis или при ошибке в коде Lowkiq.
|
366
|
+
|
367
|
+
## Rails integration
|
368
|
+
|
369
|
+
```ruby
|
370
|
+
# config/routes.rb
|
371
|
+
|
372
|
+
Rails.application.routes.draw do
|
373
|
+
# ...
|
374
|
+
mount Lowkiq::Web => '/lowkiq'
|
375
|
+
# ...
|
376
|
+
end
|
377
|
+
```
|
378
|
+
|
379
|
+
```ruby
|
380
|
+
# config/initializers/lowkiq.rb
|
381
|
+
|
382
|
+
# загружаем все lowkiq воркеры
|
383
|
+
Dir["#{Rails.root}/app/lowkiq_workers/**/*.rb"].each { |file| require_dependency file }
|
384
|
+
|
385
|
+
# конфигурация:
|
386
|
+
# Lowkiq.redis = -> { Redis.new url: ENV.fetch('LOWKIQ_REDIS_URL') }
|
387
|
+
# Lowkiq.threads_per_node = ENV.fetch('LOWKIQ_THREADS_PER_NODE').to_i
|
388
|
+
# Lowkiq.client_pool_size = ENV.fetch('LOWKIQ_CLIENT_POOL_SIZE').to_i
|
389
|
+
# ...
|
390
|
+
|
391
|
+
Lowkiq.server_middlewares << -> (worker, batch, &block) do
|
392
|
+
logger = Rails.logger
|
393
|
+
tag = "#{worker}-#{Thread.current.object_id}"
|
394
|
+
|
395
|
+
logger.tagged(tag) do
|
396
|
+
time_start = Time.now
|
397
|
+
logger.info "#{time_start} Started job, batch: #{batch}"
|
398
|
+
begin
|
399
|
+
block.call
|
400
|
+
rescue => e
|
401
|
+
logger.error e.message
|
402
|
+
raise e
|
403
|
+
ensure
|
404
|
+
time_end = Time.now
|
405
|
+
logger.info "#{time_end} Finished job, duration: #{time_end - time_start} sec"
|
406
|
+
end
|
407
|
+
end
|
408
|
+
end
|
409
|
+
|
410
|
+
# Sentry integration
|
411
|
+
Lowkiq.server_middlewares << -> (worker, batch, &block) do
|
412
|
+
opts = {
|
413
|
+
extra: {
|
414
|
+
lowkiq: {
|
415
|
+
worker: worker.name,
|
416
|
+
batch: batch,
|
417
|
+
}
|
418
|
+
}
|
419
|
+
}
|
420
|
+
|
421
|
+
Raven.capture opts do
|
422
|
+
block.call
|
423
|
+
end
|
424
|
+
end
|
425
|
+
|
426
|
+
# NewRelic integration
|
427
|
+
if defined? NewRelic
|
428
|
+
class NewRelicLowkiqMiddleware
|
429
|
+
include NewRelic::Agent::Instrumentation::ControllerInstrumentation
|
430
|
+
|
431
|
+
def call(worker, batch, &block)
|
432
|
+
opts = {
|
433
|
+
category: 'OtherTransaction/LowkiqJob',
|
434
|
+
class_name: worker.name,
|
435
|
+
name: :perform,
|
436
|
+
}
|
437
|
+
|
438
|
+
perform_action_with_newrelic_trace opts do
|
439
|
+
block.call
|
440
|
+
end
|
441
|
+
end
|
442
|
+
end
|
443
|
+
|
444
|
+
Lowkiq.server_middlewares << NewRelicLowkiqMiddleware.new
|
445
|
+
end
|
446
|
+
|
447
|
+
# Rails reloader, в том числе отвечает за высвобождение ActiveRecord коннектов
|
448
|
+
Lowkiq.server_middlewares << -> (worker, batch, &block) do
|
449
|
+
Rails.application.reloader.wrap do
|
450
|
+
block.call
|
451
|
+
end
|
452
|
+
end
|
453
|
+
|
454
|
+
Lowkiq.on_server_init = ->() do
|
455
|
+
[[ActiveRecord::Base, ActiveRecord::Base.configurations[Rails.env]]].each do |(klass, init_config)|
|
456
|
+
klass.connection_pool.disconnect!
|
457
|
+
config = init_config.merge 'pool' => Lowkiq.threads_per_node
|
458
|
+
klass.establish_connection(config)
|
459
|
+
end
|
460
|
+
end
|
461
|
+
```
|
462
|
+
|
463
|
+
Запуск: `bundle exec lowkiq -r ./config/environment.rb`
|
464
|
+
|
465
|
+
## Splitter
|
466
|
+
|
467
|
+
У каждого воркера есть несколько шардов:
|
468
|
+
|
469
|
+
```
|
470
|
+
# worker: shard ids
|
471
|
+
worker A: 0, 1, 2
|
472
|
+
worker B: 0, 1, 2, 3
|
473
|
+
worker C: 0
|
474
|
+
worker D: 0, 1
|
475
|
+
```
|
476
|
+
|
477
|
+
Lowkiq использует фиксированное кол-во тредов для обработки задач, следовательно нужно распределить шарды
|
478
|
+
между тредами. Этим занимается Splitter.
|
479
|
+
|
480
|
+
Чтобы определить набор шардов, которые будет обрабатывать тред, поместим их в один список:
|
481
|
+
|
482
|
+
```
|
483
|
+
A0, A1, A2, B0, B1, B2, B3, C0, D0, D1
|
484
|
+
```
|
485
|
+
|
486
|
+
Рассмотрим Default splitter, который равномерно распределяет шарды по тредам единственной ноды.
|
487
|
+
|
488
|
+
Если `threads_per_node` установлено в 3, то распределение будет таким:
|
489
|
+
|
490
|
+
```
|
491
|
+
# thread id: shards
|
492
|
+
t0: A0, B0, B3, D1
|
493
|
+
t1: A1, B1, C0
|
494
|
+
t2: A2, B2, D0
|
495
|
+
```
|
496
|
+
|
497
|
+
Помимо Default есть ByNode splitter. Он позволяет распределить нагрузку по нескольким процессам (нодам).
|
498
|
+
|
499
|
+
```
|
500
|
+
Lowkiq.build_splitter = -> () do
|
501
|
+
Lowkiq.build_by_node_splitter(
|
502
|
+
ENV.fetch('LOWKIQ_NUMBER_OF_NODES').to_i,
|
503
|
+
ENV.fetch('LOWKIQ_NODE_NUMBER').to_i
|
504
|
+
)
|
505
|
+
end
|
506
|
+
```
|
507
|
+
|
508
|
+
Таким образом, вместо одного процесса нужно запустить несколько и указать переменные окружения:
|
509
|
+
|
510
|
+
```
|
511
|
+
# process 0
|
512
|
+
LOWKIQ_NUMBER_OF_NODES=2 LOWKIQ_NODE_NUMBER=0 bundle exec lowkiq -r ./lib/app.rb
|
513
|
+
|
514
|
+
# process 1
|
515
|
+
LOWKIQ_NUMBER_OF_NODES=2 LOWKIQ_NODE_NUMBER=1 bundle exec lowkiq -r ./lib/app.rb
|
516
|
+
```
|
517
|
+
|
518
|
+
Отмечу, что общее количество тредов будет равно произведению `ENV.fetch('LOWKIQ_NUMBER_OF_NODES')` и `Lowkiq.threads_per_node`.
|
519
|
+
|
520
|
+
Вы можете написать свой сплиттер, если ваше приложение требует особого распределения шардов между тредами или нодами.
|
521
|
+
|
522
|
+
## Scheduler
|
523
|
+
|
524
|
+
Каждый тред обрабатывает набор шардов. За выбор шарда для обработки отвечает планировщик.
|
525
|
+
Каждый поток имеет свой собственный экземпляр планировщика.
|
526
|
+
|
527
|
+
Lowkiq имеет 2 планировщика на выбор.
|
528
|
+
Первый, `Seq` - последовательно перебирает шарды.
|
529
|
+
Второй, `Lag` - выбирает шард с самой старой задачей, т.е. стремится минимизировать лаг.
|
530
|
+
Используется по умолчанию.
|
531
|
+
|
532
|
+
Планировщик задается через настройки:
|
533
|
+
|
534
|
+
```
|
535
|
+
Lowkiq.build_scheduler = ->() { Lowkiq.build_seq_scheduler }
|
536
|
+
# или
|
537
|
+
Lowkiq.build_scheduler = ->() { Lowkiq.build_lag_scheduler }
|
538
|
+
```
|
539
|
+
|
540
|
+
## Рекомендации по настройке
|
541
|
+
|
542
|
+
### `SomeWorker.shards_count`
|
543
|
+
|
544
|
+
Сумма `shards_count` всех воркеров не должна быть меньше `Lowkiq.threads_per_node`
|
545
|
+
иначе треды будут простаивать.
|
546
|
+
|
547
|
+
Сумма `shards_count` всех воркеров может быть равна `Lowkiq.threads_per_node`.
|
548
|
+
В этом случае тред обрабатывает единственный шард. Это имеет смысл только при равномерной нагрузке на очереди.
|
549
|
+
|
550
|
+
Сумма `shards_count` всех воркеров может быть больше `Lowkiq.threads_per_node`.
|
551
|
+
В этом случае `shards_count` можно рассматривать в качестве приоритета.
|
552
|
+
Чем он выше, тем чаще задачи этой очереди будут обрабатываться.
|
553
|
+
|
554
|
+
Нет смысла устанавливать `shards_count` одного воркера больше чем `Lowkiq.threads_per_node`,
|
555
|
+
т.к. каждый тред будет обрабатывать более одного шарда этой очереди, что увеличит накладные расходы.
|
556
|
+
|
557
|
+
### `SomeWorker.max_retry_count`
|
558
|
+
|
559
|
+
Исходя из `retry_in` и `max_retry_count`,
|
560
|
+
можно вычислить примерное время, которая задача проведет в очереди.
|
561
|
+
Под задачей тут понимается payload задачи.
|
562
|
+
После достижения `max_retry_count` в морг переносится только payload с минимальным score.
|
563
|
+
|
564
|
+
Для `retry_in`, заданного по умолчанию получается следующая таблица:
|
565
|
+
|
566
|
+
```ruby
|
567
|
+
def retry_in(retry_count)
|
568
|
+
(retry_count ** 4) + 15 + (rand(30) * (retry_count + 1))
|
569
|
+
end
|
570
|
+
```
|
571
|
+
|
572
|
+
| `max_retry_count` | кол-во дней жизни задачи |
|
573
|
+
| --- | --- |
|
574
|
+
| 14 | 1 |
|
575
|
+
| 16 | 2 |
|
576
|
+
| 18 | 3 |
|
577
|
+
| 19 | 5 |
|
578
|
+
| 20 | 6 |
|
579
|
+
| 21 | 8 |
|
580
|
+
| 22 | 10 |
|
581
|
+
| 23 | 13 |
|
582
|
+
| 24 | 16 |
|
583
|
+
| 25 | 20 |
|
584
|
+
|
585
|
+
`(0...25).map{ |c| retry_in c }.sum / 60 / 60 / 24`
|
586
|
+
|
587
|
+
|
588
|
+
## Изменение количества шардов воркера
|
589
|
+
|
590
|
+
Старайтесь сразу расчитать количество шардов и не именять их количество в будущем.
|
591
|
+
|
592
|
+
Если вы можете отключить добавление новых заданий,
|
593
|
+
то дождитесь опустошения очередей и выкатите новую версию кода с измененным количеством шардов.
|
594
|
+
|
595
|
+
Если такой возможности нет, воспользуйтесь следующим сценарием.
|
596
|
+
|
597
|
+
Например, есть воркер:
|
598
|
+
|
599
|
+
```ruby
|
600
|
+
module ATestWorker
|
601
|
+
extend Lowkiq::Worker
|
602
|
+
|
603
|
+
self.shards_count = 5
|
604
|
+
|
605
|
+
def self.perform(payloads_by_id)
|
606
|
+
some_code
|
607
|
+
end
|
608
|
+
end
|
609
|
+
```
|
610
|
+
|
611
|
+
Теперь нужно указать новое кол-во шардов и задать новое имя очереди:
|
612
|
+
|
613
|
+
```ruby
|
614
|
+
module ATestWorker
|
615
|
+
extend Lowkiq::Worker
|
616
|
+
|
617
|
+
self.shards_count = 10
|
618
|
+
self.queue_name = "#{self.name}_V2"
|
619
|
+
|
620
|
+
def self.perform(payloads_by_id)
|
621
|
+
some_code
|
622
|
+
end
|
623
|
+
end
|
624
|
+
```
|
625
|
+
|
626
|
+
И добавить воркер, перекладывающий задачи из старой очереди в новую:
|
627
|
+
|
628
|
+
```ruby
|
629
|
+
module ATestMigrationWorker
|
630
|
+
extend Lowkiq::Worker
|
631
|
+
|
632
|
+
self.shards_count = 5
|
633
|
+
self.queue_name = "ATestWorker"
|
634
|
+
|
635
|
+
def self.perform(payloads_by_id)
|
636
|
+
jobs = payloads_by_id.each_with_object([]) do |(id, payloads), acc|
|
637
|
+
payloads.each do |payload|
|
638
|
+
acc << { id: id, payload: payload }
|
639
|
+
end
|
640
|
+
end
|
641
|
+
|
642
|
+
ATestWorker.perform_async jobs
|
643
|
+
end
|
644
|
+
end
|
645
|
+
```
|