1. Qué es la idempotencia
Una operación es idempotente si aplicarla una vez o varias veces seguidas deja el sistema en el mismo estado. Un script idempotente puede ejecutarse cuantas veces se quiera: la primera hace los cambios necesarios, y las siguientes, si nada cambió entretanto, no hacen nada.
La palabra viene de las matemáticas, pero la idea es cotidiana. Apretar el botón de un ascensor es idempotente: apretarlo cinco veces no trae cinco ascensores. Agregar una línea al final de un archivo no lo es: cada ejecución agrega otra.
$ for i in 1 2 3; do echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc; done
$ grep -c 'HOME/bin' ~/.bashrc # tres copias de la misma línea
3
$ for i in 1 2 3; do grep -qxF 'export PATH="$HOME/bin:$PATH"' ~/.bashrc \
> || echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc; done
$ grep -c 'HOME/bin' ~/.bashrc # en un archivo limpio: una sola
1
El tema 1 contó la idempotencia entre las cualidades de un buen script, y varios scripts del curso ya la tenían: preparar-proyecto del tema 23 solo agregaba al .gitignore las entradas que faltaban, y servidor-web del tema 13 no iniciaba el servidor si ya estaba en ejecución.
2. Por qué importa
En la práctica, todo script de automatización termina ejecutándose más de una vez sobre el mismo sistema, por motivos que no siempre se controlan:
| Situación | Sin idempotencia |
|---|---|
| Una tarea programada que se ejecuta cada día | Los cambios se acumulan día tras día. |
| Un fallo a mitad de camino, y se vuelve a lanzar | Los pasos que ya se habían hecho se repiten, o fallan porque «ya existe». |
Un reintento automático, como el de reintentar del tema 15 | Cada intento fallido deja restos que el siguiente encuentra. |
| Otra persona lo ejecuta sin saber que ya se había hecho | Duplicados, o un error que nadie entiende. |
| Preparar varias máquinas con el mismo script | No se puede volver a ejecutar en todas para aplicar un cambio nuevo. |
Con un script idempotente, la respuesta a todas estas situaciones es la misma: volver a ejecutarlo. Ante la duda de si algo se aplicó, se ejecuta de nuevo, y el sistema queda como debe. Esa tranquilidad es lo que hace que la automatización sea confiable.
3. Operaciones que lo son y que no
Muchas operaciones habituales tienen una versión idempotente y otra que no lo es:
| Intención | No idempotente | Idempotente |
|---|---|---|
| Crear un directorio | mkdir dir: la segunda vez falla. | mkdir -p dir |
| Borrar un archivo | rm archivo: la segunda vez falla. | rm -f archivo |
| Agregar una línea a un archivo | echo … >> archivo: la duplica. | Agregarla solo si no está (sección 4). |
| Crear un enlace simbólico | ln -s destino enlace: la segunda vez falla, o crea el enlace dentro de un directorio. | ln -sfn destino enlace |
| Fijar un valor en un archivo | Agregar clave=valor al final. | Reemplazarlo si existe; agregarlo si no. |
| Fijar permisos | chmod +x es idempotente… | …y chmod 755 también, y además deja un estado exacto. |
| Crear un usuario | useradd ana: la segunda vez falla. | Crearlo solo si id ana falla. |
| Sumar algo a un contador | n=$(( n + 1 )) guardado en un archivo. | Calcular el valor a partir del estado, no sumar a ciegas. |
4. Comprobar antes de actuar
La técnica general para volver idempotente una operación es preguntar primero si ya está hecha, y actuar solo si no. Es el patrón de las cláusulas de guarda del tema 12, aplicado al estado del sistema:
# Agregar una línea solo si no está, comparando la línea entera y como texto literal.
grep -qxF -- "$linea" "$archivo" || printf '%s\n' "$linea" >> "$archivo"
# Crear un usuario solo si no existe.
id -u ana > /dev/null 2>&1 || useradd -m ana
# Fijar clave=valor: reemplazar la línea si existe, agregarla si no.
if grep -q "^${clave}=" "$archivo"; then
sed -i "s|^${clave}=.*|${clave}=${valor}|" "$archivo"
else
printf '%s=%s\n' "$clave" "$valor" >> "$archivo"
fi
Las opciones de grep importan: -x exige que coincida la línea completa, para no confundirla con otra que la contenga; -F la toma como texto literal, para que sus $, puntos o asteriscos no se interpreten como expresión regular. La versión con sed tiene un límite: si el valor contiene el carácter |, que se usa como separador, o caracteres especiales para sed, hay que escaparlos; lo veremos en el tema 29.
Comprobar antes de actuar tiene además una ventaja para quien usa el script: puede informar qué cambió y qué ya estaba, como hará el script de la sección 11. Un script que dice «0 cambios» cuando todo está en orden es la mejor prueba de que es idempotente.
5. Herramientas idempotentes por diseño
Muchas herramientas ya son idempotentes, o tienen opciones que las vuelven idempotentes. Usarlas es más simple y más seguro que escribir la comprobación a mano:
| Herramienta | Por qué es idempotente |
|---|---|
mkdir -p | No falla si el directorio existe, y crea los intermedios. |
rm -f, rm -rf | No falla si lo que se borra no existe. |
ln -sfn destino enlace | -f reemplaza un enlace existente; -n evita que, si el enlace apunta a un directorio, se cree uno nuevo dentro de él. |
install -d -m 750 dir | Crea el directorio si falta y fija sus permisos, exista o no. |
install -m 644 origen destino | Copia y fija los permisos en un solo paso. |
cmp -s a b | Responde si dos archivos son idénticos, para copiar solo si cambió. |
rsync | Sincroniza: copia solo lo que difiere, y una segunda ejecución no copia nada. |
systemctl enable --now | Habilita e inicia un servicio; si ya lo estaba, no hace nada. |
if ! cmp -s -- "$origen" "$destino"; then
install -m 644 -- "$origen" "$destino"
echo "Actualizado $destino"
fi
Copiar solo si cambió no es solo una cuestión de eficiencia: evita modificar la fecha del archivo de destino sin motivo, lo que importa si otro proceso vigila esa fecha, como la regla -nt del tema 11 o una herramienta de compilación.
6. Pensar en el estado deseado
Detrás de estas técnicas hay un cambio de forma de pensar. Un script no idempotente describe acciones: «agregar esta línea», «crear este usuario». Uno idempotente describe un estado: «esta línea debe estar», «este usuario debe existir». Las acciones se hacen solo cuando el estado actual no es el deseado.
| Pensado como acción | Pensado como estado |
|---|---|
| Crear el directorio de respaldos. | El directorio de respaldos existe, con permisos 750. |
Agregar el alias al .bashrc. | El .bashrc contiene el alias, una vez. |
| Copiar la configuración. | La configuración instalada es igual a la del repositorio. |
| Iniciar el servicio. | El servicio está en ejecución. |
Es la idea sobre la que están construidas las herramientas de gestión de configuración, como Ansible, que mencionamos en el tema 1: en lugar de escribir pasos, se describe el estado de la máquina, y la herramienta decide qué cambiar. Un script de Bash bien escrito puede seguir la misma filosofía, con funciones cuyos nombres expresan el estado: asegurar_directorio, asegurar_linea, asegurar_copia.
7. Resistir un fallo a mitad de camino
La idempotencia tiene una prueba exigente: el script falla a la mitad, y se lo vuelve a ejecutar. Para que la segunda ejecución termine bien, cada paso debe dejar el sistema en un estado que el siguiente intento pueda reconocer. Dos técnicas, ya vistas, ayudan:
- La escritura atómica del tema 24. Un archivo se escribe en un temporal y se renombra al final. Si el script falla, queda el archivo anterior completo, nunca uno a medias que el siguiente intento confunda con el bueno.
- Las comprobaciones de cada paso. Si un paso pregunta antes de actuar, el segundo intento encuentra hechos los pasos que el primero completó, y los salta.
Para procesos largos con pasos costosos, como una migración en varias etapas, se usan a veces marcas: un archivo por cada paso completado, que el script consulta antes de repetirlo. Es simple, pero exige cuidado: la marca debe crearse solo después de que el paso terminó bien, y debe borrarse si el paso necesita rehacerse.
paso() { # paso NOMBRE COMANDO...
local nombre="$1"
shift
if [[ -e "$MARCAS/$nombre" ]]; then
echo "ya hecho: $nombre"
return 0
fi
"$@" # con set -e, si falla, no se marca
touch "$MARCAS/$nombre"
echo "hecho: $nombre"
}
paso descargar descargar_datos
paso convertir convertir_formato
paso importar importar_en_la_base
8. Dos ejecuciones a la vez
El otro peligro de repetir es hacerlo al mismo tiempo. Una tarea de cron que se ejecuta cada cinco minutos y un día tarda siete: cuando arranca la siguiente, la anterior todavía no terminó. Dos copias de un respaldo escriben en el mismo archivo; dos sincronizaciones se pisan; dos rotaciones de registros mueven los mismos archivos. El resultado es impredecible, y suele aparecer justamente cuando el sistema está más cargado.
La solución es un bloqueo (lock): antes de empezar, el script intenta adquirir un cerrojo exclusivo. Si lo consigue, trabaja; si otra copia ya lo tiene, termina sin hacer nada, o espera. Para que funcione, la adquisición tiene que ser atómica: si dos procesos lo intentan en el mismo instante, solo uno puede ganar.
9. flock
En Linux, la herramienta estándar para esto es flock, del paquete util-linux. Usa los bloqueos de archivos del núcleo, que tienen una propiedad muy valiosa: se liberan solos cuando el proceso termina, de cualquier manera, incluso con kill -9 o si la máquina se reinicia. No quedan bloqueos «huérfanos».
readonly CERROJO="${XDG_RUNTIME_DIR:-/tmp}/${0##*/}.lock"
exec 9> "$CERROJO" # abrir el archivo de bloqueo en el descriptor 9
if ! flock -n 9; then # intentar bloquearlo, sin esperar
echo "${0##*/}: ya hay otra ejecución en curso" >&2
exit 1
fi
# … a partir de aquí, solo una copia del script llega a ejecutarse …
exec 9> archivoabre el archivo de bloqueo, creándolo si no existe, y lo deja abierto en el descriptor 9 durante todo el script. El contenido del archivo no importa. abrirflock -n 9pide un bloqueo exclusivo sobre ese descriptor. Con-n, si otro proceso ya lo tiene, falla enseguida en lugar de esperar. bloquear- El bloqueo se mantiene mientras el descriptor esté abierto, es decir, hasta que el script termine. Al terminar, el sistema lo libera. sistema operativo
| Forma | Efecto |
|---|---|
flock -n 9 | Si está ocupado, fallar enseguida. |
flock -w 60 9 | Esperar hasta 60 segundos; si no se libera, fallar. |
flock 9 | Esperar lo que haga falta. |
flock -n archivo comando | Ejecutar un comando con el bloqueo, sin modificar el script. |
La última forma es especialmente útil en la tabla de cron: protege cualquier script de las ejecuciones superpuestas sin tocar su código.
*/5 * * * * flock -n /tmp/sincronizar.lock /home/ana/bin/sincronizar
¿Fallar o esperar? Depende de la tarea. Para una tarea periódica, como una sincronización cada cinco minutos, si la anterior todavía corre, lo razonable es no hacer nada: la próxima ejecución recogerá lo pendiente. Para una tarea que no debe perderse, como procesar un pedido, conviene esperar, con un límite.
10. Otras formas de bloquear
Donde no hay flock, como en macOS, existen alternativas, todas con alguna desventaja:
| Técnica | Cómo funciona | Problema |
|---|---|---|
mkdir como cerrojo | mkdir dir.lock es atómico y falla si ya existe; el script lo borra al terminar con un trap. | Si el script muere con kill -9, el directorio queda, y hay que borrarlo a mano. |
set -o noclobber | Con esta opción, > falla si el archivo existe, lo que permite crear un archivo de bloqueo de forma atómica. | El mismo: un bloqueo huérfano si el script no llega a limpiarlo. |
| Archivo con el PID | El script guarda su número de proceso y, al arrancar, comprueba con kill -0 si ese proceso sigue vivo, como servidor-web del tema 13. | Comprobar y escribir son dos pasos: dos copias pueden pasar la comprobación a la vez. Y un PID viejo puede pertenecer ahora a otro proceso. |
readonly CERROJO="/tmp/${0##*/}.lock.d"
if ! mkdir "$CERROJO" 2> /dev/null; then
echo "${0##*/}: ya hay otra ejecución en curso (o quedó $CERROJO)" >&2
exit 1
fi
trap 'rmdir "$CERROJO"' EXIT
En Linux, flock es la opción correcta: atómico, sin bloqueos huérfanos y en una línea.
11. Un script completo
Este script deja el entorno de trabajo de un usuario en un estado conocido: crea los directorios que necesitan los scripts del curso, asegura dos líneas en el .bashrc e instala un script del proyecto de herramientas. Es idempotente, informa qué cambió y qué ya estaba, y usa flock para que dos ejecuciones no se pisen.
#!/usr/bin/env bash
#
# configurar-equipo — deja el entorno de trabajo del usuario en un estado conocido.
# Se puede ejecutar cuantas veces se quiera: solo cambia lo que falta.
#
set -euo pipefail
readonly CERROJO="${XDG_RUNTIME_DIR:-/tmp}/configurar-equipo.lock"
exec 9> "$CERROJO"
if ! flock -n 9; then
echo "${0##*/}: ya hay otra ejecución en curso" >&2
exit 1
fi
cambios=0
hecho() { echo " cambiado: $*"; cambios=$(( cambios + 1 )); }
sin_cambio() { echo " ya estaba: $*"; }
# asegurar_directorio DIR — DIR existe.
asegurar_directorio() {
if [[ -d "$1" ]]; then
sin_cambio "directorio ${1/#$HOME/\~}"
else
mkdir -p -- "$1"
hecho "directorio ${1/#$HOME/\~}"
fi
}
# asegurar_linea ARCHIVO LÍNEA — ARCHIVO contiene LÍNEA, una vez.
asegurar_linea() {
local archivo="$1" linea="$2"
touch -- "$archivo"
if grep -qxF -- "$linea" "$archivo"; then
sin_cambio "línea en ${archivo/#$HOME/\~}: $linea"
else
printf '%s\n' "$linea" >> "$archivo"
hecho "línea en ${archivo/#$HOME/\~}: $linea"
fi
}
# asegurar_copia ORIGEN DESTINO — DESTINO es igual a ORIGEN (escritura atómica).
asegurar_copia() {
local origen="$1" destino="$2"
if [[ -f "$destino" ]] && cmp -s -- "$origen" "$destino"; then
sin_cambio "copia ${destino/#$HOME/\~}"
else
local tmp
tmp=$(mktemp "$destino.XXXXXX")
cp -- "$origen" "$tmp"
mv -f -- "$tmp" "$destino"
hecho "copia ${destino/#$HOME/\~}"
fi
}
echo "Configurando el entorno de $(id -un)…"
asegurar_directorio "$HOME/bin"
asegurar_directorio "$HOME/.local/state"
asegurar_linea "$HOME/.bashrc" 'export PATH="$HOME/bin:$PATH"'
asegurar_linea "$HOME/.bashrc" "alias ll='ls -lh'"
asegurar_copia "$HOME/proyectos/herramientas/bin/respaldar" "$HOME/bin/respaldar"
echo "Listo: $cambios cambios."
$ configurar-equipo
Configurando el entorno de ana…
cambiado: directorio ~/bin
cambiado: directorio ~/.local/state
cambiado: línea en ~/.bashrc: export PATH="$HOME/bin:$PATH"
cambiado: línea en ~/.bashrc: alias ll='ls -lh'
cambiado: copia ~/bin/respaldar
Listo: 5 cambios.
$ configurar-equipo
Configurando el entorno de ana…
ya estaba: directorio ~/bin
ya estaba: directorio ~/.local/state
ya estaba: línea en ~/.bashrc: export PATH="$HOME/bin:$PATH"
ya estaba: línea en ~/.bashrc: alias ll='ls -lh'
ya estaba: copia ~/bin/respaldar
Listo: 0 cambios.
$ configurar-equipo & configurar-equipo # dos a la vez
configurar-equipo: ya hay otra ejecución en curso
- Cada función describe un estado, no una acción, y su nombre lo dice:
asegurar_directorio,asegurar_linea,asegurar_copia. El final del script se lee como una lista de cómo debe quedar el entorno. - Cada función comprueba antes de actuar e informa lo que hizo. La segunda ejecución, con «0 cambios», demuestra que el script es idempotente.
- La copia es atómica y condicional: solo se reemplaza si el contenido difiere, con
cmp -s, y el reemplazo es unmvdesde un temporal del mismo directorio, como en el tema 24. - La línea del
PATHva entre comillas simples, para que$HOMEse escriba literalmente en el.bashrcy se expanda cuando la shell lo lea, no ahora. ${1/#$HOME/\~}muestra las rutas con~, con la expansión del tema 6, solo para que los mensajes sean más breves.- El bloqueo con
flockva al comienzo, antes de cualquier cambio, y se libera solo al terminar el script.
12. Errores frecuentes
| Escribiste | Qué pasa | Corrección |
|---|---|---|
echo "…" >> archivo en un script que se repite | La línea se duplica en cada ejecución. | grep -qxF -- … || echo … >> |
grep -q "$linea" para comprobar | Coincide con líneas que la contienen, y trata la línea como expresión regular. | grep -qxF -- |
mkdir dir en un script que se repite | Falla la segunda vez. | mkdir -p dir |
ln -s destino enlace repetido | Falla, o crea un enlace dentro del directorio enlazado. | ln -sfn destino enlace |
| Copiar siempre, aunque no haya cambios | Cambia la fecha del destino sin motivo. | cmp -s antes de copiar. |
Un script de cron frecuente sin bloqueo | Dos ejecuciones superpuestas se pisan. | flock -n, en el script o en la línea de cron. |
| Un archivo PID comprobado y luego escrito | Dos copias pueden pasar la comprobación a la vez. | flock, que es atómico. |
Un cerrojo con mkdir sin trap | Queda bloqueado para siempre tras el primer fallo. | trap 'rmdir …' EXIT, o flock. |
13. Resumen
- Un script es idempotente si ejecutarlo varias veces deja el sistema igual que ejecutarlo una: la primera vez cambia lo necesario, las siguientes nada.
- Importa porque los scripts se repiten: por
cron, por reintentos, tras un fallo, o por otra persona. - Muchas operaciones tienen una versión idempotente:
mkdir -p,rm -f,ln -sfn,install,rsync. - La técnica general es comprobar antes de actuar:
grep -qxF … || echo … >>,id usuario || useradd,cmp -santes de copiar. - Pensar en el estado deseado, no en las acciones, lleva naturalmente a scripts idempotentes, con funciones del tipo
asegurar_…. - La escritura atómica y las comprobaciones de cada paso permiten volver a ejecutar un script que falló a la mitad; las marcas, saltar pasos costosos ya hechos.
- Dos ejecuciones simultáneas se evitan con un bloqueo atómico.
exec 9> archivoyflock -n 9bloquean hasta el final del script; el bloqueo se libera solo, aun conkill -9.flock -n archivo comandoprotege un script desde la línea decron, sin modificarlo.- Sin
flock,mkdirsirve de cerrojo atómico, pero puede quedar huérfano si el script muere sin limpiar.
14. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Qué significa que un script sea idempotente?
Que ejecutarlo una vez o varias seguidas deja el sistema en el mismo estado. Las ejecuciones posteriores a la primera no cambian nada si nada cambió entretanto.
2. Menciona tres situaciones en que un script se ejecuta más de una vez sobre el mismo sistema.
Una tarea programada que se ejecuta periódicamente; un reintento después de un fallo; otra persona que lo ejecuta sin saber que ya se había hecho; preparar de nuevo varias máquinas para aplicar un cambio.
3. ¿Por qué echo "alias ll='ls -l'" >> ~/.bashrc no es idempotente, y cómo se corrige?
Porque cada ejecución agrega otra copia de la línea. Se corrige agregándola solo si no está: grep -qxF -- "$linea" ~/.bashrc || printf '%s\n' "$linea" >> ~/.bashrc.
4. ¿Para qué sirven las opciones -x y -F de grep al comprobar si una línea ya está?
-x exige que coincida la línea completa, no una parte; -F trata el texto como literal, para que sus caracteres especiales no se interpreten como expresión regular.
5. ¿Qué hacen las opciones -f y -n de ln -sfn?
-f reemplaza el enlace si ya existe. -n trata un enlace que apunta a un directorio como un archivo, de modo que se reemplaza el enlace en lugar de crear uno nuevo dentro del directorio.
6. ¿Qué diferencia hay entre pensar en acciones y pensar en el estado deseado?
Pensar en acciones lleva a «hacer esto»; pensar en el estado, a «esto debe ser así». Con el segundo enfoque, el script compara el estado actual con el deseado y actúa solo si difieren, y por eso resulta idempotente.
7. ¿Por qué dos ejecuciones simultáneas de una tarea de cron pueden ser un problema?
Porque trabajan sobre los mismos archivos al mismo tiempo, y pueden pisarse: escribir el mismo respaldo, mover los mismos archivos, dejar datos inconsistentes.
8. ¿Qué hace exec 9> archivo seguido de flock -n 9?
Abre el archivo de bloqueo en el descriptor 9 durante todo el script, y pide un bloqueo exclusivo sobre él sin esperar. Si otro proceso ya lo tiene, flock falla y el script puede terminar.
9. ¿Qué ventaja tiene flock sobre un archivo de bloqueo creado y borrado por el script?
Que el bloqueo se libera solo cuando el proceso termina, de cualquier manera, incluso con kill -9. Un archivo de bloqueo propio puede quedar huérfano si el script muere sin borrarlo.
10. ¿Cómo se protege una tarea de cron de las ejecuciones superpuestas sin modificar el script?
Ejecutándola mediante flock en la propia línea de cron: flock -n /tmp/tarea.lock /ruta/al/script.
Para practicar
- Reproduce el ejemplo de la sección 1 en un archivo de prueba, con y sin la comprobación.
- Escribe una función
asegurar_valor ARCHIVO CLAVE VALORque deje en un archivoclave=valorexactamente una línea con esa clave y ese valor, y pruébala varias veces con valores distintos. - Escribe una función
asegurar_enlace DESTINO ENLACEque compruebe conreadlinksi el enlace ya apunta al destino, y lo cree o lo corrija si no. - Revisa los scripts de temas anteriores y marca las operaciones que no son idempotentes. Corrige al menos dos.
- Escribe un script que tarde 30 segundos (con
sleep) y protégelo conflock -n. Ejecútalo en dos terminales a la vez. Prueba tambiénflock -w 5. - Agrega a
configurar-equipodos estados más que te resulten útiles, y comprueba que la segunda ejecución sigue informando «0 cambios».