Support hardlink groups, add debug

-Adds debug functionality with extended details
-Supports detecting inode groups for hardlink processing.
-Pulls files and sorts by, then groups by inode group with awk
-Checks all files in an inode group's counts when calculating skipping counts
-Removes existing skip hardlink flag
-Removes hardlinks and recreates them directly after the balance copy/delete/move operation per inode group to minimize 'downtime'
This commit is contained in:
undaunt 2024-11-06 12:50:55 -08:00 committed by GitHub
parent 4a6fb83c3d
commit 2dabc4bc4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -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 ""