Source code for eb_staged_rebuild.swap

"""Crash-aware directory exchange with a restoring two-rename fallback.

The preferred operation is Linux ``renameat2(RENAME_EXCHANGE)``.  Some shared
filesystems reject rename flags, so the fallback uses three ordinary renames.
Every caught failure in that sequence restores the two original names before
it is re-raised.  A journaled caller can use :func:`recover_interrupted_swap`
after an uncatchable process or machine failure.
"""

import ctypes
import errno
import os
from dataclasses import dataclass

from eb_staged_rebuild.atomicio import fsync_dir
from eb_staged_rebuild.errors import NeedsIntervention, Refused
from eb_staged_rebuild.faults import FaultInjector
from eb_staged_rebuild.paths import abspath, lstat_kind


# Linux UAPI: include/uapi/linux/fcntl.h defines AT_FDCWD as -100.
AT_FDCWD = -100
# Linux UAPI: include/uapi/linux/fs.h defines RENAME_EXCHANGE as (1 << 1).
RENAME_EXCHANGE = 1 << 1
FALLBACK_HOLD_SUFFIX = ".fallback-old"
UNSUPPORTED_EXCHANGE_ERRNOS = frozenset(
    {
        errno.EINVAL,
        errno.ENOSYS,
        errno.EOPNOTSUPP,
    }
)


[docs] @dataclass(frozen=True) class SwapResult: method: str
def _rename_exchange(first, second): """Exchange two paths or raise ``OSError`` with the kernel errno.""" libc = ctypes.CDLL(None, use_errno=True) if not hasattr(libc, "renameat2"): raise OSError(errno.ENOSYS, "renameat2 is not exported by libc") renameat2 = libc.renameat2 renameat2.argtypes = [ ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint, ] renameat2.restype = ctypes.c_int ctypes.set_errno(0) result = renameat2( AT_FDCWD, os.fsencode(first), AT_FDCWD, os.fsencode(second), RENAME_EXCHANGE, ) if result != 0: error = ctypes.get_errno() or errno.EIO raise OSError(error, f"renameat2(RENAME_EXCHANGE): {os.strerror(error)}") def _validate_pair(live, staged): live = abspath(live) staged = abspath(staged) if os.path.dirname(live) != os.path.dirname(staged): raise Refused("the live and staged directories must be siblings") for label, path in (("live", live), ("staged", staged)): kind = lstat_kind(path) if kind != "dir": raise Refused(f"{label} swap path is {kind}, not a real directory: {path}") return live, staged def _sync_parent(live): fsync_dir(os.path.dirname(live)) def _restore_fallback(live, staged, hold, rename): """Restore the pre-swap names from either interrupted fallback state.""" live_kind = lstat_kind(live) staged_kind = lstat_kind(staged) hold_kind = lstat_kind(hold) if hold_kind == "missing": return if hold_kind != "dir": raise NeedsIntervention(f"fallback recovery path is not a directory: {hold}") if live_kind == "missing" and staged_kind == "dir": # The old live tree was moved aside; the staged tree has not moved. rename(hold, live) elif live_kind == "dir" and staged_kind == "missing": # The staged tree became live; put it back before restoring the old tree. rename(live, staged) rename(hold, live) else: raise NeedsIntervention( "cannot recover interrupted fallback swap: " f"live={live_kind}, staged={staged_kind}, hold={hold_kind}" ) _sync_parent(live)
[docs] def recover_interrupted_swap(live, staged, rename=os.rename): """Restore an interrupted ordinary-rename fallback, if one is present.""" live = abspath(live) staged = abspath(staged) hold = staged + FALLBACK_HOLD_SUFFIX _restore_fallback(live, staged, hold, rename) if lstat_kind(hold) != "missing": raise NeedsIntervention(f"fallback recovery left its hold path behind: {hold}")
def _fallback_swap(live, staged, faults, rename, step_prefix): hold = staged + FALLBACK_HOLD_SUFFIX if lstat_kind(hold) != "missing": recover_interrupted_swap(live, staged, rename) try: faults.reach(f"{step_prefix}.fallback.live-to-hold") rename(live, hold) faults.reach(f"{step_prefix}.fallback.staged-to-live") rename(staged, live) faults.reach(f"{step_prefix}.fallback.hold-to-staged") rename(hold, staged) _sync_parent(live) except BaseException: try: _restore_fallback(live, staged, hold, rename) except BaseException as recovery_error: raise NeedsIntervention( f"fallback swap failed and could not restore {live}: {recovery_error}" ) from recovery_error raise return SwapResult("rename-fallback")
[docs] def swap_directories( live, staged, *, allow_exchange=True, faults=None, rename=os.rename, exchange_call=None, step_prefix="swap", ): """Exchange sibling directories and restore them after every caught failure. ``staged`` contains the candidate tree before the call and the retired live tree after a successful call. Unsupported rename flags select the ordinary rename fallback. Any other exchange error leaves both names unchanged. """ live, staged = abspath(live), abspath(staged) faults = faults if faults is not None else FaultInjector() exchange_call = exchange_call or _rename_exchange recover_interrupted_swap(live, staged, rename) live, staged = _validate_pair(live, staged) if allow_exchange: exchanged = False try: faults.reach(f"{step_prefix}.exchange.before") exchange_call(live, staged) exchanged = True faults.reach(f"{step_prefix}.exchange.after") _sync_parent(live) return SwapResult("renameat2-exchange") except OSError as error: if exchanged: try: exchange_call(live, staged) _sync_parent(live) except BaseException as recovery_error: raise NeedsIntervention( f"RENAME_EXCHANGE succeeded but its rollback failed for {live}: " f"{recovery_error}" ) from recovery_error if error.errno not in UNSUPPORTED_EXCHANGE_ERRNOS: raise except BaseException: if exchanged: try: exchange_call(live, staged) _sync_parent(live) except BaseException as recovery_error: raise NeedsIntervention( f"RENAME_EXCHANGE succeeded but its rollback failed for {live}: " f"{recovery_error}" ) from recovery_error raise return _fallback_swap(live, staged, faults, rename, step_prefix)