Los trabajos delay_job de larga ejecución permanecen bloqueados después de un reinicio en Heroku

Cuando se reinicia un trabajador de Heroku (ya sea por comando o como resultado de una implementación), Heroku envía SIGTERM al proceso de trabajo. En el caso de delayed_job, la SIGTERM la señal es captada y luego el trabajador deja de ejecutarse después de que el trabajo actual (si lo hay) se haya detenido.

Si el trabajador tarda demasiado en terminar, entonces Heroku enviará SIGKILL. En el caso de delayed_job, esto deja un trabajo bloqueado en la base de datos que no será recogido por otro trabajador.

Me gustaría asegurarme de que los trabajos finalmente finalicen (a menos que haya un error). Dado eso, ¿cuál es la mejor manera de abordar esto?

Veo dos opciones. Pero me gustaría obtener otra entrada:

  1. modificar delayed_job para dejar de trabajar en el trabajo actual (y liberar el bloqueo) cuando recibe un SIGTERM.
  2. Descubra una forma (programática) de detectar trabajos bloqueados huérfanos y luego desbloquearlos.

¿Alguna idea?

preguntado el 03 de mayo de 12 a las 20:05

6 Respuestas

Abortar trabajo limpiamente en SIGTERM

Una solución mucho mejor ahora está integrada en delay_job. Use esta configuración para lanzar una excepción en las señales TERM agregando esto en su inicializador:

Delayed::Worker.raise_signal_exceptions = :term

Con esa configuración, el trabajo se limpiará correctamente y se cerrará antes de que heroku emita una señal KILL final destinada a los procesos que no cooperan:

Es posible que deba generar excepciones en las señales SIGTERM, Delayed::Worker.raise_signal_exceptions = :term hará que el trabajador genere una SignalException, lo que hará que el trabajo en ejecución se cancele y se desbloquee, lo que hace que el trabajo esté disponible para otros trabajadores. El valor predeterminado para esta opción es falso.

Posibles valores para raise_signal_exceptions son:

  • false - No se harán excepciones. (Por defecto)
  • :term - Solo generará una excepción en las señales TERM, pero INT esperará a que finalice el trabajo actual.
  • true - Provocará una excepción en TERM e INT

Disponible desde la versión 3.0.5.

Vea este compromiso donde se presentó.

contestado el 08 de mayo de 19 a las 21:05

Gracias por publicar. Tendré que revisar esto. - M.Scott Ford

¿Cómo trata esto los problemas en los que un Mailer se está comunicando con un servidor SMTP, ha enviado completamente la solicitud y el servidor la ha recibido, luego un SignalException se genera antes de que Ruby pueda cerrar la conexión y terminar la respuesta? Parece que entonces estaría ejecutando el trabajo nuevamente. Parece que habría que poner un énfasis adicional en hacer que los trabajos sean 100% atómicos. - malhechor

Realmente no es posible hacer que ese tipo de comunicación de sistemas distribuidos sea "atómica" sin una reingeniería significativa de ambos sistemas. Yo pondría eso en la categoría de "cosas que pasan". A veces se enviarán varios correos. Esta solución es probablemente la mejor que se puede hacer. - Alex Neth

Pregunta: ¿tenemos que verificar o manejar esta excepción en nuestro trabajo o el trabajo "fallará" y quedará desbloqueado desde esta única línea de configuración? - Josué pinter

donde la API de envío de correo no proporciona idempotencia (como lo hace Stripe cuando agrega una ID de solicitud), puede falsificarla usted mismo adjuntar datos a los mensajes, a saber, la identificación de su trabajo que envía el correo (y algún espacio de nombre/prefijo, o un uuid guardado en el trabajo), luego use la API del servicio de correo para buscar el correo enviado con esa identificación antes de volver a enviar. Suponiendo que 1 trabajador haga el trabajo a la vez (con un bloqueo o un contrato de arrendamiento), debería garantizar el envío exactamente una vez, a menos que la búsqueda de API pueda perder los mensajes enviados recientemente. - verdad

TLDR:

Ponga esto en la parte superior de su método de trabajo:

begin
  term_now = false
  old_term_handler = trap 'TERM' do
    term_now = true
    old_term_handler.call
  end

Y DE

Asegúrese de que esto se llame al menos una vez cada diez segundos:

  if term_now
    puts 'told to terminate'
    return true
  end

Y DE

Al final de tu método, pon esto:

ensure
  trap 'TERM', old_term_handler
end

Explicación:

Yo estaba teniendo el mismo problema y me encontré este artículo de Heroku.

El trabajo contenía un bucle externo, así que seguí el artículo y agregué un trap('TERM') y exit. sin embargo delayed_job recoge eso como failed with SystemExit y marca la tarea como fallida.

Con la SIGTERM ahora atrapado por nuestro trap el controlador del trabajador no se llama y en su lugar reinicia inmediatamente el trabajo y luego obtiene SIGKILL unos segundos después. Volver al punto de partida.

Probé algunas alternativas a exit:

  • A return true marca el trabajo como exitoso (y lo elimina de la cola), pero sufre el mismo problema si hay otro trabajo esperando en la cola.

  • llamar exit! saldrá con éxito del trabajo y del trabajador, pero no permite que el trabajador elimine el trabajo de la cola, por lo que aún tiene el problema de los "trabajos bloqueados huérfanos".

Mi solución final fue la que se da en la parte superior de mi respuesta, se compone de tres partes:

  1. Antes de comenzar el trabajo potencialmente largo, agregamos un nuevo controlador de interrupciones para 'TERM' haciendo un trap (como se describe en el artículo de Heroku), y lo usamos para establecer term_now = true.

    Pero también debemos agarrar el old_term_handler que el código de trabajador de trabajo retrasado set (que es devuelto por trap) y Recuerda call él.

  2. Todavía debemos asegurarnos de devolver el control a Delayed:Job:Worker con tiempo suficiente para que se limpie y se apague, por lo que debemos verificar term_now al menos (poco menos) cada diez segundos y return si esto es true.

    Puede return true or return false dependiendo de si desea que el trabajo se considere exitoso o no.

  3. Finalmente es vital para recordar quitar su controlador e instalar de nuevo el Delayed:Job:Worker uno cuando haya terminado. Si no lo hace, mantendrá una referencia pendiente a la que agregamos, lo que puede provocar una pérdida de memoria si agrega otra encima (por ejemplo, cuando el trabajador comienza este trabajo nuevamente).

Respondido 03 Oct 12, 17:10

@M.ScottFord He actualizado la respuesta, tenga en cuenta que la anterior provocaría una pérdida de memoria. - davetapley

Nuevo en el sitio, por lo que no puede comentar la publicación de Dave y necesita agregar una nueva respuesta.

El problema que tengo con el enfoque de Dave es que mis tareas son largas (minutos hasta 8 horas) y no son repetitivas en absoluto. No puedo "asegurarme de llamar" cada 10 segundos. Además, probé la respuesta de Dave y el trabajo siempre se elimina de la cola, independientemente de lo que devuelva, verdadero o falso. No tengo claro cómo mantener el trabajo en la cola.

Ver este esta solicitud de extracción. Creo que esto puede funcionar para mí. Siéntase libre de comentarlo y apoyar la solicitud de extracción.

Actualmente estoy experimentando con una trampa y luego rescate la señal de salida... Sin suerte hasta ahora.

Respondido 30 Oct 12, 00:10

Esto no parece responder a su pregunta. Esto debe volver a publicarse como un comentario o como su propia pregunta. - austin henley

No me di cuenta de que no puedes comentar a menos que tengas una cierta cantidad de representantes, eso es molesto, así que entiendo por qué publicaste como respuesta. Dicho esto, no sé cómo no encontré esa solicitud de extracción. Le sugiero que reformule su respuesta para que sea "ver esta solicitud de extracción" porque creo que constituye una respuesta a la pregunta. También voy a publicar en ese requerimiento de extracción ahora. - davetapley

Aquí están mis pensamientos sobre esa solicitud de extracción: github.com/collectiveidea/delayed_job/pull/… - davetapley

Eso es lo que max_run_time es para: después max_run_time ha transcurrido desde el momento en que se bloqueó el trabajo, otros procesos podrán adquirir el bloqueo.

Vea esta discusión de los grupos de google

Respondido el 26 de Septiembre de 12 a las 02:09

Terminé teniendo que hacer esto en algunos lugares, así que creé un módulo que coloco en lib/, y luego ejecuté ExitOnTermSignal.execute { long_running_task } desde dentro del bloque de ejecución de mi trabajo retrasado.

# Exits whatever is currently running when a SIGTERM is received. Needed since
# Delayed::Job traps TERM, so it does not clean up a job properly if the
# process receives a SIGTERM then SIGKILL, as happens on Heroku.
module ExitOnTermSignal
  def self.execute(&block)
    original_term_handler = Signal.trap 'TERM' do
      original_term_handler.call
      # Easiest way to kill job immediately and having DJ mark it as failed:
      exit
    end

    begin
      yield
    ensure
      Signal.trap 'TERM', original_term_handler
    end
  end
end

Respondido el 12 de diciembre de 12 a las 23:12

delay_job 3.0.5 ahora admite una opción para generar una excepción en la señal TERM: github.com/collectiveidea/delayed_job/commit/… - Ari

Utilizo una máquina de estado para realizar un seguimiento del progreso de los trabajos y hago que el proceso sea idempotente para que pueda llamar a realizar un trabajo/objeto determinado varias veces y estar seguro de que no volverá a aplicar una acción destructiva. Luego actualice la tarea de rake/delayed_job para liberar el inicio de sesión en TÉRMINO.

Cuando el proceso se reinicie, continuará según lo previsto.

contestado el 04 de mayo de 12 a las 09:05

No es la respuesta que estás buscando? Examinar otras preguntas etiquetadas or haz tu propia pregunta.