Масові перейменування — це ті «дрібні зміни», які з’являються в календарі, мов безпечний візит до стоматолога, а закінчуються кореневим каналом. Це ніколи не просто перейменування. Це колізії, дивні символи, згладження регістру, переходи між файловими системами, програми, що кешують шляхи, і та одна директорія, де хтось залишив “final_FINAL_v7 (use this).docx”.
Якщо ви маєте намір перейменовувати сотні або мільйони файлів, потрібен план, який передбачає відмови. Не тому, що ви недбалi — а тому, що файлові системи, людські звички в іменуванні та корпоративні терміни часто поводяться як протидія. Це практичний, орієнтований на виробництво підхід: dry‑run, який реально прогнозує результат, двофазні перейменування для уникнення колізій, аудитні журнали для відкату й перевірки продуктивності, щоб ви не перетворили NAS на обігрівач.
Обов’язкові вимоги для безпечного перейменування
1) Перейменування — це метадані, доки не стає інакше
На одній файловій системі rename() зазвичай — «лише метадані»: швидко, атомарно й без копіювання вмісту файлу. Це хороша новина. Погана новина: ваш інструмент може не виконувати чисте перейменування. Якщо «перенесення» перетинає межі файлових систем — наприклад, ви переміщаєте файли між різними маунтами — воно може перетворитися на copy+delete. Це змінює час виконання, поведінку при помилці, права доступу й варіанти відновлення.
Рішення: розглядайте кожне масове перейменування як зміну, що потребує управління. Плануйте атомарність, але перевіряйте, що вона реально відбувається.
2) Dry‑run означає «точно передбачити, що станеться»
Dry‑run, який друкує розмитий список, — не dry‑run. Ваш dry‑run має видавати:
- один‑до‑одного відображення шлях джерела → шлях призначення
- звіт про колізії (включно з колізіями за регістром)
- план відкату (зворотна мапа, яку можна відтворити)
- стабільне впорядкування (щоб повторні запуски давали той самий результат)
Вивід dry‑run має бути тим, що ви можете дифити, переглядати й затверджувати. Якщо ви не можете пояснити, чому конкретний файл перетвориться саме на цю нову назву — це не dry‑run. Це надія.
3) Двофазне перейменування запобігає самоколізіям
Класична помилка: перейменування a.txt у A.txt на файловій системі без чутливості до регістру, або приведення кількох файлів до одного нормалізованого вигляду (пробіли в підкреслення, приведення до нижнього регістру, видалення пунктуації). Виправлення нудне, але правильне: спочатку перейменувати все у тимчасові унікальні імена, потім у фінальні імена.
4) Журнали не опціональні — відкат потребує квитанцій
Потрібен додавальний журнал з точними виконаними mv‑операціями в порядку виконання, а також інформацією про пропуски й помилки. В інциденті різниця між «ми можемо відкотитися» і «ми, можливо, зможемо» — це аудиторський файл, який можна запустити назад.
5) «Працює на моєму ноуті» — не стратегія для файлової системи
Семантика файлових систем різна: ext4, XFS, ZFS, SMB‑шари, NFS‑маунти, object‑backed FUSE — у кожного свої особливості. Деякі нечутливі до регістру, деякі мають дивні обмеження, деякі повільні при операціях у великих директоріях. Ваш план перейменування має включати швидку перевірку того, «на чому» ви працюєте.
Цитата, яку варто мати на столі: «Надія — не стратегія.» — Gene Kranz
Цікаві факти та невелика історія
- Атомарність POSIX rename: На одній файловій системі
rename()проєктовано як атомарну операцію: ви не отримаєте напівперейменованої назви файлу. Саме тому вона є опорою для безпечних оновлень файлів. - Windows сформувала очікування нечутливості до регістру: Файлові системи, нечутливі до регістру, стали поширеними на робочих станціях, і вони й досі дивують інженерів, коли код припускає, що
Report.csvіreport.csv— різні файли. - Ранні Unix‑інструменти сприймали «немає пробілів» як норму: Багато класичних shell‑патернів з’явилися у часи, коли імена файлів із пробілами вважалися помилкою користувача. Сьогодні це звичайна справа.
- SMB і NFS додають затримку метаданим: Перейменування — це метадані, але віддалені метадані можуть бути повільними та «балакучими», особливо через навантажені лінки або сувору консистентність.
- Розмір директорії має значення: Багато файлових систем тепер добре працюють із великими директоріями, але операції на кшталт listing і stat для мільйонів записів усе ще стають вузьким місцем під час планування та перевірки.
- Нормалізація Unicode — справжня пастка: Деякі платформи нормалізують Unicode по‑різному, тож «візуально ідентичне» ім’я може бути різною послідовністю байтів. Масові перейменування можуть випадково «звести» імена в колізію.
- Жорсткі посилання ускладнюють очікування: Перейменування змінює запис у директорії, а не inode. Якщо ви очікували «два файли», насправді може бути один inode з двома іменами.
- Снапшоти змінили гру для перейменувань: З ZFS та подібними системами знімок перед перейменуванням робить відкат імен більш здійсненним — іноді достатньо просто відкотити датасет.
Що насправді йде не так (і чому)
Колізія: два старі імена стають одним новим
Санітизація імен — це місце, де народжуються колізії. Замініть пробіли підкресленнями, приведення до нижнього регістру, видалення пунктуації, і раптом:
ACME - Q4.csvACME_Q4.csvacme q4.csv
…усі хочуть стати acme_q4.csv. Якщо ваш скрипт робить «останній запис перемагає», ви щойно втратили значення імені без змін у вмісті файлів. Файл є, але назва неправильна. Це гірше: прихована корупція значень.
Зміни тільки регістру на файлових системах, нечутливих до регістру
На нечутливій до регістру файловій системі перейменування foo у Foo може трактуватися як «ні‑до‑чого» або вимагати танцю (перейменувати у тимчасове ім’я, потім у бажане). Віддалені шари роблять це ще цікавішим.
«Перейменування» через межі файлових систем стає copy+delete
Якщо ви використовуєте інструменти, які переміщують файли між маунтами під виглядом «перейменування», ви більше не робите атомарні метадані. Ви копіюєте дані, витрачаєте пропускну здатність і створюєте режим відмови з частковими копіями та відсутніми оригіналами.
Зсуви прав і власності
Чисте перейменування зберігає метадані inode. Copy+delete — ні. Раптом «той самий файл» має нового власника, інші ACL і новий SELinux контекст. Так виникає інцидент, спричинений перейменуванням.
Програми не люблять, коли їхні шляхи змінюють
Деякі програми зберігають абсолютні шляхи в базах даних, конфігураціях або кешах. Перейменування під ними — це як перемістити чийсь стіл під час наради. Смішно, поки це не ви презентуєте.
Жарт №1: Перейменування файлів у продакшні — це як переробляти кухню о 2:00 ранку — ви забудете, де що лежить, і всі дізнаються про це на сніданок.
Практичні завдання: команди, виводи, рішення
Нижче — реальні операційні завдання, які ви виконуєте перед перейменуванням, під час і після. Кожне завдання містить команду, приклад виводу та рішення, яке з нього випливає.
Завдання 1: Підтвердіть, де зберігаються дані (не переходьте випадково межі файлових систем)
cr0x@server:~$ df -Th /data/projects
Filesystem Type Size Used Avail Use% Mounted on
tank/projects zfs 8.0T 5.1T 2.9T 64% /data/projects
Що це означає: Ви на ZFS датасеті tank/projects. Якщо перейменовувати в цьому маунті — це метадані лише й дружнє до снапшотів.
Рішення: Переконайтеся, що ваш скрипт не переміщує файли до іншого маунту. Тримайте джерело і призначення в межах тієї самої файлової системи.
Завдання 2: Перевірте опції маунту і чи це мережевий шар
cr0x@server:~$ mount | grep -E ' /data/projects | type (nfs|cifs)'
tank/projects on /data/projects type zfs (rw,xattr,noacl)
Що це означає: Локальний ZFS, не NFS/SMB. Затримка перейменувань має бути стабільною.
Рішення: Якщо бачите NFS/CIFS — плануйте перейменування на непіковий час і очікуйте метаданних кругових поїздок.
Завдання 3: Порахувати файли та директорії (визначити радіус ураження)
cr0x@server:~$ find /data/projects/clientA -type f | wc -l
284913
Що це означає: ~285k файлів. Кроки, що роблять повний stat() для кожного файлу, потребуватимуть часу.
Рішення: Використовуйте ефективні скани; уникайте викликів підпроцесів для кожного файлу. Розгляньте батчі або паралелізм обережно (далі про це).
Завдання 4: Виявити «проблемні символи» в іменах (пробіли, newlines, керуючі символи)
cr0x@server:~$ find /data/projects/clientA -type f -name $'*\n*' -print | head
/data/projects/clientA/inbox/weird
name.txt
Що це означає: У вас принаймні одне ім’я файлу містить newline. Це не теоретично — воно є.
Рішення: Ваша пайплайн має бути NUL‑розділеною (-print0 + read -d '') і журнали мають бути однозначними (екранірування або NUL‑безпечні формати).
Завдання 5: Перевірити наявність ведучих дефісів (інструменти трактують їх як опції)
cr0x@server:~$ find /data/projects/clientA -type f -name '-*' | head
/data/projects/clientA/inbox/-final.pdf
Що це означає: Принаймні один файл починається з -.
Рішення: Завжди використовуйте mv -- "$src" "$dst", щоб імена файлів ніколи не трактувалися як прапорці.
Завдання 6: Виявити колізії за регістром, які потурбують SMB або macOS
cr0x@server:~$ find /data/projects/clientA -maxdepth 2 -type f -printf '%f\n' | awk '{print tolower($0)}' | sort | uniq -d | head
readme.txt
Що це означає: Є принаймні два файли, імена яких відрізняються лише регістром, десь у цьому неглибокому скані.
Рішення: Якщо кінцеве середовище нечутливе до регістру (або може бути таким), потрібна політика нормалізації і вирішення колізій (суфікси, хешування або збереження оригінального регістру).
Завдання 7: Знайти дублікати базових імен після вашої нормалізації (передбачити колізії)
cr0x@server:~$ find /data/projects/clientA -type f -printf '%p\n' | \
awk -F/ '{
base=$NF
norm=tolower(base)
gsub(/[^a-z0-9._-]+/,"_",norm)
print norm
}' | sort | uniq -c | awk '$1>1{print $0}' | head
2 acme_q4.csv
3 invoice_2024_01.pdf
Що це означає: Ваша запропонована нормалізація створить колізії для принаймні двох імен.
Рішення: Визначте політику колізій зараз: пропускати, додавати суфікс __DUP2, включати батьківську директорію або додавати короткий хеш.
Завдання 8: Зробіть снапшот (якщо доступно) перед змінами імен
cr0x@server:~$ zfs snapshot tank/projects@pre_rename_clientA
cr0x@server:~$ zfs list -t snapshot -o name,creation | grep pre_rename_clientA
tank/projects@pre_rename_clientA Mon Feb 5 10:12 2026
Що це означає: Тепер у вас є точка в часі датасету.
Рішення: Якщо перейменування піде не так, ви можете відкотити датасет (важкий інструмент) або використовувати огляд снапшоту для відновлення імен.
Завдання 9: Заміряти базову продуктивність метаданих (щоб виявити вузькі місця)
cr0x@server:~$ /usr/bin/time -f 'elapsed=%E cpu=%P' bash -c 'find /data/projects/clientA -maxdepth 2 -type f -print0 | xargs -0 -n 1000 stat >/dev/null'
elapsed=0:08.41 cpu=92%
Що це означає: Стат індексів вибірки швидкий і завантажує CPU (добрий знак: локальні метадані, не мережеве обмеження).
Рішення: Якщо час великий, а CPU низький — ви заблоковані ввід/виводом або мережею. Плануйте менші батчі і виконання в непіковий час.
Завдання 10: Перевірити виснаження inode (так, це ще трапляється)
cr0x@server:~$ df -i /data/projects
Filesystem Inodes IUsed IFree IUse% Mounted on
tank/projects - - - - /data/projects
Що це означає: На ZFS звітування inode відрізняється; ви не отримаєте класичні лічильники inode.
Рішення: На ext4/XFS ви б перевірили доступність inode. На ZFS зосередьтеся на вільному просторі та продуктивності метаданих замість inode‑лічильників.
Завдання 11: Побудувати детермінований список файлів (стабільний порядок для відтворюваності)
cr0x@server:~$ cd /data/projects/clientA
cr0x@server:~$ find . -type f -print0 | sort -z > /tmp/clientA.files.zlist
cr0x@server:~$ python3 - <<'PY'
import os
p="/tmp/clientA.files.zlist"
print("bytes", os.path.getsize(p))
PY
bytes 19844712
Що це означає: Тепер у вас є стабільний NUL‑розділений список, який можна повторно використовувати для dry‑run і виконання.
Рішення: Заморозьте обсяг. Якщо нові файли з’являються під час вікна перейменування, обробляйте їх другим проходом — не ганяйтеся за рухомою метою.
Завдання 12: Запустіть реальний dry‑run‑план (source → dest) і перегляньте його
cr0x@server:~$ bash safe_rename.sh --plan --root /data/projects/clientA --rule 'lower,sanitize' --out /tmp/rename.plan.tsv
Plan written: /tmp/rename.plan.tsv
Rows: 284913
Collisions: 17
Skips: 0
Що це означає: Ваш план знайшов 17 колізій. Добре: він не прикидається, що все гаразд.
Рішення: Зупиніться і вирішіть колізії перед виконанням. Або відрегулюйте правила, або оберіть політику вирішення колізій.
Завдання 13: Перевірте, чи колізії дійсно проблемні (вибіркова перевірка)
cr0x@server:~$ awk -F'\t' '$4=="COLLISION"{print $0}' /tmp/rename.plan.tsv | head -3
/data/projects/clientA/inbox/ACME - Q4.csv /data/projects/clientA/inbox/acme_q4.csv COLLISION with:/data/projects/clientA/inbox/ACME_Q4.csv
/data/projects/clientA/inbox/ACME_Q4.csv /data/projects/clientA/inbox/acme_q4.csv COLLISION with:/data/projects/clientA/inbox/ACME - Q4.csv
/data/projects/clientA/finance/Invoice 2024-01.pdf /data/projects/clientA/finance/invoice_2024-01.pdf COLLISION with:/data/projects/clientA/finance/Invoice_2024_01.pdf
Що це означає: Два різні оригінальні імена сходяться. Це не хибнопозитив.
Рішення: Оберіть: додавати хеш‑суфікси, зберегти один варіант імені або перемістити дублікати в карантинну директорію.
Завдання 14: Запустіть маленький канарковий піднабір спершу
cr0x@server:~$ head -z -n 2000 /tmp/clientA.files.zlist > /tmp/clientA.canary.zlist
cr0x@server:~$ bash safe_rename.sh --apply --filelist /tmp/clientA.canary.zlist --root /data/projects/clientA --rule 'lower,sanitize' --log /tmp/rename.canary.log
Applied.
Renamed: 1983
Unchanged: 17
Errors: 0
Що це означає: Канарка пройшла успішно. Деякі файли залишилися без змін (вже нормалізовані або виключені).
Рішення: Підтвердіть роботу залежних додатків і права зараз, перед тим як чіпати інші 282k файлів.
Завдання 15: Перевірте, що перейменування не змінило вміст файлів (вибіркова хеш‑перевірка)
cr0x@server:~$ awk -F'\t' '$3=="RENAMED"{print $1"\t"$2}' /tmp/rename.canary.log | head -5
/data/projects/clientA/inbox/Report 1.txt /data/projects/clientA/inbox/report_1.txt
/data/projects/clientA/inbox/Spec (Draft).pdf /data/projects/clientA/inbox/spec_draft_.pdf
/data/projects/clientA/inbox/notes!.md /data/projects/clientA/inbox/notes_.md
/data/projects/clientA/inbox/Q4#plan.xlsx /data/projects/clientA/inbox/q4_plan.xlsx
/data/projects/clientA/inbox/Team Photo.JPG /data/projects/clientA/inbox/team_photo.jpg
cr0x@server:~$ src="/data/projects/clientA/inbox/Report 1.txt"
cr0x@server:~$ dst="/data/projects/clientA/inbox/report_1.txt"
cr0x@server:~$ sha256sum "$dst"
8c1b5e9b6bbfe19c1f0f3f51c2d7e1ce0b41b2dcf2a44d26e7e4a6e93c39d9d0 /data/projects/clientA/inbox/report_1.txt
Що це означає: Ви можете хешувати перейменований файл. Для чистого перейменування вміст має співпадати, але ви вибірково перевіряєте, щоб упіймати випадкові копіювання або транскодування.
Рішення: Якщо хеші відрізняються для «перейменованих» файлів — це не перейменування, а щось інше. Зупиніть процес.
Завдання 16: Моніторинг прогресу перейменування та частоти помилок у реальному часі
cr0x@server:~$ tail -f /tmp/rename.full.log
2026-02-05T10:22:11Z RENAMED /data/projects/clientA/inbox/Team Photo.JPG /data/projects/clientA/inbox/team_photo.jpg
2026-02-05T10:22:11Z UNCHANGED /data/projects/clientA/inbox/readme.txt /data/projects/clientA/inbox/readme.txt
2026-02-05T10:22:11Z ERROR /data/projects/clientA/legal/Contract?.pdf Permission denied
Що це означає: Ви бачите одну помилку прав доступу.
Рішення: Не продовжуйте, якщо помилки накопичуються. Вирішіть: пропускати й звітувати або виправити права й повторити.
Скрипт: спочатку dry‑run, двофазне перейменування, журнал відкату
Цей скрипт створено для хаотичного світу: пробіли, newlines, ведучі дефіси, колізії та файлові системи, які не поділяють ваш оптимізм. Він підтримує:
- Режим плану: формує TSV‑план з виявленням колізій
- Режим застосування: виконує двофазне перейменування, щоб уникнути колізій
- Відкат: використовує журнал для звернення операції назад
- Стабільна область: опціональний NUL‑розділений файл‑лист, щоб перейменувати саме те, що ви переглянули
Думка: якщо ви перейменовуєте більше кількох сотень файлів, не запускайте «просто один рядок». Один‑рядки чудові, доки вам не доведеться пояснювати аудит‑команді, чому revenue_final.xlsx зник. Використовуйте скрипт з журналами.
cr0x@server:~$ cat safe_rename.sh
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
safe_rename.sh --plan|--apply|--rollback
--root PATH Root directory containing files to rename
--rule RULES Comma-separated rules: lower,sanitize
--out PLAN.tsv (plan) output plan TSV
--log LOG.tsv (apply/rollback) operation log
--filelist FILE.zlist Optional NUL-delimited, sorted file list (relative or absolute)
--collision-policy POLICY one of: error, suffix-hash
USAGE
}
mode=""
root=""
rules=""
out=""
log=""
filelist=""
collision_policy="error"
while [[ $# -gt 0 ]]; do
case "$1" in
--plan|--apply|--rollback) mode="${1#--}"; shift ;;
--root) root="$2"; shift 2 ;;
--rule) rules="$2"; shift 2 ;;
--out) out="$2"; shift 2 ;;
--log) log="$2"; shift 2 ;;
--filelist) filelist="$2"; shift 2 ;;
--collision-policy) collision_policy="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$mode" ]] || { echo "Missing mode" >&2; usage; exit 2; }
[[ "$mode" == "rollback" || -n "$root" ]] || { echo "Missing --root" >&2; exit 2; }
[[ -n "$rules" || "$mode" == "rollback" ]] || { echo "Missing --rule" >&2; exit 2; }
ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
norm_name() {
local name="$1"
local outname="$name"
IFS=',' read -r -a rr <<< "$rules"
for r in "${rr[@]}"; do
case "$r" in
lower) outname="$(printf '%s' "$outname" | tr '[:upper:]' '[:lower:]')" ;;
sanitize)
outname="$(printf '%s' "$outname" | sed -E 's/[^a-zA-Z0-9._-]+/_/g; s/^_+//; s/_+$//')"
;;
*) echo "Unknown rule: $r" >&2; exit 2 ;;
esac
done
printf '%s' "$outname"
}
sha8() {
# short stable suffix for collision avoidance
printf '%s' "$1" | sha256sum | awk '{print substr($1,1,8)}'
}
emit_file_list() {
# Output NUL-delimited absolute file paths
if [[ -n "$filelist" ]]; then
# filelist may contain relative entries; anchor under root if relative
while IFS= read -r -d '' p; do
if [[ "$p" = /* ]]; then
printf '%s\0' "$p"
else
printf '%s\0' "$root/$p"
fi
done < "$filelist"
else
find "$root" -type f -print0
fi
}
plan() {
[[ -n "$out" ]] || { echo "Missing --out" >&2; exit 2; }
: > "$out"
declare -A seen=()
local collisions=0 rows=0 skips=0
while IFS= read -r -d '' src; do
rows=$((rows+1))
local dir base dstbase dst status extra
dir="$(dirname -- "$src")"
base="$(basename -- "$src")"
dstbase="$(norm_name "$base")"
dst="$dir/$dstbase"
if [[ "$src" == "$dst" ]]; then
status="UNCHANGED"; extra=""
skips=$((skips+1))
else
# collision check: destination path already targeted by another source
if [[ -n "${seen[$dst]:-}" ]]; then
status="COLLISION"; extra="with:${seen[$dst]}"
collisions=$((collisions+1))
else
status="OK"; extra=""
fi
seen[$dst]="$src"
fi
printf '%s\t%s\t%s\t%s\n' "$src" "$dst" "$status" "$extra" >> "$out"
done < <(emit_file_list)
echo "Plan written: $out"
echo "Rows: $rows"
echo "Collisions: $collisions"
echo "Skips: $skips"
if [[ "$collisions" -gt 0 && "$collision_policy" == "error" ]]; then
echo "Refusing to proceed with collisions under policy=error." >&2
fi
}
apply() {
[[ -n "$log" ]] || { echo "Missing --log" >&2; exit 2; }
local planfile
planfile="$(mktemp /tmp/rename.plan.XXXXXX.tsv)"
out="$planfile"
plan >/tmp/rename.plan.summary
if grep -q $'\tCOLLISION\t' "$planfile"; then
if [[ "$collision_policy" == "error" ]]; then
echo "Collisions detected; aborting. See: $planfile" >&2
exit 3
fi
fi
: > "$log"
# Phase 1: rename to temporary unique names in same directory
# We only rename items that will change.
while IFS=$'\t' read -r src dst status extra; do
[[ "$status" == "OK" ]] || continue
local dir base tmp
dir="$(dirname -- "$src")"
base="$(basename -- "$src")"
tmp="$dir/.rename_tmp.$(sha8 "$src").$base"
# Ensure temp name doesn't exist
if [[ -e "$tmp" ]]; then
echo -e "$(ts)\tERROR\t$src\t$temp_exists" >> "$log"
echo "Temp already exists: $tmp" >&2
exit 4
fi
mv -- "$src" "$tmp"
printf '%s\tPHASE1\t%s\t%s\n' "$(ts)" "$tmp" "$dst" >> "$log"
done < "$planfile"
# Phase 2: rename temp to final, with optional collision suffixing
while IFS=$'\t' read -r src dst status extra; do
if [[ "$status" == "UNCHANGED" ]]; then
printf '%s\tUNCHANGED\t%s\t%s\n' "$(ts)" "$src" "$dst" >> "$log"
continue
fi
[[ "$status" == "OK" || "$status" == "COLLISION" ]] || continue
local dir base tmp final
dir="$(dirname -- "$src")"
base="$(basename -- "$src")"
tmp="$dir/.rename_tmp.$(sha8 "$src").$base"
final="$dst"
if [[ "$status" == "COLLISION" && "$collision_policy" == "suffix-hash" ]]; then
# append hash before extension
local b ext h
h="$(sha8 "$src")"
b="$(basename -- "$dst")"
ext=""
if [[ "$b" == *.* && "$b" != .* ]]; then
ext=".${b##*.}"
b="${b%.*}"
fi
final="$dir/${b}__${h}${ext}"
elif [[ "$status" == "COLLISION" ]]; then
printf '%s\tERROR\t%s\t%s\n' "$(ts)" "$src" "collision" >> "$log"
continue
fi
if [[ -e "$final" ]]; then
printf '%s\tERROR\t%s\t%s\n' "$(ts)" "$src" "dest_exists:$final" >> "$log"
continue
fi
mv -- "$tmp" "$final"
printf '%s\tRENAMED\t%s\t%s\n' "$(ts)" "$src" "$final" >> "$log"
done < "$planfile"
echo "Applied."
echo "Renamed: $(awk -F'\t' '$2=="RENAMED"{c++} END{print c+0}' "$log")"
echo "Unchanged: $(awk -F'\t' '$2=="UNCHANGED"{c++} END{print c+0}' "$log")"
echo "Errors: $(awk -F'\t' '$2=="ERROR"{c++} END{print c+0}' "$log")"
}
rollback() {
[[ -n "$log" ]] || { echo "Missing --log" >&2; exit 2; }
# Roll back in reverse order; only RENAMED and PHASE1 entries matter.
tac "$log" | while IFS=$'\t' read -r t action a b; do
case "$action" in
RENAMED)
# a=src(original), b=final; move final back to original name
if [[ -e "$b" ]]; then
mv -- "$b" "$a"
printf '%s\tROLLED_BACK\t%s\t%s\n' "$(ts)" "$b" "$a"
else
printf '%s\tROLLBACK_MISSING\t%s\t%s\n' "$(ts)" "$b" "$a"
fi
;;
PHASE1)
# a=temp, b=dst planned; if temp still exists, move back to original? not stored here
# We don't have original in PHASE1 entry; rollback relies primarily on RENAMED lines.
:
;;
*) : ;;
esac
done
}
case "$mode" in
plan) plan ;;
apply) apply ;;
rollback) rollback ;;
*) echo "Bad mode: $mode" >&2; exit 2 ;;
esac
Як запускати безпечно (по‑дорослому)
1) Згенеруйте список файлів (опційно, але рекомендовано). 2) Зробіть план. 3) Перегляньте колізії. 4) Виконайте канарковий запуск. 5) Повне застосування з логуванням. 6) Перевірте результат.
cr0x@server:~$ cd /data/projects/clientA
cr0x@server:~$ find . -type f -print0 | sort -z > /tmp/clientA.files.zlist
cr0x@server:~$ bash safe_rename.sh --plan --root /data/projects/clientA --filelist /tmp/clientA.files.zlist --rule 'lower,sanitize' --out /tmp/clientA.plan.tsv
Plan written: /tmp/clientA.plan.tsv
Rows: 284913
Collisions: 17
Skips: 9211
Тепер ви приймаєте рішення. Якщо колізії неприйнятні — виправте правила іменування або обробіть їх політикою. Якщо колізії очікувані і вам потрібна детермінована унікальність — використайте --collision-policy suffix-hash. Це дає стабільні й прозорі імена.
cr0x@server:~$ bash safe_rename.sh --apply --root /data/projects/clientA --filelist /tmp/clientA.files.zlist --rule 'lower,sanitize' --collision-policy suffix-hash --log /tmp/clientA.rename.log
Applied.
Renamed: 275702
Unchanged: 9211
Errors: 0
Жарт №2: Масове перейменування без журналу — це як фокус без аудиторії: ніхто не знає, що сталося, навіть ви.
Швидкий план діагностики
Коли масове перейменування повільне або нестабільне, люди люблять сперечатися про «мережу» або «ZFS дивний». Не сперечайтеся. Зміряйте три речі, і здебільшого ви знайдете вузьке місце за десять хвилин.
По‑перше: локальні метадані чи віддалені?
- Перевірте тип маунту (
mount,df -Th). Якщо це NFS/SMB/FUSE — очікуйте латентності метаданих. - Симптом віддалених метаданих: низький CPU, великий час виконання, прогрес ривками.
cr0x@server:~$ /usr/bin/time -f 'elapsed=%E cpu=%P' bash -c 'for i in {1..2000}; do stat /data/projects/clientA/inbox/readme.txt >/dev/null; done'
elapsed=0:00.36 cpu=99%
Рішення: Якщо це секунди‑до‑хвилин з низьким CPU — ви латентні. Зменшуйте кількість раундів (батчуйте операції, уникайте зовнішніх команд на файл) і плануйте на непіковий час.
По‑друге: чи велика директорія і listing — вузьке місце?
cr0x@server:~$ /usr/bin/time -f 'elapsed=%E' ls -U /data/projects/clientA/inbox >/dev/null
elapsed=0:02.91
Рішення: Якщо перелік однієї директорії займає секунди — перейменування тисяч всередині неї буде важким. Розбийте роботу по піддиректоріях або подумайте про реструктуризацію перед перейменуванням.
По‑третє: чи випадково ви копіюєте вміст файлів?
cr0x@server:~$ awk -F: '$2==" /data/projects"{print}' /proc/mounts
tank/projects /data/projects zfs rw,xattr,noacl 0 0
Рішення: Переконайтеся, що шляхи джерела й призначення залишаються в межах /data/projects. Якщо ваш інструмент «переносить» в інший маунт — зупиніться й переробіть план.
Бонус: швидко виявити конкурентність на рівні файлової системи
cr0x@server:~$ iostat -xz 1 3
Linux 6.6.0 (server) 02/05/2026 _x86_64_ (16 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
12.15 0.00 6.42 1.11 0.00 80.32
Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s wrqm/s %wrqm w_await wareq-sz aqu-sz %util
nvme0n1 2.10 88.3 0.00 0.00 1.42 42.0 55.10 1400.0 10.00 15.36 4.10 25.4 0.25 12.4
Рішення: Якщо %util близький до піку і росте час очікування — ви обмежені сховищем. Зменшіть паралельність і уникайте масових «штормів» перейменувань.
Типові помилки: симптом → корінна причина → виправлення
1) Симптом: «Деякі файли зникли»
Корінна причина: Колізія з перезаписом. Ваш скрипт перейменував кілька джерел в одне призначення та перезаписав або мовчки пропустив.
Виправлення: Двофазне перейменування плюс явна політика колізій. За замовчуванням має бути «помилка і зупинка», а не «мляво продовжити». Використовуйте --collision-policy suffix-hash лише коли зацікавлені сторони погоджуються на незграбні імена.
2) Симптом: «У dev це було швидко, а в проді — повільно»
Корінна причина: Продакшн на NFS/SMB або навантажений сервер метаданих. Dev був на локальному SSD.
Виправлення: Заміряйте латентність метаданих. Зменшіть числа підпроцесів на файл. Виконуйте в непіковий час. Якщо треба — паралелізуйте обережно (кілька воркерів), а не «по ядру».
3) Симптом: «Ми змінили лише регістр, але нічого не сталося»
Корінна причина: Файлова система нечутлива до регістру, тож foo і Foo — той самий запис і перейменування стає no‑op або помилкою.
Виправлення: Двоетапне перейменування: foo → .__tmp__ → Foo. Двофазний підхід скрипта робить це автоматично.
4) Симптом: «Половина запуску впала з Permission denied»
Корінна причина: Деякі директорії/файли мають суворіші права, ACL або прапор immutable. Або ви перейменовуєте під директорією, де можете читати, але не писати.
Виправлення: Префлайт‑перевірка прав запису на директоріях, а не лише на файлах. На Linux право запису на директорію керує перейменуваннями.
cr0x@server:~$ namei -l /data/projects/clientA/legal/Contract?.pdf
f: /data/projects/clientA/legal/Contract?.pdf
drwxr-xr-x root root /
drwxr-xr-x root root data
drwxr-xr-x root root projects
drwxr-x--- team legal clientA
dr-xr-x--- team legal legal
-rw-r----- team legal Contract?.pdf
Рішення: Якщо цільова директорія недоступна для запису (dr-x) — перейменування там зазнає поразки. Виправте права директорії або виключіть цю гілку.
5) Симптом: «Відкат не відновив усе»
Корінна причина: Нема повної мапи, ви редагували журнал або запуск мав змішані результати (деякі PHASE1 домишки залишились після збою).
Виправлення: Тримайте журнали як додавальні. Зберігайте їх навіть поза хостом, якщо параноїдально. Після помилки зробіть інвентар тимчасових імен (.rename_tmp.) і вирішіть їх, перш ніж перезапускати.
cr0x@server:~$ find /data/projects/clientA -name '.rename_tmp.*' | head
/data/projects/clientA/inbox/.rename_tmp.1a2b3c4d.Report 1.txt
Рішення: Якщо тимчасові файли існують — зупиніться з подальшими запусками. Або чисто завершіть Фазу 2, або відкотіть.
6) Симптом: «Додаток не може знайти файли після перейменування»
Корінна причина: Додаток зберіг абсолютні шляхи; ви змінили їх, не оновивши посилання.
Виправлення: Або оновіть посилання (БД/конфіг) у тому самому вікні змін, або забезпечте сумісність через символічні посилання (уважно: symlink можуть ускладнити бекапи й безпеку). Для деяких робочих процесів краще оновити додаток, ніж замальовувати проблему.
Чеклісти / покроковий план
Префлайт‑чекліст (не пропускайте, якщо не любите сюрпризи)
- Підтвердіть файлову систему та тип маунту (
df -Th,mount). Визначте, чи метадані локальні чи віддалені. - Підтвердіть обсяг: порахуйте файли, визначте гігантські директорії.
- Проскануйте на патологічні імена: newlines, ведучі дефіси, дивний Unicode. Визначте, чи будете ви санітизувати чи зберігати.
- Визначте правила іменування: нижній регістр? пробіли? пунктуація? обробка розширень? Визначте, що вважати «правильним».
- Політика колізій: за замовчуванням зупинка при колізії; use suffix‑hash лише коли потрібно.
- Бекапи/снапшоти: зробіть снапшот, якщо можете; інакше переконайтеся, що бекапи адекватно відстежують перейменування.
- Заморозьте прийом: зупиніть надходження нових файлів або домовтеся обробити їх у другому проході.
План виконання (частина, яку можна покласти в change‑ticket)
- Створіть стабільний список файлів (NUL‑розділений, відсортований).
- Згенеруйте TSV‑план; перегляньте звіт про колізії.
- Виконайте канаркове застосування на 1–2k файлах з репрезентативних директорій.
- Перевірте роботу залежних систем (індексація, збірки, додаток) за результатами канарки.
- Застосуйте повне перейменування з включеним логом; підписуйте журнал у реальному часі.
- Після завершення: проскануйте на тимчасові рештки, рядки з помилками й несподівані шаблони імен.
- Комунікуйте: опублікуйте шлях до журналу перейменувань і правила нормалізації, які застосувалися.
Чекліст перевірки після перейменування
- Помилки: підрахунок ERROR в журналі має бути нульовим або поясненим.
- Тимчасові файли: не повинно залишатися
.rename_tmp.*. - Наслідки колізій: якщо використано suffix‑hash, переконайтеся, що зацікавлені сторони прийняли формат.
- Стан додатків: ключові робочі процеси мають коректно знаходити шляхи.
- Стан бекапів: наступний бекап не повинен трактувати все як «нові несумісні дані».
cr0x@server:~$ awk -F'\t' '$2=="ERROR"{c++} END{print c+0}' /tmp/clientA.rename.log
0
Рішення: Якщо не‑нуль — вирішіть окремі шляхи і перезапустіть лише для них (бажано) або робіть відкат.
cr0x@server:~$ find /data/projects/clientA -name '.rename_tmp.*' | wc -l
0
Рішення: Якщо не‑нуль — у вас перерваний запуск або ручне втручання. Вирішіть це перед оголошенням успіху.
Три корпоративні історії з поля бою
Інцидент №1: Хибне припущення («rename завжди атомарний»)
Запит здавався безпечним: «Нормалізувати імена файлів в директорії export до нижнього регістру». Директорія була під /exports, і інженер припустив, що це звичайний локальний маунт. Написали швидкий скрипт, який переміщував файли в тимчасову директорію (також під /exports), перейменовував їх і повертав назад. У тесті все було добре.
У продакшні /exports виявився autofs‑керованим NFS‑маунтом, а тимчасова директорія була фактично на іншому бекенді. «Переміщення» тихо стало copy+delete. Під час виконання мережа підглючила. Фаза копіювання затрималася; фаза видалення все одно відбулася для деяких файлів; скрипт повторював спроби і утворив садок часткових файлів і відсутніх оригіналів.
Потім почалося цікаве: downstream‑джоби побачили «нові» файли з новими іменами й одночасно відсутні очікувані. Їхня логіка реконсиляції базувалась на шляхах, а не на вмісті, тож з’явилося багато фальшивих розбіжностей. У чаті з’явився фінансовий відділ. Ніхто не був у захваті.
Виправлення не було складним. Операцію повторили як чисте in‑place перейменування в межах однієї файлової системи після перевірки меж маунтів за допомогою df -Th. Також додали жорстку перевірку «відмовити, якщо призначення на іншому пристрої». Це той запобіжник, який не цінується, поки не стане в нагоді.
Інцидент №2: Оптимізація, що відверто нашкодила («паралелізуйте це»)
Інша команда мала мільйони дрібних зображень для перейменування. Інженер побачив годинну оцінку і вирішив паралелізувати. Не лише трохи — десятки воркерів, кожен гонить mv та stat у щільних петлях.
Сховище було спільним NAS з метаданним сервером, що вже був зайнятий. Перейменування — це операції над метаданими, а метадані часто є найбільш серіалізованим ресурсом у флоті. Паралельний запуск перетворив керовану чергу на гуркіт стада. Латентність підскочила для інших робочих навантажень. Білди загальмували. CI‑пайплайн таймаутив і повторювався, погіршуючи ситуацію.
Кінцевий результат: загальний час виконання ледве покращився. Робота не була обмежена CPU; вона була обмежена метаданими. Більше воркерів означало лише більше черг та роздратування для інших.
Відновилися, зменшивши паралельність до невеликої фіксованої кількості, батчуючи операції і зробивши канарку для вимірювання пропускної здатності перед масштабуванням. Також перенесли вікно роботи на непіковий час і узгодили з командою сховища. Оптимізація виявилась не в паралелізмі, а в тому, щоб не боротися з обмеженнями сховища.
Інцидент №3: Нудна практика, що врятувала день (снапшоти + перегляд плану)
Організація, що дотримується комплаєнсу, мала завдання санітизувати імена перед архівацією: видалити пробіли, прибрати спецсимволи, зберегти розширення. Команда розумно поставилася до цього як до операційної зміни, а не як до shell‑веселощів. Зробили снапшот, згенерували план і другий інженер переглянув звіт про колізії.
Під час перегляду виявили шаблон: підмножина файлів кодувала значення пунктуацією (типу «A/B test»), і санітизація зруйнувала б відмінності. Не втрата даних — гірше: втрата семантики. Вони відрегулювали набір правил, зберігши деякі символи, і додали суфікс‑хеш лише для кількох колізій. Потім запустили канарку, перевірили, що внутрішня індексація відтворює очікувані шаблони, і виконали повний запуск. Архів завершився без повторного інгесту, бо сховище розпізнало іноди; змінилися лише імена.
Нічого героїчного. Ніхто не отримав трофей. Але це уникнуло повільної катастрофи, що закінчилася б конференц‑залом і слайдами з таймлайном.
Поширені запитання
1) Чи можу я просто використати команду rename?
Іноді так. Але є різні реалізації rename (Perl‑версія проти util‑linux), різні прапорці й відмінна поведінка. Для продукційної роботи я віддаю перевагу скрипту, який логірує кожну дію й явно виявляє колізії.
2) Чому не використовувати find ... -exec mv?
Можна, але легко створити накладні витрати на підпроцеси для кожного файлу і майже неможливо зробити осмислений dry‑run з виявленням колізій. Також ви будете ненавидіти себе, коли newline‑ім’я порве парсер журналу.
3) Чи змінить перейменування вміст файлів або часові мітки?
Чисте перейменування в межах однієї файлової системи не змінює вміст файлів. Зазвичай воно не змінює mtime, але змінить ctime (час зміни inode), бо метадані змінено. Якщо бачите зміни вмісту — скоріше за все сталося copy+delete через перетин маунтів.
4) Що з жорсткими посиланнями — я дублюватиму дані?
Перейменування не дублює дані. Але жорсткі посилання означають, що кілька імен вказують на той самий inode. Якщо ви перейменуєте одне ім’я, інші імена залишаться. Якщо ви очікували «один файл», може виявитися, що інше ім’я і досі існує.
5) Як безпечно обробляти колізії?
За замовчуванням зупиняйтеся і виправляйте правила. Якщо бізнес вимагає «без людського перегляду», використовуйте детерміновану унікальність (suffix‑hash) і задокументуйте формат. Уникайте випадкових суфіксів; хочете повторюваних результатів.
6) Як зробити відкат?
Найкраще: відкат снапшоту файлової системи (коли це прийнятно). Інакше: відтворити зворотну мапу з журналу. Саме тому скрипт логірує RENAMED src original → dest final. Якщо у вас немає повного журналу — відкат перетворюється на археологію.
7) Чи запускати це від root?
Тільки якщо справді треба. Root робить проблеми з правами «зниклими» і замінює їх на проблеми відповідальності. Краще запускати від сервісного акаунта, що володіє даними, з явними правами доступу там, де потрібно.
8) Як зберегти працездатність додатків після перейменувань?
Або оновлюйте посилання (БД/конфіг) у рамках зміни, або використовуйте сумісність через symlink. Символьні посилання можуть зламати інструменти й очікування безпеки, тож тестуйте. Для деяких робочих процесів краще змінити додаток, аніж маскувати шляхи.
9) Чи завжди варто переводити імена в нижній регістр?
Ні. Це зручно, але може знищити семантику (коди продуктів, людські конвенції) і викликати колізії. Використовуйте нижній регістр тільки з причини й політикою колізій.
10) Як безпечно працювати з Unicode?
Як мінімум, тримайте імена як байти у вашому пайплайні і уникайте інструментів, що припускають «друкований текст». Якщо потрібно нормалізувати Unicode — робіть це через спеціалізовані бібліотеки і з виявленням колізій. «Виглядає однаково» — не гарантія.
Наступні кроки, які варто виконати
- Оберіть політику: вирішіть правила нормалізації і поведінку при колізіях. Запишіть це в тікеті.
- Заморозьте обсяг: згенеруйте відсортований NUL‑розділений список файлів, щоб перейменувати саме те, що переглядали.
- Спочатку план: згенеруйте TSV‑мапу і серйозно перегляньте колізії.
- Зробіть снапшот, якщо можете: ZFS‑snpashot, snaпшот файлової системи або хоча б переконайтеся, що бекапи здорові.
- Канарковий запуск: 1–2k файлів з репрезентативних директорій, потім валідація залежних систем.
- Повний запуск з логом: стежте за журналом, рахуйте помилки і перевіряйте тимчасові імена.
- Комунікація змін: опублікуйте набір правил і шлях до журналу. Люди будуть питати «куди пішов мій файл» ще тижнями.
Якщо запам’ятаєте одне: безпечне масове перейменування — це не про хитрі перетворення рядків. Це про доведення того, що кожен шлях‑джерело мапиться на один шлях‑призначення, без колізій, із можливістю відкату та з впевненістю, що файлова система робить те, що ви думаєте.