redis_cron_scheduler 0.0.1
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 +7 -0
- data/README.md +32 -0
- data/config/scheduled_jobs.json +12 -0
- data/lib/redis_cron_scheduler/cron_scheduler.rb +347 -0
- data/lib/redis_cron_scheduler/railtie.rb +25 -0
- data/lib/redis_cron_scheduler/redis_queue_worker.rb +544 -0
- data/lib/redis_cron_scheduler/version.rb +4 -0
- data/lib/redis_cron_scheduler/web/controllers/redis_cron_scheduler/web/redis_jobs_controller.rb +55 -0
- data/lib/redis_cron_scheduler/web/engine.rb +23 -0
- data/lib/redis_cron_scheduler/web/routes.rb +6 -0
- data/lib/redis_cron_scheduler/web/views/redis_cron_scheduler/web/redis_jobs/index.html.erb +61 -0
- data/lib/redis_cron_scheduler.rb +35 -0
- data/lib/tasks/redis_cron_scheduler.rake +20 -0
- data/lib/web.rb +9 -0
- metadata +159 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: aae119ff9453e9fef9436fb80e01e49592375abb0855dedc239c9064b8e6a496
|
|
4
|
+
data.tar.gz: e8fbe149a0bc027a715bdcb0505e03964100cf1f4e0e9bc09ae099381b97caf5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0eadc71066e408e327179f38cae367fb88a1c33658f2ce431defc20368746da66872eb4f3d593f3f25ee1d7aa700ed9569f9c76471f642ccf5a8aaa5470ba53f
|
|
7
|
+
data.tar.gz: e14379fe3db00a100e60c4347a0efe2a7e80365c8421f8f610c129ec1c7d72647bd20d4fcabcd50cc72cf262c8f72d3ebeea750a4f6329b3b9b7ed538a613ba0
|
data/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
## Web Dashboard
|
|
2
|
+
|
|
3
|
+
Acesse o dashboard em: `http://localhost:3000/redis_cron`
|
|
4
|
+
|
|
5
|
+
### Necessáio:
|
|
6
|
+
export REDIS_NAMESPACE="myapp::"
|
|
7
|
+
export REDIS_WORKER_COUNT=1
|
|
8
|
+
export REDIS_CRON_AUTO_START=true
|
|
9
|
+
|
|
10
|
+
## Necessário incluir no environment o cache_store
|
|
11
|
+
|
|
12
|
+
config.cache_store = :redis_cache_store, {
|
|
13
|
+
url: "rediss://#{ENV['elasticache_valkey_username']}:#{ENV['elasticache_valkey_password']}@#{ENV['REDIS_SERVER']}:6379/0",
|
|
14
|
+
ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE },
|
|
15
|
+
namespace: ENV['REDIS_NAMESPACE']
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# incluir no config/routes.rb
|
|
19
|
+
|
|
20
|
+
scope "/redis_cron", module: "redis_cron_scheduler/web", as: "redis_cron_scheduler_web" do
|
|
21
|
+
root to: "redis_jobs#index"
|
|
22
|
+
resources :redis_jobs, only: [:index]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
### Features do Dashboard:
|
|
26
|
+
- 📊 Estatísticas em tempo real
|
|
27
|
+
- 👀 Visualização de todas as filas
|
|
28
|
+
- ⏰ Jobs agendados
|
|
29
|
+
- 🔄 Jobs em retry
|
|
30
|
+
- 💀 Dead jobs
|
|
31
|
+
- 🏃 Jobs em execução
|
|
32
|
+
- ⚡ Ações: retry, delete, retry all, delete all
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# lib/redis_cron_scheduler/cron_scheduler.rb
|
|
2
|
+
module RedisCronScheduler
|
|
3
|
+
class CronScheduler
|
|
4
|
+
# ... (todo o código do CronScheduler aqui)
|
|
5
|
+
# Apenas mude as constantes para configuração:
|
|
6
|
+
CONFIG_FILE = File.expand_path("config/scheduled_jobs.json", Dir.pwd)
|
|
7
|
+
|
|
8
|
+
REDIS_NAMESPACE = (ENV['REDIS_NAMESPACE'] || "queue:").freeze
|
|
9
|
+
LOCK_KEY = "#{REDIS_NAMESPACE}cron_scheduler:lock"
|
|
10
|
+
POLL_INTERVAL = 45.seconds
|
|
11
|
+
LOCK_TIMEOUT = 30.seconds
|
|
12
|
+
CONFIG_CHECK_INTERVAL = 300.seconds
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def start
|
|
16
|
+
new.run
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def shutdown
|
|
20
|
+
@running = false
|
|
21
|
+
Rails.logger.info "[CronScheduler] Desligamento solicitado..."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def running?
|
|
25
|
+
@running == true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@running = Concurrent::AtomicBoolean.new(true) # Use AtomicBoolean
|
|
31
|
+
@scheduled_jobs = {}
|
|
32
|
+
@last_config_check = Time.now
|
|
33
|
+
|
|
34
|
+
Rails.logger.info "[CronScheduler] Timezone configurado: #{Time.zone.name}"
|
|
35
|
+
Rails.logger.info "[CronScheduler] Hora atual: #{Time.current.strftime('%H:%M:%S %Z')}"
|
|
36
|
+
|
|
37
|
+
setup_signal_handlers
|
|
38
|
+
load_and_schedule_jobs
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run
|
|
42
|
+
Rails.logger.info "[CronScheduler] Iniciando agendador..."
|
|
43
|
+
iteration = 0
|
|
44
|
+
|
|
45
|
+
while @running.true?
|
|
46
|
+
iteration += 1
|
|
47
|
+
Rails.logger.debug "[CronScheduler] Iteração ##{iteration} - #{Time.now.strftime('%H:%M:%S')}"
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
# Usar lock distribuído para evitar execução duplicada
|
|
51
|
+
with_redis_lock do
|
|
52
|
+
check_config_changes
|
|
53
|
+
execute_due_jobs
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
sleep_gracefully(POLL_INTERVAL)
|
|
57
|
+
rescue => e
|
|
58
|
+
Rails.logger.error "[CronScheduler] Erro no loop principal: #{e.message}"
|
|
59
|
+
sleep_gracefully(5)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def shutdown
|
|
66
|
+
@running.make_false # ⬅️ Use make_false para AtomicBoolean
|
|
67
|
+
Rails.logger.info "[CronScheduler] Desligando..."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def sleep_gracefully(seconds)
|
|
73
|
+
# ⬇️ Dormir o tempo total, mas verificar @running periodicamente ⬇️
|
|
74
|
+
end_time = Time.now + seconds
|
|
75
|
+
while Time.now < end_time && @running
|
|
76
|
+
sleep [1, end_time - Time.now].min # Verificar a cada 1 segundo
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def with_redis_lock(&block)
|
|
81
|
+
with_redis do |conn|
|
|
82
|
+
lock_acquired = conn.set(LOCK_KEY, Process.pid, nx: true, ex: LOCK_TIMEOUT)
|
|
83
|
+
if lock_acquired
|
|
84
|
+
begin
|
|
85
|
+
Rails.logger.debug "[CronScheduler] 🔒 Lock adquirido"
|
|
86
|
+
yield
|
|
87
|
+
ensure
|
|
88
|
+
conn.del(LOCK_KEY) if conn.get(LOCK_KEY) == Process.pid.to_s
|
|
89
|
+
Rails.logger.debug "[CronScheduler] 🔓 Lock liberado"
|
|
90
|
+
end
|
|
91
|
+
else
|
|
92
|
+
# ⬇️ NÃO BLOQUEIE - apenas log e continue ⬇️
|
|
93
|
+
current_pid = conn.get(LOCK_KEY)
|
|
94
|
+
Rails.logger.debug "[CronScheduler] ⏩ Lock ocupado (PID: #{current_pid}), pulando execução"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def check_config_changes
|
|
100
|
+
return unless Time.now - @last_config_check >= CONFIG_CHECK_INTERVAL
|
|
101
|
+
|
|
102
|
+
load_and_schedule_jobs
|
|
103
|
+
@last_config_check = Time.now
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def load_and_schedule_jobs
|
|
107
|
+
config = load_config
|
|
108
|
+
current_jobs = config[:jobs] || []
|
|
109
|
+
|
|
110
|
+
Rails.logger.info "[CronScheduler] 📋 Jobs encontrados no config: #{current_jobs.map { |j| j[:name] }.join(', ')}"
|
|
111
|
+
Rails.logger.debug "[CronScheduler] Config completo: #{config.inspect}" # ⬅️ DEBUG
|
|
112
|
+
|
|
113
|
+
# Identificar mudanças de forma mais eficiente
|
|
114
|
+
detect_and_apply_changes(current_jobs)
|
|
115
|
+
|
|
116
|
+
# Executar jobs que estão no horário (já dentro do lock)
|
|
117
|
+
execute_due_jobs
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_config
|
|
123
|
+
return { jobs: [] } unless File.exist?(CONFIG_FILE)
|
|
124
|
+
|
|
125
|
+
begin
|
|
126
|
+
file_content = File.read(CONFIG_FILE)
|
|
127
|
+
config = JSON.parse(file_content, symbolize_names: true)
|
|
128
|
+
|
|
129
|
+
# ⬇️ CORREÇÃO: Garanta que sempre retorne um hash com :jobs ⬇️
|
|
130
|
+
if config.is_a?(Hash) && config.has_key?(:jobs)
|
|
131
|
+
config
|
|
132
|
+
elsif config.is_a?(Array)
|
|
133
|
+
{ jobs: config } # Se for array, converta para hash
|
|
134
|
+
else
|
|
135
|
+
Rails.logger.error "[CronScheduler] Formato inválido no config: #{config.class}"
|
|
136
|
+
{ jobs: [] }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
rescue JSON::ParserError => e
|
|
140
|
+
Rails.logger.error "Erro ao parsear #{CONFIG_FILE}: #{e.message}"
|
|
141
|
+
{ jobs: [] }
|
|
142
|
+
rescue => e
|
|
143
|
+
Rails.logger.error "Erro ao carregar config: #{e.message}"
|
|
144
|
+
{ jobs: [] }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def detect_and_apply_changes(current_jobs)
|
|
149
|
+
current_names = current_jobs.map { |j| j[:name].to_s }
|
|
150
|
+
existing_names = @scheduled_jobs.keys
|
|
151
|
+
|
|
152
|
+
Rails.logger.info "[CronScheduler] 🔄 Comparando: atuais=#{current_names}, existentes=#{existing_names}"
|
|
153
|
+
|
|
154
|
+
# Remover jobs que não existem mais
|
|
155
|
+
(existing_names - current_names).each { |job_name| unschedule_job(job_name) }
|
|
156
|
+
|
|
157
|
+
# Adicionar/atualizar jobs
|
|
158
|
+
current_jobs.each do |job|
|
|
159
|
+
job_name = job[:name].to_s
|
|
160
|
+
if !@scheduled_jobs[job_name] || job_changed?(@scheduled_jobs[job_name][:config], job)
|
|
161
|
+
Rails.logger.info "[CronScheduler] ➕ Agendando/atualizando: #{job_name}"
|
|
162
|
+
schedule_job(job)
|
|
163
|
+
else
|
|
164
|
+
Rails.logger.debug "[CronScheduler] ⏩ Job unchanged: #{job_name}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def job_changed?(old_job, new_job)
|
|
170
|
+
old_job[:cron_expression] != new_job[:cron_expression] ||
|
|
171
|
+
old_job[:job_class] != new_job[:job_class] ||
|
|
172
|
+
old_job[:arguments] != new_job[:arguments] ||
|
|
173
|
+
old_job[:queue] != new_job[:queue]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def execute_due_jobs
|
|
177
|
+
now = Time.current # ⬅️ Use Time.current
|
|
178
|
+
Rails.logger.debug "[CronScheduler] Verificando jobs às #{now.strftime('%H:%M:%S %Z')}"
|
|
179
|
+
|
|
180
|
+
jobs_executados = 0
|
|
181
|
+
@scheduled_jobs.each do |job_name, job_info|
|
|
182
|
+
next unless job_info[:next_execution] && job_info[:next_execution] <= now
|
|
183
|
+
|
|
184
|
+
Rails.logger.info "[CronScheduler] ⏰ EXECUTANDO: #{job_name} (agendado para #{job_info[:next_execution].strftime('%H:%M:%S %Z')})"
|
|
185
|
+
|
|
186
|
+
begin
|
|
187
|
+
enqueue_job_for_execution(job_info[:config])
|
|
188
|
+
jobs_executados += 1
|
|
189
|
+
|
|
190
|
+
# Reagendar para próxima execução
|
|
191
|
+
next_time = calculate_next_execution(job_info[:cron_expression])
|
|
192
|
+
@scheduled_jobs[job_name][:next_execution] = next_time
|
|
193
|
+
@scheduled_jobs[job_name][:last_execution] = now
|
|
194
|
+
|
|
195
|
+
Rails.logger.info "[CronScheduler] ✅ #{job_name} executado. Próximo: #{next_time.strftime('%H:%M:%S %Z')}"
|
|
196
|
+
rescue => e
|
|
197
|
+
Rails.logger.error "[CronScheduler] ❌ Erro ao executar #{job_name}: #{e.message}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
Rails.logger.debug "[CronScheduler] #{jobs_executados} jobs executados nesta verificação"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def calculate_next_execution(cron_expression)
|
|
205
|
+
fugit_cron = Fugit::Cron.parse(cron_expression)
|
|
206
|
+
|
|
207
|
+
unless fugit_cron
|
|
208
|
+
Rails.logger.error "[CronScheduler] Expressão cron inválida: #{cron_expression}"
|
|
209
|
+
return Time.current + 24.hours
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# ⬇️ USE Time.current (que respeita config.time_zone) ⬇️
|
|
213
|
+
next_time = fugit_cron.next_time(Time.current)
|
|
214
|
+
Rails.logger.debug "[CronScheduler] Fugit: '#{cron_expression}' -> #{next_time} (timezone: #{Time.zone.name})"
|
|
215
|
+
next_time
|
|
216
|
+
|
|
217
|
+
rescue => e
|
|
218
|
+
Rails.logger.error "[CronScheduler] Erro no cron expression '#{cron_expression}': #{e.message}"
|
|
219
|
+
Time.current + 24.hours
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# app/services/cron_scheduler.rb
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
def enqueue_job_for_execution(job_config)
|
|
227
|
+
# ⬇️ ESTRUTURA COMPATÍVEL COM REDIS QUEUE WORKER ⬇️
|
|
228
|
+
job_data = {
|
|
229
|
+
job_class: job_config[:job_class],
|
|
230
|
+
arguments: job_config[:arguments] || [],
|
|
231
|
+
queue: job_config[:queue] || "default",
|
|
232
|
+
job_id: "cron_#{job_config[:name]}_#{Time.now.to_i}_#{SecureRandom.hex(4)}",
|
|
233
|
+
|
|
234
|
+
# ⬇️ METADADOS ESPECÍFICOS PARA JOBS CRON ⬇️
|
|
235
|
+
cron_expression: job_config[:cron_expression],
|
|
236
|
+
cron_uuid: SecureRandom.uuid,
|
|
237
|
+
cron_name: job_config[:name],
|
|
238
|
+
scheduled_at: Time.now.iso8601,
|
|
239
|
+
retry_count: 0,
|
|
240
|
+
enqueued_at: Time.now.iso8601
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
queue_name = job_config[:queue] || "default"
|
|
244
|
+
|
|
245
|
+
# ⬇️ CORREÇÃO: Use a chave correta para o RedisQueueWorker ⬇️
|
|
246
|
+
redis_key = "#{REDIS_NAMESPACE}queue:#{queue_name}" # SEM "queue:" extra!
|
|
247
|
+
|
|
248
|
+
# ⬇️ ENFILEIRAR NO REDIS ⬇️
|
|
249
|
+
with_redis do |conn|
|
|
250
|
+
conn.rpush(redis_key, job_data.to_json)
|
|
251
|
+
Rails.logger.info "[CronScheduler] Job #{job_config[:name]} enfileirado na chave: #{redis_key}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
rescue => e
|
|
255
|
+
Rails.logger.error "[CronScheduler] Erro ao enfileirar #{job_config[:name]}: #{e.message}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def schedule_job(job_config)
|
|
260
|
+
job_name = job_config[:name].to_s
|
|
261
|
+
cron_expression = job_config[:cron_expression]
|
|
262
|
+
|
|
263
|
+
begin
|
|
264
|
+
next_time = calculate_next_execution(cron_expression)
|
|
265
|
+
@scheduled_jobs[job_name] = {
|
|
266
|
+
config: job_config,
|
|
267
|
+
cron_expression: cron_expression,
|
|
268
|
+
next_execution: next_time,
|
|
269
|
+
last_execution: nil
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
Rails.logger.info "[CronScheduler] 📅 Agendado #{job_name} para #{next_time.strftime('%H:%M:%S')} (cron: #{cron_expression})"
|
|
273
|
+
rescue => e
|
|
274
|
+
Rails.logger.error "[CronScheduler] Erro ao agendar #{job_name}: #{e.message}"
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def unschedule_job(job_name)
|
|
279
|
+
@scheduled_jobs.delete(job_name)
|
|
280
|
+
Rails.logger.info "[CronScheduler] Removido agendamento para #{job_name}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def load_config
|
|
284
|
+
return { jobs: [] } unless File.exist?(CONFIG_FILE)
|
|
285
|
+
|
|
286
|
+
begin
|
|
287
|
+
file_content = File.read(CONFIG_FILE)
|
|
288
|
+
JSON.parse(file_content, symbolize_names: true) || { jobs: [] }
|
|
289
|
+
rescue JSON::ParserError => e
|
|
290
|
+
Rails.logger.error "Erro ao parsear #{CONFIG_FILE}: #{e.message}"
|
|
291
|
+
{ jobs: [] }
|
|
292
|
+
rescue => e
|
|
293
|
+
Rails.logger.error "Erro ao carregar config: #{e.message}"
|
|
294
|
+
{ jobs: [] }
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def with_redis
|
|
299
|
+
Rails.cache.redis.with { |conn| yield conn }
|
|
300
|
+
rescue Redis::BaseConnectionError => e
|
|
301
|
+
Rails.logger.error "Redis connection error: #{e.message}"
|
|
302
|
+
raise
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def setup_signal_handlers
|
|
306
|
+
# ⬇️ Use $stdout.puts em vez de Rails.logger dentro do trap ⬇️
|
|
307
|
+
Signal.trap("TERM") do
|
|
308
|
+
$stdout.puts "[CronScheduler] Sinal TERM recebido, iniciando shutdown..."
|
|
309
|
+
@running.make_false # ⬅️ Use make_false
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
Signal.trap("INT") do
|
|
313
|
+
$stdout.puts "[CronScheduler] Sinal INT recebido, iniciando shutdown..."
|
|
314
|
+
@running.make_false # ⬅️ Use make_false
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Remover métodos de classe antigos não utilizados
|
|
319
|
+
class << self
|
|
320
|
+
undef_method :scheduled_key, :cron_jobs_registry_key, :schedule_all_jobs,
|
|
321
|
+
:clear_existing_config_jobs if method_defined?(:schedule_all_jobs)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# Adicione métodos de classe para configuração
|
|
329
|
+
class << self
|
|
330
|
+
def config_file=(path)
|
|
331
|
+
@config_file = path
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def config_file
|
|
335
|
+
@config_file || CONFIG_FILE
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def redis_namespace=(namespace)
|
|
339
|
+
@redis_namespace = namespace
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def redis_namespace
|
|
343
|
+
@redis_namespace || REDIS_NAMESPACE
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require 'rails'
|
|
2
|
+
require 'redis_cron_scheduler/web/engine' # <-- garante que a engine existe
|
|
3
|
+
|
|
4
|
+
module RedisCronScheduler
|
|
5
|
+
class Railtie < Rails::Railtie
|
|
6
|
+
initializer "redis_cron_scheduler.setup" do |app|
|
|
7
|
+
app.config.after_initialize do
|
|
8
|
+
if ENV['REDIS_CRON_AUTO_START'] != 'false'
|
|
9
|
+
RedisCronScheduler.start_scheduler
|
|
10
|
+
RedisCronScheduler.start_workers
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# initializer "redis_cron_scheduler.routes" do |app|
|
|
16
|
+
# app.routes.append do
|
|
17
|
+
# mount RedisCronScheduler::Web::Engine => '/redis_cron'
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
|
|
21
|
+
rake_tasks do
|
|
22
|
+
load File.expand_path('../tasks/redis_cron_scheduler.rake', __FILE__)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
# lib/redis_cron_scheduler/redis_queue_worker.rb
|
|
2
|
+
module RedisCronScheduler
|
|
3
|
+
class RedisQueueWorker
|
|
4
|
+
# --- Configuráveis via ENV ---
|
|
5
|
+
POLL_INTERVAL = (ENV['REDIS_POLL_INTERVAL'] || 10).to_f # segundos quando fila vazia
|
|
6
|
+
RUNNING_TTL = (ENV['REDIS_RUNNING_TTL'] || 15).to_i # lease por job (segundos)
|
|
7
|
+
LEASE_RENEW_INTERVAL = (ENV['REDIS_LEASE_RENEW_INTERVAL'] || 5).to_i # frequência de renovação do lease
|
|
8
|
+
CLEANUP_INTERVAL = (ENV['REDIS_CLEANUP_INTERVAL'] || 300).to_i # varredura de cleanup (segundos)
|
|
9
|
+
MAX_JOBS_PER_CYCLE = (ENV['REDIS_MAX_JOBS_PER_CYCLE'] || 100).to_i
|
|
10
|
+
SHUTDOWN_TIMEOUT = (ENV['REDIS_SHUTDOWN_TIMEOUT'] || 30).to_i # tempo para aguardar jobs terminarem no shutdown (s)
|
|
11
|
+
REDIS_NAMESPACE = (ENV['REDIS_NAMESPACE'] || "queue:").freeze
|
|
12
|
+
|
|
13
|
+
# filas + peso (peso influencia chance de ser escolhida)
|
|
14
|
+
QUEUE_WEIGHTS = {
|
|
15
|
+
"critical" => 3,
|
|
16
|
+
"mailers" => 2,
|
|
17
|
+
"default" => 1
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
RETRY_INTERVALS = [5, 15, 30, 60, 300].freeze
|
|
21
|
+
|
|
22
|
+
# --- Lua scripts (mantêm atomicidade de movimentos) ---
|
|
23
|
+
LUA_MOVE_SCHEDULED = <<~LUA
|
|
24
|
+
local scheduled_key = KEYS[1]
|
|
25
|
+
local queue_key = KEYS[2]
|
|
26
|
+
local now = tonumber(ARGV[1])
|
|
27
|
+
local jobs = redis.call('ZRANGEBYSCORE', scheduled_key, 0, now)
|
|
28
|
+
for i, job in ipairs(jobs) do
|
|
29
|
+
redis.call('RPUSH', queue_key, job)
|
|
30
|
+
redis.call('ZREM', scheduled_key, job)
|
|
31
|
+
end
|
|
32
|
+
return #jobs
|
|
33
|
+
LUA
|
|
34
|
+
|
|
35
|
+
LUA_MOVE_RETRY = <<~LUA
|
|
36
|
+
local retry_key = KEYS[1]
|
|
37
|
+
local queue_key = KEYS[2]
|
|
38
|
+
local now = tonumber(ARGV[1])
|
|
39
|
+
local jobs = redis.call('ZRANGEBYSCORE', retry_key, 0, now)
|
|
40
|
+
for i, job in ipairs(jobs) do
|
|
41
|
+
redis.call('RPUSH', queue_key, job)
|
|
42
|
+
redis.call('ZREM', retry_key, job)
|
|
43
|
+
end
|
|
44
|
+
return #jobs
|
|
45
|
+
LUA
|
|
46
|
+
|
|
47
|
+
# LMOVE atômico (fila -> running). Lease é gerenciado depois no Ruby.
|
|
48
|
+
LUA_POP_JOB = <<~LUA
|
|
49
|
+
local queue_key = KEYS[1]
|
|
50
|
+
local running_key = KEYS[2]
|
|
51
|
+
return redis.call('LMOVE', queue_key, running_key, 'RIGHT', 'LEFT')
|
|
52
|
+
LUA
|
|
53
|
+
|
|
54
|
+
# --- Inicialização ---
|
|
55
|
+
def self.start
|
|
56
|
+
new.run
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize
|
|
60
|
+
@worker_id = "#{Socket.gethostname}:#{Process.pid}:#{Thread.current.object_id}"
|
|
61
|
+
@running = Concurrent::AtomicBoolean.new(true)
|
|
62
|
+
@shutdown_immediate = Concurrent::AtomicBoolean.new(false)
|
|
63
|
+
|
|
64
|
+
# estrutura para acompanhar leases que este worker criou
|
|
65
|
+
@leases_mutex = Mutex.new
|
|
66
|
+
@active_leases = {} # lease_key => expiration_time (apenas para inspeção rápida)
|
|
67
|
+
|
|
68
|
+
# preparar array ponderado para seleção de fila
|
|
69
|
+
@weighted_queues = build_weighted_queue_array(QUEUE_WEIGHTS)
|
|
70
|
+
|
|
71
|
+
# controle de limpeza periódico
|
|
72
|
+
@last_cleanup = Time.at(0)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# --- Loop principal ---
|
|
76
|
+
def run
|
|
77
|
+
Rails.logger.info "[RedisQueueWorker] Iniciando worker #{@worker_id}..."
|
|
78
|
+
setup_signal_handlers
|
|
79
|
+
|
|
80
|
+
# start lease renewer thread
|
|
81
|
+
@renewer_thread = Thread.new { lease_renewer_loop }
|
|
82
|
+
@last_config_check = Time.now
|
|
83
|
+
|
|
84
|
+
# carregar scripts em cada conexão não estritamente necessário, usamos EVAL direto
|
|
85
|
+
while @running.true?
|
|
86
|
+
begin
|
|
87
|
+
# cleanup periódico de stuck jobs
|
|
88
|
+
if Time.now - @last_cleanup >= CLEANUP_INTERVAL
|
|
89
|
+
cleanup_stuck_jobs
|
|
90
|
+
@last_cleanup = Time.now
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# mover scheduled/retry prontos
|
|
94
|
+
move_scheduled_to_queue
|
|
95
|
+
move_retry_to_queue
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
processed = process_up_to_limit(MAX_JOBS_PER_CYCLE)
|
|
99
|
+
|
|
100
|
+
# polling inteligente: só dorme quando não processou nada
|
|
101
|
+
if processed == 0 && !@shutdown_immediate.true?
|
|
102
|
+
sleep POLL_INTERVAL
|
|
103
|
+
end
|
|
104
|
+
rescue => e
|
|
105
|
+
Rails.logger.error "[RedisQueueWorker] Erro no loop principal: #{e.class} - #{e.message}"
|
|
106
|
+
Rails.logger.error e.backtrace.join("\n")
|
|
107
|
+
sleep 1
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# se shutdown normal (graceful) aguardar jobs em andamento
|
|
112
|
+
if !@shutdown_immediate.true?
|
|
113
|
+
wait_for_active_jobs_or_timeout(SHUTDOWN_TIMEOUT)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# finalizar renewer
|
|
117
|
+
@renewer_thread&.kill
|
|
118
|
+
Rails.logger.info "[RedisQueueWorker] Worker #{@worker_id} finalizado."
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def shutdown(immediate: false)
|
|
122
|
+
@shutdown_immediate.make_true if immediate
|
|
123
|
+
@running.make_false
|
|
124
|
+
Rails.logger.info "[RedisQueueWorker] Shutdown solicitado (immediate: #{immediate})"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# --------------- Redis helpers (usa connection pool do Rails)
|
|
130
|
+
def with_redis
|
|
131
|
+
Rails.cache.redis.with do |conn|
|
|
132
|
+
yield conn
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def queue_key(queue); "#{REDIS_NAMESPACE}queue:#{queue}"; end
|
|
137
|
+
def running_key(queue); "#{REDIS_NAMESPACE}running:#{queue}:#{@worker_id}"; end
|
|
138
|
+
def lease_key(queue, job_id); "#{REDIS_NAMESPACE}running_lease:#{queue}:#{job_id}"; end
|
|
139
|
+
def retry_key(queue); "#{REDIS_NAMESPACE}retry:#{queue}"; end
|
|
140
|
+
def dead_key(queue); "#{REDIS_NAMESPACE}dead:#{queue}"; end
|
|
141
|
+
def scheduled_key(queue); "#{REDIS_NAMESPACE}schedule:#{queue}"; end
|
|
142
|
+
|
|
143
|
+
# --------------- Weighted queue array
|
|
144
|
+
def build_weighted_queue_array(weights_hash)
|
|
145
|
+
arr = []
|
|
146
|
+
weights_hash.each do |q, w|
|
|
147
|
+
next if w.to_i <= 0
|
|
148
|
+
w.times { arr << q.to_s }
|
|
149
|
+
end
|
|
150
|
+
arr.freeze
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Escolhe uma fila aleatoriamente ponderada
|
|
154
|
+
def pick_weighted_queue
|
|
155
|
+
@weighted_queues.sample
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# --------------- Move scheduled -> queue
|
|
159
|
+
def move_scheduled_to_queue
|
|
160
|
+
with_redis do |conn|
|
|
161
|
+
now = Time.now.to_f
|
|
162
|
+
QUEUE_WEIGHTS.keys.each do |queue|
|
|
163
|
+
begin
|
|
164
|
+
moved = conn.eval(LUA_MOVE_SCHEDULED, [scheduled_key(queue), queue_key(queue)], [now])
|
|
165
|
+
Rails.logger.debug "[RedisQueueWorker] moved #{moved} scheduled -> #{queue}" if moved.to_i > 0
|
|
166
|
+
rescue => e
|
|
167
|
+
Rails.logger.error "[RedisQueueWorker] Erro move_scheduled_to_queue #{queue}: #{e.message}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# --------------- Move retry -> queue
|
|
174
|
+
def move_retry_to_queue
|
|
175
|
+
with_redis do |conn|
|
|
176
|
+
now = Time.now.to_f
|
|
177
|
+
QUEUE_WEIGHTS.keys.each do |queue|
|
|
178
|
+
begin
|
|
179
|
+
moved = conn.eval(LUA_MOVE_RETRY, [retry_key(queue), queue_key(queue)], [now])
|
|
180
|
+
Rails.logger.debug "[RedisQueueWorker] moved #{moved} retry -> #{queue}" if moved.to_i > 0
|
|
181
|
+
rescue => e
|
|
182
|
+
Rails.logger.error "[RedisQueueWorker] Erro move_retry_to_queue #{queue}: #{e.message}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# --------------- Processamento: até N jobs por ciclo (evita starvation)
|
|
189
|
+
def process_up_to_limit(limit)
|
|
190
|
+
processed = 0
|
|
191
|
+
limit.times do
|
|
192
|
+
break unless @running.true?
|
|
193
|
+
found = process_one_job_from_any_queue
|
|
194
|
+
break if found == 0
|
|
195
|
+
processed += found
|
|
196
|
+
end
|
|
197
|
+
processed
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Tenta pop atômico (LMOVE) de acordo com prioridade ponderada.
|
|
201
|
+
# Retorna 1 se processou um job, 0 caso contrário.
|
|
202
|
+
def process_one_job_from_any_queue
|
|
203
|
+
# Try several times picking weighted queues; this balances priorities but still probes other queues.
|
|
204
|
+
trials = @weighted_queues.size
|
|
205
|
+
trials.times do
|
|
206
|
+
queue = pick_weighted_queue
|
|
207
|
+
with_redis do |conn|
|
|
208
|
+
begin
|
|
209
|
+
job_json = conn.eval(LUA_POP_JOB, [queue_key(queue), running_key(queue)], [])
|
|
210
|
+
rescue => e
|
|
211
|
+
Rails.logger.error "[RedisQueueWorker] Erro LMOVE em #{queue}: #{e.message}"
|
|
212
|
+
next
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
next unless job_json
|
|
216
|
+
|
|
217
|
+
# processou
|
|
218
|
+
process_single_job(job_json, queue, conn)
|
|
219
|
+
return 1
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# se nenhum dos picks retornou job, realizar uma verificação direta nas filas (fallback)
|
|
224
|
+
QUEUE_WEIGHTS.keys.each do |queue|
|
|
225
|
+
with_redis do |conn|
|
|
226
|
+
job_json = begin
|
|
227
|
+
conn.eval(LUA_POP_JOB, [queue_key(queue), running_key(queue)], [])
|
|
228
|
+
rescue => e
|
|
229
|
+
Rails.logger.error "[RedisQueueWorker] Erro LMOVE fallback em #{queue}: #{e.message}"
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
next unless job_json
|
|
233
|
+
process_single_job(job_json, queue, conn)
|
|
234
|
+
return 1
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
0
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# --- Processa job que já está no running (job_json já movido atômicamente)
|
|
242
|
+
def process_single_job(job_json, queue, conn)
|
|
243
|
+
job_data = parse_job_json(job_json)
|
|
244
|
+
unless job_data
|
|
245
|
+
# job corrompido: joga no dead para análise
|
|
246
|
+
Rails.logger.error "[RedisQueueWorker] Job inválido detectado na fila running: #{job_json.inspect}"
|
|
247
|
+
conn.zadd(dead_key(queue), Time.now.to_f, job_json)
|
|
248
|
+
# remove da running list onde foi colocado
|
|
249
|
+
conn.lrem(running_key(queue), 0, job_json)
|
|
250
|
+
return
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
job_id = job_data[:job_id] || Digest::SHA1.hexdigest(job_json)
|
|
254
|
+
lease_k = lease_key(queue, job_id)
|
|
255
|
+
|
|
256
|
+
# criar lease e registrar localmente
|
|
257
|
+
conn.setex(lease_k, RUNNING_TTL, job_json)
|
|
258
|
+
register_lease_local(lease_k, Time.now + RUNNING_TTL)
|
|
259
|
+
|
|
260
|
+
begin
|
|
261
|
+
run_job(job_json, queue, conn)
|
|
262
|
+
# sucesso: remove da running da lista (a LMOVE já colocou o job lá)
|
|
263
|
+
conn.lrem(running_key(queue), 0, job_json)
|
|
264
|
+
rescue => e
|
|
265
|
+
Rails.logger.error "[RedisQueueWorker] Erro inesperado ao processar job #{job_id}: #{e.class} - #{e.message}"
|
|
266
|
+
# run_job já faz retry/dead quando apropriado
|
|
267
|
+
ensure
|
|
268
|
+
# remover lease local e redis (caso ainda exista)
|
|
269
|
+
unregister_lease_local(lease_k)
|
|
270
|
+
conn.del(lease_k) rescue nil
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def run_job(job_json, queue, conn)
|
|
275
|
+
job_data = parse_job_json(job_json)
|
|
276
|
+
return unless job_data
|
|
277
|
+
|
|
278
|
+
klass_name = job_data[:job_class]
|
|
279
|
+
args = job_data[:arguments] || []
|
|
280
|
+
retry_count = job_data[:retry_count] || 0
|
|
281
|
+
job_id = job_data[:job_id] || Digest::SHA1.hexdigest(job_json)
|
|
282
|
+
|
|
283
|
+
klass = klass_name.to_s.safe_constantize
|
|
284
|
+
unless klass
|
|
285
|
+
Rails.logger.error "[RedisQueueWorker] Classe inválida para job #{job_id}: #{klass_name.inspect}"
|
|
286
|
+
conn.zadd(dead_key(queue), Time.now.to_f, job_json)
|
|
287
|
+
return
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
Rails.logger.info "[RedisQueueWorker] Executando #{klass_name} (#{job_id}) fila=#{queue} retry=#{retry_count}"
|
|
291
|
+
|
|
292
|
+
begin
|
|
293
|
+
klass.new.perform(*args)
|
|
294
|
+
Rails.logger.info "[RedisQueueWorker] Job concluído #{job_id} (#{klass_name})"
|
|
295
|
+
|
|
296
|
+
true
|
|
297
|
+
rescue => e
|
|
298
|
+
Rails.logger.error "[RedisQueueWorker] Falha no job #{job_id} (#{klass_name}): #{e.class} - #{e.message}"
|
|
299
|
+
|
|
300
|
+
false
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# ⬇️ NOVO MÉTODO: Tratamento especial para falhas de jobs cron ⬇️
|
|
305
|
+
def handle_cron_job_failure(job_data, job_json, queue, conn, error, retry_count)
|
|
306
|
+
max_retries = RETRY_INTERVALS.size
|
|
307
|
+
|
|
308
|
+
if retry_count < max_retries
|
|
309
|
+
# ⬇️ Tentar novamente (retry normal) ⬇️
|
|
310
|
+
delay = RETRY_INTERVALS[retry_count]
|
|
311
|
+
retry_job = job_data.merge(
|
|
312
|
+
retry_count: retry_count + 1,
|
|
313
|
+
last_error: "#{error.class}: #{error.message}",
|
|
314
|
+
failed_at: Time.now.iso8601
|
|
315
|
+
).to_json
|
|
316
|
+
|
|
317
|
+
conn.zadd(retry_key(queue), Time.now.to_f + delay, retry_job)
|
|
318
|
+
Rails.logger.info "[RedisQueueWorker] Job cron #{job_data[:cron_name]} reenfileirado para retry em #{delay}s"
|
|
319
|
+
else
|
|
320
|
+
# ⬇️ MÁXIMO DE RETRIES ATINGIDO - REAGENDAR MESMO ASSIM ⬇️
|
|
321
|
+
Rails.logger.error "[RedisQueueWorker] Job cron #{job_data[:cron_name]} excedeu retries, mas será reagendado (cron)"
|
|
322
|
+
|
|
323
|
+
# Registrar no dead para análise, mas SEMPRE reagendar
|
|
324
|
+
conn.zadd(dead_key(queue), Time.now.to_f, job_json)
|
|
325
|
+
|
|
326
|
+
# ⬇️ REAGENDAR PARA PRÓXIMA OCORRÊNCIA ⬇️
|
|
327
|
+
reschedule_cron_job(job_data, queue, conn)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# ⬇️ MÉTODO EXISTENTE PARA JOBS REGULARES (não cron) ⬇️
|
|
332
|
+
def handle_regular_job_failure(job_data, job_json, queue, conn, error, retry_count)
|
|
333
|
+
max_retries = RETRY_INTERVALS.size
|
|
334
|
+
|
|
335
|
+
if retry_count < max_retries
|
|
336
|
+
delay = RETRY_INTERVALS[retry_count]
|
|
337
|
+
retry_job = job_data.merge(
|
|
338
|
+
retry_count: retry_count + 1,
|
|
339
|
+
last_error: "#{error.class}: #{error.message}",
|
|
340
|
+
failed_at: Time.now.iso8601
|
|
341
|
+
).to_json
|
|
342
|
+
|
|
343
|
+
conn.zadd(retry_key(queue), Time.now.to_f + delay, retry_job)
|
|
344
|
+
Rails.logger.info "[RedisQueueWorker] Job #{job_data[:job_id]} reenfileirado para retry em #{delay}s"
|
|
345
|
+
else
|
|
346
|
+
# ⬇️ JOBS REGULARES VÃO PARA DEAD (comportamento normal) ⬇️
|
|
347
|
+
conn.zadd(dead_key(queue), Time.now.to_f, job_json)
|
|
348
|
+
Rails.logger.error "[RedisQueueWorker] Job #{job_data[:job_id]} movido para dead (excedeu retries)"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# ⬇️ MÉTODO DE REAGENDAMENTO (já existente) ⬇️
|
|
353
|
+
def reschedule_cron_job(job_data, queue, conn)
|
|
354
|
+
cron_expression = job_data[:cron_expression]
|
|
355
|
+
cron_uuid = job_data[:cron_uuid]
|
|
356
|
+
|
|
357
|
+
next_time = CronParser.next_occurrence(cron_expression, Time.current + 1.minute)
|
|
358
|
+
|
|
359
|
+
new_job_data = job_data.merge(
|
|
360
|
+
job_id: "cron_#{cron_uuid}_#{next_time.to_i}",
|
|
361
|
+
scheduled_at: next_time.iso8601,
|
|
362
|
+
retry_count: 0, # ⬅️ Resetar retry count para próxima execução
|
|
363
|
+
last_error: nil,
|
|
364
|
+
failed_at: nil
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
conn.zadd(
|
|
368
|
+
scheduled_key(queue),
|
|
369
|
+
next_time.to_f,
|
|
370
|
+
new_job_data.to_json
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
Rails.logger.info "[RedisQueueWorker] Job cron #{job_data[:cron_name]} reagendado para #{next_time}"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# --- Lease renewer (único thread por worker) ---
|
|
377
|
+
def lease_renewer_loop
|
|
378
|
+
loop do
|
|
379
|
+
break unless @running.true?
|
|
380
|
+
begin
|
|
381
|
+
lease_keys = nil
|
|
382
|
+
@leases_mutex.synchronize { lease_keys = @active_leases.keys.dup }
|
|
383
|
+
unless lease_keys.empty?
|
|
384
|
+
with_redis do |conn|
|
|
385
|
+
lease_keys.each do |lk|
|
|
386
|
+
# renova apenas se ainda existir no redis (proteção adicional)
|
|
387
|
+
if conn.exists?(lk)
|
|
388
|
+
conn.expire(lk, RUNNING_TTL)
|
|
389
|
+
# atualizar nossa expiração local (apenas estimativa)
|
|
390
|
+
@leases_mutex.synchronize { @active_leases[lk] = Time.now + RUNNING_TTL }
|
|
391
|
+
else
|
|
392
|
+
# remove local se não existir
|
|
393
|
+
@leases_mutex.synchronize { @active_leases.delete(lk) }
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
rescue => e
|
|
399
|
+
Rails.logger.error "[RedisQueueWorker] Erro no lease_renewer: #{e.class} - #{e.message}"
|
|
400
|
+
ensure
|
|
401
|
+
sleep LEASE_RENEW_INTERVAL
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Registra lease_local thread-safe
|
|
407
|
+
def register_lease_local(lease_key, expiry_time)
|
|
408
|
+
@leases_mutex.synchronize { @active_leases[lease_key] = expiry_time }
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def unregister_lease_local(lease_key)
|
|
412
|
+
@leases_mutex.synchronize { @active_leases.delete(lease_key) }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# --- Cleanup: reencaminha jobs em running sem lease ---
|
|
416
|
+
def cleanup_stuck_jobs
|
|
417
|
+
Rails.logger.info "[RedisQueueWorker] Iniciando cleanup_stuck_jobs..."
|
|
418
|
+
with_redis do |conn|
|
|
419
|
+
QUEUE_WEIGHTS.keys.each do |queue|
|
|
420
|
+
cursor = "0"
|
|
421
|
+
loop do
|
|
422
|
+
cursor, keys = conn.scan(cursor, match: "#{REDIS_NAMESPACE}running:#{queue}:*", count: 200)
|
|
423
|
+
keys.each do |running_k|
|
|
424
|
+
begin
|
|
425
|
+
jobs = conn.lrange(running_k, 0, -1)
|
|
426
|
+
next if jobs.nil? || jobs.empty?
|
|
427
|
+
jobs.each do |job_json|
|
|
428
|
+
jd = parse_job_json(job_json) rescue nil
|
|
429
|
+
next unless jd
|
|
430
|
+
job_id = jd[:job_id] || Digest::SHA1.hexdigest(job_json)
|
|
431
|
+
lease_k = lease_key(queue, job_id)
|
|
432
|
+
unless conn.exists?(lease_k)
|
|
433
|
+
Rails.logger.warn "[RedisQueueWorker] Cleanup: re-enfileirando job stuck #{job_id} (from #{running_k})"
|
|
434
|
+
conn.rpush(queue_key(queue), job_json)
|
|
435
|
+
conn.lrem(running_k, 0, job_json)
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
# remove lista running vazia
|
|
439
|
+
conn.del(running_k) if conn.llen(running_k).to_i == 0
|
|
440
|
+
rescue => e
|
|
441
|
+
Rails.logger.error "[RedisQueueWorker] Erro no cleanup para #{running_k}: #{e.message}"
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
break if cursor == "0"
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# --- Utilitários e fallback ---
|
|
451
|
+
def parse_job_json(json)
|
|
452
|
+
data = JSON.parse(json, symbolize_names: true)
|
|
453
|
+
|
|
454
|
+
# ⬇️ COMPATIBILIDADE COM CRON_SCHEDULER ⬇️
|
|
455
|
+
# Se não tiver os campos cron, mas tiver job_class, é um job regular
|
|
456
|
+
if data[:job_class] && !data[:cron_expression]
|
|
457
|
+
data.merge(
|
|
458
|
+
cron_expression: nil,
|
|
459
|
+
cron_uuid: nil,
|
|
460
|
+
cron_name: "regular_job"
|
|
461
|
+
)
|
|
462
|
+
else
|
|
463
|
+
data
|
|
464
|
+
end
|
|
465
|
+
rescue JSON::ParserError => e
|
|
466
|
+
Rails.logger.error "[RedisQueueWorker] JSON inválido: #{json.inspect}"
|
|
467
|
+
nil
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def wait_for_active_jobs_or_timeout(timeout_seconds)
|
|
474
|
+
start = Time.now
|
|
475
|
+
loop do
|
|
476
|
+
break if @active_leases.empty?
|
|
477
|
+
break if Time.now - start >= timeout_seconds
|
|
478
|
+
Rails.logger.info "[RedisQueueWorker] aguardando #{@active_leases.size} jobs finalizarem antes do shutdown..."
|
|
479
|
+
sleep 1
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
if @active_leases.any?
|
|
483
|
+
Rails.logger.warn "[RedisQueueWorker] Timeout ao aguardar jobs; reencaminhando leases restantes..."
|
|
484
|
+
# re-enfileira qualquer job que ainda esteja no running sem aguardar conclusão
|
|
485
|
+
with_redis do |conn|
|
|
486
|
+
@leases_mutex.synchronize do
|
|
487
|
+
@active_leases.keys.each do |lk|
|
|
488
|
+
# tentamos extrair queue e job_id do lease key (formato conhecido)
|
|
489
|
+
if lk =~ /\Arunning_lease:(.+?):(.+)\z/
|
|
490
|
+
q = $1
|
|
491
|
+
jid = $2
|
|
492
|
+
# job body guard (se existir, já está no lease value)
|
|
493
|
+
job_json = conn.get(lk) rescue nil
|
|
494
|
+
if job_json
|
|
495
|
+
conn.rpush(queue_key(q), job_json) rescue nil
|
|
496
|
+
end
|
|
497
|
+
conn.del(lk) rescue nil
|
|
498
|
+
else
|
|
499
|
+
# nome fora do padrão: apenas delete para evitar ostentação
|
|
500
|
+
conn.del(lk) rescue nil
|
|
501
|
+
end
|
|
502
|
+
@active_leases.delete(lk)
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def setup_signal_handlers
|
|
510
|
+
# ⬇️ Use $stdout.puts em vez de Rails.logger ⬇️
|
|
511
|
+
Signal.trap("TERM") do
|
|
512
|
+
$stdout.puts "[RedisQueueWorker] Sinal TERM recebido, iniciando shutdown..."
|
|
513
|
+
shutdown(immediate: true)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
Signal.trap("INT") do
|
|
517
|
+
$stdout.puts "[RedisQueueWorker] Sinal INT recebido, iniciando shutdown..."
|
|
518
|
+
shutdown(immediate: true)
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# Métodos de classe para configuração
|
|
526
|
+
class << self
|
|
527
|
+
def start_workers
|
|
528
|
+
worker_count = (ENV['REDIS_WORKER_COUNT'] || [3, Concurrent.processor_count].min).to_i
|
|
529
|
+
|
|
530
|
+
worker_count.times do |i|
|
|
531
|
+
Thread.new do
|
|
532
|
+
begin
|
|
533
|
+
new.run
|
|
534
|
+
rescue => e
|
|
535
|
+
Rails.logger.error "[RedisQueueWorker] Worker #{i} crashed: #{e.message}"
|
|
536
|
+
sleep 5
|
|
537
|
+
retry
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
end
|
data/lib/redis_cron_scheduler/web/controllers/redis_cron_scheduler/web/redis_jobs_controller.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# lib/redis_cron_scheduler/web/controllers/redis_jobs_controller.rb
|
|
2
|
+
module RedisCronScheduler
|
|
3
|
+
module Web
|
|
4
|
+
class RedisJobsController < ActionController::Base
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
respond_to do |format|
|
|
8
|
+
format.html do
|
|
9
|
+
@queues_info = fetch_queues_info
|
|
10
|
+
end
|
|
11
|
+
format.json do
|
|
12
|
+
render json: fetch_queues_info
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def fetch_queues_info
|
|
20
|
+
redis_namespace = ENV['REDIS_NAMESPACE']
|
|
21
|
+
queues_info = {}
|
|
22
|
+
queues = %w[default mailers critical]
|
|
23
|
+
|
|
24
|
+
Rails.cache.redis.with do |conn|
|
|
25
|
+
queues.each do |queue|
|
|
26
|
+
queue_key = "#{redis_namespace}queue:#{queue}"
|
|
27
|
+
scheduled_key = "#{redis_namespace}schedule:#{queue}"
|
|
28
|
+
retry_key = "#{redis_namespace}retry:#{queue}"
|
|
29
|
+
running_key = "#{redis_namespace}running:#{queue}"
|
|
30
|
+
dead_key = "#{redis_namespace}dead:#{queue}"
|
|
31
|
+
|
|
32
|
+
pending_count = conn.llen(queue_key) || 0
|
|
33
|
+
scheduled_count = conn.zcard(scheduled_key) || 0
|
|
34
|
+
retry_count = conn.zcard(retry_key) || 0
|
|
35
|
+
dead_count = conn.zcard(dead_key) || 0
|
|
36
|
+
|
|
37
|
+
# Contar keys de running (prefixo usado no worker)
|
|
38
|
+
running_keys = conn.keys("#{running_key}:*") || []
|
|
39
|
+
running_count = running_keys.size
|
|
40
|
+
|
|
41
|
+
queues_info[queue] = {
|
|
42
|
+
pending: pending_count,
|
|
43
|
+
scheduled: scheduled_count,
|
|
44
|
+
retry: retry_count,
|
|
45
|
+
running: running_count,
|
|
46
|
+
dead: dead_count
|
|
47
|
+
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
queues_info
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# lib/redis_cron_scheduler/web/engine.rb
|
|
2
|
+
require "rails/engine"
|
|
3
|
+
|
|
4
|
+
module RedisCronScheduler
|
|
5
|
+
module Web
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace RedisCronScheduler::Web
|
|
8
|
+
|
|
9
|
+
# garante que o Rails enxergue controllers e views dentro do engine
|
|
10
|
+
paths["app/controllers"] << "lib/redis_cron_scheduler/web/controllers"
|
|
11
|
+
paths["app/views"] << "lib/redis_cron_scheduler/web/views"
|
|
12
|
+
# 👇 carrega as rotas internas do engine
|
|
13
|
+
|
|
14
|
+
initializer "redis_cron_scheduler.web.load_routes" do
|
|
15
|
+
config.paths["config/routes.rb"] = File.expand_path("routes.rb", __dir__)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# 👇 este require é essencial para as rotas do engine serem carregadas
|
|
23
|
+
#require_relative "routes"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!-- lib/redis_cron_scheduler/web/views/redis_jobs/index.html.erb -->
|
|
2
|
+
<h1 class="mb-4">Painel Redis Jobs</h1>
|
|
3
|
+
|
|
4
|
+
<table class="table table-striped table-bordered">
|
|
5
|
+
<thead class="table-dark">
|
|
6
|
+
<tr>
|
|
7
|
+
<th>Fila</th>
|
|
8
|
+
<th>Pending</th>
|
|
9
|
+
<th>Scheduled</th>
|
|
10
|
+
<th>Retry</th>
|
|
11
|
+
<th>Running</th>
|
|
12
|
+
<th>Morto</th>
|
|
13
|
+
</tr>
|
|
14
|
+
</thead>
|
|
15
|
+
<tbody id="queues-table-body">
|
|
16
|
+
<% @queues_info.each do |queue, info| %>
|
|
17
|
+
<tr>
|
|
18
|
+
<td><%= queue %></td>
|
|
19
|
+
<td class="pending"><%= info[:pending] %></td>
|
|
20
|
+
<td class="scheduled"><%= info[:scheduled] %></td>
|
|
21
|
+
<td class="retry"><%= info[:retry] %></td>
|
|
22
|
+
<td class="running"><%= info[:running] %></td>
|
|
23
|
+
<td class="dead"><%= info[:dead] %></td>
|
|
24
|
+
</tr>
|
|
25
|
+
<% end %>
|
|
26
|
+
</tbody>
|
|
27
|
+
</table>
|
|
28
|
+
|
|
29
|
+
<script>
|
|
30
|
+
function fetchQueueData() {
|
|
31
|
+
fetch("/redis_cron/redis_jobs.json")
|
|
32
|
+
.then(res => res.json())
|
|
33
|
+
.then(data => {
|
|
34
|
+
const tbody = document.getElementById('queues-table-body');
|
|
35
|
+
tbody.innerHTML = '';
|
|
36
|
+
for (const [queue, info] of Object.entries(data)) {
|
|
37
|
+
const tr = document.createElement('tr');
|
|
38
|
+
tr.innerHTML = `
|
|
39
|
+
<td>${queue}</td>
|
|
40
|
+
<td class="pending">${info.pending}</td>
|
|
41
|
+
<td class="scheduled">${info.scheduled}</td>
|
|
42
|
+
<td class="retry">${info.retry}</td>
|
|
43
|
+
<td class="running">${info.running}</td>
|
|
44
|
+
<td class="dead">${info.dead}</td>
|
|
45
|
+
`;
|
|
46
|
+
tbody.appendChild(tr);
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
.catch(err => console.error("Erro ao atualizar painel:", err));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Atualiza a cada 5 segundos
|
|
53
|
+
setInterval(fetchQueueData, 5000);
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<style>
|
|
57
|
+
.pending { color: blue; font-weight: bold; }
|
|
58
|
+
.scheduled { color: orange; font-weight: bold; }
|
|
59
|
+
.retry { color: red; font-weight: bold; }
|
|
60
|
+
.running { color: green; font-weight: bold; }
|
|
61
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# lib/redis_cron_scheduler.rb
|
|
2
|
+
require "concurrent"
|
|
3
|
+
require "fugit"
|
|
4
|
+
require "redis"
|
|
5
|
+
require "json"
|
|
6
|
+
require "rails"
|
|
7
|
+
|
|
8
|
+
# Carregue ActiveSupport para ter .seconds mesmo sem Rails completo
|
|
9
|
+
require "active_support"
|
|
10
|
+
require "active_support/core_ext/numeric/time"
|
|
11
|
+
require "active_support/core_ext/time/calculations"
|
|
12
|
+
|
|
13
|
+
require_relative "redis_cron_scheduler/version"
|
|
14
|
+
require_relative "redis_cron_scheduler/cron_scheduler"
|
|
15
|
+
require_relative "redis_cron_scheduler/redis_queue_worker"
|
|
16
|
+
require_relative "redis_cron_scheduler/railtie" if defined?(Rails)
|
|
17
|
+
|
|
18
|
+
module RedisCronScheduler
|
|
19
|
+
class Error < StandardError; end
|
|
20
|
+
|
|
21
|
+
def self.start_scheduler
|
|
22
|
+
Thread.new do
|
|
23
|
+
CronScheduler.start
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.start_workers
|
|
28
|
+
RedisQueueWorker.start_workers
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Método para configurar manualmente sem Rails
|
|
32
|
+
def self.configure
|
|
33
|
+
yield self if block_given?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# lib/tasks/redis_cron_scheduler.rake
|
|
2
|
+
namespace :redis_cron do
|
|
3
|
+
desc "Start the cron scheduler"
|
|
4
|
+
task :start_scheduler => :environment do
|
|
5
|
+
RedisCronScheduler.start_scheduler
|
|
6
|
+
puts "Cron scheduler started"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
desc "Start the queue workers"
|
|
10
|
+
task :start_workers => :environment do
|
|
11
|
+
RedisCronScheduler.start_workers
|
|
12
|
+
puts "Queue workers started"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
desc "Start both scheduler and workers"
|
|
16
|
+
task :start_all => :environment do
|
|
17
|
+
Rake::Task['redis_cron:start_scheduler'].invoke
|
|
18
|
+
Rake::Task['redis_cron:start_workers'].invoke
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/web.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: redis_cron_scheduler
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Guy Novaes
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-09-03 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rails
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 8.0.2
|
|
20
|
+
- - ">="
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: 8.0.2.1
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - "~>"
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: 8.0.2
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 8.0.2.1
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: concurrent-ruby
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.1'
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: 1.1.9
|
|
43
|
+
type: :runtime
|
|
44
|
+
prerelease: false
|
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - "~>"
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '1.1'
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: 1.1.9
|
|
53
|
+
- !ruby/object:Gem::Dependency
|
|
54
|
+
name: fugit
|
|
55
|
+
requirement: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: 1.11.0
|
|
60
|
+
type: :runtime
|
|
61
|
+
prerelease: false
|
|
62
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - "~>"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: 1.11.0
|
|
67
|
+
- !ruby/object:Gem::Dependency
|
|
68
|
+
name: redis
|
|
69
|
+
requirement: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - "~>"
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '5.4'
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: 5.4.1
|
|
77
|
+
type: :runtime
|
|
78
|
+
prerelease: false
|
|
79
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - "~>"
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '5.4'
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: 5.4.1
|
|
87
|
+
- !ruby/object:Gem::Dependency
|
|
88
|
+
name: bundler
|
|
89
|
+
requirement: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: 1.15.0
|
|
94
|
+
type: :development
|
|
95
|
+
prerelease: false
|
|
96
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: 1.15.0
|
|
101
|
+
- !ruby/object:Gem::Dependency
|
|
102
|
+
name: rake
|
|
103
|
+
requirement: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - "~>"
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: 13.3.0
|
|
108
|
+
type: :development
|
|
109
|
+
prerelease: false
|
|
110
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
111
|
+
requirements:
|
|
112
|
+
- - "~>"
|
|
113
|
+
- !ruby/object:Gem::Version
|
|
114
|
+
version: 13.3.0
|
|
115
|
+
description: A distributed cron scheduler and background job worker using Redis with
|
|
116
|
+
web dashboard
|
|
117
|
+
email:
|
|
118
|
+
- guynovaes@gmail.com
|
|
119
|
+
executables: []
|
|
120
|
+
extensions: []
|
|
121
|
+
extra_rdoc_files: []
|
|
122
|
+
files:
|
|
123
|
+
- README.md
|
|
124
|
+
- config/scheduled_jobs.json
|
|
125
|
+
- lib/redis_cron_scheduler.rb
|
|
126
|
+
- lib/redis_cron_scheduler/cron_scheduler.rb
|
|
127
|
+
- lib/redis_cron_scheduler/railtie.rb
|
|
128
|
+
- lib/redis_cron_scheduler/redis_queue_worker.rb
|
|
129
|
+
- lib/redis_cron_scheduler/version.rb
|
|
130
|
+
- lib/redis_cron_scheduler/web/controllers/redis_cron_scheduler/web/redis_jobs_controller.rb
|
|
131
|
+
- lib/redis_cron_scheduler/web/engine.rb
|
|
132
|
+
- lib/redis_cron_scheduler/web/routes.rb
|
|
133
|
+
- lib/redis_cron_scheduler/web/views/redis_cron_scheduler/web/redis_jobs/index.html.erb
|
|
134
|
+
- lib/tasks/redis_cron_scheduler.rake
|
|
135
|
+
- lib/web.rb
|
|
136
|
+
homepage: https://github.com/guynovaes/redis_cron_scheduler
|
|
137
|
+
licenses:
|
|
138
|
+
- MIT
|
|
139
|
+
metadata: {}
|
|
140
|
+
post_install_message:
|
|
141
|
+
rdoc_options: []
|
|
142
|
+
require_paths:
|
|
143
|
+
- lib
|
|
144
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
145
|
+
requirements:
|
|
146
|
+
- - ">="
|
|
147
|
+
- !ruby/object:Gem::Version
|
|
148
|
+
version: 2.7.0
|
|
149
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
150
|
+
requirements:
|
|
151
|
+
- - ">="
|
|
152
|
+
- !ruby/object:Gem::Version
|
|
153
|
+
version: '0'
|
|
154
|
+
requirements: []
|
|
155
|
+
rubygems_version: 3.4.20
|
|
156
|
+
signing_key:
|
|
157
|
+
specification_version: 4
|
|
158
|
+
summary: Redis-based cron scheduler and queue worker
|
|
159
|
+
test_files: []
|