Artículo técnico

Guardado a prueba de fallos en HotXLS: archivos temporales en dos fases en Delphi

Un guardado que muere a mitad de camino, ya sea por un reinicio forzado, un proceso terminado a la fuerza o un disco que se llena durante la escritura, tradicionalmente ha significado una cosa para un formato construido en torno a escrituras in situ: os quedáis con los bytes que llegaron al disco antes de la interrupción, y un libro truncado no vuelve a abrirse. HotXLS cierra ese modo de fallo con una vía de guardado a prueba de fallos que se usa para cada archivo XLSX, ODS y XLS clásico que escribe. Cada llamada a SaveAs escribe el archivo nuevo completo en un archivo temporal creado junto al destino, y después lo confirma con un único renombrado atómico MoveFileExW de la API de Windows, así que un guardado interrumpido solo puede fallar en producir el archivo nuevo, nunca daña el que ya teníais. La misma disciplina de preparar-y-luego-sustituir se ejecuta de manera uniforme en los dos motores de guardado de HotXLS, el escritor BIFF8 que hay detrás del XLS clásico y el escritor OOXML que hay detrás de XLSX y ODS, y es un patrón que merece la pena tomar prestado para cualquier archivo que vuestro propio código Delphi sobrescriba directamente, sean hojas de cálculo o no

¿Qué ocurre si se interrumpe a mitad de camino el guardado de un libro?

La respuesta directa es que depende por completo de cómo toque el destino el escritor, y la implementación habitual, abrir el archivo de destino y volcar el contenido nuevo directamente en él, funciona bien mientras nada salga mal jamás. En el momento en que algo sí sale mal, un fallo, un proceso terminado a la fuerza, una unidad de red que se cae a mitad de escritura, el archivo en disco queda en cualquiera que sea el estado intermedio al que hubiera llegado el escritor: un directorio central ZIP que nunca llegó a añadirse para XLSX u ODS, o un flujo BIFF al que le faltan registros que un lector espera para el XLS clásico. Excel no repara eso con elegancia, y tampoco lo hace ningún otro consumidor que espere un archivo completo, así que el resultado práctico es un libro que ayer se abría perfectamente y hoy se niega a abrirse

Cómo prepara HotXLS cada guardado detrás de una sustitución atómica

HotXLS nunca abre el archivo de destino para escritura directamente, en ninguno de los tres formatos que guarda. La secuencia tiene la misma forma cada vez: construir la salida completa en algún lugar que no sea el archivo que el usuario ya tiene en disco, y solo moverla a su sitio una vez que esa construcción haya tenido éxito por completo. En concreto, SaveAs crea un archivo temporal vacío en la misma carpeta que la ruta de destino, escribe el libro nuevo entero en ese archivo temporal, y solo después de que esa escritura regrese sin error confirma el archivo temporal sobre el destino con un único renombrado. Nada de esto requiere ninguna propiedad que activar; es simplemente lo que hace SaveAs para una ruta de archivo normal, en cada llamada

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Report');
    Sheet.Cells[1, 1].Value := 'Nothing special to enable here';
    // If this call is interrupted, monthly-report.xlsx on disk stays
    // either the old version, complete, or the new version, complete
    if Book.SaveAs('monthly-report.xlsx', xlsxOpenXMLWorkbook) <> 1 then
      raise Exception.Create('Save failed, see Book.LastDiagnostic');
  finally
    Book.Free;
  end;
end;

La misma disciplina se aplica al escritor XLS clásico, no solo al de OOXML, y los dos archivos temporales incluso comparten una convención de nombres: ambos llaman a la API de Windows GetTempFileNameW con el prefijo hxl, así que un guardado interrumpido antes de la limpieza puede dejar atrás un archivo suelto con un nombre como hxl4C2A.tmp junto a vuestro libro. Ese archivo no es corrupción, es la prueba de que el mecanismo funcionó exactamente como estaba diseñado: la escritura incompleta se detuvo ahí, y vuestro libro real nunca llegó a abrirse para escritura en primer lugar. Ver uno tras un fallo es seguro de eliminar y no hay nada que investigar

¿Por qué preparar el archivo temporal junto al libro en lugar de en %TEMP%?

La respuesta corta es que el renombrado de MoveFileExW solo es atómico cuando el origen y el destino residen en el mismo volumen, y la forma más segura de garantizarlo sin pedirle a quien llama que configure nada es derivar la ubicación del archivo temporal de la propia ruta de destino. HotXLS calcula la carpeta propia del destino y entrega ese directorio directamente a GetTempFileNameW, así que el archivo temporal siempre se crea en la misma unidad, el mismo volumen, que el archivo que está a punto de sustituir, automáticamente, en cada guardado. Si la biblioteca en cambio hubiera preparado las escrituras en la carpeta temporal del sistema, una ruta de destino en una unidad distinta o en un volumen de red asignado habría convertido el paso final en una operación entre volúmenes, que la API de Windows o bien rechaza de plano o bien, si quien llama opta explícitamente por un indicador adicional que HotXLS no establece aquí, degrada silenciosamente a una copia no atómica seguida de un borrado, reabriendo exactamente la ventana de interrupción que todo este mecanismo existe para cerrar

El paso de confirmación: MoveFileExW, escritura garantizada y qué ocurre si falla

El paso final de cada guardado es exactamente una llamada a la API de Windows, MoveFileExW, que lleva dos indicadores que hacen cada uno un trabajo distinto. MOVEFILE_REPLACE_EXISTING es lo que permite que el renombrado recaiga sobre un archivo que ya existe; sin él, un renombrado dirigido a una ruta existente simplemente falla, lo que echaría por tierra todo el propósito de un guardado pensado para sustituir un libro que ya tenéis. MOVEFILE_WRITE_THROUGH cubre la durabilidad: le indica a la función que no regrese hasta que el movimiento realmente se haya completado en disco, en lugar de regresar en cuanto el renombrado simplemente se ha puesto en cola, cerrando una ventana de carrera más estrecha pero real en la que un fallo justo después de que SaveAs regrese aún podría atrapar la sustitución en pleno vuelo. Si el archivo temporal no se puede crear, o el renombrado final falla por cualquier motivo (un problema de permisos, un destino bloqueado, un volumen distinto), HotXLS elimina él mismo el archivo temporal en lugar de dejar basura atrás, y el archivo de destino queda exactamente como estaba antes de la llamada

Result := Book.SaveAs(TargetPath, xlsxOpenXMLWorkbook);
if Result <> 1 then
begin
  // TargetPath on disk is unchanged; safe to retry, alert, or
  // fall back to a different path without touching prior output
  LogWriter.Write(Format('SaveAs failed (%d): %s',
    [Book.LastDiagnostic.Code, Book.LastDiagnostic.Message]));
  Exit(False);
end;

El propio SaveAs mantiene la convención de retorno compartida en toda HotXLS, uno en caso de éxito, un número negativo en caso de fallo, pero un simple entero no dice por qué falló un guardado, y tratar cada resultado negativo de la misma manera desperdicia información que una política de reintento podría aprovechar de verdad. La propiedad LastDiagnostic, y la colección Diagnostics más completa que hay detrás, lleva el mensaje que HotXLS generó internamente, distinguiendo un archivo temporal que no se pudo crear de un renombrado que Windows rechazó. Un trabajo por lotes que registre Code y Message en cada SaveAs fallido acumula exactamente la evidencia que queréis tener el día que un cliente informe de un guardado que en silencio no hizo nada

El XLS clásico paga con memoria, XLSX y ODS pagan con disco

Los dos motores de guardado alcanzan el mismo resultado a prueba de fallos por rutas distintas, y la diferencia importa si ya estáis ajustando alguno de los dos para un trabajo por lotes grande. El escritor XLS clásico construye primero todo el documento compuesto OLE en memoria, usando almacenamiento estructurado respaldado por un handle de memoria, y solo copia ese búfer terminado al archivo temporal hermano en una única escritura; el razonamiento en el propio código fuente de HotXLS es directo: construir primero el archivo completo en memoria es lo que impide que un guardado fallido o cancelado llegue jamás a truncar el destino. El escritor de XLSX y ODS, en cambio, vuelca sus entradas ZIP en el archivo temporal a medida que se producen, la misma preparación a nivel de archivo pero con un perfil de memoria distinto. Si ya estáis recurriendo a StreamingWrite para mantener las exportaciones XLSX grandes dentro del límite de memoria de un contenedor, tened en cuenta que la palanca equivalente para la exportación de XLS clásico no existe en la misma forma: la garantía a prueba de fallos es incondicional en ambos casos, pero una exportación .xls heredada muy grande retiene toda su salida en RAM de todos modos, una compensación cubierta con más detalle en nuestro artículo sobre escrituras en flujo para trabajos por lotes en servidor

Aplicar el mismo patrón fuera de HotXLS, y dónde termina la garantía

Tomar prestado el patrón es sobre todo cuestión de conectar las mismas dos llamadas a la API de Windows en las que se apoya internamente HotXLS. GetTempFileNameW os entrega un archivo vacío con nombre único en una carpeta que elijáis, y MoveFileExW confirma vuestra escritura terminada sobre el destino real en un solo paso; una versión mínima de la misma rutina que HotXLS ejecuta antes de cada SaveAs tiene este aspecto

function SaveFileAtomically(const Path: WideString; const Contents: TBytes): Boolean;
var
  Dir, TempName: WideString;
  Buffer: array[0..MAX_PATH] of WideChar;
  FS: TFileStream;
begin
  Result := False;
  Dir := ExtractFilePath(ExpandFileName(Path));
  FillChar(Buffer, SizeOf(Buffer), 0);
  if GetTempFileNameW(PWideChar(Dir), 'app', 0, @Buffer[0]) = 0 then
    Exit;
  TempName := PWideChar(@Buffer[0]);
  try
    FS := TFileStream.Create(TempName, fmCreate or fmShareExclusive);
    try
      FS.WriteBuffer(Contents[0], Length(Contents));
    finally
      FS.Free;
    end;
    Result := MoveFileExW(PWideChar(TempName), PWideChar(ExpandFileName(Path)),
      MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH);
  finally
    if not Result then
      DeleteFileW(PWideChar(TempName));
  end;
end;

La garantía tiene límites reales que conviene conocer antes de confiar en ella a ciegas. Preparar una copia completa antes de sustituir el original significa que un guardado necesita brevemente espacio en disco tanto para el archivo antiguo como para el nuevo, aproximadamente el doble del tamaño del libro durante la escritura, lo cual está bien para un informe y merece comprobarse para una exportación de varios gigabytes que se ejecute contra un volumen casi lleno. El archivo temporal también tiene que aterrizar en la misma carpeta que el destino, así que la cuenta bajo la que se ejecute HotXLS necesita permiso de creación de archivos en esa carpeta en concreto, no solo permiso para sobrescribir el único archivo que ya conoce; un despliegue que restrinja una carpeta de destino a ediciones in situ de nombres de archivo existentes específicos, en lugar de acceso de escritura a nivel de carpeta, verá fallar SaveAs en el paso del archivo temporal aunque la escritura directa equivalente hubiera tenido éxito

Merece la pena señalar claramente dos límites más. Un destino en una unidad de red o dentro de una carpeta sincronizada por OneDrive o un cliente similar puede comportarse de forma distinta a NTFS local aunque Windows lo siga reportando como un único volumen, ya que el controlador de sistema de archivos que hay delante puede no implementar el renombrado del mismo modo; si vuestro entorno de despliegue guarda a través de una ruta de red, merece la pena probar allí específicamente una interrupción forzada en lugar de asumir que el comportamiento de disco local se traslada sin más. Y todo el mecanismo está acotado a guardar en un archivo con nombre. Llamad a SaveAs contra un TStream en su lugar, y HotXLS escribe directamente en el flujo que le hayáis entregado, sin ningún archivo de destino que preparar ni proteger, porque la durabilidad de ese flujo (un búfer de memoria, una subida por red, un blob de base de datos) es responsabilidad íntegra de vuestro código a partir de ese punto

Una pasada de verificación puede confiar exactamente en esta garantía después, incluida la que lleva incorporada un banco de auditoría y conversión de libros: un archivo reabierto que vuelve incompleto o ausente es un problema de conversión real que investigar, nunca un guardado que se interrumpió a mitad de camino y dejó algo ambiguo en disco. Las escrituras en dos fases a prueba de fallos están integradas en SaveAs para cada libro XLSX, ODS y XLS clásico producido por el componente HotXLS para Delphi y C++Builder, sin necesidad de configuración alguna para activarlo