Uploaded image for project: 'MariaDB Server'
  1. MariaDB Server
  2. MDEV-41194

Binlog group-commit write-failure: findings summary (probable bug: 11.8+)

    XMLWordPrintable

Details

    • Bug
    • Status: Open (View Workflow)
    • Minor
    • Resolution: Unresolved
    • 11.8.9, 12.3.3
    • None
    • Binary Protocol
    • None
    • Can result in data loss

    Description

      Background
       
      A real issue bit me in 10.6, so I tried to reproduce it: a disk-full
      test, real ENOSPC, concurrent writers, no crash/restart involved.
      While 10.6 is now EOL, there may be some people still using it, and I
      also wanted to see if this was fixed in later code. That led to a
      mixed picture: one of the two defects below is fixed by 11.8, the
      other still looks present through 12.3, from source inspection only
      (with the help of AI). So: one issue confirmed by reproduction on
      10.6, a second believed still present in current code but not tested.
       
      Pinned commits (verify with `git show <hash>:path`, not branch tip)
        10.6  cb32aa9aa85b243bead6ed47653db0f0ffc218e8 (10.6.25-39-g..., 2026-03-31)
        11.8  b3a5d24ef499f1f963aaa3507082afd6505e7019 (11.8.6-...)
        12.3  2f4743f1515d241ed0c903e376759291c9c41800 (12.3.1-147-g..., 2026-03-15)
      origin/10.6 and origin/12.3 have moved past these since (210ab6e.../
      881b64e... as of 2026-09-17); nothing relevant changed in that drift.
       
      ------------------------------------------------------------------
      TL;DR
       
      Under group commit, when one session's binlog write fails partway
      (e.g. ENOSPC):
       
        1. Failure 1 (10.6 only, fixed by 11.8): the write's own error is
           discarded. CacheWriter::write() always returns 0.
        2. Failure 2 (10.6, 11.8, 12.3 - all checked, current included):
           even when the failure is detected, the group-commit loop does
           not stop. It writes the next queued session's transaction into
           the same file immediately after the torn one, no marker.
       
      Reproduced in 10.6 (real ENOSPC, 3 concurrent writers, no
      crash/restart): clean GTID/Table_map, then ~3.8KB of garbled event
      data, then a 45,155,721-byte hole of physically unwritten zero bytes,
      then a different valid transaction (GTID 14621) resuming cleanly.
      Cross-checked 3 ways (our scanner, mariadb-binlog -c, a replica's
      checksum failure) - offsets agree. Note: the exact mechanism that
      produced a 45MB hole specifically (vs. a small torn region) is not
      established; see "what actually ends up on disk" below.
       
      ------------------------------------------------------------------
      Failure 1: CacheWriter::write() eats the error (10.6 only)
       
        sql/log.cc, class CacheWriter::write(), ~line 7421-7450
       
          if (first) write_header(pos, len);   // return value discarded
          else       write_data(pos, len);     // return value discarded
          remains -= len;                      // advances regardless
          if ((first = !remains)) write_footer(); // discarded
          DBUG_RETURN(0);                      // always "success"
       
        write_header/write_data/write_footer -> Log_event_writer::
        write_internal() (log_event_server.cc:817-827) does check
        my_b_safe_write() and returns 1 on error correctly. CacheWriter
        just never looks at it.
       
        Caller MYSQL_BIN_LOG::write_cache() has 4 guard sites (~7536, 7562,
        7574, 7604) of the form `if (writer.write(...)) return ER_ERROR_
        ON_WRITE;` - dead code, since writer.write() can't return nonzero.
       
        Consequence: `remains` decrements as if bytes landed even when they
        didn't. current->error never gets set from this path, so
        run_commit_ordered() still runs -> storage engine commits while the
        binlog image for that same txn is torn.
       
        Fixed between 10.6 and 11.8 by commit 24c923d4984 (MDEV-31273,
        "Refactor write_cache()", a checksum-precompute prep refactor).
        Commit message explicitly notes it as a fix "bonus". Not a
        deliberate targeted fix. 11.8's Event_log::write_cache() checks each
        write_header/write_data/write_footer call individually and
        propagates ER_ERROR_ON_WRITE correctly. Verified same in 12.3.
       
      ------------------------------------------------------------------
      Failure 2: group-commit loop doesn't stop on a failed entry
      (10.6, 11.8, 12.3 - unfixed in all)
       
        sql/log.cc, commit-ordering loop over the queued-txn linked list
        10.6: ~8469-8488   11.8: ~9295-9320   12.3: 10834-10858
        (line numbers move, same shape all three)
       
          for (current = queue; current; current = current->next) {
            if (unlikely((current->error = write_transaction_or_stmt(...))))
              current->commit_errno = errno;
            // no break, no return, no skip - falls straight through
            strmake_buf(cache_mngr->last_commit_pos_file, log_file_name);
            commit_offset = my_b_write_tell(&log_file);
            ...
            // continues to current->next regardless
          }
       
        queue spans multiple client sessions being flushed into one shared
        file in this group-commit cycle. On failure: error is recorded on
        `current` (for later delivery to that session - can't call
        my_error() here per the code's own comment), then the loop
        immediately proceeds to write the next session's txn into the same
        file from wherever my_b_write_tell() currently claims to be. No
        break, no rotate, no incident flag.
       
        12.3 detail: commit_offset/update_gtid_index bookkeeping is gated by
        `if (!opt_binlog_engine_hton)`; with the opt-in binlog storage
        engine active, tracking happens elsewhere - doesn't change the
        defect, loop still doesn't break/skip either way.
       
        Not fixed in any version checked, including 12.3 (latest stable at
        time of check).
       
      ------------------------------------------------------------------
      What actually ends up on disk
       
        Observed on 10.6: a torn/garbled transaction, immediately followed
        by a gap of bytes that were never actually written, then a
        different, later transaction's data landing right on top - with
        nothing anywhere flagging that any of it happened. Failure 2
        explains why writing continues past the tear; the hole's exact
        size isn't explained yet (see below). This is the part that matters
        for anyone relying on the binlog as a source of truth, especially
        if it's being fed to downstream replicas.
       
        Reproduced in 10.6 (binlog.000002, ENOSPC during a 10,109-row
        DELETE, 3 concurrent writers, zero crashes/restarts):
          32735543            Table_map ends cleanly
          32735543-32739328   ~3.8KB garbled (CRC fail; includes leaked
                               payload - literal repeated 'x' from this
                               session's own VARCHAR, in event-framing
                               position)
          32739328-77895049   45,155,721 zero bytes, confirmed unwritten
          77895049+           GTID 14621, different transaction, fully valid
        Cross-validated: our scanner, mariadb-binlog -c, replica checksum
        failure - all agree on tear offset.
       
        Blocking open question: my_write() (mysys/my_write.c) is documented
        to block and retry under MY_WAIT_IF_FULL for plain ENOSPC, via
        wait_for_free_space() - so a binlog write hitting disk-full should
        normally just stall, not fail. What actually made write_header/
        write_data return failure here, rather than blocking the way
        my_write() does, hasn't been isolated - and it's likely the missing
        piece that would explain why the gap is exactly 45,155,721 bytes
        rather than some smaller torn region. Failure 1's own bookkeeping
        (`remains -= len`) is local event-framing logic that doesn't feed
        the file offset, so it isn't what walks the position forward by
        45MB either. Doesn't change the finding: the file ends up with a
        real hole followed by someone else's data either way.
       
        Also unconfirmed: whether GTID 14621 (the transaction right after
        the gap) was the next session already queued in the same group-
        commit round, or the same session committing again in a later
        round - both leave an identical trace, since nothing marks the file
        as damaged either way. Doesn't matter for the finding: the loop not
        stopping on a failed write is what allows it, regardless of whose
        transaction lands there.
       
      ------------------------------------------------------------------
      Version matrix
       
        Ver   Failure 1 (error discarded)   Failure 2 (loop doesn't stop)
        ----  -----------------------------  -----------------------------
        10.6  present                        present
        11.8  fixed                          present, unchanged
        12.3  fixed, same as 11.8            present, unchanged
                                              (verified sql/log.cc:10848)
       
        10.6's status is from reproducing it; 11.8's and 12.3's are from
        reading the pinned source only, not reproduced there.
       
        Headline, and the reason for this report: this is not a 10.6-only,
        already-fixed bug that upgrading makes moot. Failure 1 (the error
        getting thrown away) is fixed in 11.8+. Failure 2 - the part that
        actually lets a torn transaction be silently followed by someone
        else's data in the same file - is present, unchanged, in every
        version checked, current stable (12.3) included. A 10.6 user with
        replication who upgrades to 11.8+ gets correct failure detection
        back, but the file can still end up silently corrupted the same way
        once a write fails.
       
        From a DBA/DBRE's perspective: a replica picks up the corrupted
        event from the master and can't process it. The master's own binlog
        was never written properly, so there's nothing to recover from -
        the only way to fix the replicas is to reclone them all from the
        master. That can be painful on a busy cluster.
       
      ------------------------------------------------------------------
      Related: InnoDB tablespace growth doesn't retry ENOSPC at all
       
        storage/innobase/os/os0file.cc, os_file_set_size(), 10.6:1531-1589
       
          do {
            ...
            if (!fallocate(file, 0, current_size, size - current_size)) {
              err = 0; break;
            }
            err = errno;
          } while (err == EINTR && srv_shutdown_state <= SRV_SHUTDOWN_INITIATED);
          switch (err) {
          case 0: return true;
          default: sql_print_error(...); /* fall through */
          case EINTR: errno = err; return false;   // ENOSPC lands here
          }
       
        Retry condition is EINTR only - ENOSPC isn't retried, fails on
        first attempt, propagates as ERROR 1114 (table full) immediately.
        Reproduced: 3 sessions got the error instantly, no wait,
        even though freeing disk space a moment later would let the same
        INSERT succeed.
       
        Asymmetry: my_write()/wait_for_free_space() (the binlog write path)
        blocks transparently on ENOSPC and completes once space returns -
        client sees a slow commit, nothing else. InnoDB's os_file_set_size()
        hits the identical OS condition on the same disk and fails hard
        immediately. No principled reason for the difference (fd stays open
        and valid in both cases).
       
        Unchanged in 11.8 and 12.3 (identical EINTR-only retry condition).
        12.3 adds a DBUG_EXECUTE_IF("ib_alloc_file_disk_full", ...) fault-
        injection hook just before the loop (mirrors binlog side's
        fail_binlog_write_1/inject_error_writing_xid) but retry logic itself
        is untouched.
       
        Still open: how does Aria handle the same condition? It's the
        other major engine actually in play here (aria_log.0000000N present
        in every datadir seen), with its own independent file-growth and
        logging code, not examined at all in this investigation - may share
        this same fail-hard-no-retry gap, may not.
       
      ------------------------------------------------------------------
      What seems to be required to fix this
       
        Minimum safe: stop on a failed write. Don't advance the position
        tracker past unwritten bytes; don't let the group-commit loop write
        the next session's txn into the file until the failure is handled.
        Turns silent corruption into a loud, recoverable error - but the
        failed txn is lost and every other queued session in that round has
        to be told their commit failed too, even though their own write was
        fine.  In reality that's not practical.
       
        Ideal: don't just detect and abort, finish the write. my_write()
        (mysys/my_write.c) already retries a partial write correctly,
        resuming at the exact byte offset, blocking via
        wait_for_free_space() until space returns. That discipline isn't
        carried up to where it matters: pre-11.8, CacheWriter ignores the
        failure outright (Failure 1); post-fix, write_cache() detects it
        correctly but treats it as terminal rather than resumable. Either
        way, and in the group-commit loop too, nothing waits or retries
        like my_write() does. Correct fix:
        hold the transaction (and the group-commit batch behind it) exactly
        where it is, wait for space the same way my_write() does, and
        finish writing the rest of that same event before any subsequent
        transaction is allowed to write into the file.
      

      Attachments

        Activity

          People

            bnestere Brandon Nesterenko
            sjmudd Simon Mudd
            Votes:
            1 Vote for this issue
            Watchers:
            6 Start watching this issue

            Dates

              Created:
              Updated:

              Time Tracking

                Estimated:
                Original Estimate - Not Specified
                Not Specified
                Remaining:
                Remaining Estimate - 0d
                0d
                Logged:
                Time Spent - 0.75d
                0.75d

                Git Integration

                  Error rendering 'com.xiplink.jira.git.jira_git_plugin:git-issue-webpanel'. Please contact your Jira administrators.