Coverage for bzfs_main/replication.py: 99%

908 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 18:44 +0000

1# Copyright 2024 Wolfgang Hoschek AT mac DOT com 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14# 

15"""The core replication algorithm is in replicate_dataset(), which performs reliable full and/or incremental 'zfs send' and 

16'zfs receive' operations on snapshots, using resumable ZFS sends when possible. 

17 

18For replication of multiple datasets, including recursive replication, see bzfs.py:replicate_datasets(). 

19""" 

20 

21from __future__ import ( 

22 annotations, 

23) 

24import base64 

25import logging 

26import os 

27import re 

28import shlex 

29import subprocess 

30import sys 

31import threading 

32import time 

33from collections.abc import ( 

34 Iterable, 

35 Iterator, 

36 Mapping, 

37) 

38from concurrent.futures import ( 

39 Executor, 

40 Future, 

41) 

42from subprocess import ( 

43 DEVNULL, 

44 PIPE, 

45) 

46from typing import ( 

47 TYPE_CHECKING, 

48 Final, 

49 final, 

50) 

51 

52from bzfs_main.argparse_actions import ( 

53 has_timerange_filter, 

54) 

55from bzfs_main.configuration import ( 

56 is_same_remote, 

57) 

58from bzfs_main.detect import ( 

59 POOL_GUID, 

60 ZFS_VERSION_IS_AT_LEAST_2_1_0, 

61 ZFS_VERSION_IS_AT_LEAST_2_2_0, 

62 are_bookmarks_enabled, 

63 is_zpool_feature_enabled_or_active, 

64) 

65from bzfs_main.filter import ( 

66 filter_properties, 

67 filter_snapshots, 

68) 

69from bzfs_main.incremental_send_steps import ( 

70 incremental_send_steps, 

71) 

72from bzfs_main.parallel_batch_cmd import ( 

73 run_ssh_cmd_batched, 

74 run_ssh_cmd_parallel, 

75) 

76from bzfs_main.progress_reporter import ( 

77 PV_FILE_THREAD_SEPARATOR, 

78) 

79from bzfs_main.util.connection import ( 

80 DEDICATED, 

81 SHARED, 

82 ConnectionPool, 

83 dquote, 

84 squote, 

85 timeout, 

86) 

87from bzfs_main.util.parallel_iterator import ( 

88 parallel_iterator, 

89 run_in_parallel, 

90) 

91from bzfs_main.util.retry import ( 

92 Retry, 

93 RetryableError, 

94) 

95from bzfs_main.util.utils import ( 

96 DONT_SKIP_DATASET, 

97 FILE_PERMISSIONS, 

98 LOG_DEBUG, 

99 LOG_TRACE, 

100 Subprocesses, 

101 append_if_absent, 

102 cut, 

103 die, 

104 getenv_bool, 

105 human_readable_bytes, 

106 is_descendant, 

107 list_formatter, 

108 open_nofollow, 

109 replace_prefix, 

110 sha256_urlsafe_base64, 

111 sort_datasets, 

112 stderr_to_str, 

113 xprint, 

114) 

115 

116if TYPE_CHECKING: # pragma: no cover - for type hints only 

117 from bzfs_main.bzfs import ( 

118 Job, 

119 ) 

120 from bzfs_main.configuration import ( 

121 Params, 

122 Remote, 

123 ) 

124 

125 

126# constants: 

127INJECT_DST_PIPE_FAIL_KBYTES: Final[int] = 400 # for testing only 

128_RIGHT_JUST: Final[int] = 7 

129 

130 

131def replicate_dataset(job: Job, src_dataset: str, tid: str, retry: Retry, *, has_descendant_dataset: bool) -> bool: 

132 """Replicates src_dataset to dst_dataset (thread-safe); For replication of multiple datasets, including recursive 

133 replication, see bzfs.py:replicate_datasets(); Return False to skip descendants; True to recurse into them.""" 

134 p, log = job.params, job.params.log 

135 src, dst = p.src, p.dst 

136 dst_dataset: str = replace_prefix(src_dataset, old_prefix=src.root_dataset, new_prefix=dst.root_dataset) 

137 log.debug(p.dry(f"{tid} Replicating: %s"), f"{src_dataset} --> {dst_dataset} ...") 

138 

139 list_result: bool | tuple[list[str], list[str], list[str], dict[str, str], str, str, _Continuity | None] = ( 

140 _list_and_filter_src_and_dst_snapshots(job, src_dataset, dst_dataset, tid, has_descendant_dataset) 

141 ) 

142 if isinstance(list_result, bool): 

143 return list_result 

144 ( 

145 basis_src_snapshots_with_guids, 

146 _src_snapshots_with_guids, 

147 dst_snapshots_with_guids, 

148 included_src_guids, 

149 latest_src_snapshot, 

150 oldest_src_snapshot, 

151 continuity, 

152 ) = list_result 

153 assert latest_src_snapshot 

154 assert oldest_src_snapshot 

155 latest_dst_snapshot: str = "" 

156 latest_common_src_snapshot: str = "" 

157 done_checking: bool = False 

158 

159 if job.dst_dataset_exists[dst_dataset]: 

160 rollback_result: tuple[str, str, bool] = _rollback_dst_dataset_if_necessary( 

161 job, 

162 dst_dataset, 

163 latest_src_snapshot, 

164 basis_src_snapshots_with_guids, 

165 dst_snapshots_with_guids, 

166 done_checking, 

167 tid, 

168 ) 

169 latest_dst_snapshot, latest_common_src_snapshot, done_checking = rollback_result 

170 if latest_src_snapshot and latest_src_snapshot == latest_common_src_snapshot: 

171 log.info(f"{tid} Already up-to-date: %s", dst_dataset) 

172 return True 

173 

174 log.debug("latest_common_src_snapshot: %s", latest_common_src_snapshot) # is a snapshot or bookmark 

175 log.log(LOG_TRACE, "latest_dst_snapshot: %s", latest_dst_snapshot) 

176 props_cache: dict[tuple[str, ...], dict[str, str | None]] = {} # fresh empty ZFS props cache for each dataset 

177 dry_run_no_send: bool = False 

178 if not latest_common_src_snapshot: 

179 # no common snapshot exists; delete all dst snapshots and perform a full send of the oldest selected src snapshot 

180 full_result: tuple[str, bool, bool] = _replicate_dataset_fully( 

181 job, 

182 src_dataset, 

183 dst_dataset, 

184 oldest_src_snapshot, 

185 latest_src_snapshot, 

186 latest_dst_snapshot, 

187 included_src_guids, 

188 dst_snapshots_with_guids, 

189 continuity, 

190 props_cache, 

191 dry_run_no_send, 

192 done_checking, 

193 tid, 

194 ) 

195 # we have now created a common snapshot 

196 latest_common_src_snapshot, dry_run_no_send, done_checking = full_result 

197 if latest_common_src_snapshot: 197 ↛ 236line 197 didn't jump to line 236 because the condition on line 197 was always true

198 # finally, incrementally replicate all selected snapshots from latest common snapshot until latest src snapshot 

199 recv_resume_token_result: tuple[str | None, list[str], list[str]] = _recv_resume_token(job, dst_dataset) 

200 recv_resume_token, _send_resume_opts, _recv_resume_opts = recv_resume_token_result 

201 if recv_resume_token: 

202 latest_common_src_snapshot = _replicate_dataset_incrementally( 

203 job, 

204 src_dataset, 

205 dst_dataset, 

206 latest_common_src_snapshot, 

207 latest_src_snapshot, 

208 basis_src_snapshots_with_guids, 

209 included_src_guids, 

210 recv_resume_token_result, 

211 continuity, 

212 props_cache, 

213 dry_run_no_send, 

214 done_checking, 

215 tid, 

216 ) 

217 recv_resume_token = None # we have now caught up 

218 recv_resume_token_result = (recv_resume_token,) + recv_resume_token_result[1:] 

219 dry_run_no_send = dry_run_no_send or p.dry_run 

220 

221 latest_common_src_snapshot = _replicate_dataset_incrementally( 

222 job, 

223 src_dataset, 

224 dst_dataset, 

225 latest_common_src_snapshot, 

226 latest_src_snapshot, 

227 basis_src_snapshots_with_guids, 

228 included_src_guids, 

229 recv_resume_token_result, 

230 continuity, 

231 props_cache, 

232 dry_run_no_send, 

233 done_checking, 

234 tid, 

235 ) 

236 return True 

237 

238 

239def _list_and_filter_src_and_dst_snapshots( 

240 job: Job, src_dataset: str, dst_dataset: str, tid: str, has_descendant: bool 

241) -> bool | tuple[list[str], list[str], list[str], dict[str, str], str, str, _Continuity | None]: 

242 """On replication, list and filter src and dst snapshots; return early with False to skip descendants; True to recurse 

243 into them.""" 

244 p, log = job.params, job.params.log 

245 src, dst = p.src, p.dst 

246 

247 # list GUID and name for dst snapshots, sorted ascending by createtxg (more precise than creation time) 

248 dst_cmd: list[str] = p.split_args(f"{p.zfs_program} list -t snapshot -d 1 -s createtxg -Hp -o guid,name", dst_dataset) 

249 

250 # list GUID and name for src snapshots + bookmarks, primarily sort ascending by transaction group (which is more 

251 # precise than creation time), secondarily sort such that snapshots appear after bookmarks for the same GUID. 

252 # Note: A snapshot and its ZFS bookmarks always have the same GUID, creation time and transaction group. A snapshot 

253 # changes its transaction group but retains its creation time and GUID on 'zfs receive' on another pool, i.e. 

254 # comparing createtxg is only meaningful within a single pool, not across pools from src to dst. Comparing creation 

255 # time remains meaningful across pools from src to dst. Creation time is a UTC Unix time in integer seconds. 

256 # Note that 'zfs create', 'zfs snapshot' and 'zfs bookmark' CLIs enforce that snapshot names must not contain a '#' 

257 # char, bookmark names must not contain a '@' char, and dataset names must not contain a '#' or '@' char. 

258 # GUID and creation time also do not contain a '#' or '@' char. 

259 filter_needs_creation_time: bool = has_timerange_filter(p.snapshot_filters) 

260 types: str = "snapshot" 

261 types += ",bookmark" if (p.use_bookmark or p.create_bookmarks != "none") and are_bookmarks_enabled(p, src) else "" 

262 props: str = job.creation_prefix + "creation,guid,name" if filter_needs_creation_time else "guid,name" 

263 src_cmd = p.split_args(f"{p.zfs_program} list -t {types} -s createtxg -s type -d 1 -Hp -o {props}", src_dataset) 

264 job.maybe_inject_delete(src, dataset=src_dataset, delete_trigger="zfs_list_snapshot_src") 

265 src_snapshots_and_bookmarks, dst_snapshots_with_guids_str = run_in_parallel( # list src+dst snapshots in parallel 

266 lambda: job.try_ssh_command(src, LOG_TRACE, cmd=src_cmd), 

267 lambda: job.try_ssh_command(dst, LOG_TRACE, cmd=dst_cmd, error_trigger="zfs_list_snapshot_dst"), 

268 ) 

269 job.dst_dataset_exists[dst_dataset] = dst_snapshots_with_guids_str is not None 

270 dst_snapshots_with_guids: list[str] = (dst_snapshots_with_guids_str or "").splitlines() 

271 if src_snapshots_and_bookmarks is None: 

272 log.warning("Third party deleted source: %s", src_dataset) 

273 return False # src dataset has been deleted by some third party while we're running - nothing to do anymore 

274 raw_src_snapshots_with_guids: list[str] = src_snapshots_and_bookmarks.splitlines() 

275 src_snapshots_with_guids: list[str] = raw_src_snapshots_with_guids.copy() 

276 if not p.use_bookmark: 

277 src_snapshots_with_guids = [snapshot for snapshot in src_snapshots_with_guids if "@" in snapshot] 

278 src_snapshots_and_bookmarks = None 

279 if len(dst_snapshots_with_guids) == 0 and "bookmark" in types: 

280 # src bookmarks serve no purpose if the destination dataset has no snapshot; ignore them 

281 src_snapshots_with_guids = [snapshot for snapshot in src_snapshots_with_guids if "@" in snapshot] 

282 num_src_snapshots_found: int = sum(1 for snapshot in src_snapshots_with_guids if "@" in snapshot) 

283 with job.stats_lock: 

284 job.num_snapshots_found += num_src_snapshots_found 

285 # apply include/exclude regexes to ignore irrelevant src snapshots 

286 basis_src_snapshots_with_guids: list[str] = src_snapshots_with_guids 

287 src_snapshots_with_guids = filter_snapshots(job, src_snapshots_with_guids) 

288 if filter_needs_creation_time: 

289 raw_src_snapshots_with_guids = cut(field=2, lines=raw_src_snapshots_with_guids) 

290 src_snapshots_with_guids = cut(field=2, lines=src_snapshots_with_guids) 

291 basis_src_snapshots_with_guids = cut(field=2, lines=basis_src_snapshots_with_guids) 

292 

293 # find oldest and latest selected "true" snapshot, and map selected snapshot GUIDs to names. 

294 # a snapshot is "true" if it is not a bookmark. 

295 oldest_src_snapshot: str = "" 

296 latest_src_snapshot: str = "" 

297 included_src_guids: dict[str, str] = {} 

298 for line in src_snapshots_with_guids: 

299 guid, snapshot = line.split("\t", 1) 

300 assert guid 

301 assert snapshot 

302 if "@" in snapshot: 

303 included_src_guids[guid] = snapshot 

304 latest_src_snapshot = snapshot 

305 if not oldest_src_snapshot: 

306 oldest_src_snapshot = snapshot 

307 if len(src_snapshots_with_guids) == 0: 

308 if p.skip_missing_snapshots == "fail": 

309 die(f"Source dataset includes no snapshot: {src_dataset!r}. Consider using --skip-missing-snapshots=dataset") 

310 elif p.skip_missing_snapshots == "dataset": 

311 log.warning("Skipping source dataset because it includes no snapshot: %s", src_dataset) 

312 recurse = has_descendant and job.dst_dataset_exists[dst_dataset] and not is_inconsistent(job, dst, dst_dataset) 

313 if has_descendant and not recurse: 

314 log.warning("Skipping descendant datasets as dst dataset does not exist or is inconsistent: %s", dst_dataset) 

315 return recurse 

316 log.debug("latest_src_snapshot: %s", latest_src_snapshot) 

317 if latest_src_snapshot == "": 

318 recurse = has_descendant and not (job.dst_dataset_exists[dst_dataset] and is_inconsistent(job, dst, dst_dataset)) 

319 if has_descendant and not recurse: 

320 die( 

321 f"Cannot replicate descendant datasets as destination dataset is inconsistent: {dst_dataset!r} " 

322 f"and source dataset selects no snapshot: {src_dataset!r}. Resolve the interrupted receive before retrying." 

323 ) 

324 log.info(f"{tid} Already-up-to-date: %s", dst_dataset) 

325 return recurse 

326 if p.create_bookmarks != "none" and are_bookmarks_enabled(p, src) and not p.dry_run: 

327 continuity: _Continuity | None = _Continuity( 

328 p, 

329 src_dataset, 

330 dst_dataset, 

331 raw_src_snapshots_with_guids, 

332 dst_snapshots_with_guids, 

333 ) 

334 else: 

335 continuity = None 

336 return ( 

337 basis_src_snapshots_with_guids, 

338 src_snapshots_with_guids, 

339 dst_snapshots_with_guids, 

340 included_src_guids, 

341 latest_src_snapshot, 

342 oldest_src_snapshot, 

343 continuity, 

344 ) 

345 

346 

347def _rollback_dst_dataset_if_necessary( 

348 job: Job, 

349 dst_dataset: str, 

350 latest_src_snapshot: str, 

351 src_snapshots_with_guids: list[str], 

352 dst_snapshots_with_guids: list[str], 

353 done_checking: bool, 

354 tid: str, 

355) -> tuple[str, str, bool]: 

356 """On replication, rollback dst if necessary; error out if not permitted.""" 

357 p, log = job.params, job.params.log 

358 dst = p.dst 

359 latest_dst_snapshot: str = "" 

360 latest_dst_guid: str = "" 

361 if len(dst_snapshots_with_guids) > 0: 

362 latest_dst_guid, latest_dst_snapshot = dst_snapshots_with_guids[-1].split("\t", 1) 

363 if p.force_rollback_to_latest_snapshot: 

364 log.info(p.dry(f"{tid} Rolling back destination to most recent snapshot: %s"), latest_dst_snapshot) 

365 # rollback just in case the dst dataset was modified since its most recent snapshot 

366 done_checking = done_checking or _check_zfs_dataset_busy(job, dst, dst_dataset) 

367 cmd: list[str] = p.split_args(f"{dst.sudo} {p.zfs_program} rollback", latest_dst_snapshot) 

368 job.try_ssh_command(dst, LOG_DEBUG, is_dry=p.dry_run, print_stdout=True, cmd=cmd, exists=False) 

369 

370 # find most recent snapshot (or bookmark) that src and dst have in common - we'll start to replicate 

371 # from there up to the most recent src snapshot. any two snapshots are "common" iff their ZFS GUIDs (i.e. 

372 # contents) are equal. See https://github.com/openzfs/zfs/commit/305bc4b370b20de81eaf10a1cf724374258b74d1 

373 def latest_common_snapshot(snapshots_with_guids: list[str], intersect_guids: set[str]) -> tuple[str | None, str]: 

374 """Returns a true snapshot instead of its bookmark with the same GUID, per the sort order previously used for 'zfs 

375 list -s ...'.""" 

376 for _line in reversed(snapshots_with_guids): 

377 guid_, snapshot_ = _line.split("\t", 1) 

378 if guid_ in intersect_guids: 

379 return guid_, snapshot_ # can be a snapshot or bookmark 

380 return None, "" 

381 

382 latest_common_guid, latest_common_src_snapshot = latest_common_snapshot( 

383 src_snapshots_with_guids, set(cut(field=1, lines=dst_snapshots_with_guids)) 

384 ) 

385 log.debug("latest_common_src_snapshot: %s", latest_common_src_snapshot) # is a snapshot or bookmark 

386 log.log(LOG_TRACE, "latest_dst_snapshot: %s", latest_dst_snapshot) 

387 

388 if latest_common_src_snapshot and latest_common_guid != latest_dst_guid: 

389 # found latest common snapshot but dst has an even newer snapshot. rollback dst to that common snapshot. 

390 assert latest_common_guid 

391 _, latest_common_dst_snapshot = latest_common_snapshot(dst_snapshots_with_guids, {latest_common_guid}) 

392 assert latest_common_dst_snapshot 

393 if not (p.force_rollback_to_latest_common_snapshot or p.force): 

394 die( 

395 f"Conflict: Most recent destination snapshot {latest_dst_snapshot} is more recent than " 

396 f"most recent common snapshot {latest_common_dst_snapshot}. Rollback destination first, " 

397 "for example via --force-rollback-to-latest-common-snapshot (or --force) option." 

398 ) 

399 if p.force_once: 

400 p.force.value = False 

401 p.force_rollback_to_latest_common_snapshot.value = False 

402 log.info(p.dry(f"{tid} Rolling back destination to most recent common snapshot: %s"), latest_common_dst_snapshot) 

403 done_checking = done_checking or _check_zfs_dataset_busy(job, dst, dst_dataset) 

404 cmd = p.split_args( 

405 f"{dst.sudo} {p.zfs_program} rollback -r {p.force_unmount} {p.force_hard}", latest_common_dst_snapshot 

406 ) 

407 try: 

408 job.run_ssh_command(dst, LOG_DEBUG, is_dry=p.dry_run, print_stdout=True, cmd=cmd) 

409 except (subprocess.CalledProcessError, UnicodeDecodeError) as e: 

410 stderr: str = stderr_to_str(e.stderr) if hasattr(e, "stderr") else "" 

411 retry_immediately_once: bool = _clear_resumable_recv_state_if_necessary(job, dst_dataset, stderr) 

412 # op isn't idempotent so retries regather current state from the start of replicate_dataset() 

413 raise RetryableError(display_msg="zfs rollback", retry_immediately_once=retry_immediately_once) from e 

414 

415 return latest_dst_snapshot, latest_common_src_snapshot, done_checking 

416 

417 

418def _replicate_dataset_fully( 

419 job: Job, 

420 src_dataset: str, 

421 dst_dataset: str, 

422 oldest_src_snapshot: str, 

423 latest_src_snapshot: str, 

424 latest_dst_snapshot: str, 

425 included_src_guids: Mapping[str, str], 

426 dst_snapshots_with_guids: list[str], 

427 continuity: _Continuity | None, 

428 props_cache: dict[tuple[str, ...], dict[str, str | None]], 

429 dry_run_no_send: bool, 

430 done_checking: bool, 

431 tid: str, 

432) -> tuple[str, bool, bool]: 

433 """On replication, deletes all dst snapshots and performs a full send of the oldest selected src snapshot, which in turn 

434 creates a common snapshot; error out if not permitted.""" 

435 p, log = job.params, job.params.log 

436 src, dst = p.src, p.dst 

437 latest_common_src_snapshot: str = "" 

438 if latest_dst_snapshot: 

439 if not p.force: 

440 die( 

441 f"Conflict: No common snapshot found between {src_dataset} and {dst_dataset} even though " 

442 "destination has at least one snapshot. Aborting. Consider using --force option to first " 

443 "delete all existing destination snapshots in order to be able to proceed with replication." 

444 ) 

445 if p.force_once: 445 ↛ 448line 445 didn't jump to line 448 because the condition on line 445 was always true

446 p.force.value = False 

447 p.force_rollback_to_latest_common_snapshot.value = False 

448 done_checking = done_checking or _check_zfs_dataset_busy(job, dst, dst_dataset) 

449 # extract SNAPSHOT_TAG from GUID<TAB>DATASET@SNAPSHOT_TAG 

450 delete_snapshots(job, dst, dst_dataset, snapshot_tags=cut(2, separator="@", lines=dst_snapshots_with_guids)) 

451 if p.dry_run: 

452 # As we're in --dryrun (--force) mode this conflict resolution step (see above) wasn't really executed: 

453 # "no common snapshot was found. delete all dst snapshots". In turn, this would cause the subsequent 

454 # 'zfs receive -n' to fail with "cannot receive new filesystem stream: destination has snapshots; must 

455 # destroy them to overwrite it". So we skip the zfs send/receive step and keep on trucking. 

456 dry_run_no_send = True 

457 

458 # to start with, fully replicate oldest snapshot, which in turn creates a common snapshot 

459 if p.no_stream: 

460 oldest_src_snapshot = latest_src_snapshot 

461 if oldest_src_snapshot: 461 ↛ 507line 461 didn't jump to line 507 because the condition on line 461 was always true

462 if not job.dst_dataset_exists[dst_dataset]: 

463 # on destination, create parent filesystem and ancestors if they do not yet exist 

464 dst_dataset_parent: str = os.path.dirname(dst_dataset) 

465 if not job.dst_dataset_exists[dst_dataset_parent]: 

466 if p.dry_run: 

467 dry_run_no_send = True 

468 if dst_dataset_parent: 468 ↛ 471line 468 didn't jump to line 471 because the condition on line 468 was always true

469 _create_zfs_filesystem(job, dst_dataset_parent) 

470 

471 recv_resume_token_result: tuple[str | None, list[str], list[str]] = _recv_resume_token(job, dst_dataset) 

472 recv_resume_token, send_resume_opts, recv_resume_opts = recv_resume_token_result 

473 curr_size: int = _estimate_send_size(job, src, dst_dataset, recv_resume_token, oldest_src_snapshot) 

474 humansize: str = _format_size(curr_size) 

475 if recv_resume_token: 

476 oldest_src_snapshot = _decode_resume_token(job, recv_resume_token, src_dataset, dst_dataset, included_src_guids) 

477 send_opts: list[str] = p.curr_zfs_send_resume_opts + send_resume_opts # e.g. curr + ["-t", "1-c740b4779-..."] 

478 else: 

479 send_opts = p.curr_zfs_send_program_opts + [oldest_src_snapshot] 

480 send_cmd: list[str] = p.split_args(f"{src.sudo} {p.zfs_program} send", send_opts) 

481 recv_opts: list[str] = p.zfs_full_recv_opts.copy() + recv_resume_opts 

482 recv_opts, set_opts = _add_recv_property_options(job, True, recv_opts, src_dataset, props_cache) 

483 recv_cmd: list[str] = p.split_args( 

484 f"{dst.sudo} {p.zfs_program} receive -F", p.dry_run_recv, recv_opts, dst_dataset, allow_all=True 

485 ) 

486 log.info(p.dry(f"{tid} Full send: %s"), f"{oldest_src_snapshot} --> {dst_dataset} ({humansize.strip()}) ...") 

487 done_checking = done_checking or _check_zfs_dataset_busy(job, dst, dst_dataset) 

488 dry_run_no_send = dry_run_no_send or p.dry_run_no_send 

489 job.maybe_inject_params(error_trigger="full_zfs_send_params") 

490 humansize = humansize.rjust(_RIGHT_JUST * 3 + 2) 

491 if continuity is not None: 

492 continuity.create_tmp_bookmarks(job, src, [oldest_src_snapshot]) 

493 _run_zfs_send_receive( # do the real work 

494 job, src_dataset, dst_dataset, send_cmd, recv_cmd, curr_size, humansize, dry_run_no_send, "full_zfs_send" 

495 ) 

496 latest_common_src_snapshot = oldest_src_snapshot # we have now created a common snapshot 

497 if not p.dry_run: 

498 job.dst_dataset_exists[dst_dataset] = True 

499 with job.stats_lock: 

500 job.num_snapshots_replicated += 1 

501 if continuity is not None: 

502 continuity.mark_snapshots_as_replicated([oldest_src_snapshot]) 

503 continuity.promote_and_gc_bookmarks(job, src) 

504 _zfs_set(job, set_opts, dst, dst_dataset) 

505 dry_run_no_send = dry_run_no_send or p.dry_run 

506 

507 return latest_common_src_snapshot, dry_run_no_send, done_checking 

508 

509 

510def _replicate_dataset_incrementally( 

511 job: Job, 

512 src_dataset: str, 

513 dst_dataset: str, 

514 latest_common_src_snapshot: str, 

515 latest_src_snapshot: str, 

516 basis_src_snapshots_with_guids: list[str], 

517 included_src_guids: Mapping[str, str], 

518 recv_resume_token_result: tuple[str | None, list[str], list[str]], 

519 continuity: _Continuity | None, 

520 props_cache: dict[tuple[str, ...], dict[str, str | None]], 

521 dry_run_no_send: bool, 

522 done_checking: bool, 

523 tid: str, 

524) -> str: 

525 """Incrementally replicates all selected snapshots from latest common snapshot until latest src snapshot.""" 

526 p, log = job.params, job.params.log 

527 src, dst = p.src, p.dst 

528 recv_resume_token, send_resume_opts, recv_resume_opts = recv_resume_token_result 

529 set_opts: list[str] = [] 

530 

531 def replication_candidates() -> tuple[list[str], list[str]]: 

532 assert len(basis_src_snapshots_with_guids) > 0 

533 result_snapshots: list[str] = [] 

534 result_guids: list[str] = [] 

535 last_appended_guid: str = "" 

536 snapshot_itr: Iterator[str] = reversed(basis_src_snapshots_with_guids) 

537 while True: 

538 guid, snapshot = next(snapshot_itr).split("\t", 1) 

539 if "@" in snapshot: 

540 result_snapshots.append(snapshot) 

541 result_guids.append(guid) 

542 last_appended_guid = guid 

543 if snapshot == latest_common_src_snapshot: # latest_common_src_snapshot is a snapshot or bookmark 

544 if guid != last_appended_guid and "@" not in snapshot: 

545 # only appends the src bookmark if it has no snapshot. If the bookmark has a snapshot then that 

546 # snapshot has already been appended, per the sort order previously used for 'zfs list -s ...' 

547 result_snapshots.append(snapshot) 

548 result_guids.append(guid) 

549 break 

550 result_snapshots.reverse() 

551 result_guids.reverse() 

552 assert len(result_snapshots) > 0 

553 assert len(result_snapshots) == len(result_guids) 

554 return result_guids, result_snapshots 

555 

556 if recv_resume_token: 

557 estimate_send_sizes: list[int] = [_estimate_send_size(job, src, dst_dataset, recv_resume_token)] 

558 decoded_src_snapshot: str = _decode_resume_token( 

559 job, recv_resume_token, src_dataset, dst_dataset, included_src_guids 

560 ) 

561 steps_todo: list[tuple[str, str, str, list[str]]] = [ 

562 ("-i", latest_common_src_snapshot, decoded_src_snapshot, [decoded_src_snapshot]) 

563 ] 

564 else: 

565 # collect the most recent common snapshot (which may be a bookmark) followed by all src snapshots 

566 # (that are not a bookmark) that are more recent than that. 

567 cand_guids, cand_snapshots = replication_candidates() 

568 if len(cand_snapshots) == 1: 

569 # latest_src_snapshot is a (true) snapshot that is equal to latest_common_src_snapshot or LESS recent 

570 # than latest_common_src_snapshot. The latter case can happen if latest_common_src_snapshot is a 

571 # bookmark whose snapshot has been deleted on src. 

572 return latest_common_src_snapshot # nothing more tbd 

573 if p.no_stream: 

574 if not any(guid in included_src_guids for guid in cand_guids[1:]): 

575 # The cand_snapshots list (and basis_src_snapshots_with_guids) can contain snapshots that are newer than 

576 # what the user selected via --include/exclude-snapshot-*. These newer snapshots are irrelevant here. 

577 # Example: A resume token represents @s3, and @s4 exists on src but is excluded by --exclude-snapshot-*. 

578 # Replanning from @s3 sees [@s3, @s4] in cand_snapshots, but no selected snapshot remains after @s3, so 

579 # replication is done. 

580 return latest_common_src_snapshot # nothing more tbd 

581 # skip intermediate snapshots 

582 steps_todo = [("-i", latest_common_src_snapshot, latest_src_snapshot, [latest_src_snapshot])] 

583 else: 

584 # include intermediate src snapshots that pass --{include,exclude}-snapshot-* policy, using 

585 # a series of -i/-I send/receive steps that skip excluded src snapshots. 

586 steps_todo = _incremental_send_steps_wrapper(p, cand_snapshots, cand_guids, set(included_src_guids), False) 

587 estimate_send_sizes = _estimate_send_sizes_in_parallel(job, src, dst_dataset, recv_resume_token, steps_todo) 

588 

589 log.log(LOG_TRACE, "steps_todo: %s", list_formatter(steps_todo, "; ")) 

590 total_size: int = sum(estimate_send_sizes) 

591 total_num: int = sum(len(to_snapshots) for incr_flag, from_snap, to_snap, to_snapshots in steps_todo) 

592 done_size: int = 0 

593 done_num: int = 0 

594 for i, (incr_flag, from_snap, to_snap, to_snapshots) in enumerate(steps_todo): 

595 curr_num_snapshots: int = len(to_snapshots) 

596 curr_size: int = estimate_send_sizes[i] 

597 humansize: str = _format_size(total_size) + "/" + _format_size(done_size) + "/" + _format_size(curr_size) 

598 human_num: str = f"{total_num}/{done_num}/{curr_num_snapshots} snapshots" 

599 if recv_resume_token: 

600 send_opts: list[str] = p.curr_zfs_send_resume_opts + send_resume_opts # e.g. curr + ["-t", "1-c740b4779-..."] 

601 else: 

602 send_opts = p.curr_zfs_send_program_opts + [incr_flag, from_snap, to_snap] 

603 send_cmd: list[str] = p.split_args(f"{src.sudo} {p.zfs_program} send", send_opts) 

604 recv_opts: list[str] = p.zfs_recv_program_opts.copy() + recv_resume_opts 

605 recv_opts, set_opts = _add_recv_property_options(job, False, recv_opts, src_dataset, props_cache) 

606 recv_cmd: list[str] = p.split_args( 

607 f"{dst.sudo} {p.zfs_program} receive", p.dry_run_recv, recv_opts, dst_dataset, allow_all=True 

608 ) 

609 dense_size: str = p.two_or_more_spaces_regex.sub("", humansize.strip()) 

610 log.info( 

611 p.dry(f"{tid} Incremental send {incr_flag}: %s"), 

612 f"{from_snap} .. {to_snap[to_snap.index('@'):]} --> {dst_dataset} ({dense_size}) ({human_num}) ...", 

613 ) 

614 done_checking = done_checking or _check_zfs_dataset_busy(job, dst, dst_dataset, busy_if_send=False) 

615 if p.dry_run and not job.dst_dataset_exists[dst_dataset]: 

616 dry_run_no_send = True 

617 dry_run_no_send = dry_run_no_send or p.dry_run_no_send 

618 

619 assert p.create_bookmarks 

620 snaps_to_bookmark: list[str] = [] 

621 if p.create_bookmarks == "all": 

622 snaps_to_bookmark = to_snapshots 

623 elif p.create_bookmarks != "none": 

624 threshold_millis: int = p.xperiods.label_milliseconds("_" + p.create_bookmarks) 

625 snaps_to_bookmark = [snap for snap in to_snapshots if p.xperiods.label_milliseconds(snap) >= threshold_millis] 

626 if i == len(steps_todo) - 1 and (len(snaps_to_bookmark) == 0 or snaps_to_bookmark[-1] != to_snap): 

627 snaps_to_bookmark.append(to_snap) # ensure latest common snapshot is bookmarked 

628 if continuity is not None: 

629 continuity.create_tmp_bookmarks(job, src, snaps_to_bookmark) 

630 

631 job.maybe_inject_params(error_trigger="incr_zfs_send_params") 

632 _run_zfs_send_receive( # do the real work 

633 job, src_dataset, dst_dataset, send_cmd, recv_cmd, curr_size, humansize, dry_run_no_send, "incr_zfs_send" 

634 ) 

635 done_size += curr_size 

636 done_num += curr_num_snapshots 

637 latest_common_src_snapshot = to_snap 

638 with job.stats_lock: 

639 job.num_snapshots_replicated += curr_num_snapshots 

640 if continuity is not None: 

641 continuity.mark_snapshots_as_replicated(to_snapshots) 

642 if continuity is not None: 

643 continuity.promote_and_gc_bookmarks(job, src) 

644 _zfs_set(job, set_opts, dst, dst_dataset) 

645 return latest_common_src_snapshot 

646 

647 

648def _format_size(num_bytes: int) -> str: 

649 """Formats a byte count for human-readable logs.""" 

650 return human_readable_bytes(num_bytes, separator="").rjust(_RIGHT_JUST) 

651 

652 

653def _prepare_zfs_send_receive( 

654 job: Job, 

655 src_dataset: str, 

656 send_cmd: list[str], 

657 recv_cmd: list[str], 

658 size_estimate_bytes: int, 

659 size_estimate_human: str, 

660 src_ssh_cmd: tuple[str, ...], 

661 dst_ssh_cmd: tuple[str, ...], 

662) -> str: 

663 """Constructs zfs send/recv pipelines with optional compression, mbuffer and pv.""" 

664 p = job.params 

665 src, dst = p.src, p.dst 

666 send_cmd_str: str = shlex.join(send_cmd) 

667 recv_cmd_str: str = shlex.join(recv_cmd) 

668 recordsize: int = abs(job.src_properties[src_dataset].recordsize) 

669 same_remote: bool = is_same_remote(src, dst) 

670 r2r_mode: str = p.r2r_mode 

671 assert r2r_mode in ("off", "pull", "push"), r2r_mode 

672 

673 is_large_enough: bool = p.no_estimate_send_size or size_estimate_bytes >= p.min_pipe_transfer_size 

674 is_networked: bool = src.is_nonlocal or dst.is_nonlocal 

675 src_has_shell: bool = p.is_program_available("sh", "src") 

676 dst_has_shell: bool = p.is_program_available("sh", "dst") 

677 use_src_pipeline: bool = src_has_shell and (r2r_mode == "off" or not same_remote) 

678 use_dst_pipeline: bool = dst_has_shell and (r2r_mode == "off" or not same_remote) 

679 use_compression: bool = p.is_program_available("zstd", "src") and p.is_program_available("zstd", "dst") 

680 use_compression = use_compression and is_large_enough and is_networked and use_src_pipeline and use_dst_pipeline 

681 use_src_buffer: bool = is_large_enough and is_networked and use_src_pipeline and p.is_program_available("mbuffer", "src") 

682 use_dst_buffer: bool = is_large_enough and is_networked and use_dst_pipeline and p.is_program_available("mbuffer", "dst") 

683 use_local_buffer: bool = p.is_program_available("mbuffer", "local") 

684 use_local_buffer = use_local_buffer and r2r_mode == "off" and is_large_enough and src.is_nonlocal and dst.is_nonlocal 

685 

686 pv_location: str = "" 

687 if r2r_mode == "off" and p.is_program_available("pv", "local"): 

688 if not src.ssh_user_host: 

689 pv_location = "src" 

690 elif not dst.ssh_user_host: 

691 pv_location = "dst" 

692 else: 

693 pv_location = "local" 

694 

695 # assemble pipeline running on source leg 

696 src_stages: list[str] = [] 

697 src_pipe_fail_offset = job.inject_params.pop("inject_src_pipe_fail_offset", None) 

698 if src_pipe_fail_offset is not None: 

699 assert isinstance(src_pipe_fail_offset, int) 

700 src_stages.append(f"(dd bs=1 count={src_pipe_fail_offset} 2>/dev/null && false)") 

701 elif job.inject_params.pop(f"inject_src_pipe_fail_{src_dataset}", False): 

702 # for testing; forward enough bytes that zfs receive can usually leave resumable state 

703 src_stages.append(f"(dd bs=1024 count={INJECT_DST_PIPE_FAIL_KBYTES} 2>/dev/null && false)") 

704 elif job.inject_params.get("inject_src_pipe_fail", False): 

705 # for testing; initially forward some bytes and then fail 

706 src_stages.append("(dd bs=64 count=1 2>/dev/null && false)") 

707 if job.inject_params.get("inject_src_pipe_garble", False): 

708 src_stages.append("gzip -1 -c -n") # for testing; forward garbled bytes 

709 if pv_location == "src": 

710 src_stages.append(_pv_cmd(job, size_estimate_bytes, size_estimate_human)) 

711 if use_compression: 

712 src_stages.append(_compress_cmd(p)) 

713 if use_src_buffer: 

714 src_stages.append(_mbuffer_cmd(p, "src", recordsize)) 

715 if job.inject_params.get("inject_src_send_error", False): 

716 send_cmd_str = f"{send_cmd_str} --injectedGarbageParameter" # for testing; induce CLI parse error 

717 src_pipe: str = " | ".join([send_cmd_str] + src_stages) 

718 if len(src_stages) > 0 and src.ssh_user_host: 

719 src_pipe = p.shell_program + " -c " + dquote(src_pipe) 

720 

721 # assemble pipeline running on destination leg 

722 dst_stages: list[str] = [] 

723 if use_dst_buffer: 

724 dst_stages.append(_mbuffer_cmd(p, "dst", recordsize)) 

725 if use_compression: 

726 dst_stages.append(_decompress_cmd(p)) 

727 if pv_location == "dst": 

728 dst_stages.append(_pv_cmd(job, size_estimate_bytes, size_estimate_human)) 

729 if job.inject_params.get("inject_dst_pipe_fail", False): 

730 # interrupt zfs receive for testing retry/resume; initially forward some bytes and then stop forwarding 

731 dst_stages.append(f"dd bs=1024 count={INJECT_DST_PIPE_FAIL_KBYTES} 2>/dev/null") 

732 if job.inject_params.get("inject_dst_pipe_garble", False): 

733 dst_stages.append("gzip -1 -c -n") # for testing; forward garbled bytes 

734 if job.inject_params.get("inject_dst_receive_error", False): 

735 recv_cmd_str = f"{recv_cmd_str} --injectedGarbageParameter" # for testing; induce CLI parse error 

736 dst_pipe: str = " | ".join(dst_stages + [recv_cmd_str]) 

737 if len(dst_stages) > 0 and dst.ssh_user_host: 

738 dst_pipe = p.shell_program + " -c " + dquote(dst_pipe) 

739 

740 if r2r_mode == "off": 

741 # If there's no support for shell pipelines, we can't do compression, mbuffering, monitoring and rate-limiting, 

742 # so we fall back to simple zfs send/receive. 

743 if not src_has_shell: 

744 src_pipe = send_cmd_str 

745 if not dst_has_shell: 

746 dst_pipe = recv_cmd_str 

747 

748 # assemble pipeline running on middle leg between source and destination. only enabled for pull-push mode 

749 local_stages: list[str] = [] 

750 if use_local_buffer: 

751 local_stages.append(_mbuffer_cmd(p, "local", recordsize)) 

752 if pv_location == "local": 

753 # pull-push mode with compression enabled: reporting "percent complete" isn't straightforward because 

754 # localhost observes the compressed data instead of the uncompressed data, so we disable the progress bar. 

755 local_stages.append(_pv_cmd(job, size_estimate_bytes, size_estimate_human, disable_progress_bar=use_compression)) 

756 if use_local_buffer: 

757 local_stages.append(_mbuffer_cmd(p, "local", recordsize)) 

758 

759 src_leg: str = " ".join(src_ssh_cmd + (squote(src, src_pipe),)) 

760 dst_leg: str = " ".join(dst_ssh_cmd + (squote(dst, dst_pipe),)) 

761 return " | ".join([src_leg] + local_stages + [dst_leg]) 

762 

763 def nested_ssh_cmd(remote: Remote) -> str: 

764 """Builds nested ssh command text for r2r leg.""" 

765 if same_remote: 

766 # Perf: use local replication if initiator sees src+dst have same user@host, port and ssh configfile. 

767 # Example: ssh alice@src.example.com "sh -c 'zfs send <src_dataset> | zfs receive <dst_dataset>'" 

768 return "" 

769 cmd: list[str] = [p.ssh_program] 

770 cmd += remote.ssh_extra_opts 

771 if remote.ssh_config_file: 

772 cmd += ["-F", remote.ssh_config_file] 

773 if remote.ssh_cipher: 

774 cmd += ["-c", remote.ssh_cipher] 

775 if remote.ssh_port: 

776 cmd += ["-p", str(remote.ssh_port)] 

777 assert remote.ssh_user_host 

778 cmd.append(remote.ssh_user_host) 

779 return shlex.join(cmd) 

780 

781 if r2r_mode == "push": 

782 # Example: ssh alice@src.example.com "sh -c 'zfs send ... | ssh bob@dst.example.com zfs receive ...'" 

783 dst_pipe = dst_pipe if same_remote else shlex.quote(dst_pipe) 

784 outer_ssh_cmd: tuple[str, ...] = src_ssh_cmd 

785 r2r_cmd: str = f"{src_pipe} | {nested_ssh_cmd(dst)} {dst_pipe}" 

786 else: 

787 assert r2r_mode == "pull", r2r_mode 

788 # Example: ssh bob@dst.example.com "sh -c 'ssh alice@src.example.com zfs send ... | zfs receive ...'" 

789 src_pipe = src_pipe if same_remote else shlex.quote(src_pipe) 

790 outer_ssh_cmd = dst_ssh_cmd 

791 r2r_cmd = f"{nested_ssh_cmd(src)} {src_pipe} | {dst_pipe}" 

792 r2r_cmd = shlex.quote(p.shell_program + " -c " + shlex.quote(r2r_cmd)) 

793 return " ".join(outer_ssh_cmd + (r2r_cmd,)) 

794 

795 

796def _run_zfs_send_receive( 

797 job: Job, 

798 src_dataset: str, 

799 dst_dataset: str, 

800 send_cmd: list[str], 

801 recv_cmd: list[str], 

802 size_estimate_bytes: int, 

803 size_estimate_human: str, 

804 dry_run_no_send: bool, 

805 error_trigger: str | None = None, 

806) -> None: 

807 """Executes a zfs send/receive pipeline between source and destination.""" 

808 p, log = job.params, job.params.log 

809 r2r_mode: str = p.r2r_mode 

810 assert r2r_mode in ("off", "pull", "push"), r2r_mode 

811 log.log(LOG_TRACE, "r2r_mode: %s", r2r_mode) 

812 conn_pool_name: str = DEDICATED if p.dedicated_tcp_connection_per_zfs_send and r2r_mode == "off" else SHARED 

813 src_conn_pool: ConnectionPool = p.connection_pools[p.src.location].pool(conn_pool_name) 

814 dst_conn_pool: ConnectionPool = p.connection_pools[p.dst.location].pool(conn_pool_name) 

815 with src_conn_pool.connection() as src_conn, dst_conn_pool.connection() as dst_conn: 

816 src_ssh_cmd: tuple[str, ...] = src_conn.ssh_cmd_quoted 

817 dst_ssh_cmd: tuple[str, ...] = dst_conn.ssh_cmd_quoted 

818 cmd_str: str = _prepare_zfs_send_receive( 

819 job, src_dataset, send_cmd, recv_cmd, size_estimate_bytes, size_estimate_human, src_ssh_cmd, dst_ssh_cmd 

820 ) 

821 src_conn.refresh_ssh_connection_if_necessary(job) 

822 dst_conn.refresh_ssh_connection_if_necessary(job) 

823 cmd = [p.shell_program_local, "-c", cmd_str] 

824 msg: str = "Would execute: %s" if dry_run_no_send else "Executing: %s" 

825 log.debug(msg, cmd_str.lstrip()) 

826 if not dry_run_no_send: 

827 try: 

828 job.maybe_inject_error(cmd=cmd, error_trigger=error_trigger) 

829 sp: Subprocesses = job.subprocesses 

830 process = sp.subprocess_run( 

831 cmd, stdin=DEVNULL, stdout=PIPE, stderr=PIPE, text=True, timeout=timeout(job), check=True, log=log 

832 ) 

833 except (subprocess.CalledProcessError, UnicodeDecodeError) as e: 

834 retry_immediately_once: bool = False 

835 if not isinstance(e, UnicodeDecodeError): 835 ↛ 838line 835 didn't jump to line 838 because the condition on line 835 was always true

836 xprint(log, stderr_to_str(e.stdout), file=sys.stdout) 

837 log.warning("%s", stderr_to_str(e.stderr).rstrip()) 

838 if isinstance(e, subprocess.CalledProcessError): 838 ↛ 843line 838 didn't jump to line 843 because the condition on line 838 was always true

839 retry_immediately_once = _clear_resumable_recv_state_if_necessary( 

840 job, dst_dataset, stderr_to_str(e.stderr) 

841 ) 

842 # op isn't idempotent so retries regather current state from the start of replicate_dataset() 

843 raise RetryableError(display_msg="zfs send/receive", retry_immediately_once=retry_immediately_once) from e 

844 else: 

845 xprint(log, process.stdout, file=sys.stdout) 

846 xprint(log, process.stderr, file=sys.stderr) 

847 if match := re.search( 

848 r"(cannot send '[^'\r\n]+': |WARNING: could not send [^@\r\n]+@[^@\r\n]+:( |\r?\n))([^\r\n]+)", 

849 process.stderr, 

850 ): 

851 msg = f"Cannot send {src_dataset}: {match.group(3)}" 

852 raise RetryableError(display_msg="zfs send") from RuntimeError(msg) 

853 

854 

855def _clear_resumable_recv_state(job: Job, dst_dataset: str) -> bool: 

856 p, log = job.params, job.params.log 

857 log.warning(p.dry("Aborting an interrupted zfs receive -s, deleting partially received state: %s"), dst_dataset) 

858 cmd: list[str] = p.split_args(f"{p.dst.sudo} {p.zfs_program} receive -A", dst_dataset) 

859 job.try_ssh_command(p.dst, LOG_TRACE, is_dry=p.dry_run, print_stdout=True, cmd=cmd) 

860 log.log(LOG_TRACE, p.dry("Done Aborting an interrupted zfs receive -s: %s"), dst_dataset) 

861 return True 

862 

863 

864def _clear_resumable_recv_state_if_necessary(job: Job, dst_dataset: str, stderr: str) -> bool: 

865 """Deletes leftover ZFS resume token state on the receiving dataset if necessary to continue operations. 

866 

867 To make resumable ZFS receive a reliable feature, we cope with the following ZFS facts: 

868 - A failed `zfs receive -s` prohibits the following subsequent operations, until the situation is explicitly resolved 

869 via a successful subsequent `zfs receive`, or cleared via `zfs receive -A`: 

870 - `zfs receive` without the resumable receive token (`zfs send -t <token>` is now required) 

871 - `zfs destroy <snapshot>` 

872 - `zfs rollback` 

873 - `zfs send -t` does not support sending more than a single snapshot; e.g. https://github.com/openzfs/zfs/issues/16764 

874 - A stale receive token prohibits subsequent `zfs send -t` if not handled (meanwhile, state changed on src or dst). 

875 - `zfs receive -A` fails if the receiving dataset has no ZFS resume token (anymore). 

876 """ 

877 

878 # No i18n needed here. OpenZFS ships no translation catalogs, so gettext falls back to English msgids and locale settings 

879 # have no effect. If translations ever appear, revisit this or inject LC_ALL=C. 

880 

881 # "cannot resume send: 'wb_src/tmp/src@s1' is no longer the same snapshot used in the initial send" 

882 # "cannot resume send: 'wb_src/tmp/src@s1' used in the initial send no longer exists" 

883 # "cannot resume send: incremental source 0xa000000000000000 no longer exists" 

884 if "cannot resume send" in stderr and ( 

885 "is no longer the same snapshot used in the initial send" in stderr 

886 or "used in the initial send no longer exists" in stderr 

887 or re.search(r"incremental source [0-9a-fx]+ no longer exists", stderr) 

888 ): 

889 return _clear_resumable_recv_state(job, dst_dataset) 

890 

891 # "cannot receive resume stream: incompatible embedded data stream feature with encrypted receive." 

892 # see https://github.com/openzfs/zfs/issues/12480 

893 # 'cannot receive new filesystem stream: destination xx contains partially-complete state from "zfs receive -s"' 

894 # this indicates that --no-resume-recv detects that dst contains a previously interrupted recv -s 

895 elif "cannot receive" in stderr and ( 

896 "cannot receive resume stream: incompatible embedded data stream feature with encrypted receive" in stderr 

897 or 'contains partially-complete state from "zfs receive -s"' in stderr 

898 ): 

899 return _clear_resumable_recv_state(job, dst_dataset) 

900 

901 elif ( # this signals normal behavior on interrupt of 'zfs receive -s' if running without --no-resume-recv 

902 "cannot receive new filesystem stream: checksum mismatch or incomplete stream" in stderr 

903 and "Partially received snapshot is saved" in stderr 

904 ): 

905 return True 

906 

907 # "cannot destroy 'wb_dest/tmp/dst@s1': snapshot has dependent clones ... use '-R' to destroy the following 

908 # datasets: wb_dest/tmp/dst/%recv" # see https://github.com/openzfs/zfs/issues/10439#issuecomment-642774560 

909 # This msg indicates a failed 'zfs destroy' via --delete-dst-snapshots. This "clone" is caused by a previously 

910 # interrupted 'zfs receive -s'. The fix used here is to delete the partially received state of said 

911 # 'zfs receive -s' via 'zfs receive -A', followed by an automatic retry, which will now succeed to delete the 

912 # snapshot without user intervention. 

913 elif ( 

914 "cannot destroy" in stderr 

915 and "snapshot has dependent clone" in stderr 

916 and "use '-R' to destroy the following dataset" in stderr 

917 and f"\n{dst_dataset}/%recv\n" in stderr 

918 ): 

919 return _clear_resumable_recv_state(job, dst_dataset) 

920 

921 # Same cause as above, except that this error can occur during 'zfs rollback' 

922 # Also see https://github.com/openzfs/zfs/blob/master/cmd/zfs/zfs_main.c 

923 elif ( 

924 "cannot rollback to" in stderr 

925 and "clones of previous snapshots exist" in stderr 

926 and "use '-R' to force deletion of the following clones and dependents" in stderr 

927 and f"\n{dst_dataset}/%recv\n" in stderr 

928 ): 

929 return _clear_resumable_recv_state(job, dst_dataset) 

930 

931 return False 

932 

933 

934def _recv_resume_token(job: Job, dst_dataset: str) -> tuple[str | None, list[str], list[str]]: 

935 """Gets recv_resume_token ZFS property from dst_dataset and returns corresponding opts to use for send+recv.""" 

936 p, log = job.params, job.params.log 

937 if not p.resume_recv: 

938 return None, [], [] 

939 warning: str | None = None 

940 if not is_zpool_feature_enabled_or_active(p, p.dst, "feature@extensible_dataset"): 

941 warning = "not available on destination dataset" 

942 elif not p.is_program_available(ZFS_VERSION_IS_AT_LEAST_2_1_0, "dst"): 

943 warning = "unreliable as zfs version is too old" # e.g. zfs-0.8.3 "internal error: Unknown error 1040" 

944 if warning: 

945 log.warning(f"ZFS receive resume feature is {warning}. Falling back to --no-resume-recv: %s", dst_dataset) 

946 return None, [], [] 

947 recv_resume_token: str | None = None 

948 send_resume_opts: list[str] = [] 

949 if job.dst_dataset_exists[dst_dataset]: 

950 cmd: list[str] = p.split_args(f"{p.zfs_program} get -Hp -o value -s none receive_resume_token", dst_dataset) 

951 job.maybe_inject_delete(p.dst, dataset=dst_dataset, delete_trigger="zfs_get_recv_resume_token") 

952 try: 

953 recv_resume_token = job.run_ssh_command(p.dst, LOG_TRACE, cmd=cmd).rstrip() 

954 except (subprocess.CalledProcessError, UnicodeDecodeError) as e: 

955 raise RetryableError(display_msg="zfs get receive_resume_token") from e 

956 if recv_resume_token == "-" or not recv_resume_token: # noqa: S105 

957 recv_resume_token = None 

958 else: 

959 send_resume_opts += ["-n"] if p.dry_run_no_send else [] 

960 send_resume_opts += ["-v"] if p.verbose_zfs else [] 

961 send_resume_opts += ["-t", recv_resume_token] 

962 recv_resume_opts = ["-s"] 

963 return recv_resume_token, send_resume_opts, recv_resume_opts 

964 

965 

966def _decode_resume_token( 

967 job: Job, recv_resume_token: str, src_dataset: str, dst_dataset: str, included_src_guids: Mapping[str, str] 

968) -> str: 

969 """Return the token's source snapshot after validating identity, GUID/name and effective raw mode.""" 

970 p, log = job.params, job.params.log 

971 decode_cmd: list[str] = p.split_args(f"{p.src.sudo} {p.zfs_program} send -n -v -t", recv_resume_token) 

972 try: 

973 decoded_resume_token: str = job.run_ssh_command(p.src, LOG_DEBUG, cmd=decode_cmd) 

974 except (subprocess.CalledProcessError, UnicodeDecodeError) as e: 

975 stderr: str = stderr_to_str(e.stderr) if hasattr(e, "stderr") else "" 

976 retry_immediately_once: bool = _clear_resumable_recv_state_if_necessary(job, dst_dataset, stderr) 

977 # op isn't idempotent so retries regather current state from the start of replicate_dataset() 

978 raise RetryableError(display_msg="zfs send -t", retry_immediately_once=retry_immediately_once) from e 

979 

980 name_match: re.Match[str] | None = re.search(r"(?m)^\s+toname\s=\s(.+)$", decoded_resume_token) 

981 guid_match: re.Match[str] | None = re.search(r"(?m)^\s+toguid\s=\s(.+)$", decoded_resume_token) 

982 rawok_match: re.Match[str] | None = re.search(r"(?m)^\s+rawok\s=", decoded_resume_token) 

983 assert name_match is not None and guid_match is not None, f"Cannot parse {recv_resume_token}, {decoded_resume_token}" 

984 decoded_src_snapshot: str = name_match.group(1) 

985 decoded_src_guid: str = str(int(guid_match.group(1).strip(), base=16)) 

986 log.log(LOG_TRACE, f"decoded recv_resume_token src snapshot: {decoded_src_snapshot}, guid: {decoded_src_guid}") 

987 assert "@" in decoded_src_snapshot, decoded_src_snapshot 

988 decoded_src_dataset, decoded_src_tag = decoded_src_snapshot.split("@", 1) 

989 assert decoded_src_tag 

990 if decoded_src_dataset != src_dataset: 

991 _clear_resumable_recv_state(job, dst_dataset) 

992 msg = "because zfs receive resume token is stale" 

993 raise RetryableError(display_msg=msg, retry_immediately_once=True) from RuntimeError(msg) 

994 if included_src_guids.get(decoded_src_guid) != decoded_src_snapshot: 

995 _clear_resumable_recv_state(job, dst_dataset) 

996 msg = "because zfs receive resume token does not match a selected source snapshot GUID and name" 

997 raise RetryableError(display_msg=msg, retry_immediately_once=True) from RuntimeError(msg) 

998 

999 def _is_raw_zfs_send(send_opts: Iterable[str]) -> bool: 

1000 return any(opt == "--raw" or ("w" in opt and opt.startswith("-") and not opt.startswith("--")) for opt in send_opts) 

1001 

1002 is_raw_token: bool = bool(rawok_match) 

1003 is_raw_send: bool = _is_raw_zfs_send(p.curr_zfs_send_program_opts) 

1004 is_raw_mode_conflict: bool = is_raw_token != is_raw_send 

1005 if is_raw_mode_conflict and not is_raw_token: 

1006 # `--raw` does not produce a raw stream for an unencrypted source, so a token without `rawok` remains compatible. 

1007 cmd: list[str] = p.split_args(f"{p.zfs_program} get -Hp -o value -s none encryptionroot", src_dataset) 

1008 encryptionroot: str = (job.try_ssh_command(p.src, LOG_TRACE, cmd=cmd, exists=False) or "").rstrip("\n") 

1009 assert encryptionroot 

1010 is_raw_mode_conflict = encryptionroot != "-" # encryptionroot != "-" indicates an encrypted dataset 

1011 if is_raw_mode_conflict: 

1012 _clear_resumable_recv_state(job, dst_dataset) 

1013 if p.dry_run: 

1014 die("Cannot clear the ZFS receive resume token because --dryrun never modifies state.") 

1015 msg = f"as zfs receive resume token raw mode conflicts with --zfs-send-program-opts: {p.curr_zfs_send_program_opts}" 

1016 raise RetryableError(display_msg=msg, retry_immediately_once=True) from RuntimeError(msg) 

1017 return decoded_src_snapshot 

1018 

1019 

1020def _mbuffer_cmd(p: Params, loc: str, recordsize: int) -> str: 

1021 """Use mbuffer in the network pipe between 'zfs send' and 'zfs receive' to smooth out the rate of data flow and prevent 

1022 bottlenecks caused by network latency or speed fluctuation.""" 

1023 recordsize = max(recordsize, 2 * 1024 * 1024) 

1024 mbuffer_program_opts: list[str] = [p.mbuffer_program, "-s", str(recordsize)] + p.mbuffer_program_opts 

1025 if p.bwlimit: 

1026 mbuffer_program_opts += ["-R" if loc == "src" else "-r", p.bwlimit.upper()] 

1027 return shlex.join(mbuffer_program_opts) 

1028 

1029 

1030def _compress_cmd(p: Params) -> str: 

1031 """Use compression (e.g. zstd) in the network pipe between 'zfs send' and 'zfs receive' to reduce network bottlenecks.""" 

1032 return shlex.join([p.compression_program, "-c"] + p.compression_program_opts) 

1033 

1034 

1035def _decompress_cmd(p: Params) -> str: 

1036 """Formats the decompression half of an enabled compression pair.""" 

1037 return shlex.join([p.compression_program, "-dc"]) 

1038 

1039 

1040_WORKER_THREAD_NUMBER_REGEX: Final[re.Pattern[str]] = re.compile(r"ThreadPoolExecutor-\d+_(\d+)") 

1041 

1042 

1043def _pv_cmd(job: Job, size_estimate_bytes: int, size_estimate_human: str, disable_progress_bar: bool = False) -> str: 

1044 """`pv` monitors the progress of data transfer from 'zfs send' to 'zfs receive'; Progress can be viewed via 

1045 `tail -f $pv_log_file` aka `tail -f ~/bzfs-logs/current/current.pv` or similar.""" 

1046 p = job.params 

1047 size: str = f"--size={size_estimate_bytes}" 

1048 if disable_progress_bar or p.no_estimate_send_size: 

1049 size = "" 

1050 pv_log_file: str = p.log_params.pv_log_file 

1051 thread_name: str = threading.current_thread().name 

1052 if match := _WORKER_THREAD_NUMBER_REGEX.fullmatch(thread_name): 

1053 worker = int(match.group(1)) 

1054 if worker > 0: 

1055 pv_log_file += PV_FILE_THREAD_SEPARATOR + f"{worker:04}" 

1056 if job.is_first_replication_task.get_and_set(False): 

1057 if not p.log_params.quiet: 

1058 job.progress_reporter.start() 

1059 job.replication_start_time_nanos = time.monotonic_ns() 

1060 if not p.log_params.quiet: 

1061 with open_nofollow(pv_log_file, mode="a", encoding="utf-8", perm=FILE_PERMISSIONS) as fd: 

1062 fd.write("\n") # mark start of new stream so ProgressReporter can reliably reset bytes_in_flight 

1063 job.progress_reporter.enqueue_pv_log_file(pv_log_file) 

1064 pv_program_opts: list[str] = [p.pv_program] + p.pv_program_opts 

1065 if job.progress_update_intervals is not None: # for testing 

1066 pv_program_opts += [f"--interval={job.progress_update_intervals[0]}"] 

1067 pv_program_opts += ["--force", f"--name={size_estimate_human}"] 

1068 pv_program_opts += [size] if size else [] 

1069 return f"LC_ALL=C {shlex.join(pv_program_opts)} 2>> {shlex.quote(pv_log_file)}" 

1070 

1071 

1072def is_inconsistent(job: Job, remote: Remote, dataset: str) -> bool: 

1073 """Returns the `inconsistent` ZFS property of the given dataset.""" 

1074 p = job.params 

1075 cmd: list[str] = p.split_args(f"{p.zfs_program} list -t filesystem,volume -Hp -o inconsistent", dataset) 

1076 try: 

1077 return job.run_ssh_command(remote, LOG_TRACE, cmd=cmd).rstrip() != "0" 

1078 except (subprocess.CalledProcessError, UnicodeDecodeError) as e: 

1079 raise RetryableError(display_msg="zfs list -o inconsistent") from e 

1080 

1081 

1082def delete_snapshots(job: Job, remote: Remote, dataset: str, snapshot_tags: list[str]) -> None: 

1083 """Deletes snapshots in manageable batches on the specified remote.""" 

1084 if len(snapshot_tags) == 0: 

1085 return 

1086 p, log = job.params, job.params.log 

1087 log.info(p.dry(f"Deleting {len(snapshot_tags)} snapshots within %s: %s"), dataset, snapshot_tags) 

1088 # delete snapshots in batches without creating a command line that's too big for the OS to handle 

1089 run_ssh_cmd_batched( 

1090 job, 

1091 remote, 

1092 _delete_snapshot_cmd(p, remote, dataset + "@"), 

1093 snapshot_tags, 

1094 lambda batch: _delete_snapshot(job, remote, dataset, dataset + "@" + ",".join(batch)), 

1095 max_batch_items=job.params.max_snapshots_per_minibatch_on_delete_snaps, 

1096 sep=",", 

1097 ) 

1098 

1099 

1100def _delete_snapshot(job: Job, r: Remote, dataset: str, snapshots_to_delete: str) -> None: 

1101 """Runs zfs destroy for a comma-separated snapshot list.""" 

1102 p = job.params 

1103 cmd: list[str] = _delete_snapshot_cmd(p, r, snapshots_to_delete) 

1104 is_dry: bool = False # False is safe because we're using the 'zfs destroy -n' flag 

1105 try: 

1106 job.maybe_inject_error(cmd=cmd, error_trigger="zfs_delete_snapshot") 

1107 job.run_ssh_command(r, LOG_DEBUG, is_dry=is_dry, print_stdout=True, cmd=cmd) 

1108 except (subprocess.CalledProcessError, UnicodeDecodeError) as e: 

1109 stderr: str = stderr_to_str(e.stderr) if hasattr(e, "stderr") else "" 

1110 retry_immediately_once: bool = _clear_resumable_recv_state_if_necessary(job, dataset, stderr) 

1111 # op isn't idempotent so retries regather current state from the start of delete_destination_snapshots() or similar 

1112 raise RetryableError(display_msg="zfs destroy snapshot", retry_immediately_once=retry_immediately_once) from e 

1113 

1114 

1115def _delete_snapshot_cmd(p: Params, r: Remote, snapshots_to_delete: str) -> list[str]: 

1116 """Builds zfs destroy command for given snapshots.""" 

1117 return p.split_args( 

1118 f"{r.sudo} {p.zfs_program} destroy", p.force_hard, p.verbose_destroy, p.dry_run_destroy, snapshots_to_delete 

1119 ) 

1120 

1121 

1122def delete_bookmarks(job: Job, remote: Remote, dataset: str, snapshot_tags: list[str], loglevel: int = logging.INFO) -> None: 

1123 """Removes bookmarks individually since zfs lacks batch deletion.""" 

1124 if len(snapshot_tags) == 0: 

1125 return 

1126 # Unfortunately ZFS has no syntax yet to delete multiple bookmarks in a single CLI invocation 

1127 p, log = job.params, job.params.log 

1128 log.log( 

1129 loglevel, 

1130 p.dry(f"Deleting {len(snapshot_tags)} bookmarks within %s: %s"), 

1131 dataset, 

1132 dataset + "#" + ",".join(snapshot_tags), 

1133 ) 

1134 cmd: list[str] = p.split_args(f"{remote.sudo} {p.zfs_program} destroy") 

1135 run_ssh_cmd_parallel( 

1136 job, 

1137 remote, 

1138 [(cmd, (f"{dataset}#{snapshot_tag}" for snapshot_tag in snapshot_tags))], 

1139 lambda _cmd, batch: job.try_ssh_command(remote, LOG_DEBUG, is_dry=p.dry_run, print_stdout=True, cmd=_cmd + batch), 

1140 max_batch_items=1, 

1141 ) 

1142 

1143 

1144def delete_datasets(job: Job, remote: Remote, datasets: Iterable[str]) -> None: 

1145 """Deletes the given datasets via zfs destroy -r on the given remote.""" 

1146 # Impl is batch optimized to minimize CLI + network roundtrips: only need to run zfs destroy if previously 

1147 # destroyed dataset (within sorted datasets) is not a prefix (aka ancestor) of current dataset 

1148 p, log = job.params, job.params.log 

1149 last_deleted_dataset: str = DONT_SKIP_DATASET 

1150 for dataset in sort_datasets(datasets): 

1151 if is_descendant(dataset, of_root_dataset=last_deleted_dataset): 

1152 continue 

1153 log.info(p.dry("Deleting dataset tree: %s"), f"{dataset} ...") 

1154 cmd: list[str] = p.split_args( 

1155 f"{remote.sudo} {p.zfs_program} destroy -r {p.force_unmount} {p.force_hard} {p.verbose_destroy}", 

1156 p.dry_run_destroy, 

1157 dataset, 

1158 ) 

1159 is_dry: bool = False # False is safe because we're using the 'zfs destroy -n' flag 

1160 job.run_ssh_command(remote, LOG_DEBUG, is_dry=is_dry, print_stdout=True, cmd=cmd) 

1161 last_deleted_dataset = dataset 

1162 

1163 

1164def _create_zfs_filesystem(job: Job, filesystem: str) -> None: 

1165 """Creates destination filesystem hierarchies without mounting them.""" 

1166 # zfs create -p -u $filesystem 

1167 # To ensure the filesystems that we create do not get mounted, we apply a separate 'zfs create -p -u' 

1168 # invocation for each non-existing ancestor. This is because a single 'zfs create -p -u' applies the '-u' 

1169 # part only to the immediate filesystem, rather than to the not-yet existing ancestors. 

1170 p = job.params 

1171 parent: str = "" 

1172 no_mount: str = "-u" if p.is_program_available(ZFS_VERSION_IS_AT_LEAST_2_1_0, "dst") else "" 

1173 for component in filesystem.split("/"): 

1174 parent += component 

1175 if not job.dst_dataset_exists[parent]: 

1176 cmd: list[str] = p.split_args(f"{p.dst.sudo} {p.zfs_program} create -p", no_mount, parent) 

1177 try: 

1178 job.maybe_inject_error(cmd=cmd, error_trigger="zfs_create_filesystem") 

1179 job.run_ssh_command(p.dst, LOG_DEBUG, is_dry=p.dry_run, print_stdout=True, cmd=cmd) 

1180 except (subprocess.CalledProcessError, UnicodeDecodeError) as e: 

1181 # ignore harmless error caused by 'zfs create' without the -u flag, or by dataset already existing 

1182 stderr: str = stderr_to_str(e.stderr) if hasattr(e, "stderr") else "" 

1183 is_harmless: bool = isinstance(e, subprocess.CalledProcessError) and ( 

1184 "filesystem successfully created, but it may only be mounted by root" in stderr 

1185 or "filesystem successfully created, but not mounted" in stderr # SolarisZFS 

1186 or "dataset already exists" in stderr 

1187 or "filesystem already exists" in stderr # SolarisZFS? 

1188 ) 

1189 if not is_harmless: 

1190 raise RetryableError(display_msg="zfs create") from e 

1191 if not p.dry_run: 

1192 job.dst_dataset_exists[parent] = True 

1193 parent += "/" 

1194 

1195 

1196def _create_zfs_bookmarks( 

1197 job: Job, remote: Remote, snapshots: list[str], bookmarks: list[str], *, expected_guids: list[str] 

1198) -> None: 

1199 """Creates bookmarks with the given names for the given snapshots (or bookmarks), using the 'zfs bookmark' CLI; 

1200 accepts an existing target bookmark only when its GUID matches the expected GUID.""" 

1201 # Unfortunately ZFS has no syntax yet to create multiple bookmarks in a single CLI invocation 

1202 p = job.params 

1203 assert len(snapshots) == len(bookmarks) 

1204 assert len(snapshots) == len(expected_guids) 

1205 bookmark_dict: dict[str, tuple[str, str]] = {} 

1206 for i, snapshot in enumerate(snapshots): 

1207 bookmark_dict[snapshot] = (bookmarks[i], expected_guids[i]) 

1208 assert len(snapshots) == len(bookmark_dict) 

1209 

1210 def create_zfs_bookmark(cmd: list[str]) -> None: 

1211 snapshot = cmd[-1] 

1212 bookmark, expected_guid = bookmark_dict[snapshot] 

1213 try: 

1214 job.run_ssh_command(remote, LOG_DEBUG, is_dry=p.dry_run, print_stderr=False, cmd=cmd + [bookmark]) 

1215 except (subprocess.CalledProcessError, UnicodeDecodeError) as e: 

1216 stderr: str = stderr_to_str(e.stderr) if hasattr(e, "stderr") else "" 

1217 if ": bookmark exists" in stderr: 

1218 # verify value of actual bookmark GUID is as expected 

1219 query_cmd: list[str] = p.split_args(f"{p.zfs_program} list -t bookmark -Hp -o guid", bookmark) 

1220 try: 

1221 job.maybe_inject_error(cmd=query_cmd, error_trigger="zfs_list_bookmark_guid") 

1222 actual_guid: str = job.run_ssh_command(remote, LOG_DEBUG, cmd=query_cmd).rstrip() 

1223 except (subprocess.CalledProcessError, UnicodeDecodeError) as query_error: 

1224 raise RetryableError(display_msg="zfs list -t bookmark") from query_error 

1225 if actual_guid == expected_guid: 

1226 return # harmless 

1227 die( 

1228 f"Conflict: Cannot create bookmark {bookmark!r} with expected GUID {expected_guid!r} from {snapshot!r} " 

1229 f"because the bookmark already exists with a different GUID {actual_guid!r}" 

1230 ) 

1231 xprint(p.log, stderr, file=sys.stderr, end="") 

1232 raise RetryableError(display_msg="zfs bookmark") from e 

1233 

1234 cmd: list[str] = p.split_args(f"{remote.sudo} {p.zfs_program} bookmark") 

1235 run_ssh_cmd_parallel( 

1236 job, remote, [(cmd, snapshots)], lambda _cmd, batch: create_zfs_bookmark(_cmd + batch), max_batch_items=1 

1237 ) 

1238 

1239 

1240def _estimate_send_size(job: Job, remote: Remote, dst_dataset: str, recv_resume_token: str | None, *items: str) -> int: 

1241 """Estimates num bytes to transfer via 'zfs send -nvP'; Thread-safe.""" 

1242 p = job.params 

1243 if p.no_estimate_send_size: 

1244 return 0 

1245 zfs_send_program_opts: list[str] = p.curr_zfs_send_resume_opts if recv_resume_token else p.curr_zfs_send_program_opts 

1246 zfs_send_program_opts = ["--parsable" if opt == "-P" else opt for opt in zfs_send_program_opts] 

1247 zfs_send_program_opts = append_if_absent(zfs_send_program_opts, "-v", "-n", "--parsable") 

1248 if recv_resume_token: 

1249 zfs_send_program_opts += ["-t", recv_resume_token] 

1250 items = () 

1251 cmd: list[str] = p.split_args(f"{remote.sudo} {p.zfs_program} send", zfs_send_program_opts, items) 

1252 try: 

1253 lines: str | None = job.try_ssh_command(remote, LOG_TRACE, cmd=cmd) 

1254 except RetryableError as retryable_error: 

1255 assert retryable_error.__cause__ is not None 

1256 if recv_resume_token: 

1257 e = retryable_error.__cause__ 

1258 stderr: str = stderr_to_str(e.stderr) if hasattr(e, "stderr") else "" 

1259 retryable_error.retry_immediately_once = _clear_resumable_recv_state_if_necessary(job, dst_dataset, stderr) 

1260 # op isn't idempotent so retries regather current state from the start of replicate_dataset() 

1261 raise 

1262 if lines is None: 1262 ↛ 1263line 1262 didn't jump to line 1263 because the condition on line 1262 was never true

1263 return 0 # src dataset or snapshot has been deleted by third party 

1264 size: str = lines.splitlines()[-1] 

1265 assert size.startswith("size") 

1266 return int(size[size.index("\t") + 1 :]) 

1267 

1268 

1269def _estimate_send_sizes_in_parallel( 

1270 job: Job, 

1271 r: Remote, 

1272 dst_dataset: str, 

1273 recv_resume_token: str | None, 

1274 steps_todo: list[tuple[str, str, str, list[str]]], 

1275) -> list[int]: 

1276 """Estimates num bytes to transfer for multiple send steps; in parallel to reduce latency.""" 

1277 p = job.params 

1278 if p.no_estimate_send_size: 

1279 return [0 for _ in steps_todo] 

1280 

1281 def iterator_builder(executor: Executor) -> Iterable[Iterable[Future[int]]]: 

1282 resume_token: str | None = recv_resume_token 

1283 return [ 

1284 ( 

1285 executor.submit( 

1286 _estimate_send_size, job, r, dst_dataset, resume_token if i == 0 else None, incr_flag, from_snap, to_snap 

1287 ) 

1288 for i, (incr_flag, from_snap, to_snap, _to_snapshots) in enumerate(steps_todo) 

1289 ) # advancing the lazy on-demand Python Generator submits the next task and yields the corresponding Future 

1290 ] 

1291 

1292 max_workers: int = min(len(steps_todo), job.max_workers[r.location]) 

1293 return list( 

1294 parallel_iterator( 

1295 iterator_builder, max_workers=max_workers, ordered=True, is_terminated=job.termination_event.is_set 

1296 ) 

1297 ) 

1298 

1299 

1300def _zfs_set(job: Job, properties: list[str], remote: Remote, dataset: str) -> None: 

1301 """Applies the given property key=value pairs via 'zfs set' CLI to the given dataset on the given remote.""" 

1302 p = job.params 

1303 if len(properties) == 0: 

1304 return 

1305 # set properties in batches without creating a command line that's too big for the OS to handle 

1306 cmd: list[str] = p.split_args(f"{remote.sudo} {p.zfs_program} set") 

1307 run_ssh_cmd_batched( 

1308 job, 

1309 remote, 

1310 cmd + [dataset], 

1311 properties, 

1312 lambda batch: job.run_ssh_command( 

1313 remote, LOG_DEBUG, is_dry=p.dry_run, print_stdout=True, cmd=cmd + batch + [dataset] 

1314 ), 

1315 max_batch_items=2**29, 

1316 ) 

1317 

1318 

1319def _zfs_get( 

1320 job: Job, 

1321 remote: Remote, 

1322 dataset: str, 

1323 sources: str, 

1324 output_columns: str, 

1325 propnames: str, 

1326 splitlines: bool, 

1327 props_cache: dict[tuple[str, ...], dict[str, str | None]], 

1328 refresh: bool = False, 

1329) -> dict[str, str | None]: 

1330 """Returns the results of 'zfs get' CLI on the given dataset on the given remote.""" 

1331 assert dataset 

1332 assert sources 

1333 assert output_columns 

1334 if not propnames: 

1335 return {} 

1336 p = job.params 

1337 cache_key: tuple[str, ...] = (remote.location, dataset, sources, output_columns, propnames) 

1338 if refresh: 

1339 props_cache.pop(cache_key, None) 

1340 props: dict[str, str | None] | None = props_cache.get(cache_key) 

1341 if props is None: 

1342 cmd: list[str] = p.split_args(f"{p.zfs_program} get -Hp -o {output_columns} -s {sources} {propnames}", dataset) 

1343 lines: str = job.run_ssh_command(remote, LOG_TRACE, cmd=cmd) 

1344 is_name_value_pair: bool = "," in output_columns 

1345 props = {} 

1346 # if not splitlines: omit single trailing newline that was appended by 'zfs get' CLI 

1347 assert splitlines or len(lines) == 0 or lines[-1] == "\n" 

1348 for line in lines.splitlines() if splitlines else [lines[0:-1]]: 

1349 if is_name_value_pair: 

1350 propname, propvalue = line.split("\t", 1) 

1351 props[propname] = propvalue 

1352 else: 

1353 props[line] = None 

1354 props_cache[cache_key] = props 

1355 return props 

1356 

1357 

1358def _incremental_send_steps_wrapper( 

1359 p: Params, src_snapshots: list[str], src_guids: list[str], included_guids: set[str], is_resume: bool 

1360) -> list[tuple[str, str, str, list[str]]]: 

1361 """Returns incremental send steps, optionally converting -I to -i.""" 

1362 force_convert_I_to_i: bool = not getenv_bool("no_force_convert_I_to_i", True) # noqa: N806 

1363 # force_convert_I_to_i == True implies that: 

1364 # Force convert 'zfs send -I' to a series of 'zfs send -i', for example as a workaround for 

1365 # zfs issue https://github.com/openzfs/zfs/issues/16394 

1366 return incremental_send_steps(src_snapshots, src_guids, included_guids, is_resume, force_convert_I_to_i) 

1367 

1368 

1369def _add_recv_property_options( 

1370 job: Job, full_send: bool, recv_opts: list[str], dataset: str, cache: dict[tuple[str, ...], dict[str, str | None]] 

1371) -> tuple[list[str], list[str]]: 

1372 """Reads the ZFS properties of the given src dataset; Appends zfs recv -o and -x values to a recv_opts copy according to 

1373 CLI params, and returns properties to explicitly set on the dst dataset after 'zfs receive' completes successfully.""" 

1374 is_volume: bool = job.src_properties[dataset].recordsize < 0 

1375 p = job.params 

1376 recv_opts = recv_opts.copy() 

1377 set_opts: list[str] = [] 

1378 x_names: list[str] = p.zfs_recv_x_names 

1379 x_names_set: set[str] = set(x_names) 

1380 ox_names: set[str] = p.zfs_recv_ox_names.copy() 

1381 if p.is_program_available(ZFS_VERSION_IS_AT_LEAST_2_2_0, p.dst.location): 

1382 # workaround for https://github.com/openzfs/zfs/commit/b0269cd8ced242e66afc4fa856d62be29bb5a4ff 

1383 # 'zfs recv -x foo' on zfs < 2.2 errors out if the 'foo' property isn't contained in the send stream 

1384 for propname in x_names: 

1385 recv_opts.append("-x") 

1386 recv_opts.append(propname) 

1387 ox_names.update(x_names) # union 

1388 for config in [p.zfs_recv_o_config, p.zfs_recv_x_config, p.zfs_set_config]: 

1389 if len(config.include_regexes) == 0: 

1390 continue # this is the default - it's an instant noop 

1391 if (full_send and "full" in config.targets) or (not full_send and "incremental" in config.targets): 

1392 # 'zfs get' uses newline as record separator and tab as separator between output columns. A ZFS user property 

1393 # may contain newline and tab characters (indeed anything). Together, this means that there is no reliable 

1394 # way to determine where a record ends and the next record starts when listing multiple arbitrary records in 

1395 # a single 'zfs get' call. Therefore, here we use a separate 'zfs get' call for each ZFS user property. 

1396 # TODO: perf: on zfs >= 2.3 use json via zfs get -j to safely merge all zfs gets into one 'zfs get' call 

1397 try: 

1398 props_any: dict = _zfs_get(job, p.src, dataset, config.sources, "property", "all", True, cache) 

1399 props_filtered: dict = filter_properties(p, props_any, config.include_regexes, config.exclude_regexes) 

1400 user_propnames: list[str] = [name for name in props_filtered if ":" in name] 

1401 sys_propnames: str = ",".join(name for name in props_filtered if ":" not in name) 

1402 props: dict = _zfs_get(job, p.src, dataset, config.sources, "property,value", sys_propnames, True, cache) 

1403 for propnames in user_propnames: 

1404 props.update(_zfs_get(job, p.src, dataset, config.sources, "property,value", propnames, False, cache)) 

1405 except (subprocess.CalledProcessError, UnicodeDecodeError) as e: 

1406 raise RetryableError(display_msg="zfs get") from e 

1407 for propname in sorted(props.keys()): 

1408 if config is p.zfs_recv_o_config: 

1409 if not (propname in ox_names or propname in x_names_set): 

1410 recv_opts.append("-o") 

1411 recv_opts.append(f"{propname}={props[propname]}") 

1412 ox_names.add(propname) 

1413 elif config is p.zfs_recv_x_config: 

1414 if propname not in ox_names: 

1415 recv_opts.append("-x") 

1416 recv_opts.append(propname) 

1417 ox_names.add(propname) 

1418 else: 

1419 assert config is p.zfs_set_config 

1420 set_opts.append(f"{propname}={props[propname]}") 

1421 recv_opts = _sanitize_recv_opts_for_dataset_type(recv_opts, is_volume=is_volume) 

1422 return recv_opts, set_opts 

1423 

1424 

1425_ZFS_RECV_PROPS_REJECTED_ON_ANY_DATASET: Final[frozenset[str]] = frozenset( 

1426 ["casesensitivity", "normalization", "utf8only", "volblocksize", "volsize"] 

1427) 

1428_ZFS_RECV_PROPS_REJECTED_ON_FILESYSTEM: Final[Mapping[str, frozenset[str]]] = { 

1429 "-x": _ZFS_RECV_PROPS_REJECTED_ON_ANY_DATASET, 

1430 "-o": _ZFS_RECV_PROPS_REJECTED_ON_ANY_DATASET, 

1431} 

1432_ZFS_RECV_PROPS_REJECTED_ON_ZVOL: Final[Mapping[str, frozenset[str]]] = { 

1433 "-x": _ZFS_RECV_PROPS_REJECTED_ON_ANY_DATASET, 

1434 "-o": frozenset( 

1435 { 

1436 # see https://github.com/openzfs/zfs/blob/master/module/zcommon/zfs_prop.c 

1437 "aclinherit", 

1438 "aclmode", 

1439 "acltype", 

1440 "atime", 

1441 "canmount", 

1442 "devices", 

1443 "mountpoint", 

1444 "overlay", 

1445 "recordsize", 

1446 "relatime", 

1447 "sharenfs", 

1448 "sharesmb", 

1449 "snapdir", 

1450 "xattr", 

1451 }.union(_ZFS_RECV_PROPS_REJECTED_ON_ANY_DATASET) 

1452 ), 

1453} 

1454 

1455 

1456def _sanitize_recv_opts_for_dataset_type(recv_opts: list[str], *, is_volume: bool) -> list[str]: 

1457 """Drops `zfs receive` `-o/-x` properties that `zfs receive` rejects as an error for this dataset type. Keeps properties 

1458 for which `zfs receive` emits only (harmless) warnings. 

1459 

1460 For example: 

1461 drop -o canmount=<value> on zvols 

1462 drop -o recordsize=<value> on zvols 

1463 drop -o mountpoint=<value> on zvols 

1464 drop -o/-x casesensitivity on zvols and filesystems 

1465 drop -o/-x volsize on zvols and filesystems 

1466 keep -o/-x volmode on zvols and filesystems 

1467 """ 

1468 if is_volume: 

1469 inapplicable_props_dict = _ZFS_RECV_PROPS_REJECTED_ON_ZVOL 

1470 else: 

1471 inapplicable_props_dict = _ZFS_RECV_PROPS_REJECTED_ON_FILESYSTEM 

1472 

1473 results: list[str] = [] 

1474 i = 0 

1475 while i < len(recv_opts): 

1476 opt: str = recv_opts[i] 

1477 i += 1 

1478 inapplicable_props: frozenset[str] | None = inapplicable_props_dict.get(opt) 

1479 if inapplicable_props is None or i >= len(recv_opts): 

1480 results.append(opt) 

1481 continue 

1482 arg: str = recv_opts[i] 

1483 i += 1 

1484 propname: str = arg.split("=", 1)[0] if opt == "-o" else arg 

1485 if propname not in inapplicable_props: # retain this property on this dataset type 

1486 results.append(opt) 

1487 results.append(arg) 

1488 return results 

1489 

1490 

1491def _check_zfs_dataset_busy(job: Job, remote: Remote, dataset: str, busy_if_send: bool = True) -> bool: 

1492 """Decline to start a state changing ZFS operation that is, although harmless, likely to collide with another currently 

1493 running process. Instead, retry the operation later, after some delay. For example, decline to start a 'zfs receive' into 

1494 a destination dataset if another process is already running another 'zfs receive' into the same destination dataset, as 

1495 ZFS would reject any such attempt. However, it's actually fine to run an incremental 'zfs receive' into a dataset in 

1496 parallel with a 'zfs send' out of the very same dataset. This also helps daisy chain use cases where A replicates to B, 

1497 and B replicates to C. 

1498 

1499 _check_zfs_dataset_busy() offers no guarantees, it merely proactively avoids likely collisions. In other words, even if 

1500 the process check below passes there is no guarantee that the destination dataset won't be busy by the time we actually 

1501 execute the 'zfs send' operation. In such an event ZFS will reject the operation, we'll detect that, and we'll simply 

1502 auto-retry, after some delay. _check_zfs_dataset_busy() can be disabled via --ps-program=-. 

1503 

1504 TLDR: As is common for long-running operations in distributed systems, we use coordination-free optimistic concurrency 

1505 control where the parties simply retry on collision detection (rather than coordinate concurrency via a remote lock 

1506 server). 

1507 """ 

1508 p, log = job.params, job.params.log 

1509 if not p.is_program_available("ps", remote.location): 

1510 return True 

1511 cmd: list[str] = p.split_args(f"{p.ps_program} -Ao args") 

1512 procs: list[str] = (job.try_ssh_command(remote, LOG_TRACE, cmd=cmd) or "").splitlines() 

1513 if job.inject_params.get("is_zfs_dataset_busy", False): 

1514 procs += ["sudo -n zfs receive -u -o foo:bar=/baz " + dataset] # for unit testing only 

1515 if not _is_zfs_dataset_busy(procs, dataset, busy_if_send=busy_if_send): 

1516 return True 

1517 op: str = "zfs {receive" + ("|send" if busy_if_send else "") + "} operation" 

1518 try: 

1519 die(f"Cannot continue now: Destination is already busy with {op} from another process: {dataset}") 

1520 except SystemExit as e: 

1521 log.warning("%s", e) 

1522 raise RetryableError("dst currently busy with zfs mutation op", display_msg="replication") from e 

1523 

1524 

1525_ZFS_DATASET_BUSY_PREFIX: Final[str] = r"(([^ ]*?/)?(sudo|doas)( +-n)? +)?([^ ]*?/)?zfs (receive|recv" 

1526_ZFS_DATASET_BUSY_IF_MODS: Final[re.Pattern[str]] = re.compile((_ZFS_DATASET_BUSY_PREFIX + ") .*").replace("(", "(?:")) 

1527_ZFS_DATASET_BUSY_IF_SEND: Final[re.Pattern[str]] = re.compile((_ZFS_DATASET_BUSY_PREFIX + "|send) .*").replace("(", "(?:")) 

1528 

1529 

1530def _is_zfs_dataset_busy(procs: list[str], dataset: str, busy_if_send: bool) -> bool: 

1531 """Checks if any process list entry indicates ZFS activity on dataset.""" 

1532 regex: re.Pattern[str] = _ZFS_DATASET_BUSY_IF_SEND if busy_if_send else _ZFS_DATASET_BUSY_IF_MODS 

1533 suffix: str = " " + dataset 

1534 infix: str = " " + dataset + "@" 

1535 return any((proc.endswith(suffix) or infix in proc) and regex.fullmatch(proc) for proc in procs) 

1536 

1537 

1538############################################################################# 

1539_TMP_BOOKMARK_PREFIX: Final[str] = ".TMPBZFS." 

1540_TMP_BOOKMARK_HASH_PREFIX: Final[str] = "#" + _TMP_BOOKMARK_PREFIX 

1541 

1542 

1543def is_tmp_bookmark(bookmark: str) -> bool: 

1544 """Returns whether the given name is a temporary bookmark (from the reserved namespace).""" 

1545 return _TMP_BOOKMARK_HASH_PREFIX in bookmark 

1546 

1547 

1548@final 

1549class _Continuity: 

1550 """Guarantees that a ZFS snapshot can be safely deleted after it has been successfully replicated because we can use a ZFS 

1551 bookmark as a common base to continue incremental ZFS replication. This continuity guarantee exists even if the bzfs 

1552 process is killed after `zfs send/receive` succeeds but before the post-replication bookmark creation step runs. 

1553 

1554 The mechanism works as follows: Before replication starts it creates a temporary bookmark for each snapshot that is about 

1555 to be replicated. After replication succeeds it promotes (renames) the tmp bookmark to a finalized bookmark. Both the 

1556 temporary bookmark as well as the finalized bookmark will be used as a source for incremental ZFS replication. The 

1557 mechanism also garbage collects obsolete temporary bookmarks as necessary. 

1558 

1559 There is no need to eagerly promote/gc on the "already up-to-date" branch (e.g. on retry) as promote/gc is merely 

1560 slightly delayed until the next successful replication run. 

1561 

1562 Name of tmp bookmark = .TMPBZFS.<snapshot_name>.<compact_snapshot_guid>.<dst_dataset_id> 

1563 where <snapshot_name> is the part after the '@', and 

1564 where <dst_dataset_id> is the compact ZFS GUID of the destination pool followed by a dot followed by a 11-character base64 

1565 SHA-256 prefix of the full destination dataset name. 

1566 The compact form of a ZFS GUID uses a base64 representation instead of a decimal representation. 

1567 Example tmp bookmark for snapshot "bzfs_us-west_2024-11-06_08:30:05_hourly": 

1568 ".TMPBZFS.bzfs_us-west_2024-11-06_08:30:05_hourly.ADgurPHaj8k.AAAfaLnRWNQ.mjKQwJEMnjk" 

1569 

1570 Name of finalized bookmark = same name as its corresponding snapshot except also contains <snapshot_guid> for uniqueness; 

1571 it appends the <snapshot_guid> to a suffixless <snapshot_name> or inserts <snapshot_guid> before the last suffix. 

1572 Example finalized bookmark for snapshot "bzfs_us-west_2024-11-06_08:30:05_hourly": 

1573 "bzfs_us-west_2024-11-06_08:30:05_15813919022682057_hourly" 

1574 

1575 Note that ZFS guarantees that a ZFS snapshot and its ZFS bookmarks always have the same GUID. 

1576 

1577 The bookmark namespace starting with ".TMPBZFS." is reserved for internal use. Tmp bookmarks are auto-hidden from other 

1578 bzfs operations, e.g. hidden from --delete-dst-snapshots=bookmarks and --compare-snapshot-lists. 

1579 """ 

1580 

1581 def __init__( 

1582 self, 

1583 p: Params, 

1584 src_dataset: str, 

1585 dst_dataset: str, 

1586 raw_src_snapshots_with_guids: list[str], 

1587 dst_snapshots_with_guids: list[str], 

1588 ) -> None: 

1589 self._src_dataset: Final[str] = src_dataset 

1590 dst_pool_guid: str = p.zpool_features[p.dst.location][p.dst.pool].get(POOL_GUID, "") 

1591 if not dst_pool_guid: 

1592 die( 

1593 "Cannot create bookmarks to guarantee continuity because the destination zpool GUID could not be detected " 

1594 f"for {p.dst.pool!r}. Ensure --zpool-program is enabled and 'zpool' CLI is available on the destination " 

1595 "host. Alternatively, consider using --create-bookmarks=none." 

1596 ) 

1597 self._dst_dataset_id: Final[str] = ( 

1598 self._compact_guid(dst_pool_guid) + "." + self._b64escape(sha256_urlsafe_base64(dst_dataset)[:11]) 

1599 ) 

1600 tmp_suffix: str = f".{self._dst_dataset_id}" 

1601 self._dst_snapshot_guids: Final[set[str]] = { 

1602 guid for guid, name in (line.split("\t", 1) for line in dst_snapshots_with_guids) if "@" in name 

1603 } 

1604 raw_src_snapshots_components: list[tuple[str, str, bool, bool, bool]] = [ 

1605 (name, guid, "@" in name, "#" in name, is_tmp_bookmark(name)) 

1606 for guid, name in (line.split("\t", 1) for line in raw_src_snapshots_with_guids) 

1607 ] 

1608 self._src_snapshots: Final[Mapping[str, str]] = { 

1609 name: guid 

1610 for name, guid, is_snapshot, _is_bookmark, _is_tmp_bookmark in raw_src_snapshots_components 

1611 if is_snapshot 

1612 } 

1613 self._tmp_src_bookmarks: Final[dict[str, str]] = { 

1614 name: guid 

1615 for name, guid, _is_snapshot, is_bookmark, is_tmp_bookmark in raw_src_snapshots_components 

1616 if is_bookmark and is_tmp_bookmark and name.endswith(tmp_suffix) 

1617 } 

1618 self._finalized_src_bookmarks: Final[dict[str, str]] = { 

1619 name: guid 

1620 for name, guid, _is_snapshot, is_bookmark, is_tmp_bookmark in raw_src_snapshots_components 

1621 if is_bookmark and not is_tmp_bookmark 

1622 } 

1623 

1624 def _tmp_bookmark_name_and_guid(self, snapshot: str) -> tuple[str, str]: 

1625 """Returns the name and GUID of the tmp bookmark for the given src snapshot.""" 

1626 assert "@" in snapshot 

1627 dataset, snapshot_name = snapshot.split("@", 1) 

1628 assert dataset == self._src_dataset 

1629 snapshot_guid: str = self._src_snapshots[snapshot] 

1630 tbm = f"{dataset}#{_TMP_BOOKMARK_PREFIX}{snapshot_name}.{self._compact_guid(snapshot_guid)}.{self._dst_dataset_id}" 

1631 return tbm, snapshot_guid 

1632 

1633 def _finalized_bookmark_name(self, tmp_bookmark: str) -> str: 

1634 """Returns the name of the final bookmark corresponding to the given tmp bookmark.""" 

1635 assert is_tmp_bookmark(tmp_bookmark), tmp_bookmark 

1636 dataset, tag = tmp_bookmark.split("#", 1) 

1637 assert dataset == self._src_dataset 

1638 prefix_and_snapshot_name, compact_snapshot_guid, _, _ = tag.rsplit(".", 3) 

1639 assert prefix_and_snapshot_name.startswith(_TMP_BOOKMARK_PREFIX), (prefix_and_snapshot_name, tmp_bookmark) 

1640 snapshot_name = prefix_and_snapshot_name.removeprefix(_TMP_BOOKMARK_PREFIX) # bzfs_us-west_2024-11-06_08:30:05_daily 

1641 snapshot_guid: str = self._decimal_guid(compact_snapshot_guid) 

1642 if "_" in snapshot_name: # standard name 

1643 prefix, suffix = snapshot_name.rsplit("_", 1) 

1644 fbm = f"{dataset}#{prefix}_{snapshot_guid}_{suffix}" # bzfs_us-west_2024-11-06_08:30:05_15832610720350849_hourly 

1645 else: # non-standard name; e.g. "foo" -> "foo_15832610720350849" 

1646 fbm = f"{dataset}#{snapshot_name}_{snapshot_guid}" 

1647 return fbm 

1648 

1649 def create_tmp_bookmarks(self, job: Job, src: Remote, snapshots: list[str]) -> None: 

1650 """Adds tmp bookmarks corresponding to the given src snapshots.""" 

1651 bookmarks: dict[str, str] = dict(self._tmp_bookmark_name_and_guid(snapshot) for snapshot in snapshots) 

1652 _create_zfs_bookmarks(job, src, snapshots, list(bookmarks.keys()), expected_guids=list(bookmarks.values())) 

1653 for name, guid in bookmarks.items(): 

1654 self._tmp_src_bookmarks[name] = guid 

1655 

1656 def mark_snapshots_as_replicated(self, snapshots: list[str]) -> None: 

1657 """Records that the given src snapshots have been successfully replicated to the destination dataset.""" 

1658 for snapshot in snapshots: 

1659 snapshot_guid: str = self._src_snapshots[snapshot] 

1660 self._dst_snapshot_guids.add(snapshot_guid) 

1661 

1662 def promote_and_gc_bookmarks(self, job: Job, src: Remote) -> None: 

1663 """ 

1664 For each tmp bookmark that targets the given destination dataset: 

1665 Promote aka copy it into a finalized bookmark if a snapshot with the tmp bookmark GUID exists in the dst dataset, 

1666 and a corresponding finalized bookmark with the same GUID does not yet exist. 

1667 For each tmp bookmark that targets the given destination dataset: 

1668 Delete it if a snapshot with the tmp bookmark GUID does not exist in the destination dataset, 

1669 or if a corresponding finalized bookmark with the same GUID already exists. 

1670 """ 

1671 tmp_bookmarks_to_delete: list[str] = [] 

1672 promote_bookmarks: list[tuple[str, str, str]] = [] 

1673 for tmp_bookmark_name, tmp_bookmark_guid in self._tmp_src_bookmarks.items(): 

1674 assert tmp_bookmark_name 

1675 assert tmp_bookmark_guid 

1676 if tmp_bookmark_guid in self._dst_snapshot_guids: 

1677 finalized_bookmark_name: str = self._finalized_bookmark_name(tmp_bookmark_name) 

1678 finalized_guid: str | None = self._finalized_src_bookmarks.get(finalized_bookmark_name, None) 

1679 if finalized_guid is None or tmp_bookmark_guid != finalized_guid: 

1680 promote_bookmarks.append((tmp_bookmark_name, tmp_bookmark_guid, finalized_bookmark_name)) 

1681 tmp_bookmarks_to_delete.append(tmp_bookmark_name) 

1682 else: 

1683 tmp_bookmarks_to_delete.append(tmp_bookmark_name) 

1684 

1685 # promote in parallel 

1686 _create_zfs_bookmarks( 

1687 job, 

1688 src, 

1689 [tmp_bookmark_name for tmp_bookmark_name, _, _ in promote_bookmarks], 

1690 [finalized_bookmark_name for _, _, finalized_bookmark_name in promote_bookmarks], 

1691 expected_guids=[tmp_bookmark_guid for _, tmp_bookmark_guid, _ in promote_bookmarks], 

1692 ) 

1693 for _tmp_bookmark_name, tmp_bookmark_guid, finalized_bookmark_name in promote_bookmarks: 

1694 self._finalized_src_bookmarks[finalized_bookmark_name] = tmp_bookmark_guid 

1695 

1696 # gc in parallel 

1697 bookmark_tags_to_delete: list[str] = [bookmark.split("#", 1)[1] for bookmark in tmp_bookmarks_to_delete] 

1698 delete_bookmarks(job, src, self._src_dataset, bookmark_tags_to_delete, loglevel=LOG_DEBUG) 

1699 for bookmark in tmp_bookmarks_to_delete: 

1700 self._tmp_src_bookmarks.pop(bookmark) 

1701 

1702 @staticmethod 

1703 def _compact_guid(decimal_guid: str) -> str: 

1704 """Converts a 64-bit ZFS GUID from canonical decimal format (20 bytes) to base64 (11 bytes); to shorten it.""" 

1705 guid_bytes: bytes = base64.urlsafe_b64encode(int(decimal_guid).to_bytes(8, "big")) 

1706 return _Continuity._b64escape(guid_bytes.decode().rstrip("=")) 

1707 

1708 @staticmethod 

1709 def _decimal_guid(compact_guid: str) -> str: 

1710 """Converts an 11-character base64 ZFS GUID back to its canonical decimal representation such that 

1711 _decimal_guid(_compact_guid(x)) == x""" 

1712 guid_bytes: bytes = base64.urlsafe_b64decode(_Continuity._b64unescape(compact_guid) + "=") 

1713 return str(int.from_bytes(guid_bytes, "big")) 

1714 

1715 @staticmethod 

1716 def _b64escape(name: str) -> str: 

1717 return name.replace("_", ":") 

1718 

1719 @staticmethod 

1720 def _b64unescape(name: str) -> str: 

1721 return name.replace(":", "_")