windmill-labs/windmill · error · ConversionError::Io (InvalidInput)

unknown tuple byte `{}`

Error message

unknown tuple byte `{}`

What it means

While parsing a logical-replication UPDATE ('U') message, the first tuple-type byte after the relation OID must be 'N' (new tuple), 'O' (old tuple, REPLICA IDENTITY FULL) or 'K' (key tuple). This error fires when that byte is none of those, so the UPDATE payload cannot be decoded under the pgoutput protocol rules. Like its siblings it is an InvalidInput io::Error wrapped in ConversionError and aborts decoding of the current message.

Source

Thrown at backend/windmill-trigger-postgres/src/replication_message.rs:413

                    TUPLE_NEW_BYTE => TupleData::parse(&mut buf)?,
                    TUPLE_OLD_BYTE | TUPLE_KEY_BYTE => {
                        if byte == TUPLE_OLD_BYTE {
                            old_tuple = Some(TupleData::parse(&mut buf)?);
                        } else {
                            key_tuple = Some(TupleData::parse(&mut buf)?);
                        }
                        match buf.read_u8()? {
                            TUPLE_NEW_BYTE => TupleData::parse(&mut buf)?,
                            byte => {
                                return Err(ConversionError::Io(io::Error::new(
                                    io::ErrorKind::InvalidInput,
                                    format!("unexpected tuple byte `{}`", byte),
                                )));
                            }
                        }
                    }
                    byte => {
                        return Err(ConversionError::Io(io::Error::new(
                            io::ErrorKind::InvalidInput,
                            format!("unknown tuple byte `{}`", byte),
                        )));
                    }
                };

                LogicalReplicationMessage::Update(UpdateBody::new(
                    transaction_id,
                    o_id,
                    old_tuple,
                    key_tuple,
                    new_tuple,
                ))
            }
            DELETE_BYTE => {
                let transaction_id = match logical_replication_settings.streaming {
                    true => Some(buf.read_i32::<BigEndian>()?),
                    false => None,

View on GitHub (pinned to e474e8803c)

Solutions

  1. Dump the raw frame around the failure and check the tuple marker byte: pgoutput UPDATE must carry 'O'/'K' (optional, per replica identity) followed by 'N'.
  2. Recreate the replication slot to resume from a clean WAL position if the stream is suspected corrupted (pg_drop_replication_slot, then create a new logical slot with pgoutput).
  3. Verify the source is real PostgreSQL with the pgoutput plugin, not another decoder or an intermediary that reformats messages.
  4. Fix test fixtures / fuzz inputs so the tuple byte is one of 'N', 'O', 'K'.
  5. If you need support for additional tuple markers, extend the match in replication_message.rs — but confirm the byte is actually valid pgoutput first.

Example fix

// hand-built UPDATE payload
let mut buf = [b'U', 0, 0, 0, 42, b'Q', /* tuple data */]; // before: 'Q' is invalid
let mut buf = [b'U', 0, 0, 0, 42, b'K', /* key tuple */ b'N', /* new tuple */]; // after
Defensive patterns

Strategy: try-catch

Validate before calling

// check UPDATE frame's tuple markers before parsing
fn update_marker_ok(payload: &[u8], replica_identity_full: bool) -> bool {
    payload.len() >= 6
        && payload[0] == b'U'
        && matches!(payload[5], b'N' | b'K')
        && (!replica_identity_full || payload[5] != b'N' || true) // 'O' allowed for FULL
}

Type guard

fn is_conversion_error(err: &std::io::Error) -> bool {
    err.kind() == std::io::ErrorKind::InvalidInput
}

Try / catch

match LogicalReplicationMessage::parse(buf, settings) {
    Ok(LogicalReplicationMessage::Update(u)) => handle_update(u),
    Ok(msg) => handle(msg),
    Err(e) if e.to_string().contains("unknown tuple byte") => {
        log::warn!("corrupt UPDATE frame, skipping: {e}");
    }
    Err(e) => return Err(e.into()),
}

Prevention

When it happens

Trigger: Calling LogicalReplicationMessage::parse on an XLogData payload whose first byte is UPDATE_BYTE ('U') and whose tuple-type byte after the relation OID is not 'N', 'O', or 'K' (e.g. 'D', 0x00, garbage).

Common situations: Corrupted/truncated WAL frames misaligning the reader; decoding a stream produced by a different or patched logical-decoding plugin; testing parse() with hand-built byte buffers where the marker byte was typoed; upstream PostgreSQL version protocol changes not accounted for in a hand-rolled client.

Related errors


AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03). Data as JSON: /api/errors/8c262ec649e24927. Report an issue: GitHub.