Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/borg/platform/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
Public APIs are documented in platform.base.
"""

from ..platformflags import is_win32, is_linux, is_freebsd, is_darwin, is_cygwin, is_haiku
from ..platformflags import (
is_win32,
is_linux,
is_freebsd,
is_darwin,
is_cygwin,
is_haiku,
)
Comment on lines +7 to +14
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is still a black change.


from .base import listxattr, getxattr, setxattr, ENOATTR
from .base import acl_get, acl_set
Expand All @@ -17,6 +24,7 @@

if not is_win32:
from .posix import process_alive, local_pid_alive

# POSIX swidth implementation works for: Linux, FreeBSD, Darwin, OpenIndiana, Cygwin
from .posix import swidth
from .posix import get_errno
Expand All @@ -43,6 +51,7 @@
from .darwin import acl_get, acl_set
from .darwin import is_darwin_feature_64_bit_inode, _get_birthtime_ns
from .darwin import set_flags
from .darwin import fdatasync, sync_dir # type: ignore[no-redef]


def get_birthtime_ns(st, path, fd=None):
Expand Down
1 change: 0 additions & 1 deletion src/borg/platform/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@ class SyncFile:

Calling SyncFile(path) for an existing path will raise FileExistsError, see comment in __init__.

TODO: Use F_FULLSYNC on macOS.
TODO: A Windows implementation should use CreateFile with FILE_FLAG_WRITE_THROUGH.
"""

Expand Down
28 changes: 28 additions & 0 deletions src/borg/platform/darwin.pyx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fcntl
import os

from libc.stdint cimport uint32_t
Expand Down Expand Up @@ -259,3 +260,30 @@ def set_flags(path, bsd_flags, fd=None):
path_bytes = os.fsencode(path)
if lchflags(path_bytes, c_flags) == -1:
raise OSError(errno.errno, os.strerror(errno.errno), os.fsdecode(path_bytes))


def fdatasync(fd):
"""macOS fdatasync using F_FULLFSYNC for true data durability.

On macOS, os.fsync() only flushes to the drive's write cache.
fcntl F_FULLFSYNC flushes to persistent storage.
Comment on lines +268 to +269
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first sentence is a bit weird. It "only" flushes the drive's write cache. What else is there to flush that FULLSYNC does?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fsync() on macOS

fsync() (and Python's os.fsync()) asks the OS kernel to flush dirty data from its page cache to the drive. On macOS, however, fsync() does not guarantee that data has been committed to stable storage on the physical device. It only ensures the data has been handed off to the drive's own write buffer/cache.

If the drive has a volatile write cache (most HDDs and many SSDs do), a power loss after fsync() returns can still result in data loss or corruption, because the drive firmware hasn't necessarily flushed its internal cache to the platters/NAND.

F_FULLFSYNC (macOS-specific)

F_FULLFSYNC is a macOS-specific fcntl command that goes one step further: it issues a hardware flush command (like FLUSH CACHE in ATA or SYNCHRONIZE CACHE in SCSI/NVMe) to the drive, forcing it to commit all buffered writes from the drive's own cache to persistent storage.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so, os.fsync is only a OS-level flush, while fcntl F_FULLSYNC does a hw-level write buffer flush additionally.

Falls back to os.fsync() if F_FULLFSYNC is not supported (e.g. network fs).
"""
try:
fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
except OSError:
os.fsync(fd)


def sync_dir(path):
"""Sync a directory to persistent storage on macOS using F_FULLFSYNC."""
if isinstance(path, str):
path = os.fsencode(path)
fd = os.open(path, os.O_RDONLY)
try:
fdatasync(fd)
except OSError as os_error:
if os_error.errno != errno.EINVAL:
raise
finally:
os.close(fd)
63 changes: 63 additions & 0 deletions src/borg/testsuite/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from ..platformflags import is_win32, is_linux, is_freebsd, is_netbsd, is_darwin, is_haiku
from ..platform import acl_get, acl_set, swidth
from ..platform import get_process_id, process_alive
from ..platform import fdatasync, sync_dir
from . import BaseTestCase, unopened_tempfile
from .locking import free_pid

Expand Down Expand Up @@ -221,6 +222,68 @@ def test_extended_acl(self):
self.assert_in(b'group:ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000000::0:allow:read', self.get_acl(file2.name, numeric_ids=True)['acl_extended'])


@unittest.skipUnless(is_darwin, 'macOS only test')
def test_fdatasync_uses_f_fullfsync(monkeypatch):
import fcntl as fcntl_mod
from ..platform import darwin

calls = []
original_fcntl = fcntl_mod.fcntl

def mock_fcntl(fd, cmd, *args):
calls.append((fd, cmd))
return original_fcntl(fd, cmd, *args)

monkeypatch.setattr(fcntl_mod, 'fcntl', mock_fcntl)

with tempfile.NamedTemporaryFile() as tmp:
tmp.write(b'test data')
tmp.flush()
darwin.fdatasync(tmp.fileno())

assert any(cmd == fcntl_mod.F_FULLFSYNC for _, cmd in calls), 'fdatasync should call fcntl with F_FULLFSYNC'


@unittest.skipUnless(is_darwin, 'macOS only test')
def test_fdatasync_falls_back_to_fsync(monkeypatch):
import fcntl as fcntl_mod
from ..platform import darwin

fsync_calls = []

def mock_fcntl(fd, cmd, *args):
if cmd == fcntl_mod.F_FULLFSYNC:
raise OSError('F_FULLFSYNC not supported')
return 0

def mock_fsync(fd):
fsync_calls.append(fd)

# Cython does runtime attribute lookup on module objects, so patching
# fcntl.fcntl and os.fsync here affects darwin.fdatasync as expected.
monkeypatch.setattr(fcntl_mod, 'fcntl', mock_fcntl)
monkeypatch.setattr(os, 'fsync', mock_fsync)

with tempfile.NamedTemporaryFile() as tmp:
tmp.write(b'test data')
tmp.flush()
darwin.fdatasync(tmp.fileno())

assert len(fsync_calls) == 1, 'Should fall back to os.fsync when F_FULLFSYNC fails'


def test_fdatasync_basic():
with tempfile.NamedTemporaryFile() as tmp:
tmp.write(b'test data for fdatasync')
tmp.flush()
fdatasync(tmp.fileno())


def test_sync_dir_basic():
with tempfile.TemporaryDirectory() as tmpdir:
sync_dir(tmpdir)


@unittest.skipUnless(sys.platform.startswith(('linux', 'freebsd', 'darwin')), 'POSIX only tests')
class PlatformPosixTestCase(BaseTestCase):

Expand Down
Loading