Compare commits

...

19 Commits

Author SHA1 Message Date
adminer b2fa629187 Удалить script/getlast.sh 2026-07-27 08:28:53 +03:00
adminer 4fa96f01f8 Удалить my/scripts/PATCH_CLODE.txt 2026-07-27 08:28:43 +03:00
adminer 45daa2149a Добавить my/patch_endpoint.go.txt 2026-07-27 08:28:27 +03:00
adminer 6a6698211f Добавить my/scripts/zfs_last_snap.sh 2026-07-27 08:27:26 +03:00
adminer f4e87b4b57 Обновить my/scripts/PATCH_CLODE.txt 2026-07-24 22:31:10 +03:00
adminer 9d25ab2fb1 Добавить script/getlast.sh 2026-07-24 22:07:28 +03:00
adminer d5f2dc6b8b Добавить PATCH_CLODE.txt 2026-07-24 17:43:18 +03:00
adminer 1428678b58 Обновить internal/endpoint/endpoint.go 2026-07-24 17:32:03 +03:00
adminer 06f7183608 Обновить internal/endpoint/endpoint.go 2026-07-23 22:09:40 +03:00
adminer de9ae54de9 revert 2cc7017d74
revert Обновить internal/endpoint/endpoint.go
2026-07-18 09:55:47 +03:00
adminer 1358894d5f Обновить internal/endpoint/endpoint.go 2026-07-17 23:06:06 +03:00
adminer 8b5a942d39 Обновить internal/endpoint/endpoint.go 2026-07-17 23:01:00 +03:00
adminer bf7749fae3 Обновить internal/endpoint/endpoint.go 2026-07-17 22:52:42 +03:00
adminer 2cc7017d74 Обновить internal/endpoint/endpoint.go 2026-07-16 09:01:38 +03:00
adminer 4611293a2e Обновить internal/endpoint/endpoint.go 2026-07-16 08:46:34 +03:00
adminer 71ef236e91 Обновить internal/endpoint/endpoint.go 2026-07-16 08:44:03 +03:00
adminer 53dd46c617 Обновить internal/endpoint/endpoint.go 2026-07-16 08:40:54 +03:00
adminer 996259b9ce Обновить internal/endpoint/endpoint.go 2026-07-16 08:38:03 +03:00
adminer a6d4d55b83 Обновить internal/endpoint/endpoint.go 2026-07-16 08:30:05 +03:00
3 changed files with 342 additions and 2 deletions
+43 -2
View File
@@ -635,6 +635,25 @@ func (f subroot) UserSpecifiedDatasets() zfs.UserSpecifiedDatasetsSet {
} }
} }
// senderPrefixDatasetPath returns the configured literal sender-side prefix
// (e.g. "hdd_9XG1J9J1") to strip from incoming filesystem paths, or nil if
// ZREPL_STRIP_SENDER_PREFIX is unset/empty. Using a literal string (rather
// than a component count) lets us losslessly reconstruct the sender's
// original path later in ListFilesystems, which is required for the
// sender/receiver filesystem-matching in the replication planner to work.
func senderPrefixDatasetPath() *zfs.DatasetPath {
s := envconst.String("ZREPL_STRIP_SENDER_PREFIX", "")
if s == "" {
return nil
}
dp, err := zfs.NewDatasetPath(s)
if err != nil || dp.Length() == 0 {
return nil
}
return dp
}
func (f subroot) MapToLocal(fs string) (*zfs.DatasetPath, error) { func (f subroot) MapToLocal(fs string) (*zfs.DatasetPath, error) {
p, err := zfs.NewDatasetPath(fs) p, err := zfs.NewDatasetPath(fs)
if err != nil { if err != nil {
@@ -643,11 +662,17 @@ func (f subroot) MapToLocal(fs string) (*zfs.DatasetPath, error) {
if p.Length() == 0 { if p.Length() == 0 {
return nil, errors.Errorf("cannot map empty filesystem") return nil, errors.Errorf("cannot map empty filesystem")
} }
// PATCH: strip a configured literal prefix (e.g. the source zpool name)
// from the *sender's* path before appending it under root_fs.
if prefix := senderPrefixDatasetPath(); prefix != nil && p.HasPrefix(prefix) {
p.TrimPrefix(prefix)
}
c := f.localRoot.Copy() c := f.localRoot.Copy()
c.Extend(p) c.Extend(p)
return c, nil return c, nil
} }
func (s *Receiver) ListFilesystems(ctx context.Context, req *pdu.ListFilesystemReq) (*pdu.ListFilesystemRes, error) { func (s *Receiver) ListFilesystems(ctx context.Context, req *pdu.ListFilesystemReq) (*pdu.ListFilesystemRes, error) {
defer trace.WithSpanFromStackUpdateCtx(&ctx)() defer trace.WithSpanFromStackUpdateCtx(&ctx)()
@@ -688,12 +713,28 @@ func (s *Receiver) ListFilesystems(ctx context.Context, req *pdu.ListFilesystemR
a.TrimPrefix(root) a.TrimPrefix(root)
// PATCH: re-prepend the literal sender prefix that MapToLocal strips,
// so that the Path we report here matches what the sender reports
// for the same filesystem (see replication_logic.go's Path-based
// sender/receiver matching). Without this, the planner can never
// find a matching receiverFS and will treat every cycle as if the
// filesystem doesn't exist yet on the receiver.
reportPath := a.ToString()
if prefix := senderPrefixDatasetPath(); prefix != nil {
if reportPath == "" {
reportPath = prefix.ToString()
} else {
reportPath = prefix.ToString() + "/" + reportPath
}
}
fs := &pdu.Filesystem{ fs := &pdu.Filesystem{
Path: a.ToString(), Path: reportPath,
IsPlaceholder: ph.IsPlaceholder, IsPlaceholder: ph.IsPlaceholder,
ResumeToken: token, ResumeToken: token,
} }
fss = append(fss, fs) fss = append(fss, fs)
} }
if len(fss) == 0 { if len(fss) == 0 {
getLogger(ctx).Debug("no filesystems found") getLogger(ctx).Debug("no filesystems found")
@@ -1103,4 +1144,4 @@ func doDestroySnapshots(ctx context.Context, lp *zfs.DatasetPath, snaps []*pdu.F
return &pdu.DestroySnapshotsRes{ return &pdu.DestroySnapshotsRes{
Results: ress, Results: ress,
}, nil }, nil
} }
+177
View File
@@ -0,0 +1,177 @@
https://claude.ai/chat/f688c411-b11e-4455-918b-fde5e6577430
Diagnosed replication snapshot duplication caused by version listing failures
Diagnosed replication snapshot duplication caused by version listing failures
Отличная зацепка — снапшот chunk_9XG1J8T0/nomad/ha-zah@zrepl_20260724_054520_000 реально существует (0B allocated, 195M refer — то есть данные там есть), но планировщик каждый цикл продолжает считать, что на приёмнике версий нет вообще. Значит проблема не в наличии данных, а в том, что ListFilesystemVersions (то, чем сверяется планировщик) их почему-то не видит. Погружусь в код листинга снапшотов на ресивере.
Ran 3 commands, viewed 3 files
Ran 3 commands, viewed 3 files
Кажется, нашёл настоящий баг — и это очень похоже на правду. Проверю до конца, как формируется список receiverFS/IsPlaceholder, которым пользуется планировщик (не тот же код, что читает placeholder при самом receive).
Viewed 5 files, ran 4 commands
Viewed 5 files, ran 4 commands
Нашёл настоящую причину, и она серьёзнее, чем казалось — это баг именно от моего патча, а не от zrepl самого по себе. Дело в сопоставлении списков sender/receiver в планировщике.
Проверяю, есть ли envconst.String для строкового значения
Проверяю, есть ли envconst.String для строкового значения
Вот и реальный баг — и он от патча
Планировщик (replication_logic.go:302-306) сопоставляет sender и receiver filesystem'ы строковым сравнением путей:
go
for _, rfs := range rfss {
if rfs.Path == fs.Path { // fs.Path — путь СЕНДЕРА, напр. "hdd_9XG1J9J1/nomad/ha-zah"
receiverFS = rfs
}
}
А rfs.Path формируется в Receiver.ListFilesystems (endpoint.go:695) как a.TrimPrefix(root) — локальный путь chunk_9XG1J8T0/nomad/ha-zah минус root_fs = nomad/ha-zah.
До патча это совпадало с sender-путём один-в-один (root_fs + sender_path → обрезаем root_fs → получаем обратно sender_path). А теперь, когда MapToLocal откусывает ещё и hdd_9XG1J9J1, обратная обрезка даёts nomad/ha-zah — а сендер сообщает hdd_9XG1J9J1/nomad/ha-zah. Строки не совпадают → receiverFS всегда nil → планировщик каждый раз считает, что на приёмнике версий нет → пытается full receive → ловит "destination exists". Именно поэтому цикл повторяется бесконечно.
Чиню — добавляю обратное восстановление отрезанного префикса именно в отчёт ListFilesystems, чтобы сопоставление путей снова работало:
Architected shared helper function to consolidate prefix-handling logic
Architected shared helper function to consolidate prefix-handling logic
Есть всё нужное. Переписываю патч целиком на литеральный строковый префикс — это устраняет и проблему с расчётом пути, и делает обратное сопоставление в ListFilesystems возможным:
Reconciled edge case behavior and prepared filesystem prefix restoration
Reconciled edge case behavior and prepared filesystem prefix restoration
Смотрю текущее состояние MapToLocal перед переработкой
Переключаю strip с "N компонентов" на литеральный строковый префикс, чтобы его можно было симметрично восстановить в ListFilesystems для сопоставления путей с sender'ом
endpoint.go
+35
-18
Reconciled edge case behavior and prepared filesystem prefix restoration.
Done
Теперь патчу ListFilesystems, чтобы при формировании отчётного пути (используемого для сверки с sender) обрезанный префикс восстанавливался обратно:
Восстанавливаю отрезанный sender-префикс в пути, который ListFilesystems возвращает планировщику — иначе receiver Path не совпадает с sender Path и планировщик считает receiver пустым на каждом цикле
Восстанавливаю отрезанный sender-префикс в пути, который ListFilesystems возвращает планировщику — иначе receiver Path не совпадает с sender Path и планировщик считает receiver пустым на каждом цикле
Проверяю синтаксис и то, что старая переменная ZREPL_STRIP_SENDER_PREFIX_COMPS больше нигде не используется:
Проверяю синтаксис и итоговое использование переменных окружения
Проверяю синтаксис и итоговое использование переменных окружения
Синтаксис чистый, старой переменной нигде не осталось. Собираю (насколько позволяет toolchain в песочнице):
Ran 2 commands
Ran 2 commands
Отлично, всё корректно, включая граничный случай равных путей. Патч готов. Итог:
Что было не так
Планировщик сопоставляет filesystem'ы sender'а и receiver'а строковым равенством путей (replication_logic.go:303: rfs.Path == fs.Path). До патча receiver.Path (локальный путь минус root_fs) всегда совпадал с sender.Path один в один. Мой первый патч ломал этот инвариант: обрезав ещё и hdd_9XG1J9J1 при записи, но не восстанавливая его при отчёте — receiver стал сообщать "nomad/ha-zah", а sender — "hdd_9XG1J9J1/nomad/ha-zah". Строки никогда не совпадали → receiverFS всегда nil → планировщик каждый цикл считал, что на приёмнике ничего нет → пытался полный recv в уже занятый датасет → ошибка. Бесконечный цикл был не багом zrepl, а прямым следствием несимметричности моего же патча.
Что изменилось
Заменил обрезку "N компонентов" на обрезку литерального строкового префикса — это делает операцию обратимой.
В MapToLocal префикс отрезается при вычислении локального пути хранения.
В ListFilesystems тот же префикс приклеивается обратно к пути, который сообщается планировщику — так путь снова совпадает с тем, что говорит sender, и сопоставление работает.
Как применить
Пересоберите бинарник, затем поменяйте имя переменной в systemd-юните (старое ZREPL_STRIP_SENDER_PREFIX_COMPS=1 → новое, с явным значением префикса):
ini
Environment=ZREPL_STRIP_SENDER_PREFIX=hdd_9XG1J9J1
bash
systemctl daemon-reload
systemctl restart zrepl_rcv
Датасеты, скорее всего, снова придётся снести (zfs destroy -r chunk_9XG1J8T0/nomad) и дать реплицироваться с нуля, раз в прошлый раз всё было в неконсистентном состоянии из-за бага. После рестарта проверьте:
bash
journalctl -u zrepl_rcv -n 100 --no-pager | grep -iE "conflict|receive failed|error"
Ключевой признак, что теперь всё работает правильно: строка "conflict automatically resolved" с пустым sorted receiver versions должна появиться только один раз (при первой инициализации датасета), а не повторяться на каждом цикле после успешного приёма.
а где патч?
Показываю итоговый diff патча
Показываю итоговый diff патча
дай исправленные функции
Показываю итоговый код исправленных функций целиком
Показываю итоговый код исправленных функций целиком
Вот три исправленные функции целиком (файл internal/endpoint/endpoint.go):
1. Новая вспомогательная функция (строки 638–654):
go
// senderPrefixDatasetPath returns the configured literal sender-side prefix
// (e.g. "hdd_9XG1J9J1") to strip from incoming filesystem paths, or nil if
// ZREPL_STRIP_SENDER_PREFIX is unset/empty. Using a literal string (rather
// than a component count) lets us losslessly reconstruct the sender's
// original path later in ListFilesystems, which is required for the
// sender/receiver filesystem-matching in the replication planner to work.
func senderPrefixDatasetPath() *zfs.DatasetPath {
s := envconst.String("ZREPL_STRIP_SENDER_PREFIX", "")
if s == "" {
return nil
}
dp, err := zfs.NewDatasetPath(s)
if err != nil || dp.Length() == 0 {
return nil
}
return dp
}
2. MapToLocal (строки 656672):
go
func (f subroot) MapToLocal(fs string) (*zfs.DatasetPath, error) {
p, err := zfs.NewDatasetPath(fs)
if err != nil {
return nil, err
}
if p.Length() == 0 {
return nil, errors.Errorf("cannot map empty filesystem")
}
// PATCH: strip a configured literal prefix (e.g. the source zpool name)
// from the *sender's* path before appending it under root_fs.
if prefix := senderPrefixDatasetPath(); prefix != nil && p.HasPrefix(prefix) {
p.TrimPrefix(prefix)
}
c := f.localRoot.Copy()
c.Extend(p)
return c, nil
}
3. Кусок ListFilesystems, где формируется Path (строки 712–734, остальная часть функции без изменений):
go
a.TrimPrefix(root)
// PATCH: re-prepend the literal sender prefix that MapToLocal strips,
// so that the Path we report here matches what the sender reports
// for the same filesystem (see replication_logic.go's Path-based
// sender/receiver matching). Without this, the planner can never
// find a matching receiverFS and will treat every cycle as if the
// filesystem doesn't exist yet on the receiver.
reportPath := a.ToString()
if prefix := senderPrefixDatasetPath(); prefix != nil {
if reportPath == "" {
reportPath = prefix.ToString()
} else {
reportPath = prefix.ToString() + "/" + reportPath
}
}
fs := &pdu.Filesystem{
Path: reportPath,
IsPlaceholder: ph.IsPlaceholder,
ResumeToken: token,
}
fss = append(fss, fs)
Больше в файле ничего не менялось — MapToLocal вызывается ещё и из Receive/ListFilesystemVersions/DestroySnapshots, но там аргументом всегда приходит исходный путь сендера (req.Filesystem), так что дополнительных правок там не требуется.
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
#
# zrepl_last_snapshot_age.sh
#
# Показывает, сколько минут назад был создан последний снапшот
# (по умолчанию с префиксом "zrepl_") на резервной (приёмной) стороне.
#
# Использование:
# ./zrepl_last_snapshot_age.sh <датасет> [<датасет2> ...]
# ./zrepl_last_snapshot_age.sh -r <датасет> # рекурсивно по всем дочерним
# ./zrepl_last_snapshot_age.sh -p zrepl_ -r chunk_9XG1J8T0/nomad
#
# Опции:
# -r рекурсивно проверять все дочерние датасеты
# -p PREFIX фильтровать снапшоты по префиксу имени (по умолчанию "zrepl_")
# -w MINUTES задать порог в минутах: если снапшот старше — exit code 1
# (удобно для мониторинга / cron)
# -q тихий режим: не печатать построчный вывод, только итог
#
# Коды возврата:
# 0 — всё ок (снапшот найден, и если задан -w, он не старше порога)
# 1 — снапшот найден, но старше порога -w
# 2 — снапшот не найден вообще (для датасета/выборки)
# 3 — ошибка использования / zfs недоступен
set -euo pipefail
PREFIX="zrepl_"
RECURSIVE=0
WARN_MINUTES=""
QUIET=0
usage() {
grep '^#' "$0" | sed -e '1d' -e 's/^# \{0,1\}//'
exit 3
}
while getopts ":rp:w:qh" opt; do
case "$opt" in
r) RECURSIVE=1 ;;
p) PREFIX="$OPTARG" ;;
w) WARN_MINUTES="$OPTARG" ;;
q) QUIET=1 ;;
h) usage ;;
*) usage ;;
esac
done
shift $((OPTIND - 1))
if [[ $# -lt 1 ]]; then
echo "Ошибка: укажите хотя бы один датасет." >&2
usage
fi
if ! command -v zfs >/dev/null 2>&1; then
echo "Ошибка: команда zfs не найдена." >&2
exit 3
fi
now_epoch=$(date +%s)
overall_newest_epoch=0
overall_newest_name=""
any_found=0
for dataset in "$@"; do
zfs_opts=(-H -p -o name,creation -t snapshot -s creation)
if [[ $RECURSIVE -eq 1 ]]; then
zfs_opts+=(-r)
else
zfs_opts+=(-d 1)
fi
# -H без заголовков, -p значения в "машинном" виде (creation -> epoch seconds)
mapfile -t lines < <(zfs list "${zfs_opts[@]}" "$dataset" 2>/dev/null | grep "@${PREFIX}" || true)
if [[ ${#lines[@]} -eq 0 ]]; then
if [[ $QUIET -eq 0 ]]; then
echo "[$dataset] снапшотов с префиксом '${PREFIX}' не найдено"
fi
continue
fi
# последняя строка — самый свежий снапшот (сортировка -s creation)
last_line="${lines[-1]}"
snap_name="${last_line%%$'\t'*}"
snap_epoch="${last_line##*$'\t'}"
age_seconds=$(( now_epoch - snap_epoch ))
age_minutes=$(( age_seconds / 60 ))
any_found=1
if (( snap_epoch > overall_newest_epoch )); then
overall_newest_epoch=$snap_epoch
overall_newest_name=$snap_name
fi
if [[ $QUIET -eq 0 ]]; then
printf "[%s] последний снапшот: %s (%d мин. назад)\n" \
"$dataset" "$snap_name" "$age_minutes"
fi
done
if [[ $any_found -eq 0 ]]; then
echo "Ни одного снапшота с префиксом '${PREFIX}' не найдено ни в одном из указанных датасетов." >&2
exit 2
fi
overall_age_minutes=$(( (now_epoch - overall_newest_epoch) / 60 ))
echo "---"
printf "Самый свежий снапшот в выборке: %s (%d мин. назад)\n" \
"$overall_newest_name" "$overall_age_minutes"
if [[ -n "$WARN_MINUTES" ]]; then
if (( overall_age_minutes > WARN_MINUTES )); then
echo "ВНИМАНИЕ: последний снапшот старше порога ${WARN_MINUTES} мин." >&2
exit 1
fi
fi
exit 0