diff --git a/zfs-inplace-rebalancing.sh b/zfs-inplace-rebalancing.sh index b9fe6ce..f20084b 100755 --- a/zfs-inplace-rebalancing.sh +++ b/zfs-inplace-rebalancing.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash -# exit script on error +# Exit script on error set -e -# exit on undeclared variable +# Exit on undeclared variable set -u -# file used to track processed files +# File used to track processed files rebalance_db_file_name="rebalance_db.txt" -# index used for progress +# Index used for progress current_index=0 ## Color Constants @@ -24,19 +24,18 @@ Cyan='\033[0;36m' # Cyan ## Functions -# print a help message +# Print a help message function print_usage() { - echo "Usage: zfs-inplace-rebalancing --checksum true --skip-hardlinks false --passes 1 /my/pool" + echo "Usage: zfs-inplace-rebalancing.sh --checksum true --passes 1 --debug true /my/pool" } -# print a given text entirely in a given color +# Print a given text entirely in a given color function color_echo () { color=$1 text=$2 echo -e "${color}${text}${Color_Off}" } - function get_rebalance_count () { file_path=$1 @@ -52,131 +51,99 @@ function get_rebalance_count () { fi } -# rebalance a specific file -function rebalance () { - file_path=$1 - - # check if file has >=2 links in the case of --skip-hardlinks - # this shouldn't be needed in the typical case of `find` only finding files with links == 1 - # but this can run for a long time, so it's good to double check if something changed - if [[ "${skip_hardlinks_flag,,}" == "true"* ]]; then - if [[ "${OSTYPE,,}" == "linux-gnu"* ]]; then - # Linux - # - # -c --format=FORMAT - # use the specified FORMAT instead of the default; output a - # newline after each use of FORMAT - # %h number of hard links - - hardlink_count=$(stat -c "%h" "${file_path}") - elif [[ "${OSTYPE,,}" == "darwin"* ]] || [[ "${OSTYPE,,}" == "freebsd"* ]]; then - # Mac OS - # FreeBSD - # -f format - # Display information using the specified format - # l Number of hard links to file (st_nlink) - - hardlink_count=$(stat -f %l "${file_path}") - else - echo "Unsupported OS type: $OSTYPE" - exit 1 - fi - - if [ "${hardlink_count}" -ge 2 ]; then - echo "Skipping hard-linked file: ${file_path}" - return - fi - fi +# Rebalance a group of files that are hardlinked together +function process_inode_group() { + paths=("$@") + num_paths="${#paths[@]}" + # Progress tracking current_index="$((current_index + 1))" - progress_percent=$(printf '%0.2f' "$((current_index*10000/file_count))e-2") - color_echo "${Cyan}" "Progress -- Files: ${current_index}/${file_count} (${progress_percent}%)" + progress_raw=$((current_index * 10000 / file_count)) + progress_percent=$(printf '%0.2f' "${progress_raw}e-2") + color_echo "${Cyan}" "Progress -- Files: ${current_index}/${file_count} (${progress_percent}%)" - if [[ ! -f "${file_path}" ]]; then - color_echo "${Yellow}" "File is missing, skipping: ${file_path}" + if [ "$debug_flag" = true ]; then + echo "Processing inode group with ${num_paths} paths:" + for path in "${paths[@]}"; do + echo " - $path" + done fi - if [ "${passes_flag}" -ge 1 ]; then - # check if target rebalance count is reached - rebalance_count=$(get_rebalance_count "${file_path}") + # Check rebalance counts for all files + should_skip=false + for path in "${paths[@]}"; do + rebalance_count=$(get_rebalance_count "${path}") if [ "${rebalance_count}" -ge "${passes_flag}" ]; then - color_echo "${Yellow}" "Rebalance count (${passes_flag}) reached, skipping: ${file_path}" - return + should_skip=true + break fi - fi - - tmp_extension=".balance" - tmp_file_path="${file_path}${tmp_extension}" + done - echo "Copying '${file_path}' to '${tmp_file_path}'..." + if [ "${should_skip}" = true ]; then + if [ "${num_paths}" -gt 1 ]; then + color_echo "${Yellow}" "Rebalance count (${passes_flag}) reached, skipping group: ${paths[*]}" + else + color_echo "${Yellow}" "Rebalance count (${passes_flag}) reached, skipping: ${paths[0]}" + fi + return + fi + + main_file="${paths[0]}" + + # Check if main_file exists + if [[ ! -f "${main_file}" ]]; then + color_echo "${Yellow}" "File is missing, skipping: ${main_file}" + return + fi + + tmp_extension=".balance" + tmp_file_path="${main_file}${tmp_extension}" + + echo "Copying '${main_file}' to '${tmp_file_path}'..." + if [ "$debug_flag" = true ]; then + echo "Executing copy command:" + fi if [[ "${OSTYPE,,}" == "linux-gnu"* ]]; then # Linux - - # --reflink=never -- force standard copy (see ZFS Block Cloning) - # -a -- keep attributes, includes -d -- keep symlinks (dont copy target) and - # -p -- preserve ACLs to - # -x -- stay on one system - cp --reflink=never -ax "${file_path}" "${tmp_file_path}" + cmd=(cp --reflink=never -ax "${main_file}" "${tmp_file_path}") + if [ "$debug_flag" = true ]; then + echo "${cmd[@]}" + fi + "${cmd[@]}" elif [[ "${OSTYPE,,}" == "darwin"* ]] || [[ "${OSTYPE,,}" == "freebsd"* ]]; then - # Mac OS - # FreeBSD - - # -a -- Archive mode. Same as -RpP. Includes preservation of modification - # time, access time, file flags, file mode, ACL, user ID, and group - # ID, as allowed by permissions. - # -x -- File system mount points are not traversed. - cp -ax "${file_path}" "${tmp_file_path}" + # Mac OS and FreeBSD + cmd=(cp -ax "${main_file}" "${tmp_file_path}") + if [ "$debug_flag" = true ]; then + echo "${cmd[@]}" + fi + "${cmd[@]}" else echo "Unsupported OS type: $OSTYPE" exit 1 fi - # compare copy against original to make sure nothing went wrong + # Compare copy against original to make sure nothing went wrong if [[ "${checksum_flag,,}" == "true"* ]]; then echo "Comparing copy against original..." if [[ "${OSTYPE,,}" == "linux-gnu"* ]]; then # Linux - - # file attributes - original_md5=$(lsattr "${file_path}" | awk '{print $1}') - # file permissions, owner, group - # shellcheck disable=SC2012 - original_md5="${original_md5} $(ls -lha "${file_path}" | awk '{print $1 " " $3 " " $4}')" - # file content - original_md5="${original_md5} $(md5sum -b "${file_path}" | awk '{print $1}')" - - # file attributes - copy_md5=$(lsattr "${tmp_file_path}" | awk '{print $1}') - # file permissions, owner, group - # shellcheck disable=SC2012 - copy_md5="${copy_md5} $(ls -lha "${tmp_file_path}" | awk '{print $1 " " $3 " " $4}')" - # file content - copy_md5="${copy_md5} $(md5sum -b "${tmp_file_path}" | awk '{print $1}')" + original_md5=$(md5sum -b "${main_file}" | awk '{print $1}') + copy_md5=$(md5sum -b "${tmp_file_path}" | awk '{print $1}') elif [[ "${OSTYPE,,}" == "darwin"* ]] || [[ "${OSTYPE,,}" == "freebsd"* ]]; then - # Mac OS - # FreeBSD - - # file attributes - original_md5=$(lsattr "${file_path}" | awk '{print $1}') - # file permissions, owner, group - # shellcheck disable=SC2012 - original_md5="${original_md5} $(ls -lha "${file_path}" | awk '{print $1 " " $3 " " $4}')" - # file content - original_md5="${original_md5} $(md5 -q "${file_path}")" - - # file attributes - copy_md5=$(lsattr "${tmp_file_path}" | awk '{print $1}') - # file permissions, owner, group - # shellcheck disable=SC2012 - copy_md5="${copy_md5} $(ls -lha "${tmp_file_path}" | awk '{print $1 " " $3 " " $4}')" - # file content - copy_md5="${copy_md5} $(md5 -q "${tmp_file_path}")" + # Mac OS and FreeBSD + original_md5=$(md5 -q "${main_file}") + copy_md5=$(md5 -q "${tmp_file_path}") else echo "Unsupported OS type: $OSTYPE" exit 1 fi - if [[ "${original_md5}" == "${copy_md5}"* ]]; then + if [ "$debug_flag" = true ]; then + echo "Original MD5: $original_md5" + echo "Copy MD5: $copy_md5" + fi + + if [[ "${original_md5}" == "${copy_md5}" ]]; then color_echo "${Green}" "MD5 OK" else color_echo "${Red}" "MD5 FAILED: ${original_md5} != ${copy_md5}" @@ -184,30 +151,52 @@ function rebalance () { fi fi - echo "Removing original '${file_path}'..." - rm "${file_path}" + echo "Removing original files..." + for path in "${paths[@]}"; do + if [ "$debug_flag" = true ]; then + echo "Removing $path" + fi + rm "${path}" + done - echo "Renaming temporary copy to original '${file_path}'..." - mv "${tmp_file_path}" "${file_path}" + echo "Renaming temporary copy to original '${main_file}'..." + if [ "$debug_flag" = true ]; then + echo "Moving ${tmp_file_path} to ${main_file}" + fi + mv "${tmp_file_path}" "${main_file}" + + echo "Recreating hardlinks..." + for (( i=1; i<${#paths[@]}; i++ )); do + if [ "$debug_flag" = true ]; then + echo "Linking ${main_file} to ${paths[$i]}" + fi + ln "${main_file}" "${paths[$i]}" + done if [ "${passes_flag}" -ge 1 ]; then - # update rebalance "database" - line_nr=$(grep -xF -n "${file_path}" "./${rebalance_db_file_name}" | head -n 1 | cut -d: -f1) - if [ -z "${line_nr}" ]; then - rebalance_count=1 - echo "${file_path}" >> "./${rebalance_db_file_name}" - echo "${rebalance_count}" >> "./${rebalance_db_file_name}" - else - rebalance_count_line_nr="$((line_nr + 1))" - rebalance_count="$((rebalance_count + 1))" - sed -i '' "${rebalance_count_line_nr}s/.*/${rebalance_count}/" "./${rebalance_db_file_name}" - fi + # Update rebalance "database" for all files + for path in "${paths[@]}"; do + line_nr=$(grep -xF -n "${path}" "./${rebalance_db_file_name}" | head -n 1 | cut -d: -f1) + if [ -z "${line_nr}" ]; then + rebalance_count=1 + echo "${path}" >> "./${rebalance_db_file_name}" + echo "${rebalance_count}" >> "./${rebalance_db_file_name}" + else + rebalance_count_line_nr="$((line_nr + 1))" + rebalance_count=$(awk "NR == ${rebalance_count_line_nr}" "./${rebalance_db_file_name}") + rebalance_count="$((rebalance_count + 1))" + if [ "$debug_flag" = true ]; then + echo "Updating rebalance count for ${path} to ${rebalance_count}" + fi + sed -i "${rebalance_count_line_nr}s/.*/${rebalance_count}/" "./${rebalance_db_file_name}" + fi + done fi } checksum_flag='true' -skip_hardlinks_flag='false' passes_flag='1' +debug_flag='false' if [[ "$#" -eq 0 ]]; then print_usage @@ -228,18 +217,18 @@ while true ; do fi shift 2 ;; - --skip-hardlinks ) - if [[ "$2" == 1 || "$2" =~ (on|true|yes) ]]; then - skip_hardlinks_flag="true" - else - skip_hardlinks_flag="false" - fi - shift 2 - ;; -p | --passes ) passes_flag=$2 shift 2 ;; + --debug ) + if [[ "$2" == 1 || "$2" =~ (on|true|yes) ]]; then + debug_flag="true" + else + debug_flag="false" + fi + shift 2 + ;; *) break ;; @@ -252,29 +241,84 @@ color_echo "$Cyan" "Start rebalancing $(date):" color_echo "$Cyan" " Path: ${root_path}" color_echo "$Cyan" " Rebalancing Passes: ${passes_flag}" color_echo "$Cyan" " Use Checksum: ${checksum_flag}" -color_echo "$Cyan" " Skip Hardlinks: ${skip_hardlinks_flag}" +color_echo "$Cyan" " Debug Mode: ${debug_flag}" -# count files -if [[ "${skip_hardlinks_flag,,}" == "true"* ]]; then - file_count=$(find "${root_path}" -type f -links 1 | wc -l) +# Generate files_list.txt with device and inode numbers using stat, separated by a pipe '|' +if [[ "${OSTYPE,,}" == "linux-gnu"* ]]; then + # Linux + find "$root_path" -type f -not -path '*/.zfs/*' -exec stat --printf '%d:%i|%n\n' {} \; > files_list.txt +elif [[ "${OSTYPE,,}" == "darwin"* ]] || [[ "${OSTYPE,,}" == "freebsd"* ]]; then + # Mac OS and FreeBSD + find "$root_path" -type f -not -path '*/.zfs/*' -exec sh -c 'stat -f "%d:%i|%N" "$0"' {} \; {} \; > files_list.txt else - file_count=$(find "${root_path}" -type f | wc -l) + echo "Unsupported OS type: $OSTYPE" + exit 1 fi -color_echo "$Cyan" " File count: ${file_count}" +if [ "$debug_flag" = true ]; then + echo "Contents of files_list.txt:" + cat files_list.txt +fi -# create db file +# Sort files_list.txt by device and inode number +sort -t '|' -k1,1 files_list.txt > sorted_files_list.txt + +if [ "$debug_flag" = true ]; then + echo "Contents of sorted_files_list.txt:" + cat sorted_files_list.txt +fi + +# Use awk to group paths by inode key +awk -F'|' '{ + key = $1 + path = $2 + if (key == prev_key) { + paths = paths " " path + } else { + if (NR > 1) { + print prev_key "|" paths + } + prev_key = key + paths = path + } +} +END { + if (NR > 0) { + print prev_key "|" paths + } +}' sorted_files_list.txt > grouped_inodes.txt + +if [ "$debug_flag" = true ]; then + echo "Contents of grouped_inodes.txt:" + cat grouped_inodes.txt +fi + +# Count number of inode groups +file_count=$(wc -l < grouped_inodes.txt | tr -d ' ') + +color_echo "$Cyan" " Number of files to process: ${file_count}" + +# Initialize current_index +current_index=0 + +# Create db file if [ "${passes_flag}" -ge 1 ]; then touch "./${rebalance_db_file_name}" fi -# recursively scan through files and execute "rebalance" procedure -# in the case of --skip-hardlinks, only find files with links == 1 -if [[ "${skip_hardlinks_flag,,}" == "true"* ]]; then - find "$root_path" -type f -links 1 -print0 | while IFS= read -r -d '' file; do rebalance "$file"; done -else - find "$root_path" -type f -print0 | while IFS= read -r -d '' file; do rebalance "$file"; done -fi +# Read grouped_inodes.txt and process each group +while IFS='|' read -r key paths; do + if [ "$debug_flag" = true ]; then + echo "Detected inode group: key=${key}" + echo "Paths:${paths}" + fi + # Split the paths into an array + read -a path_array <<< "${paths}" + process_inode_group "${path_array[@]}" +done < grouped_inodes.txt + +# Clean up temporary files +rm files_list.txt sorted_files_list.txt grouped_inodes.txt echo "" echo ""