apache/kafka · error · IllegalArgumentException
Invalid control record type for end transaction marker {}
Error message
Invalid control record type for end transaction marker {} What it means
Thrown by EndTransactionMarker.ensureTransactionMarkerControlType() (also called from deserializeValue) when the control record type parsed from a transaction marker record is neither COMMIT nor ABORT. EndTransactionMarker is specifically the record value that terminates a transaction, so only those two control types are legal; any other (or UNKNOWN) indicates the record is not actually an end-txn marker or the key was corrupted/mis-parsed. It is an IllegalArgumentException because the input violates the method's precondition rather than indicating I/O failure.
Source
Thrown at clients/src/main/java/org/apache/kafka/common/record/internal/EndTransactionMarker.java:79
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
EndTransactionMarker that = (EndTransactionMarker) o;
return coordinatorEpoch == that.coordinatorEpoch && type == that.type;
}
@Override
public int hashCode() {
int result = type != null ? type.hashCode() : 0;
result = 31 * result + coordinatorEpoch;
return result;
}
private static void ensureTransactionMarkerControlType(ControlRecordType type) {
if (type != ControlRecordType.COMMIT && type != ControlRecordType.ABORT)
throw new IllegalArgumentException("Invalid control record type for end transaction marker " + type);
}
public static EndTransactionMarker deserialize(Record record) {
ControlRecordType type = ControlRecordType.parse(record.key());
return deserializeValue(type, record.value());
}
// Visible for testing
static EndTransactionMarker deserializeValue(ControlRecordType type, ByteBuffer value) {
ensureTransactionMarkerControlType(type);
short version = value.getShort();
if (version < EndTxnMarker.LOWEST_SUPPORTED_VERSION)
throw new InvalidRecordException("Invalid version found for end transaction marker: " + version +
". May indicate data corruption");
if (version > EndTxnMarker.HIGHEST_SUPPORTED_VERSION) {
log.debug("Received end transaction marker value version {}. Parsing as version {}", version,View on GitHub (pinned to c31c9215e1)
Solutions
- Inspect the control record's key with kafka-dump-log.sh --deep-iteration to confirm the ControlRecordType; verify the key length and version byte are the expected 4 bytes.
- If the record is a non-txn control type being routed through end-txn decoding, fix the caller to only call EndTransactionMarker.deserialize on batches whose type is COMMIT/ABORT.
- If caused by corruption, recover from ISR or truncate the segment.
- If caused by version skew, upgrade the broker/client to a version that understands the control record types in use.
Example fix
// before: blindly deserializing every control record as end-txn
for (RecordBatch b : records) {
if (b.isControlBatch())
EndTransactionMarker.deserialize(b.iterator().next());
}
// after: check the control type before decoding
for (RecordBatch b : records) {
if (!b.isControlBatch()) continue;
Record r = b.iterator().next();
ControlRecordType t = ControlRecordType.parse(r.key());
if (t == ControlRecordType.COMMIT || t == ControlRecordType.ABORT)
EndTransactionMarker.deserialize(r);
} Defensive patterns
Strategy: validation
Validate before calling
// Validate BEFORE constructing EndTransactionMarker:
import org.apache.kafka.common.record.ControlRecordType;
ControlRecordType type = /* resolved from control record key */;
if (type != ControlRecordType.COMMIT && type != ControlRecordType.ABORT) {
throw new IllegalArgumentException(
"EndTransactionMarker requires COMMIT or ABORT, got: " + type);
}
return new EndTransactionMarker(type, coordinatorEpoch); Type guard
// Narrow ControlRecordType to the only two legal end-txn marker kinds.
static boolean isEndTxnMarkerType(org.apache.kafka.common.record.ControlRecordType t) {
return t == org.apache.kafka.common.record.ControlRecordType.COMMIT
|| t == org.apache.kafka.common.record.ControlRecordType.ABORT;
} Prevention
- Only COMMIT and ABORT are legal end-transaction marker types — ABORT marker (and COMMIT) are the entire valid set; filter at the boundary.
- When parsing control records, skip/filter unknown ControlRecordType values rather than passing them to EndTransactionMarker construction.
- Validate ControlRecordType.parse(key) output before use; a corrupt key can deserialize to an unexpected enum.
- Keep this check at the ingestion boundary (one place) so downstream code can assume the type is already constrained.
- Unit-test the boundary guard against every ControlRecordType enum value to lock in the COMMIT/ABORT allow-list.
When it happens
Trigger: Calling EndTransactionMarker.deserialize(record) or constructing new EndTransactionMarker(type, epoch) with a ControlRecordType other than COMMIT/ABORT. Concretely: ControlRecordType.parse(record.key()) returns ABORT_MARKER/COMMIT_MARKER for valid data, but returns UNKNOWN or another type for an unexpected key, then deserializeValue calls ensureTransactionMarkerControlType and throws.
Common situations: Reading a control record that is actually a different control type (e.g. a Raft leader-change or snapshot records interpreted as end-txn), a transactional producer writing a malformed control record key, log corruption that scrambled the control record key bytes, or a client/broker version skew where new control record types exist that this older build does not recognize as transactional. Can also arise from incorrect manual decoding of control batches.
Related errors
- Invalid version found for end transaction marker: {}. May in
- Invalid producer ID and epoch values: {producerId}:{epoch}.
- Invalid serialized transaction state format: {serializedStat
- Must set retries to non-zero when using the idempotent produ
- Cannot set a transactional.id without also enabling idempote
AI-assisted analysis of apache/kafka@c31c9215e1 (2026-08-03).
Data as JSON: /data/errors/982a58672a930bf3.json.
Report an issue: GitHub.