Tema representativo de entrevista

Entrevista de Linux: ¿Cómo hacer que una actualización atómica de archivos sea tolerante a fallos (Crash-Safe)?

GeneralDifícil
Equipo editorial de Offer.ccPublicado Actualizado

Pregunta

Un demonio de Linux reemplaza un archivo de estado de 64 KiB mientras otros procesos lo leen. Después de que el demonio reporte éxito, la nueva versión debe sobrevivir a una pérdida de energía. Tras cualquier caída previa, la ruta de destino debe contener o bien la versión completa confirmada anteriormente o bien la nueva versión completa, nunca una mezcla truncada. Diseñe el protocolo de escritura, el contrato de errores, el límite de concurrencia, el comportamiento de recuperación y las pruebas de caídas (crash tests).

Problema y casos de uso

Este problema contiene tres garantías diferentes. La visibilidad atómica significa que un lector que abre la ruta de destino ve una versión completa, no una reescritura parcial en el lugar. La durabilidad ante caídas significa que una versión confirmada permanece accesible después de reiniciar. El aislamiento de escritores decide qué sucede cuando dos actualizaciones compiten entre sí. Un único rename() solo aborda la primera garantía bajo su contrato de sistema de archivos.

Asuma un sistema de archivos local de Linux que implementa correctamente fsync() y el reemplazo atómico en el mismo sistema de archivos, un único escritor serializado y versiones previamente confirmadas escritas mediante el mismo protocolo. El tamaño de 64 KiB es una suposición de entrevista. Los sistemas de archivos de red, el hardware defectuoso que miente sobre las descargas de caché (cache flushes), las transacciones de múltiples archivos y la modificación hostil de directorios requieren contratos independientes.

Los usos típicos incluyen instantáneas de configuración, estado de agentes locales, manifiestos de puntos de control (checkpoints), metadatos de paquetes y guardados de editores. Si el estado abarca varios archivos o necesita consultas y transacciones concurrentes, una base de datos embebida o un journal suele ser más seguro que extender este protocolo a mano.

Qué evalúa el entrevistador

La primera señal es si el candidato distingue entre el éxito de write(), la atomicidad de rename() y la confirmación duradera. Linux documenta que write() puede ser parcial y un retorno exitoso no demuestra la persistencia en disco. fsync(file) vacía los datos modificados del archivo y los metadatos asociados, pero no necesariamente persiste su entrada de directorio. Ese último extremo requiere fsync(parent_directory).

La segunda señal es el ordenamiento. Los nuevos bytes deben ser duraderos antes de que el espacio de nombres apunte el nombre de destino hacia ellos. La entrada de directorio debe ser duradera antes de reportar éxito. Invertir u omitir cualquiera de las barreras crea una ventana de vulnerabilidad ante caídas.

La tercera señal es una semántica de fallos honesta. fsync() puede revelar errores retrasados de EIO, ENOSPC o de cuota. Si la sincronización final del directorio falla, el renombramiento puede ser ya visible mientras que la durabilidad es desconocida. La API debe devolver un fallo indeterminado, no afirmar el éxito ni fingir que siempre puede revertir los cambios.

Preguntas de aclaración antes de responder

  • ¿Cubre lo "atómico" las caídas de procesos o la pérdida de energía del host? La sola terminación del proceso deja viva la caché de páginas del kernel; la consigna requiere durabilidad ante caídas del host y, por lo tanto, barreras de sincronización.
  • ¿Está el destino en un sistema de archivos local con semántica documentada? NFS, FUSE, sistemas de archivos superpuestos (overlay) y pilas de almacenamiento inusuales pueden proporcionar comportamientos de error y persistencia diferentes. Pruebe el contrato de despliegue real.
  • ¿Hay múltiples escritores? Este diseño base serializa a los escritores. Los nombres temporales únicos evitan colisiones, pero no impiden las actualizaciones donde el último escritor gana (last-writer-wins).
  • ¿Deben cambiar varios archivos juntos? Un renombramiento confirma el reemplazo de una sola ruta. Utilice un directorio de generación, un journal o una base de datos para una invariante de múltiples archivos.
  • ¿Puede un descriptor de archivo abierto anteriormente seguir leyendo la versión antigua? Sí. Rename cambia el mapeo del directorio; un descriptor ya abierto sigue haciendo referencia al inodo antiguo. Los lectores deben volver a abrir el archivo cuando necesiten la última generación.
  • ¿Qué constituye un contenido válido? Valide la serialización, el esquema, la suma de comprobación y la versión antes de la publicación. La atomicidad del sistema de archivos no puede hacer correcto un payload mal formado pero completo.

Estructura de respuesta de 30 segundos

“Separo la visibilidad atómica de la durabilidad. Abro el directorio padre, creo un archivo temporal único en ese mismo directorio con creación exclusiva, escribo en un bucle hasta que se acepten todos los bytes, establezco los metadatos requeridos y ejecuto fsync sobre el archivo temporal. Luego lo cierro, lo renombro atómicamente sobre el destino y ejecuto fsync sobre el directorio padre antes de retornar éxito. La sincronización del archivo hace duradero el contenido del nuevo inodo; rename publica una versión completa; la sincronización del directorio hace duradero ese cambio de nombre a inodo. Serializo a los escritores, trato cada error de llamada al sistema —incluido el fallo final de sincronización del directorio— como un no éxito con un estado indeterminado explícito, limpio los archivos temporales huérfanos durante la recuperación y pruebo puntos de pérdida de energía después de cada paso en lugar de usar la terminación de procesos como prueba de durabilidad.”

Análisis paso a paso en profundidad

Defina el punto de confirmación desde la perspectiva de quien llama: solo un fsync exitoso del directorio padre permite un éxito confirmado. El esquema de implementación es:

text
durableReplace(parentDir, targetName, bytes):
  dirfd = open(parentDir, read-only | directory | close-on-exec)
  tmpName = uniqueSiblingName(targetName)
  tmpfd = openat(dirfd, tmpName, create | exclusive | write-only | close-on-exec, mode)

  writeAll(tmpfd, bytes)          // retry EINTR; advance after partial writes
  setRequiredMetadata(tmpfd)      // mode/ownership if part of the contract
  fsync(tmpfd)                    // check delayed I/O and allocation errors
  close(tmpfd)                    // check the result

  renameat(dirfd, tmpName, dirfd, targetName)
  fsync(dirfd)                    // persist the namespace change
  close(dirfd)
  return committed

El archivo temporal debe ser hermano del destino. La ubicación en el mismo directorio hace que el renombramiento permanezca en un único sistema de archivos y evita una alternativa de copia con EXDEV. Utilice creación exclusiva y un sufijo local del proceso no predecible; no siga un enlace simbólico existente controlado por un atacante. Establezca los permisos antes de la sincronización del archivo para que los metadatos requeridos se unan al estado duradero del archivo.

writeAll importa incluso para un archivo regular. Un retorno positivo menor que el conteo solicitado es una escritura parcial, así que avance el búfer y continúe. Reintente únicamente la operación interrumpida apropiada. Un write() exitoso significa que los datos alcanzaron el estado aceptado del kernel, no un medio estable. close() por sí solo no es la barrera de confirmación, y los errores pueden reportarse más tarde mediante fsync().

A continuación, sincronice el archivo temporal. fsync cubre los datos modificados del archivo y los metadatos del inodo, y espera a que el dispositivo de almacenamiento reporte la finalización. fdatasync puede reducir el trabajo de metadatos no relacionados, pero aún debe persistir los metadatos necesarios para recuperar los datos, como el tamaño del archivo. Para una respuesta simple y auditable, use fsync a menos que la medición y el contrato exacto del sistema de archivos justifiquen fdatasync.

Solo después de que esa sincronización tenga éxito, renameat debe reemplazar al destino. Linux garantiza que, cuando el destino ya existe, otro proceso que lo abra no observará ningún momento en que esté ausente. Un lector con el archivo antiguo ya abierto puede terminar de leer el inodo antiguo; una apertura posterior se resolverá en el nuevo. Ambas son versiones completas, que es el contrato de lectura deseado.

Rename actualiza los metadatos del directorio. El fsync del archivo no necesariamente hace duradera esa entrada de directorio, por lo que debe abrir el directorio padre y sincronizarlo después de rename. El ordenamiento es la prueba:

text
new contents durable
  -> target name atomically points to new inode
  -> target-name mapping durable
  -> caller may receive success

Antes de rename, una caída puede dejar el destino antiguo más un archivo temporal huérfano. Después de rename pero antes de la sincronización del directorio, el destino visible puede ser nuevo, mientras que la accesibilidad tras el reinicio aún no está prometida. Después de una sincronización exitosa del directorio, el mapeo de destino confirmado y el contenido ya sincronizado forman la generación confirmada. La recuperación elimina únicamente archivos temporales obsoletos reconocibles tras validar la propiedad y el nombre; nunca elimina el destino simplemente porque la operación anterior devolvió un error.

El reporte de errores necesita un estado más rico que el éxito booleano. Antes de rename, el fallo es not_committed y el destino confirmado antiguo sigue siendo fidedigno. Después de rename, un fallo de sincronización del directorio es indeterminate: el nuevo archivo puede ser visible y puede o no sobrevivir a una caída. Devuelva un error, conserve los diagnósticos y permita que la recuperación inspeccione un número de generación o suma de comprobación. Escribir a ciegas el valor antiguo de vuelta crea otra transición no confirmada y puede sobrescribir a un escritor más nuevo.

Para múltiples escritores, coloque un bloqueo consultivo (advisory lock) alrededor de la secuencia de lectura-modificación-escritura solo si todos los escritores lo respetan, o almacene una generación esperada y rechace actualizaciones obsoletas. Un archivo temporal único simplemente mantiene separados los archivos de preparación. Sin serialización, dos operaciones rename válidas son individualmente atómicas pero la posterior gana silenciosamente. Para múltiples archivos relacionados, publique un directorio de generación inmutable a través de un único puntero duradero, agregue un journal write-ahead o use SQLite; las operaciones rename repetidas no crean una transacción de múltiples archivos.

La durabilidad estricta paga el costo de al menos un vaciado de archivo y un vaciado de directorio por cada actualización confirmada. Si no todas las actualizaciones necesitan sobrevivir a la pérdida de energía, ofrezca un modo relajado con un nombre independiente que use archivo temporal más rename para visibilidad atómica y permita explícitamente la pérdida de la última versión. Para altas tasas de actualización, agrupe varios cambios lógicos en una sola confirmación de journal o use una base de datos con group commit. Nunca debilite silenciosamente el contrato para mejorar una prueba de rendimiento (benchmark).

Pruebe el protocolo en cada límite: escritura parcial; EINTR; ENOSPC; EIO en la sincronización de archivos; caída antes y después de la sincronización de archivos; caída antes, durante y después de rename; y fallo en la sincronización del directorio. Después del reinicio, asegure mediante aserciones que el destino se pueda parsear, coincida con la última generación confirmada o con una nueva generación no confirmada permitida, y nunca sea una mezcla de bytes. Asegure también que cada generación confirmada sobreviva. Una prueba de SIGKILL del proceso verifica la limpieza y el comportamiento de los descriptores de archivos, pero solo una prueba de caída controlada de VM o de almacenamiento ejercita la pérdida de cachés volátiles.

Ejemplo de respuesta de alta calidad

“Comenzaría definiendo tres contratos: los lectores ven una versión completa, una actualización confirmada sobrevive a una caída del host y los escritores están serializados. Se asume que el destino actual ha sido confirmado mediante el mismo protocolo.

Abro el directorio padre y creo un archivo temporal único y exclusivo junto al destino. Hago un bucle sobre write porque puede devolver un conteo corto, aplico los permisos requeridos, llamo a fsync en el archivo temporal y verifico cada resultado. Luego renombro el archivo hermano sobre el destino. Ese rename es el límite de visibilidad atómica: los lectores existentes pueden retener el inodo antiguo, mientras que las nuevas aperturas se resuelven en el nuevo inodo completo.

El rename aún no es mi confirmación duradera. Cambió una entrada de directorio, y sincronizar el archivo no necesariamente persiste esa entrada. Por lo tanto, ejecuto fsync sobre el directorio padre abierto y retorno éxito solo después de que este tenga éxito. Una caída antes de rename deja el destino antiguo y tal vez un archivo temporal eliminable. Un fallo después de rename pero antes de la sincronización del directorio es indeterminado, por lo que devuelvo un error e inspecciono los metadatos de generación durante la recuperación en lugar de prometer una reversión.

Inyectaría fallos en escrituras parciales y en cada fallo de llamada al sistema, y luego provocaría la caída de una máquina virtual en cada límite. La invariante es que el destino siempre sea una generación antigua o nueva válida, nunca una mezcla rota, y que cada generación confirmada permanezca después del reinicio. Si necesito escritores concurrentes o una transacción de múltiples archivos, agrego comprobaciones de generación y bloqueos o cambio a un journal/base de datos en lugar de afirmar que rename resuelve esos problemas.”

Errores comunes

  • Sobrescribir el destino in situ → una caída expone un truncamiento o contenido mixto → prepare un archivo hermano completo y renómbrelo.
  • Asumir que un solo write() escribe todo → las escrituras cortas truncan silenciosamente el archivo preparado → haga un bucle hasta que se escriban todos los bytes o un error termine la operación.
  • Llamar a rename antes de sincronizar el archivo temporal → el nombre duradero puede apuntar a datos incompletos o perdidos → sincronice el nuevo inodo antes de la publicación.
  • Tratar rename como una confirmación duradera → la visibilidad atómica no persiste la entrada de directorio → sincronice el directorio padre después de rename.
  • Usar un directorio temporal en otro punto de montaje → rename falla con EXDEV o se degrada a una copia no atómica → cree el archivo temporal en el directorio de destino.
  • Ignorar errores de fsync el fallo de almacenamiento retrasado se convierte en un falso éxito → propague un resultado explícito de no éxito o indeterminado.
  • Probar únicamente con SIGKILL la caché del kernel sobrevive a la muerte del proceso → agregue inyección de caídas controladas de host/almacenamiento.
  • Permitir que los escritores concurrentes compitan → las actualizaciones individualmente atómicas aún pierden una generación → serialice o compare las generaciones esperadas.

Preguntas de seguimiento y respuestas

Pregunta de seguimiento 1: ¿Por qué es insuficiente fsync(temp)?

Hace duraderos el contenido del inodo temporal y los metadatos asociados según el contrato del sistema de archivos. La ruta de destino es una entrada de directorio. Después de rename, ese mapeo ha cambiado, y Linux indica explícitamente que una sincronización de archivo no necesariamente persiste la entrada del directorio contenedor. Sincronice el directorio padre antes de confirmar.

Pregunta de seguimiento 2: ¿Es rename() atómico si otro proceso ya abrió el destino?

El reemplazo en el espacio de nombres es atómico para la búsqueda de rutas. Un descriptor existente continúa haciendo referencia al inodo antiguo, mientras que una nueva apertura se resuelve en el nuevo inodo. Si un lector necesita una sola generación a lo largo de varias lecturas, debe mantener un descriptor abierto en lugar de reabrir a mitad del proceso.

Pregunta de seguimiento 3: ¿Qué debe devolver la API cuando fsync de directorio falla después de rename?

Devuelva un error con un estado de confirmación indeterminado. Es posible que el renombramiento ya sea visible y la aplicación no puede demostrar si sobrevivirá al reinicio. Registre la generación prevista y el error, deje de afirmar el éxito y permita que la recuperación valide el destino. Una reversión automática a ciegas puede destruir una actualización válida posterior.

Pregunta de seguimiento 4: ¿Puede fdatasync reemplazar a fsync?

Puede hacerlo cuando el contrato de la plataforma y las mediciones lo justifiquen. Omite metadatos no relacionados con la posterior recuperación de datos, pero aún debe persistir los metadatos requeridos, como el tamaño del archivo. No elimina la necesidad de sincronizar el directorio padre después de rename. Prefiera fsync en una respuesta genérica porque su contrato es más fácil de auditar.

Pregunta de seguimiento 5: ¿Cómo actualizaría tres archivos de forma atómica?

Tres renombramientos independientes pueden exponer generaciones mixtas. Escriba todos los archivos bajo un directorio de generación inmutable, haga que ese directorio sea duradero, luego cambie y sincronice atómicamente un manifiesto o puntero; como alternativa, use un journal o una base de datos embebida. Los lectores resuelven una generación y la mantienen durante toda la operación.

Pregunta de seguimiento 6: ¿Cómo evitan dos escritores perder actualizaciones?

Use un bloqueo respetado por cada escritor alrededor de la secuencia completa de lectura-modificación-confirmación, o incluya una generación esperada y rechace una confirmación obsoleta. Los nombres temporales únicos solo evitan colisiones durante la preparación. El renombramiento atómico no compara versiones de negocio y, de forma natural, permite que el último escritor gane.

Fuentes públicas

Preguntas relacionadas