Skip to content

Title: rw=trimwrite: termination can split a trim+write pair, leaving the last trimmed block deallocated #2122

Description

@Vladyyy

Please acknowledge the following before creating a ticket

Description of the bug:

rw=trimwrite issues each block as two consecutive io_us: a DDIR_TRIM
followed by a DDIR_WRITE to the same offset/length (the HOWTO documents it
as "Blocks will be trimmed first, then the same blocks will be written to").

The main loop in do_io() checks its termination conditions
(runtime_exceeded(), and the bytes_issued >= total_bytes budget) once per
io_u, with no awareness of the trim/write pairing (set_rw_ddir() alternates
based on f->last_start[DDIR_WRITE] == f->last_start[DDIR_TRIM], so the trim
and its paired write are two independent trips through the loop). When the
job terminates after a trim has been issued but before its paired write is
generated, the run ends with a "dangling trim": the final block is left
deallocated and its paired write is never issued.

Impact:

  • The trimwrite contract ("every trimmed block is written back") is violated
    for the final block; its data is deallocated, not rewritten. The job itself
    exits 0, so this is silent.
  • Any verification workflow that replays the job's write_iolog (e.g.
    read_iolog + replay_skip=trim on a time_based run that looped over the
    region more than once) fails with verify: bad magic header 0 on the
    dangling range: the log's earlier writes to that block were wiped by the
    final trim, and no write follows.

Proposed fix (validated locally: 0/10 dangling trims after, vs 10/10 before;
a loops-bounded run still issues the same op counts, so runs that already
terminated on pair boundaries are unaffected): defer termination while a pair
is open, so the loop issues exactly one more io_u (the paired write) and
terminates on the next iteration.

--- a/backend.c
+++ b/backend.c
@@ -1080,6 +1080,25 @@
  *
  * Returns number of bytes written and trimmed.
  */
+/*
+ * In trimwrite mode a block is trimmed and then written back by two
+ * consecutive io_us. Terminating between the two leaves the block
+ * deallocated with its paired write never issued (a "dangling trim"),
+ * which breaks replay-based verification of the write log. The pair
+ * state lives in the per-file last_start markers: unequal means a trim
+ * was issued and its paired write is still pending.
+ */
+static bool trimwrite_mid_pair(struct thread_data *td)
+{
+	struct fio_file *f;
+
+	if (!td_trimwrite(td) || td->o.nr_files != 1)
+		return false;
+
+	f = td->files[0];
+	return f->last_start[DDIR_WRITE] != f->last_start[DDIR_TRIM];
+}
+
 static void do_io(struct thread_data *td, uint64_t *bytes_done)
 {
 	unsigned int i;
@@ -1144,7 +1163,8 @@
 
 		if (runtime_exceeded(td, &td->ts_cache)) {
 			__update_ts_cache(td);
-			if (runtime_exceeded(td, &td->ts_cache)) {
+			if (runtime_exceeded(td, &td->ts_cache) &&
+			    !trimwrite_mid_pair(td)) {
 				fio_mark_td_terminate(td);
 				break;
 			}
@@ -1161,6 +1181,7 @@
 		 */
 		if (bytes_issued >= total_bytes &&
 		    !td->o.read_iolog_file &&
+		    !trimwrite_mid_pair(td) &&
 		    (!td->o.time_based ||
 		     (td->o.time_based && td->o.verify != VERIFY_NONE)))
 			break;

The helper conservatively bails for nr_files != 1; the pairing markers are
per-file, so a multi-file generalization would just loop over the files.

Environment: Amazon Linux 2, x86_64, kernel loop device over a sparse file
on ext4

fio version: fio-3.42 (release tarball). The termination logic in
do_io() is unchanged at current master, so the bug should be present there
as well.

Reproduction steps

Any discard-capable block device works; a loop device is the easiest:

truncate -s 1G /tmp/backing.img
sudo losetup -f --show /tmp/backing.img    # assume /dev/loop0

repro.fio:

[trimwrite]
filename=/dev/loop0
rw=trimwrite
direct=1
ioengine=libaio
iodepth=8
bsrange=4k-128k
time_based=1
runtime=4
write_iolog=/tmp/trimwrite.iolog
serialize_overlap=1
size=64m

Run it, then compare trim and write op counts in the iolog:

sudo fio repro.fio
awk '$3=="trim"{t++;last="trim"} $3=="write"{w++;last="write"} \
     END{print "trims="t, "writes="w, "last_op="last}' /tmp/trimwrite.iolog

Observed (3 consecutive runs):

trims=3412 writes=3411 last_op=trim
trims=2412 writes=2411 last_op=trim
trims=2373 writes=2372 last_op=trim

Expected: trims == writes, with the last op for every block pair being the
write. One extra trim with last_op=trim means the final block was
deallocated and never rewritten.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions