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

unexpected tuple byte `{}`

Error message

unexpected tuple byte `{}`

What it means

In a logical replication Update/Delete message, each tuple is prefixed by a byte indicating its kind ('N' for new, etc.). The parser only accepts the known tuple bytes and raises InvalidInput 'unexpected tuple byte `{}`' otherwise. Like the other replication byte errors, it usually signals a malformed or misaligned message stream.

Source

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

            TYPE_BYTE => {
                buf.read_u32::<BigEndian>()?;
                buf.read_cstr()?;
                buf.read_cstr()?;

                LogicalReplicationMessage::Type
            }
            INSERT_BYTE => {
                let transaction_id = match logical_replication_settings.streaming {
                    true => Some(buf.read_i32::<BigEndian>()?),
                    false => None,
                };
                let o_id = buf.read_u32::<BigEndian>()?;
                let byte = buf.read_u8()?;

                let tuple = match byte {
                    TUPLE_NEW_BYTE => TupleData::parse(&mut buf)?,
                    byte => {
                        return Err(ConversionError::Io(io::Error::new(
                            io::ErrorKind::InvalidInput,
                            format!("unexpected tuple byte `{}`", byte),
                        )));
                    }
                };

                LogicalReplicationMessage::Insert(InsertBody::new(transaction_id, o_id, tuple))
            }
            UPDATE_BYTE => {
                let transaction_id = match logical_replication_settings.streaming {
                    true => Some(buf.read_i32::<BigEndian>()?),
                    false => None,
                };
                let o_id = buf.read_u32::<BigEndian>()?;
                let byte = buf.read_u8()?;
                let mut key_tuple = None;
                let mut old_tuple = None;

View on GitHub (pinned to e474e8803c)

Solutions

  1. Restart replication / recreate the slot so parsing begins from a clean boundary.
  2. Identify the offending byte to check whether it's a valid-but-unsupported tuple kind and extend the parser if so.
  3. Confirm the pgoutput protocol options (proto version, publication) match the parser's expectations.
  4. Check for an earlier desynchronizing parse error in the logs and fix that first.
Defensive patterns

Strategy: try-catch

Try / catch

match parse(msg_bytes) {
    Err(ConversionError::Io(e)) if e.to_string().contains("unexpected tuple byte") => {
        tracing::warn!("unexpected tuple kind in update/delete message: {e}; resyncing slot");
    }
    Ok(msg) => handle(msg),
    Err(e) => return Err(e.into()),
}

Prevention

When it happens

Trigger: Parsing an Update/Delete replication message where the byte after the tuple's relation OID/column count is not the expected tuple marker (e.g. TUPLE_NEW_BYTE) — from stream misalignment, protocol mismatch, or corruption.

Common situations: Continuing to parse after an earlier partial/failed message; a Postgres version emitting tuple kinds the parser doesn't handle; corrupted replication connections.

Related errors


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