Summary
On UFS, fdatasync(2) and writes to a descriptor opened with O_DSYNC could return success while the new file size and the block pointers to the new data were still only in memory. On soft updates file systems the same held for O_SYNC writes. A crash before the syncer caught up lost data the application had been told was on stable storage. Appending and then calling fdatasync(), as log writers and database WALs do, hits every one of these cases.
The series fixes four separate problems and adds a test. Git branch: https://github.com/sobomax/freebsd/tree/ufs-fdatasync-iflag
1. ufs: make fdatasync(2) and O_DSYNC writes update the inode
ffs_syncvnode() (the DATA_ONLY case) and ffs_write() (IO_DATASYNC) decide whether the inode must be written by testing IN_SIZEMOD | IN_IBLKDATA against ip->i_flags, the chflags(2) word, instead of ip->i_flag. Those bits alias UF_READONLY and UF_ARCHIVE:
- For ordinary files the test was always false, so the inode was never written.
- Files with urdonly or uarchive set got a synchronous inode write on every call instead.
journal_mount() has the same mix-up and sets IN_MODIFIED in i_flags. That only left UF_OPAQUE on the in-core .sujournal inode. The ffs_update() right after it is synchronous anyway.
The fix is one letter in each of the three places.
Fixes: 7428630b757, 52488b51489, e7347be9e34, 113db2dddb7
2. ufs: make fdatasync(2) write dirty indirect blocks
A data-only ffs_syncvnode() skipped dirty indirect blocks without soft updates. With soft updates, it stopped making passes once only indirect blocks were left dirty.
Indirect blocks only become dirty when the block pointers in them change, and those pointers are what makes a new block reachable. So an append or hole fill beyond the direct blocks had its data and size on disk, but not the pointer to the new block.
The fix treats indirect blocks the same way in data-only and full syncs, as the loop did before 2f514f92cf0. Plain overwrites, the case data-only sync is meant to speed up, don't dirty indirect blocks, so they still write neither indirect blocks nor the inode.
Fixes: 2f514f92cf0
3. ufs: flush soft updates dependencies for O_SYNC and O_DSYNC writes
For an IO_SYNC write, ffs_write() writes the data with bwrite() and then the inode with ffs_update(vp, 1). With soft updates, a new block pointer (and, for an extending write, the size) is rolled back in the buffer being written until the block's dependencies, such as its cylinder group map, are on disk. ffs_update() still clears IN_SIZEMOD and IN_IBLKDATA. So the correct inode was only written later, by the syncer. The same happened to the pointer in an indirect block, which ffs_balloc() writes synchronously but rolled back.
With soft updates, a synchronous write that allocated blocks now finishes with ffs_syncvnode(), as fsync(2) and fdatasync(2) do: DATA_ONLY for O_DSYNC, a full sync for O_SYNC. Whether the write allocated anything is decided by comparing the inode's block count before and after the write. The inode flags can't be used, because allocating a block below an existing indirect block sets neither of them. A write that allocated nothing, such as an overwrite of existing blocks, keeps writing only the inode as before, so overwrite workloads don't pay for a sync of the whole vnode. ffs_syncvnode() only drops the vnode lock for directory dependencies, and a KASSERT checks that it never returns ERELOOKUP here.
The same commit adds a sync on open. ffs_syncvnode() also writes whatever other descriptors left dirty on the vnode, so the first synchronous write after a lot of buffered writes would pay for all of them. A new ffs_open() syncs the vnode when a regular file on a soft updates file system is opened for writing with O_SYNC (full sync) or O_DSYNC (data-only sync), so this cost is mostly paid once, by the open.
- It calls ffs_syncvnode() rather than VOP_FSYNC(), because softdep_fsync() may drop the vnode lock in the middle of the open. A writable open holds the vnode lock exclusively.
- It only moves work the writes would otherwise do. So it skips the sync rather than wait when writes are suspended (vn_start_write() with V_NOWAIT), and does not fail the open on sync errors.
- It does nothing without soft updates. There the writes sync only their own blocks and the inode, so there is no cost to move, and a sync on open would only add one: opening a large file that another process is writing would block until all of it was on disk.
4. ufs: do not let a rolled back inode write end a data-only sync
With soft updates, ffs_syncvnode() writes the inode between passes while buffers are still dirty. That write can go out with new block pointers rolled back, yet it clears IN_SIZEMOD and IN_IBLKDATA.
This is exposed by change 2 on an append that allocates a file's first indirect block. The inode goes out while the new indirect block is still unwritten, so it is written with the new pointer rolled back. The indirect block is written in a later pass. The final check then sees no flags set and skips the inode, which is never written with the real pointer.
The fix: note on entry whether a data-only sync has to write the inode, and base the final write on that alone. Nothing else can set the flags meanwhile, because the vnode is locked.
5. tests: add UFS synchronous write durability tests
tests/sys/fs/ufs/sync_test (ATF, 72 cases) checks that once fsync() or fdatasync() returns, or a write with O_SYNC or O_DSYNC returns, the new data, the file size and every block pointer leading to the data are on disk.
The cases cover six scenarios, each with the four sync methods, on UFS without soft updates, with soft updates and with SU+J:
- appending a block
- filling a hole
- appending the first block that needs an indirect block
- appending below an existing indirect block
- appending a direct block after another write left a new direct block unsynced
- the same, with the synced append allocating the first indirect block
The last two model a descriptor without O_SYNC writing next to one with it. With soft updates, the unsynced direct block cuts the size and the later pointers of any inode written meanwhile. For O_SYNC and O_DSYNC on soft updates and SU+J, these cases also check right after the open, before any write, that the sync on open already made the unsynced block, its pointer and the size durable.
Each case runs on an md(4)-backed file system. Right after the call returns, it reads the inode, the indirect block and the data block straight from the device with libufs. There is no cache in front of the disk device, so this shows what would survive a crash at that moment without crashing anything.
Files: sys/ufs/ffs/ffs_vnops.c, sys/ufs/ffs/ffs_softdep.c (2 lines), tests/sys/fs/ufs/ (new), tests/sys/fs/Makefile, etc/mtree/BSD.tests.dist.
Test Plan
All kernel testing was on releng/14.3, amd64 (AWS EC2, 4 vCPUs, 16 GB RAM), with the series cherry-picked. The first revisions were tested with INVARIANTS, INVARIANT_SUPPORT, WITNESS and DEBUG_VFS_LOCKS; the last two, including this one, on non-debug kernels. The changes apply to main without conflicts. On main, the UFS module was only compiled, not booted.
This revision goes back to ffs_syncvnode() for synchronous writes (change 3 as in the revision before last) and adds change 4. It was tested on non-debug 14.3 kernels. The UFS module also builds with -Werror on main.
- sync_test: 72/72 with this revision. The new after-open check fails on a kernel without change 4, on all 12 O_SYNC/O_DSYNC unsynced cases and all three file system types: the size and the unsynced block are not on disk after the open, and the rest of each case passes. With change 4 they pass. In those 12 cases, DTrace showed ffs_open() calling ffs_syncvnode() once per case, plus 8 calls from ffs_write() for the allocating writes on soft updates and SU+J.
- stress2: the whole list below passed with this revision. In 20-second samples of the -o sync loads, ffs_write() called ffs_syncvnode() 19,776 times on soft updates, 136 on SU+J and 102 on soft updates with snapshots.
sync_test
With change 3 as in this revision, sync_test passed all 72 cases, before the after-open check was added. With the targeted flush of the last revision, it also passed all 72. Adding the fixes one at a time shows each one's effect on the 48 original cases:
| Kernel | Passed | Still failing |
|---|---|---|
| change 1 only | 26/48 | O_SYNC/O_DSYNC on soft updates and SU+J (16); fdatasync() past the direct blocks on all three file system types (6) |
| changes 1 + 3 | 38/48 | fdatasync() and O_DSYNC past the direct blocks (10) |
| changes 1 + 3 + 2 | 46/48 | fdatasync() on soft updates and SU+J when the append allocates the first indirect block (2) |
| full series | 48/48 | none |
- Before the series: the original version of the test failed every fdatasync() and O_DSYNC case. The on-disk inode still had the old size and a zero pointer for the new block.
- Test controls: in every case the test first checks what it reads after a plain fsync(), so it can tell a written inode from an unwritten one. A build of the test with fdatasync() replaced by fsync() passed on the unfixed kernel.
- Repeats: the two cases change 5 fixes failed 5 out of 5 times before it and passed 5 out of 5 times after it.
- DTrace: it confirmed the mechanism behind change 3, the rolled-back O_DSYNC inode write (buf_start() in ffs_geom_strategy() replaces the new di_size in the outgoing buffer), and behind change 5, the intermediate inode write that clears the flags before the indirect block is written.
stress2
These all passed with this revision, and earlier with change 3 alone. The ones that end with a file system check reported it clean, and there were no panics, KASSERT failures or new WITNESS warnings attributable to the series:
- fdatasync.sh and fdatasync2.sh, each on UFS without soft updates, with soft updates and with SU+J, each followed by a clean fsck_ffs -fn
- ftruncate2.sh
- fsync2.sh (soft updates and SU+J) and fsync3.sh
- gnop7.sh (soft updates and SU+J), gnop9.sh, gnop10.sh and fsck6.sh: forced unmounts and injected I/O errors, each followed by fsck
- 10-minute marcus.cfg loads on file systems mounted -o sync, where every write has IO_SYNC and takes the new path from change 3: soft updates, SU+J, and soft updates with a snapshot taken every 20 seconds. DTrace counted about 14,000 ffs_syncvnode() calls per second from ffs_write() on soft updates, which confirms that path was exercised.
The last revision, with the targeted flush, also passed the whole list.
During testing of this revision, one gnop9.sh run hung its forced unmount. That was a separate, pre-existing bug: ufs_rename() leaks the journal record for .. when the rewrite of .. fails. It is fixed in a separate review. With that fix, gnop9.sh passed 4 times in a row.
WITNESS reported one snaplk -> bufwait lock order reversal during the snapshot run. Both stacks (ffs_snapshot() -> softdep_sync_metadata(), and a full fsync -> ffs_copyonwrite()) go through code this series does not change, so it appears to predate the series. The same reversal came back in the rerun, again in the snapshot run. It is a false positive: the order is established when a snapshot file is unlinked (the .snap directory fsync writes a directory buffer, and ffs_copyonwrite() takes snaplk), and reversed by the final ffs_syncvnode() in ffs_snapshot() waiting on a cylinder group buffer. Cylinder group blocks are in the snapshot's block list, so their writes never wait for snaplk, and there is no cycle.
Performance
Not benchmarked against a kernel without the series. Changes 2 to 4 add I/O by design:
- O_SYNC and O_DSYNC on soft updates: each write that allocates blocks now syncs the vnode. Writes that allocate nothing are unchanged.
- Opening for writing with O_SYNC or O_DSYNC: syncs the vnode, on all UFS.
- fdatasync() of a large file after an append: it now writes the dirty indirect blocks.
With change 3 on SU+J under the -o sync load, most synchronous writes took 8-64 us in ffs_syncvnode(), and about 8% took 4 ms or more, up to about 1 s, waiting for journal and cylinder group writes.
Not covered
- UFS1. The test's on-disk reader handles it, but no case creates a UFS1 file system.
- Double and triple indirect blocks.
- ffs_extwrite(), which has the same IO_SYNC -> ffs_update() pattern for extended attributes. It is not changed here.
Side effect on the PR 297976 reproducer
The mkdir_blkreuse.sh reproducer for PR 297976 relied on fdatasync() not writing the inode, which is the bug change 1 fixes. It has been reworked in that review to write blocks with O_DIRECT instead.