unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
error-handlingmemory-managementtesting-strategy

server: limit accumulated COM_STMT_SEND_LONG_DATA size (release-7.5-20260724-v7.5.7) (#69694)

Project: pingcap/tidb | Language: Go | Concepts: error-handling, memory-management, testing-strategy | Difficulty: mid
Link: https://github.com/pingcap/tidb/pull/70172

The problem

Prepared statements in TiDB could accumulate a large amount of parameter data using repeated COM_STMT_SEND_LONG_DATA commands, potentially exceeding the configured max_allowed_packet limit. This could allow unbounded memory usage or cause the server to behave incorrectly when executing statements with oversized parameters.

Why it's non-trivial

A naive fix might check the size only when data is appended, or only at execution, but not both. MySQL's behavior is to defer the error until execution, not during data accumulation. Additionally, after an oversized payload is detected, the server must not retain the problematic data and must reset its internal tracking to avoid future errors or memory leaks.

The approach

1. Add a size check interface to prepared statements

A new method is added to the PreparedStatement interface so that any implementation can check if the accumulated parameter data is too large:

+	// CheckLongDataSize checks whether the statement has accumulated too much data through COM_STMT_SEND_LONG_DATA.
+	CheckLongDataSize() error

This ensures all statement implementations can enforce the size limit.

2. Track and enforce the data size in the statement implementation

In TiDBStatement, a new boolean flag is introduced to record if any parameter has exceeded the allowed size. The AppendParam method is updated to check the size before appending more data:

+		if uint64(len(ts.boundParams[paramID]))+uint64(len(data)) > ts.ctx.GetSessionVars().MaxAllowedPacket {
+			// MySQL reports the packet-too-large error on the following EXECUTE, not on SEND_LONG_DATA.
+			// Stop appending more bytes once the limit is exceeded so the statement cannot grow unboundedly.
+			ts.boundParamsTooLarge = true
+			return nil
+		}

This prevents further growth of the parameter once the limit is reached, but does not immediately error out (matching MySQL behavior).

The actual check is performed in the new CheckLongDataSize method:

func (ts *TiDBStatement) CheckLongDataSize() error {
	if ts.boundParamsTooLarge {
		return servererr.ErrNetPacketTooLarge
	}
	maxAllowedPacket := ts.ctx.GetSessionVars().MaxAllowedPacket
	for _, boundParam := range ts.boundParams {
		if uint64(len(boundParam)) > maxAllowedPacket {
			return servererr.ErrNetPacketTooLarge
		}
	}
	return nil
}

This method returns an error if any parameter is too large, either because the flag was set or because the data is oversized.

3. Reset the state after execution

The Reset method is updated to clear both the parameter data and the oversized flag:

+	ts.boundParamsTooLarge = false

This ensures that after an oversized statement is handled, the state is clean for future use.

4. Enforce the check at statement execution

The server now checks the long data size before executing the statement:

-		err = parse.ExecArgs(cc.ctx.GetSessionVars().StmtCtx, args, stmt.BoundParams(), nullBitmaps, stmt.GetParamsType(), paramValues, cc.inputDecoder)
+		err = stmt.CheckLongDataSize()
+		if err == nil {
+			err = parse.ExecArgs(cc.ctx.GetSessionVars().StmtCtx, args, stmt.BoundParams(), nullBitmaps, stmt.GetParamsType(), paramValues, cc.inputDecoder)
+		}

This ensures that an error is returned to the client if the accumulated data is too large, matching MySQL's protocol.

5. Add targeted tests

A new test covers the entire workflow: sending long data up to the limit, exceeding the limit, checking for the correct error, and verifying that parameters are cleared after an error:

func TestStmtSendLongDataMaxAllowedPacket(t *testing.T) {
    ...
    c.Context().GetSessionVars().MaxAllowedPacket = 1024
    require.NoError(t, dispatchSendLongData(c, stmt.ID(), 0, bytes.Repeat([]byte{'a'}, 1024)))
    require.NoError(t, dispatchSendLongData(c, stmt.ID(), 1, bytes.Repeat([]byte{'b'}, 1024)))
    require.NoError(t, stmt.CheckLongDataSize())

    err = dispatchSendLongData(c, stmt.ID(), 0, []byte{'c'})
    require.NoError(t, err)
    require.Len(t, stmt.BoundParams()[0], 1024)
    require.Len(t, stmt.BoundParams()[1], 1024)

    err = c.Dispatch(context.Background(), append(
        binary.LittleEndian.AppendUint32([]byte{mysql.ComStmtExecute}, uint32(stmt.ID())),
        0x0, 0x1, 0x0, 0x0, 0x0,
        0x0, 0x0,
    ))
    require.ErrorIs(t, err, servererr.ErrNetPacketTooLarge)
    require.Nil(t, stmt.BoundParams()[0])
    require.Nil(t, stmt.BoundParams()[1])
    require.NoError(t, stmt.CheckLongDataSize())
}

This test ensures both correct error handling and proper cleanup.

From the review thread

No notable review discussion on this PR.

Takeaways

  • Enforce resource limits at the protocol boundary and at execution time, not just during data accumulation.
  • Defer error reporting to the correct protocol phase to match client/server expectations and compatibility (e.g., MySQL defers the error until execution).
  • Always reset internal state and clear sensitive data after error conditions to avoid memory leaks and inconsistent state.