MySQL

Replication Internals: Decoding the MySQL Binary Log Part 10: ROTATE_EVENT — Closing the File and Pointing at the Next One

8 min read

7 days ago

Replication Internals: Decoding the MySQL Binary Log Part 10: ROTATE_EVENT — Closing the File and Pointing at the Next One

In this tenth post of our series, we decode the ROTATE_EVENT — the event that closes a binary log file and tells every reader, replica or local, where to look next.


Introduction

The ROTATE_EVENT (event type 4, 0x04) is the bookmark MySQL leaves at the end of a binary log file. It carries two pieces of information:

  • The name of the binary log file to read next.
  • The starting position within that next file (always 4, the byte right after the 4-byte magic number from Part 2).

Together those two fields are enough for any reader — a replica's I/O thread, a mysqlbinlog invocation, a CDC tool — to seamlessly continue from one file into the next. ROTATE_EVENT also has a second life on the wire: when a replica connects, the source sends an artificial ROTATE_EVENT first to tell the replica which file the upcoming events belong to. We'll see how to tell the two apart.

For our binlog.000024, the ROTATE_EVENT is the last event in the file — every byte before it has now been decoded across Parts 2–9.


Event Location

Position 1397: XID_EVENT (31 bytes) ← Last DML transaction commits Position 1428: ROTATE_EVENT (44 bytes) ← End of file → binlog.000025 Position 1472: (end of file)

Reading the Raw Bytes

$ xxd -s 1428 -l 44 binlog.000024 00000594: 3910 3568 0401 0000 002c 0000 00c0 0500 9.5h.....,...... 000005a4: 0000 0004 0000 0000 0000 0062 696e 6c6f ...........binlo 000005b4: 672e 3030 3030 3235 de7e 7110 g.000025.~q.

The event is 44 bytes: 19-byte common header + 8-byte post-header + 13-byte body (the filename) + 4-byte checksum.


Common Header (19 bytes)

39103568 04 01000000 2c000000 c0050000 0000 │ │ │ │ │ │ │ │ │ │ │ └─→ Flags: 0x0000 │ │ │ │ └───────────→ Next Position: 1472 │ │ │ └────────────────────→ Event Size: 44 bytes │ │ └─────────────────────────────→ Server ID: 1 │ └────────────────────────────────→ Event Type: 4 (ROTATE_EVENT) └─────────────────────────────────────────→ Timestamp: 1748308025
FieldBytesLittle-EndianValue
Timestamp391035680x683510391748308025 (2025-05-27 01:07:05)
Event Type040x044 (ROTATE_EVENT)
Server ID010000000x000000011
Event Size2c0000000x0000002c44 bytes
Next Positionc00500000x000005c01472
Flags00000x0000No flags

Cross-check: 1428 + 44 = 1472, and the file is exactly 1472 bytes long. ✓

The flags field is 0x0000. In particular, LOG_EVENT_ARTIFICIAL_F (bit 0x0020, defined in sql/log_event.h:277) is not set — this is a real on-disk ROTATE_EVENT, written by the server when it closed binlog.000024. We'll come back to artificial ROTATE_EVENTs in a moment.


ROTATE_EVENT Structure

The layout is documented in the `Rotate_event` class:

FieldSizeDescription
Post-Header (8 bytes)ROTATE_HEADER_LEN
pos8 bytesByte position at which the first event begins in the next file (little-endian uint64)
Body
new_log_identVariableName of the next binary log file — no null terminator, length runs to the end of the event

The body is intentionally not null-terminated. The reader computes its length from the bytes still available after the post-header, minus the trailing 4-byte checksum. From Rotate_event::Rotate_event() in libs/mysql/binlog/event/control_events.cpp:

if (post_header_len) { READER_ASSERT_POSITION(header_size + R_POS_OFFSET); READER_TRY_SET(pos, read<uint64_t>); ... } else pos = 4; ident_len = READER_CALL(available_to_read); if (ident_len == 0) READER_THROW("Event is smaller than expected"); if (ident_len > FN_REFLEN - 1) ident_len = FN_REFLEN - 1; READER_TRY_SET(new_log_ident, strndup<const char *>, ident_len);

Two things worth noting:

  • The fallback pos = 4 exists for legacy binary logs from MySQL versions where ROTATE_EVENT had no post-header. In binlog format version 4 (every modern MySQL), the post-header is always 8 bytes and pos is read from it. The default value 4 is also what we expect to see: it's LOG_EVENT_OFFSET, the byte right after the 4-byte magic number where the first event of any binary log lives.
  • The filename is capped at FN_REFLEN - 1 (511) bytes, but in practice it's tens of bytes — basenames like binlog.000025.

The write side (Rotate_log_event::write() in sql/log_event.cc) mirrors the layout exactly:

bool Rotate_log_event::write(Basic_ostream *ostream) { char buf[Binary_log_event::ROTATE_HEADER_LEN]; int8store(buf + R_POS_OFFSET, pos); return ( write_header(ostream, Binary_log_event::ROTATE_HEADER_LEN + ident_len) || wrapper_my_b_safe_write(ostream, (uchar *)buf, Binary_log_event::ROTATE_HEADER_LEN) || wrapper_my_b_safe_write(ostream, pointer_cast<const uchar *>(new_log_ident), (uint)ident_len) || write_footer(ostream)); }

int8store is MySQL's 8-byte little-endian integer store (the same primitive we've been seeing since Part 1), then the filename is appended as raw bytes.


Field-by-Field Decoding

Post-Header — pos0400000000000000

04 00 00 00 00 00 00 00 → little-endian uint64 → 0x0000000000000004 = 4

The first event in the next binary log starts at byte 4, immediately after the 4-byte magic number. As we saw above, this is LOG_EVENT_OFFSET and it's the value used for every "normal" rotation. (This field exists at all because the same ROTATE_EVENT structure is used when a replica's I/O thread has been told to start reading from a non-default offset — but that's a special case the server-side rotation never produces.)

Body — new_log_ident: 13 bytes

62 69 6e 6c 6f 67 2e 30 30 30 30 32 35 b i n l o g . 0 0 0 0 2 5

The next binary log is binlog.000025. The string runs from the start of the body to the byte before the 4-byte checksum — there is no null terminator and no length prefix.

Checksum: de7e7110

de 7e 71 10 → CRC32 of the entire event

Visual Breakdown

Position 1428: ROTATE_EVENT (44 bytes) ┌─────────────────────────────────────────────────────────────────────────┐ │ COMMON HEADER (19 bytes) │ ├─────────────────────────────────────────────────────────────────────────┤ │ 39103568 │ 04 │ 01000000 │ 2c000000 │ c0050000 │ 0000 │ │ Timestamp │ Type │ ServerID │ Size │ NextPos │ Flags │ │ 1748308025 │ 4 │ 1 │ 44 │ 1472 │ 0x0000 │ └─────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────┐ │ POST-HEADER (8 bytes) │ ├──────────────────────────────┬──────────────────────────────────────────┤ │ 0400000000000000 │ pos: 4 (start of first event in next file)│ └──────────────────────────────┴──────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────┐ │ BODY (13 bytes) │ ├──────────────────────────────┬──────────────────────────────────────────┤ │ 62696e6c6f672e303030303235 │ new_log_ident: "binlog.000025" │ │ │ (no null terminator; length runs to CRC) │ └──────────────────────────────┴──────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────┐ │ CHECKSUM (4 bytes) │ ├──────────────────────────────┬──────────────────────────────────────────┤ │ de7e7110 │ CRC32 │ └──────────────────────────────┴──────────────────────────────────────────┘ mysqlbinlog output: Rotate to binlog.000025 pos: 4

When Is a ROTATE_EVENT Written?

The on-disk ROTATE_EVENT we just decoded is written by MYSQL_BIN_LOG::new_file_impl() — the function the server calls whenever it has decided to close the current binary log file and start a new one. There are several triggers that flow into that single code path:

TriggerDescription
max_binlog_size reachedThe current file grew past the configured limit (default 1 GB); the server rotates as soon as the next transaction tries to append.
FLUSH BINARY LOGS / FLUSH LOGSA user (or a backup tool) explicitly requested a rotation.
Server startupEach restart begins a new binary log file, so the previous file gets a closing ROTATE_EVENT pointing at the new one.
RESET BINARY LOGS AND GTIDSWipes existing logs and starts fresh; the residual rotate marker preserves the file linkage during the reset.

All four paths produce the same on-disk shape: post-header pos = 4, body = the next file's basename, no special flags. The construction at the call site looks like this:

Rotate_log_event r(new_name + dirname_length(new_name), 0, LOG_EVENT_OFFSET, is_relay_log ? Rotate_log_event::RELAY_LOG : 0);

LOG_EVENT_OFFSET is 4, and dirname_length(new_name) strips the directory so only the basename ends up in the body. The RELAY_LOG flag is a runtime-only bit on the in-memory class (used to tag relay-log rotations on the replica side); it is not written into the on-disk flags field, which is why our event shows 0x0000 even though it lives in a binlog (rather than relay log).


Two Kinds of ROTATE_EVENT

ROTATE_EVENT also shows up on the network protocol between source and replica, in two different forms. Telling them apart is mostly a matter of looking at the common header.

Real (on-disk) ROTATE_EVENT — the one we just decoded:

  • Written by the source as the last event of a binary log file when it closes that file.
  • Has a real timestamp (the rotation time).
  • Flags = 0x0000 (no LOG_EVENT_ARTIFICIAL_F).
  • The next_position field in the common header points past the end of the current file.
  • Persisted to disk; it stays in the file forever.

Artificial ROTATE_EVENT — the very first event a replica's I/O thread receives when it (re)connects to a source. It exists so the replica knows which file the upcoming events belong to, before it has seen any FORMAT_DESCRIPTION_EVENT for that file:

  • Generated on the fly in memory by the source's dump thread; never written to disk.
  • Timestamp = 0 (epoch) — this is a tell-tale sign you're looking at an artificial event.
  • Flag LOG_EVENT_ARTIFICIAL_F (0x0020) is set in the common header.
  • The body still carries a filename and a position, but they describe where the dump thread will start streaming from, not a file boundary.

Both shapes parse with exactly the same code; only the surrounding context (and those two flag/timestamp tells) distinguishes them. The class doc on Rotate_event calls this out:

ROTATE_EVENT is generated locally and written to the binary log on the master. It is written to the relay log on the slave when FLUSH LOGS occurs, and when receiving a ROTATE_EVENT from the master. In the latter case, there will be two rotate events in total originating on different servers.

Try It Yourself

import struct from datetime import datetime, timezone ROTATE_EVENT = 4 LOG_EVENT_ARTIFICIAL_F = 0x0020 with open('binlog.000024', 'rb') as f: f.seek(1428) header = f.read(19) timestamp, event_type, server_id, event_size, next_pos, flags = \ struct.unpack('<IBIIIH', header) assert event_type == ROTATE_EVENT, f"Not a ROTATE_EVENT (got {event_type})" # Post-header: 8-byte little-endian position pos = struct.unpack('<Q', f.read(8))[0] # Body: filename runs up to the 4-byte CRC at the end of the event body_len = event_size - 19 - 8 - 4 new_log_ident = f.read(body_len).decode('ascii') crc = f.read(4) ts = datetime.fromtimestamp(timestamp, tz=timezone.utc) if timestamp \ else "(epoch — likely artificial)" print(f"ROTATE_EVENT at offset 1428 ({event_size} bytes)") print(f" Timestamp: {timestamp} ({ts})") print(f" Server ID: {server_id}") print(f" Next position: {next_pos}") print(f" Flags: 0x{flags:04x}" f"{' [ARTIFICIAL]' if flags & LOG_EVENT_ARTIFICIAL_F else ''}") print(f" pos: {pos}") print(f" new_log_ident: {new_log_ident!r}") print(f" CRC32: {crc.hex()}") print(f" → Rotate to {new_log_ident} pos: {pos}")

Output:

ROTATE_EVENT at offset 1428 (44 bytes) Timestamp: 1748308025 (2025-05-27 01:07:05+00:00) Server ID: 1 Next position: 1472 Flags: 0x0000 pos: 4 new_log_ident: 'binlog.000025' CRC32: de7e7110 → Rotate to binlog.000025 pos: 4

Cross-check against mysqlbinlog:

$ mysqlbinlog --no-defaults binlog.000024 | tail -5 # at 1428 #250527 1:07:05 server id 1 end_log_pos 1472 CRC32 0x1071 7ede Rotate to binlog.000025 pos: 4
Note: The binary log files used in this series (binlog.000024binlog_gtid_tag.000001, and others) are available at github.com/altmannmarcelo/presentations/tree/main/binlog.

References


What's Next?

That's the last event in binlog.000024. Across nine posts (Parts 2 through 10), we've now manually decoded every byte of a real MySQL binary log — from the magic number to the closing ROTATE_EVENT — and seen how each event maps onto MySQL's source code in mysql-9.6.0.

But there's one event we deliberately deferred all the way back in Part 5: the GTID_TAGGED_LOG_EVENT introduced in MySQL 8.4. Tagged GTIDs use a different on-disk encoding from the classic untagged GTID we decoded, and they're worth a post of their own. That's the subject of Part 11.


Next up: Part 11: GTID_TAGGED_LOG_EVENT — Tagged GTIDs in MySQL 8.4+


This series is based on a presentation given at the MySQL Online Summit. The goal is to help MySQL users understand what goes under the hood of replication by manually decoding binary log files.