Skip to content

Cronjobs y tareas programadas

Catálogo consolidado de todo lo que se ejecuta de forma autónoma en el backend de HUMAE: jobs programados vía Laravel Scheduler, cronjobs del sistema operativo, y trabajos planificados para Fase 2.

Arquitectura

HUMAE usa dos capas de automatización:

┌──────────────────────────────────────────────────┐
│  1. Cron del sistema operativo                   │
│                                                  │
│  Entry único en /etc/crontab (www-data):         │
│  * * * * * php artisan schedule:run              │
│                                                  │
│  Se ejecuta cada minuto, delega a Laravel.       │
└──────────────────────┬───────────────────────────┘

┌──────────────────────────────────────────────────┐
│  2. Laravel Scheduler                            │
│                                                  │
│  Registrado en routes/console.php (o Kernel.php):│
│  Schedule::job(new ExpireMembershipsJob)         │
│      ->dailyAt('00:15')->onOneServer();          │
│                                                  │
│  Laravel decide qué jobs correr este minuto.     │
└──────────────────────┬───────────────────────────┘

┌──────────────────────────────────────────────────┐
│  3. Queue worker (Supervisor)                    │
│                                                  │
│  Los jobs encolados (ShouldQueue) se procesan    │
│  aquí, no directamente desde el scheduler.       │
└──────────────────────────────────────────────────┘

Adicionalmente hay cronjobs externos (no Laravel) para cosas como backups de DB.


Inventario maestro — MVP actual

Jobs programados en Laravel

JobFrecuenciaHoraTimezoneQué haceTablas que toca
ExpireMembershipsJobdiario00:15America/Mexico_CityMarca como expired las membresías con expires_at <= now()memberships

Total actual: 1 job programado.

Cronjobs externos (sistema operativo)

TareaFrecuenciaHoraDescripciónImplementación
Laravel scheduler tickcada minutoEntry base que dispara schedule:run* * * * * php artisan schedule:run
Backup MySQLdiario03:00 UTCmysqldump → S3Script /usr/local/bin/humae-db-backup.sh
Rotación de logs LaraveldiariaautomáticaLaravel con LOG_CHANNEL=daily mantiene los últimos 14 archivosSin cronjob, es parte del driver
Let's Encrypt renew (si aplica)2 veces/día00:00 y 12:00Renovar certificados SSLcertbot.timer de systemd

Detalle de cada job

ExpireMembershipsJob

Archivo: app/Jobs/ExpireMembershipsJob.php

Qué hace exactamente:

  1. Query memberships donde status = active AND expires_at <= now().
  2. Actualiza status = expired + updated_at = now().
  3. Loguea el count: [ExpireMembershipsJob] expired N memberships.

Efectos secundarios:

  • El candidato deja de aparecer en el directorio (DirectorySearchService::applyMembershipFilter).
  • Sus asignaciones activas y entrevistas agendadas siguen funcionando.
  • No envía notificación al candidato en MVP (pendiente Fase 2 con MembershipExpiredNotification).

Registro del schedule:

php
// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::job(new ExpireMembershipsJob)
    ->timezone('America/Mexico_City')
    ->dailyAt('00:15')
    ->name('memberships:expire')
    ->onOneServer()
    ->withoutOverlapping(10);

Por qué 00:15 y no 00:00: evita coincidir con otros jobs que podrían correr al inicio exacto del día (backups, rotaciones) y da margen si el reloj del server está ligeramente desfasado.

Dependencias:

  • Servicio inyectado: MembershipService (Laravel resuelve automáticamente).
  • Acceso a DB MySQL (conexión default).

Falla si:

  • MySQL no disponible → job falla, reintenta 3 veces (definido en public int $tries = 3).
  • Si falla las 3 veces → pasa a failed_jobs, alerta por Notification::route('mail', 'admin@humae.com.mx') si se configuró (Fase 2).

Cómo ver qué está programado

Listar todo

bash
php artisan schedule:list

Salida esperada:

+--------+----------------+-----------------------------+----------+
| 0 15 * * * | memberships:expire | ExpireMembershipsJob | 00:15 (America/Mexico_City) |
| * * * * *  | schedule:interrupt |                       |         |
+--------+----------------+-----------------------------+----------+

Ejecutar el scheduler manualmente (dry run)

bash
# Ejecuta cualquier job cuya hora coincida con "now"
php artisan schedule:run

# Probar un job específico (si está nombrado)
php artisan schedule:test
# Muestra menú interactivo

Forzar un job fuera de horario

Para testing en prod o desarrollo:

bash
php artisan tinker
>>> \App\Jobs\ExpireMembershipsJob::dispatchSync();
# Se ejecuta inmediato, bloqueante, ignora schedule

O desde CLI:

bash
php artisan queue:work --once
# Procesa el primer job en la cola

Debugging en producción

¿Se está ejecutando el scheduler?

bash
# 1. Verificar que el cronjob OS está activo
sudo crontab -u www-data -l
# Debe incluir: * * * * * cd /var/www/humae_backend && php artisan schedule:run >> /dev/null 2>&1

# 2. Ver si Laravel lo recibe
tail -f storage/logs/laravel-$(date +%F).log | grep -i "schedule\|ExpireMembership"

# 3. Dry run manual
cd /var/www/humae_backend
php artisan schedule:run -v
# Output verbose de qué se ejecutó y qué no

¿El job corrió pero no hizo nada?

bash
php artisan tinker

# Ver cuántas membresías hay activas que deberían expirar
>>> \App\Models\Membership::where('status', 'active')
...     ->where('expires_at', '<=', now())
...     ->count();

# Si hay pero el job no las tocó, revisar timezone del servidor vs app
>>> now()->timezone;
>>> config('app.timezone');

Causa común: timezone mismatch. El server corre en UTC pero el job espera America/Mexico_City. Si hay 6h de diferencia, el job de medianoche mexicana corre a las 06:15 UTC.

¿El job falló?

bash
php artisan queue:failed
# Lista de failed_jobs

# Ver detalle de uno
php artisan queue:failed:show <uuid>

# Reintentar
php artisan queue:retry <uuid>

# O limpiar
php artisan queue:forget <uuid>

Logs del worker:

bash
sudo supervisorctl tail -f humae-queue:humae-queue_00 stderr

Cómo agregar un nuevo cronjob

Paso 1 — Crear el job

bash
php artisan make:job SendMembershipExpiringWarningJob

Archivo generado en app/Jobs/SendMembershipExpiringWarningJob.php:

php
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Enums\MembershipStatus;
use App\Models\Membership;
use App\Notifications\MembershipExpiringNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

final class SendMembershipExpiringWarningJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $timeout = 120;

    public function handle(): void
    {
        $warningDays = (int) config('humae.membership.expiring_warning_days', 15);

        $expiring = Membership::query()
            ->with('user')
            ->where('status', MembershipStatus::Active->value)
            ->whereBetween('expires_at', [now(), now()->addDays($warningDays)])
            ->whereDoesntHave('warningsSent', fn ($q) =>
                $q->where('type', 'expiring_soon')
                  ->where('sent_at', '>', now()->subDays($warningDays))
            )
            ->get();

        foreach ($expiring as $membership) {
            $membership->user?->notify(new MembershipExpiringNotification($membership));
            // Registrar en membership_notifications_sent para no duplicar
        }

        logger()->info("[SendMembershipExpiringWarningJob] notified {$expiring->count()} users");
    }
}

Paso 2 — Registrar en el scheduler

routes/console.php:

php
use App\Jobs\SendMembershipExpiringWarningJob;

Schedule::job(new SendMembershipExpiringWarningJob)
    ->timezone('America/Mexico_City')
    ->dailyAt('09:00')          // buena hora para correos
    ->name('memberships:warn')
    ->onOneServer()
    ->withoutOverlapping(30);

Paso 3 — Actualizar esta documentación

Agregar fila a la tabla "Inventario maestro" en esta página.

Paso 4 — Verificar

bash
php artisan schedule:list
# Debe listar "memberships:warn" a las 09:00

php artisan schedule:test
# Ejecutar manualmente para verificar el comportamiento

Helpers de frecuencia

Laravel Scheduler ofrece una DSL legible. Los más usados:

MétodoQué hace
->everyMinute()cada minuto
->everyFiveMinutes()cada 5 minutos
->everyTenMinutes()cada 10
->everyThirtyMinutes()cada 30
->hourly()cada hora, al :00
->hourlyAt(15)cada hora, al :15
->daily()diario a las 00:00
->dailyAt('09:00')diario a las 09:00
->twiceDaily(9, 21)dos veces al día
->weekly()semanal (domingo 00:00)
->weeklyOn(1, '08:00')semanal (lunes 08:00)
->monthly()mensual (día 1 00:00)
->monthlyOn(15, '03:00')mensual (día 15 03:00)
->quarterly()trimestral
->yearly()anual
->cron('*/5 * * * *')expresión cron cruda

Timezone

Siempre setear explícitamente para evitar bugs cuando el server cambia de zona:

php
Schedule::job(new MyJob)
    ->timezone('America/Mexico_City')
    ->dailyAt('09:00');

Overlap protection

Si un job puede tardar más que su intervalo, proteger:

php
Schedule::job(new LongJob)
    ->hourly()
    ->withoutOverlapping(60);  // lock expira a los 60 min

Multi-server safety

Si tienes varias instancias del backend con cron activo, evitar que todas corran el mismo job:

php
Schedule::job(new MyJob)
    ->daily()
    ->onOneServer();

Requiere que uses CACHE_STORE=redis o database (no file).

Condicionales

php
Schedule::job(new ReindexSearchJob)
    ->hourly()
    ->when(fn () => config('features.search_enabled'));

Schedule::job(new SomeJob)
    ->daily()
    ->environments(['production', 'staging']);  // no corre en local

Fase 2 — Cronjobs planificados

Jobs mencionados en el roadmap que aún no están implementados:

Job propuestoFrecuencia sugeridaPropósito
SendMembershipExpiringWarningJobdiario 09:00Email 15 días antes de expirar membresía
NotifyMembershipExpiredJobdiario 09:00Email tras expiración + sugerencia de renovar
SendInterviewReminder24hJobcada horaRecordatorio 24h antes de entrevistas confirmadas
SendInterviewReminder1hJobcada 15 minRecordatorio 1h antes + enlace Meet
NotifyVacancyExpiringJobdiario 09:00Avisar a empresas de vacantes próximas a expires_at
AutoCancelExpiredVacanciesJobdiario 00:30Marcar como cancelada vacantes con expires_at < now() y estado no terminal
CleanupDeletedAccountsJobsemanalBorrar data de cuentas soft-deleted > 90 días (GDPR/LFPDPPP)
PreAggregateReportsJobdiario 02:00Calcular métricas del día para /admin/reportes y cachear en Redis
ReindexDirectorySearchJobdiario 03:00Si se agrega Meilisearch/Algolia
PrunePsychometricAttemptsJobmensualLimpiar intentos in_progress abandonados > 30 días
NotifyStaleFavoritesJobsemanalAvisar al recruiter sobre favoritos sin acción > 30 días

Al implementar cualquiera de estos, actualizar esta página con frecuencia, hora y propósito.


Cronjobs externos del sistema operativo

Estos NO son Laravel, son parte de la infra:

1 · Backup de base de datos

Ubicación: /usr/local/bin/humae-db-backup.sh

bash
#!/bin/bash
set -e
DATE=$(date +%F-%H%M)
DUMP_FILE=/tmp/humae_${DATE}.sql.gz

mysqldump \
  -h $DB_HOST -u humae_backup -p"$BACKUP_PASSWORD" \
  --single-transaction --routines --triggers \
  humae_production | gzip > $DUMP_FILE

aws s3 cp $DUMP_FILE s3://humae-backups/db/ \
  --storage-class STANDARD_IA

rm $DUMP_FILE

# Alerta si falla
if [ $? -ne 0 ]; then
  curl -X POST https://hooks.slack.com/services/... \
    -d '{"text":"❌ HUMAE DB backup failed"}'
fi

Crontab:

0 3 * * * /usr/local/bin/humae-db-backup.sh >> /var/log/humae-backup.log 2>&1

Retención: 30 días en S3 (lifecycle rule), 1 día local.

2 · Rotación de logs de Nginx

Laravel rota logs propios con LOG_CHANNEL=daily. Nginx usa logrotate estándar de Linux:

/etc/logrotate.d/nginx-humae:

/var/log/nginx/humae_*.log {
    daily
    rotate 14
    compress
    delaycompress
    notifempty
    create 0640 www-data adm
    sharedscripts
    postrotate
        /usr/sbin/nginx -s reopen
    endscript
}

3 · Renovación SSL (Let's Encrypt)

Si usas certbot, ya viene configurado con systemd timer:

bash
sudo systemctl list-timers | grep certbot
# → certbot.timer   2x al día

Si usas Cloudflare como proxy o gestionado (Vercel/Render), este paso no aplica.

4 · Limpieza de uploads temporales

Si alguna vez acumulas archivos en /tmp:

0 4 * * * find /tmp -name "humae_*" -mtime +7 -delete

Monitoreo operativo

Alertas recomendadas

  1. Job no se ejecutó en las últimas 24h → Dead man's switch. Recomendado con Dead Man's Snitch o similar.

    php
    Schedule::job(new ExpireMembershipsJob)
        ->dailyAt('00:15')
        ->pingOnSuccess('https://nosnch.in/abc123');
  2. failed_jobs crece → alerta si supera N entries en 1h.

  3. Queue backlog → alerta si LLEN queues:default > 100.

  4. Backup DB fallido → alerta desde el script (curl a Slack webhook).

Dashboard admin (Fase 2)

Ruta /admin/cronjobs con:

  • Listado de schedules registrados con última ejecución + próxima ejecución
  • Botón "ejecutar ahora" por cada uno (requires permission)
  • Gráfico de success rate
  • Logs inline

Implementación sugerida: spatie/laravel-schedule-monitor.


Relaciones con otros documentos

Siguiente

Cuándo falla algo y cómo diagnosticarlo: Troubleshooting →

Manual de usuario HUMAE · Uso interno