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 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,12 @@
1
+ {
2
+ "jobs": [
3
+ {
4
+ "name": "example_job",
5
+ "job_class": "ExampleJob",
6
+ "cron_expression": "0 * * * *",
7
+ "arguments": [],
8
+ "queue": "default",
9
+ "description": "Example job running hourly"
10
+ }
11
+ ]
12
+ }
@@ -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
@@ -0,0 +1,4 @@
1
+ # lib/redis_cron_scheduler/version.rb
2
+ module RedisCronScheduler
3
+ VERSION = "0.0.1"
4
+ end
@@ -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,6 @@
1
+ # lib/redis_cron_scheduler/web/routes.rb
2
+ RedisCronScheduler::Web::Engine.routes.draw do
3
+ get "/" => "redis_jobs#index"
4
+ resources :redis_jobs, only: [:index]
5
+ end
6
+
@@ -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
@@ -0,0 +1,9 @@
1
+ require 'rails/engine'
2
+
3
+ module RedisCronScheduler
4
+ module Web
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace RedisCronScheduler::Web
7
+ end
8
+ end
9
+ end
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: []