Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add helper to finalise interrupted validations #136

Open
WhyNotHugo opened this issue May 24, 2022 · 11 comments
Open

Add helper to finalise interrupted validations #136

WhyNotHugo opened this issue May 24, 2022 · 11 comments

Comments

@WhyNotHugo
Copy link
Owner

Find receipts that seem to have failed a previous submission and call revalidate for them.

@npazosmendez
Copy link
Contributor

Buenas! Cómo te imaginabas la implementación de esto? Una que se me ocurre es agregarle un tercer "estado" RESULT_PENDING a ReceiptValidation, y que el procedimiento para validar un Receipt sea:

  1. Crear ReceiptValidation con result=RESULT_PENDING
  2. Validar el Receipt
  3. Setear result=RESULT_APPROVED o result=RESULT_REJECTED al ReceiptValidation existente, según corresponda.

Luego la app al arrancar busca todos los ReceiptValidation en pending y llama revalidate para ellos.

Otra opción más barata podría ser un write ahead log. Te ahorras las llamadas extra a la DB, pero es más laburo de implementación y probablemente requiera persistent storage.

PD: excelente laburo este proyecto

@npazosmendez
Copy link
Contributor

Ah, estuve mirando más en detalle el código y veo que sería más simple, por las garantías que ya da la implementación actual: buscar los Receipt sin un ReceiptValidation pero con un receipt_number. Esos son los candidatos a validaciones interrumpidas.

@WhyNotHugo
Copy link
Owner Author

WhyNotHugo commented Jun 20, 2024

Creo que esto podría funcionar:

for receipt in Receipt.objects.filter(
    receipt_number__isnull=False,
    validation__isnull=True,
):
    receipt.revalidate()

@WhyNotHugo
Copy link
Owner Author

WhyNotHugo commented Jun 20, 2024

Corrección: ese ejemplo saltearía comprobantes con alguna validación fallida.

@WhyNotHugo
Copy link
Owner Author

Capaz así?

for receipt in Receipt.objects.filter(
    receipt_number__isnull=False,
).exclude(
    validation__result=ReceiptValidation.RESULT_APPROVED,
):
    receipt.revalidate()

@npazosmendez
Copy link
Contributor

Suena bien 👍 . Salvo que me esté perdiendo algo, el código actual nunca crea un ReceiptValidation con ReceiptValidation.RESULT_REJECTED ante errores, solamente ReceiptValidation.RESULT_APPROVED:

for cae_data in response.FeDetResp.FECAEDetResponse:
if cae_data.Resultado == ReceiptValidation.RESULT_APPROVED:
validation = ReceiptValidation.objects.create(
result=cae_data.Resultado,
cae=cae_data.CAE,
cae_expiration=parsers.parse_date(cae_data.CAEFchVto),
receipt=self.get(
receipt_number=cae_data.CbteDesde,
),
processed_date=parsers.parse_datetime(
response.FeCabResp.FchProceso,
),
)
if cae_data.Observaciones:
for obs in cae_data.Observaciones.Obs:
observation = Observation.objects.create(
code=obs.Code,
message=obs.Msg,
)
validation.observations.add(observation)
elif cae_data.Observaciones:
for obs in cae_data.Observaciones.Obs:
errs.append(f"Error {obs.Code}: {parsers.parse_string(obs.Msg)}")
# Remove the number from ones that failed to validate:
self.filter(validation__isnull=True).update(receipt_number=None)
return errs

En ese caso, tu versión anterior también debería funcionar.

Pero no sé si en algún momento se usó el RESULT_REJECTED, y la DB podría tener alguno con result = RESULT_REJECTED.

@npazosmendez
Copy link
Contributor

npazosmendez commented Jun 21, 2024

Tema relacionado: que opinás de que el revalidate setee receipt_number=None en caso de que no haya sido validado? Así como hace validate para los fallidos:

self.filter(validation__isnull=True).update(receipt_number=None)

Si un Receipt no validado queda con receipt_number != None, al llamar Receipt.validate() se va a intentar usar ese receipt_number ya seteado, que probablemente sea viejo.

Edit: y ahora que lo pienso, no setearlo en None hace que se intente revalidar para siempre.

@WhyNotHugo
Copy link
Owner Author

Puede que valga la pena eliminar ReceiptValidation.RESULT_REJECTED completamente. Por ahora dejo un issue como recordatorio: #214

que opinás de que el revalidate setee receipt_number=None en caso de que no haya sido validado?

Puede haber problemas de concurrencia:

  • [thread1] validate setea el receipt_number y empieza a comunicarse con el AFIP.
  • [thread2] revalidate encuentra el comprobante a medio validar, consulta al AFIP el estado.
  • [thread2] revalidate recibe su respuesta antes que validate. revalidate elimina el receipt_number.
  • [thread1] guarda los datos de validación, pero el receipt_number queda perdido.

Es poco probable pero posible.

Creo que habría que lockear las fila durante la validación para evitar este problema.

@npazosmendez
Copy link
Contributor

npazosmendez commented Jun 22, 2024

Buen catch. Estuve pensando en esto de setear el receipt_number en null, acá lo que pensé:

Opcion 1: Lockear las filas

Te dejo el walltext oculto, pero TLDR: creo que se pueden lockear para asegurar la integridad de datos, pero me surgen algunos caveats que hacen medio mala la UX.

walltext

Estuve viendo de cambiar validate para que lockee las filas, algo así (se poco de Django, perdón de antemano):

def validate(...):
      ...
      qs = self.filter(validation__isnull=True).check_groupable()

      # Return early if queryset is empty:
      first = qs.first()
      if first is None:
          return []

      qs.order_by("issued_date", "id")._assign_numbers()
      with transaction.atomic():
          qs = qs.filter(receipt_number__isnull=False).select_for_update()
          ...
          check_response(response)
          errs = []
          for cae_data in response.FeDetResp.FECAEDetResponse:
              validation = ReceiptValidation.objects.create(
              ...

          # Remove the number from ones that failed to validate:
          qs.filter(validation__isnull=True).update(receipt_number=None)

De yapa, esto debería hacer al validate() thread safe (en cuanto a la integridad de datos, no en cuanto a excepciones de AFIP), por poner el qs.filter(validation__isnull=True).update(receipt_number=None) adentro de la transacción. Por otro lado revalidate() haría algo como:

def revalidate(self) -> ReceiptValidation | None:
    ...
    if receipt_data.Resultado == ReceiptValidation.RESULT_APPROVED:
        ...
        return validation
    # Update atomically, skip if it was just validated
    Receipt.objects.filter(pk=self.pk, validation__isnull=True).update(
        receipt_number=None,
    )
    return None

Tendría que pensar un poco más para ver si me estoy periendo algún caso, pero hay algo de esto que no me gusta: si bien (creo) que esto hace que la race condition de tu ejemplo no pierda el receipt_number de una validación, lo que sí ocurre es que no se valida un Receipt al que el usuario llamó .validate(), se skipea. Se mantiene integridad, pero es un comportamiento medio inesperado.

Una que se me ocurre es, luego del qs = qs.filter(receipt_number__isnull=False).select_for_update(), checkear si el set de receipts que quedó es el mismo, y si no, agregar un error a errs (o throwear?). No haría a revalidate y validate thread safe "entre sí", pero al menos el comportamiento es más intuitivo.

Otra opción es empezar a jugar con locks con timeouts.

Opcion 2: comparar con último comprobante

Esta se me ocurrió después, mucho más simple: que el revalidate compare el Receipt.receipt_number con FECompUltimoAutorizado, probablemente dentro de una transacción por si cambia el point of sales y receipt type. Si ya fue usado, borrarlo.

Si no fue usado y se lo deja seteado, todavía podría haber problemas: podría asignarsele el mismo receipt_number a otro Receipt, y si se intentan validar juntos, qs.validate() va a throwear. Lo que podemos hacer es que el validate haga un except <El numero o fecha del comprobante no se corresponde con el proximo a autorizar. ..>, y setee todos los receipt_number = None si tal cosa pasa. Así, el próximo intento debería funcionar. Esto podría hacerse siempre de hecho, más allá del contexto de este problema.


PD: tal vez esté dandole mucha vuelta a un caso bastante anómalo 😅 . La verdad es que la implementación actual no tiene ningún problema de integridad de datos. Para clarificar, lo que estoy queriendo resolver sería: (1) que no se apilen los Receipts no validados por crashes, y (2) que Receipts.validate() para un receipt no validado por crashes no throwee.

@WhyNotHugo
Copy link
Owner Author

Me parece que la opción 1 va bien 👍

@npazosmendez
Copy link
Contributor

Acá hay un primer draft con la opción 1: #217

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants