Odoo · DevOps · Automatización
Un ir.cron mal diseñado no falla de forma ruidosa: se ejecuta dos veces, se solapa consigo mismo o deja registros a medio procesar sin que nadie se entere hasta semanas después. La regla que evita el 90% de estos incidentes es simple: toda tarea programada debe poder ejecutarse dos veces sin romper nada. Veamos cómo diseñar crons fiables e idempotentes.
Diseño
ir.cron vs cola de trabajos
ir.cron está pensado para tareas periódicas de mantenimiento: limpiar registros caducados, sincronizar un catálogo cada hora, recalcular un indicador cada noche. Cuando la tarea nace de una acción de usuario (confirmar un pedido, subir un fichero) y necesita reintentos con backoff, lo correcto es delegarla a una cola como queue_job en lugar de forzarla dentro de un cron.
<record id="ir_cron_sync_catalog" model="ir.cron">
<field name="name">Sincronizar catálogo con ERP</field>
<field name="model_id" ref="model_product_template"/>
<field name="state">code</field>
<field name="code">model._cron_sync_catalog()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
</record>
La lógica nunca vive en el XML: el code del cron llama a un único método Python testeable, y ese método es el que contiene las precondiciones y el manejo de errores.
Idempotencia
Que la segunda ejecución no duplique nada
class ProductTemplate(models.Model):
_inherit = 'product.template'
def _cron_sync_catalog(self):
pending = self.search([
('sync_state', '=', 'pending'),
], limit=200)
for product in pending:
try:
product._push_to_erp()
product.sync_state = 'done'
except ExternalApiError:
product.sync_state = 'error'
_logger.warning(
"Fallo sincronizando %s", product.default_code)
El truco no es evitar que el cron se ejecute dos veces —eso pasa igualmente por reinicios, workers duplicados o un numbercall mal calculado—, sino que cada ejecución consulte el estado real en base de datos antes de actuar. Si un registro ya está en done, el filtro del search lo excluye solo; no hace falta lógica extra de deduplicación.
doall=True, si el cron estuvo parado varios días, Odoo intenta recuperar todas las ejecuciones perdidas de golpe. Para tareas de sincronización esto genera un pico de carga innecesario; deja que retome desde "ahora" salvo que el negocio exija recuperar el histórico.
Concurrencia
Evitar que dos workers procesen el mismo lote
def _cron_sync_catalog(self):
pending = self.search([
('sync_state', '=', 'pending'),
], limit=200)
pending._for_update_skip_locked()
for product in pending:
...
Con varios workers cron activos, dos procesos pueden leer el mismo lote de registros pendientes antes de que ninguno los marque como procesados. Un SELECT ... FOR UPDATE SKIP LOCKED a nivel de SQL (o un candado explícito por lote) es la única forma de garantizar que cada registro lo procesa un único worker.
Checklist
Antes de activar un cron en producción
| ✅ Comprobación | Por qué |
|---|---|
| Lógica en un método Python, no en el XML | Testeable y auditable |
| Filtra por estado antes de actuar | Idempotencia ante reintentos |
doall=False salvo necesidad real | Evita picos de carga por backlog |
| Maneja excepciones por registro, no por lote | Un fallo no bloquea el resto |
numbercall y interval revisados | Evita solapes con la duración real de la tarea |
Resumen
Usa ir.cron para tareas periódicas de mantenimiento y una cola para trabajo asíncrono derivado de acciones de usuario. Diseña cada ejecución para que sea idempotente comprobando el estado real antes de actuar, evita doall=True salvo que necesites recuperar histórico, y asume que habrá ejecuciones concurrentes: bloquea a nivel de fila si varios workers pueden procesar el mismo lote.