Saltar a contenido

Jobs Quartz

El backend corre tareas de fondo con Quartz (spring-boot-starter-quartz, RAMJobStore por default). No hay crons hardcodeados: cada job se da de alta como una fila en la tabla Tareas y se agenda en el arranque. Esto reemplaza al scheduling Seam-async del legacy (TaskMainController con @Startup/@Observer + StdSchedulerFactory por tarea). El detalle del diseño está en SPEC-011; acá está la referencia operativa. Para entender qué hace cada job en el ciclo fiscal, mirá El ciclo de facturación.

Cómo se cargan los jobs

flowchart LR
  A["Arranque<br/>ApplicationReadyEvent"] --> B["TareaScheduler<br/>lee tabla Tareas"]
  B --> C["por cada Tarea:<br/>resolveJobClass + cron"]
  C --> D["scheduler.scheduleJob<br/>JobDetail + CronTrigger"]
  D --> E["REGISTRY id→Tarea<br/>(para CommonJob)"]
  E --> F["¿hay TokenManagerJob?<br/>triggerJob YA (warm-up TA)"]

El componente central es TareaScheduler (action/quartz/TareaScheduler.java):

  1. En @EventListener(ApplicationReadyEvent.class) recorre TareaList.instance().getResultList() (todas las filas de Tareas).
  2. Por cada Tarea resuelve la clase del job desde jobProcessor (nombre simple → se le antepone el paquete com.tipre.tifacturaonlinemanager.action.job.; o FQCN si trae punto), valida que implemente org.quartz.Job y arma un JobDetail + CronTrigger con el cron de programacion.
  3. Guarda la Tarea en un REGISTRY estático (id → Tarea) para que CommonJob la recupere en cada ejecución (el id viaja como description del JobDetail).
  4. Warm-up del TA: si entre las tareas agendadas hay un TokenManagerJob, lo dispara ya mismo (scheduler.triggerJob, en un worker de Quartz) sin esperar al primer cron — así el TA de WSAA está listo apenas arranca el servicio y el POS no pega contra un TA frío. Si el disparo falla, se loguea un WARN y el job igual reintenta por su cron.

La detección del warm-up es por CLASE resuelta, no por string

Detectar el TokenManagerJob matcheando el string crudo "TokenManagerJob" contra jobProcessor era frágil: resolveJobClass acepta tanto el simple name como el FQCN, así que una fila de Tareas con el FQCN (com.tipre...TokenManagerJob) salteaba el warm-up en silencio (sin TA al arranque hasta el primer cron). Ahora la detección (esTokenManagerJob) compara la clase resuelta contra TokenManagerJob.class, así una fila con FQCN también dispara el warm-up.

El scheduler limpia el scope EVENT al terminar

scheduleAllFromDb() usa un componente EVENT-scoped (TareaList.instance()) en el thread del event-multicaster, que es de larga vida y sin filtro HTTP que limpie el scope después. Por eso el método envuelve todo su cuerpo en un try { ... } finally { SeamEventScope.clear() } — el mismo patrón que CommonJob.finallyDo aplica al final de cada job (ver "Limpieza del scope EVENT al fin de cada job" más abajo).

Un solo Scheduler, cargado desde DB

El legacy creaba un scheduler por tarea con new StdSchedulerFactory(). Acá hay un único Scheduler de Spring; consolidarlo no cambia el comportamiento observable (documentado en SPEC-011). Si una Tarea falla al agendarse (clase inexistente, cron inválido), se loguea un ERROR y el resto sigue agendándose.

La entidad Tarea

model/tarea/Tarea.java, mapeada a la tabla Tareas:

Columna Campo Rol
Id id (PK, String) Identidad de la tarea; viaja como description del job.
Descripcion descripcion Nombre legible (lo muestra el cockpit).
Programacion programacion Expresión cron de Quartz (6/7 campos).
JobProcessor jobProcessor Nombre de la clase Java del job.

El JobFactory (QuartzConfig)

config/QuartzConfig.java instala un AutowiringSpringBeanJobFactory vía SchedulerFactoryBeanCustomizer. Así, cuando Quartz instancia un job, sus dependencias (@Autowired) se resuelven contra el contexto de Spring. Por eso cada job es una clase fina que delega la lógica a un @Service.

Los jobs

Cada job extiende CommonJob e implementa org.quartz.Job. El patrón es siempre el mismo: startExecute (lock + bitácora), try { servicio.hacerAlgo() } catch (Throwable) { registrarError } finally { finallyDo }. La lógica de negocio vive en el @Service, no en el job.

Job (jobProcessor) Servicio que invoca Propósito Frecuencia típica
TokenManagerJob EnteFacturadorTokenProvider.get(ente) por cada ente habilitado Mantiene el TA de WSAA vigente proactivamente para todos los comercios (cache-hit casi siempre; LoginCms solo si venció). Frecuente (p. ej. cada 2 min)
CaeaManagerJob CaeaGestionService.gestionarCaeas() Consulta/solicita y persiste los CAEA de la quincena. Según Tareas
ComprobantesEmitidosJob ComprobantesEmitidosService.informarComprobantesEmitidos() Informa a AFIP los comprobantes emitidos con CAEA (FECAEARegInformativo). Según Tareas
PvSinMovimientosJob PvSinMovimientoService.informarPvSinMovimientos() Informa a AFIP los PV sin movimientos de los CAEA vencidos. Según Tareas
NotaCreditoReconciliacionJob NotaCreditoReconciliacionService.reconciliarYEmitirNC(ventanaDias) Reconciliación CAE↔CAEA: detecta tickets doblemente facturados y emite la NC que anula el CAE. Diario (sugerido 0 30 2 * * ?)

El job más crítico: reconciliación de doble facturación

NotaCreditoReconciliacionJob es la pieza más sensible: evita que un mismo ticket quede facturado dos veces en AFIP (una fila CAE + una CAEA con la misma identidad de ticket). Si detecta el cruce, emite la Nota de Crédito CAE que anula el CAE. La ventana hacia atrás es configurable: nc.reconciliacion.ventana-dias (default 35 días, para cubrir CAEAs informados con retraso). Ver Configuración y perfiles.

Por qué TokenManagerJob corre tan seguido — y al arranque

Correrlo cada par de minutos es barato: tokenProvider.get(ente) reutiliza el TA vigente (cache memoria/DB) y solo emite un LoginCms cuando expiró. Así renueva dentro del intervalo apenas el TA caduca y el POS nunca pega contra un TA frío. El TA queda persistido en EnteFacturador y sobrevive reinicios.

Además, TareaScheduler lo dispara una vez apenas arranca el servicio (triggerNow, en un worker de Quartz) en vez de esperar al primer tick del cron. El job reusa el TA persistido si sigue vigente o hace LoginCms; así el TA está caliente desde el segundo cero y no hay una ventana inicial sin token. Si ese disparo de arranque falla, no es fatal: el cron lo reintenta.

Multi-ente: el job (y el warm-up) iteran los N comercios

TokenManagerJob consulta todos los EnteFacturador con habilitado=true (query JPA order by id asc) y llama tokenProvider.get(ente) por cada uno. Cada renovación corre en su propia transacción REQUIRES_NEW y está aislada: si un comercio falla (cert vencido, WSAA caído) se loguea el error, se registra en la bitácora y el loop sigue con el siguiente ente; el job nunca aborta por un solo CUIT. Loguea un resumen ok=N, fallo=M de T entes al terminar. El warm-up del arranque dispara este mismo job, así que también cubre los N comercios. Con 1 solo ente habilitado el comportamiento es byte-equivalente al anterior single-comercio.

Alta de un job nuevo

Insertás una fila en Tareas. Ejemplo (del propio NotaCreditoReconciliacionJob):

INSERT INTO Tareas (Id, Descripcion, Programacion, JobProcessor)
VALUES ('NC_RECONCILIACION', 'Reconciliacion NC CAE-CAEA',
        '0 30 2 * * ?', 'NotaCreditoReconciliacionJob');

Programacion es cron de Quartz: seg min hora díaMes mes díaSemana [año]. El alta se toma en el próximo arranque (el scheduler lee Tareas en ApplicationReadyEvent).

Locking de ejecución (ThreadSafety)

Antes de cada corrida, CommonJob.startExecute toma el lock de ThreadSafety.setExecuting(tarea); finallyDo lo libera con setAvailable(tarea). Es un mapa estático (Map<Tarea,String>) con acceso totalmente sincronizado sobre el propio mapa: si la Tarea ya está en el mapa, setExecuting devuelve false y el job no corre (se loguea MULTIPLE EJECUCION). Esto garantiza un solo proceso por Tarea a la vez (INVARIANTE §5.13), aun si un cron dispara mientras la corrida anterior sigue viva.

Toda mutación del mapa va bajo synchronized (hardening F2)

setExecuting ya tomaba el lock (synchronized (THREADS)) al chequear-y-poner. El setAvailable (remove) y el clearThread (clear) hacían su mutación sin lock → race con el containsKey/put del setExecuting corriendo en otro worker de Quartz (HashMap no es thread-safe: una mutación concurrente puede corromper la estructura o hacer perder un release). El hardening envolvió también remove y clear en synchronized (THREADS), así toda lectura/escritura del mapa va bajo el mismo monitor.

El lock es por-JVM, no distribuido

ThreadSafety es un mapa en memoria del proceso. Protege contra solapamiento dentro de la misma instancia, no entre instancias. Con RAMJobStore y un único deploy esto alcanza; si algún día corren varias instancias contra la misma DB, el locking habría que repensarlo (es una de las invariantes del sistema, §5.13).

Limpieza del scope EVENT al fin de cada job

Al final de cada ejecución, CommonJob.finallyDo llama a SeamEventScope.clear() (commit e7b5ebf, plan 006). Es el equivalente, para los jobs, de lo que SeamEventScopeFilter hace por cada request HTTP: vaciar el scope EVENT del shim seam-compat.

Por qué hace falta: los *Home/*List (EnteFacturadorHome, EnteFacturadorList, etc.) son beans de scope EVENT, guardados en un ThreadLocal por hilo. Quartz corre los jobs en un pool de worker threads de larga vida: sin esta limpieza, cuando un worker reusa el hilo para la corrida siguiente se encuentra los *Home/*List de la corrida anterior — con su filtro/id/entidad managed adentro. Eso es fuga de estado entre ejecuciones, y toca el money-path (la reconciliación de NC y la notificación CAEA leen EnteFacturadorList.instance() desde dentro de un job). El detalle del mecanismo está en La capa de compatibilidad Seam.

El clear() va en un finally propio, después de la escritura de bitácora (para que JobEjecucionRegistro.registrar(...) todavía vea el scope vivo) y blindado para que un fallo de la bitácora no se lo saltee. Como está centralizado en CommonJob.finallyDo, todo job que extienda CommonJob y lo llame en su finally (el patrón establecido) hereda la limpieza sin cambios por job.

Auditoría de ejecuciones (JobEjecucion)

Cada corrida deja una fila en la tabla JobEjecucion (entidad model/job/JobEjecucion.java), que graba CommonJob.finallyDo vía JobEjecucionRegistro:

Columna Significado
tareaId Id de la Tarea.
jobNombre Descripción legible.
inicio / fin Timestamps de la corrida.
resultado 'OK' o 'ERROR'.
error Detalle si falló (truncado a 2000 chars); null si OK.

El registro sobrevive al rollback del job

JobEjecucionRegistro.registrar(...) corre con @Transactional(REQUIRES_NEW): en su propia transacción. Así la bitácora se asienta aunque la transacción del job haya hecho rollback. Y como los jobs tragan la excepción (igual que el legacy), registrarError la reporta igual para que quede en la bitácora y la vea el cockpit. La tabla JobEjecucion la administra el cliente (ddl-auto=none); ver db/migration/add-JobEjecucion.sql.

El cockpit muestra la última ejecución por job: JobEjecucionRegistro.ultimasPorTarea() (una fila por tareaId) más el próximo disparo del trigger Quartz (TareaScheduler.nextFireTime). Eso es lo que arma CockpitJobs para GET /api/cockpit/jobs. Ver Cockpit.

El registro también alimenta la alerta de fallos consecutivos

Para la alerta roja de jobs (hardening F2), JobEjecucionRegistro expone ultimas(tareaId, n): las últimas N ejecuciones de una Tarea ordenadas por inicio desc (@Transactional(readOnly=true)). El cockpit usa este query para detectar cuándo las últimas N corridas consecutivas terminaron en ERROR (umbral cockpit.jobs-alerta-umbral, default 3). La lógica de alerta y su presentación viven del lado del cockpit; acá solo se provee el dato. Ver Cockpit y Config y perfiles.

Por dónde seguir