From 12ab7b59c0b9627f9a3775090a194f22e4fa943a Mon Sep 17 00:00:00 2001 From: Dmitrii Tunikov Date: Fri, 19 Jun 2026 09:55:02 +0200 Subject: [PATCH 1/3] Classify NULL-to-non-Nullable MV CDC errors ClickHouse error code 349 (CANNOT_INSERT_NULL_IN_ORDINARY_COLUMN, "Cannot convert NULL value to non-Nullable type") was surfacing as errorClass=OTHER / errorCode=UNKNOWN because it is not handled in the exception switch and is not wrapped as a ViewError/NormalizationError. PeerDB always creates Nullable destination columns, so a 349 only arises when a user-defined MV/view casts a nullable source column to a non-Nullable type. Classify it as NOTIFY_MV_OR_VIEW so the user is told their MV/view is the problem. Co-Authored-By: Claude Opus 4.8 (1M context) --- flow/alerting/classifier.go | 7 +++++++ flow/alerting/classifier_test.go | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/flow/alerting/classifier.go b/flow/alerting/classifier.go index 9a6463af0..d3e97f4ae 100644 --- a/flow/alerting/classifier.go +++ b/flow/alerting/classifier.go @@ -944,6 +944,13 @@ func GetErrorClass(ctx context.Context, err error) (ErrorClass, ErrorInfo) { if _, ok := errors.AsType[*peerdb_clickhouse.ViewError](err); ok { return ErrorNotifyMVOrView, chErrorInfo } + case chproto.ErrCannotInsertNullInOrdinaryColumn: + // A NULL value from a nullable source column is being inserted into a non-Nullable + // destination column, e.g. "Cannot convert NULL value to non-Nullable type ... + // while pushing to view ...". PeerDB always creates Nullable destination columns, so + // this only happens through a user-defined MV/view that casts the column to a + // non-Nullable type. + return ErrorNotifyMVOrView, chErrorInfo case chproto.ErrMemoryLimitExceeded: return ErrorNotifyOOM, chErrorInfo case chproto.ErrUnknownDatabase, diff --git a/flow/alerting/classifier_test.go b/flow/alerting/classifier_test.go index db9781f25..f1832bc85 100644 --- a/flow/alerting/classifier_test.go +++ b/flow/alerting/classifier_test.go @@ -689,6 +689,23 @@ func TestNonClassifiedNonNormalizeErrorShouldBeOtherWithSourceClickHouse(t *test }, errInfo, "Unexpected error info") } +func TestCannotInsertNullInOrdinaryColumnShouldBeNotifyMV(t *testing.T) { + // A nullable source column cast to a non-Nullable type by a user MV/view, e.g. + // "code: 349, Cannot convert NULL value to non-Nullable type ... while pushing to view ...". + // This is not wrapped as a ViewError/NormalizationError, so it must be classified by code. + err := &clickhouse.Exception{ + Code: int32(chproto.ErrCannotInsertNullInOrdinaryColumn), + //nolint:lll + Message: "Cannot convert NULL value to non-Nullable type: while converting source column street to destination column street: while pushing to view cdc_user_api.stg_cdc_user_api__customer_address_mv (some-uuid-here)", + } + errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("failed to normalize records: %w", err)) + assert.Equal(t, ErrorNotifyMVOrView, errorClass, "Unexpected error class") + assert.Equal(t, ErrorInfo{ + Source: ErrorSourceClickHouse, + Code: strconv.Itoa(int(chproto.ErrCannotInsertNullInOrdinaryColumn)), + }, errInfo, "Unexpected error info") +} + func TestNumericTruncateOrOutOfRangeWarningShouldBeLossyConversion(t *testing.T) { for code, err := range map[string]error{ "NUMERIC_TRUNCATED": exceptions.NewNumericTruncatedError(errors.New("testing numeric truncated warning"), "tableA1", "columnB2"), From 29442c175e6cd90cfdfc3f7b63ab63e5934dbe52 Mon Sep 17 00:00:00 2001 From: Dmitrii Tunikov Date: Fri, 19 Jun 2026 09:55:24 +0200 Subject: [PATCH 2/3] cleanup --- flow/alerting/classifier.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/flow/alerting/classifier.go b/flow/alerting/classifier.go index d3e97f4ae..ec10514aa 100644 --- a/flow/alerting/classifier.go +++ b/flow/alerting/classifier.go @@ -945,11 +945,6 @@ func GetErrorClass(ctx context.Context, err error) (ErrorClass, ErrorInfo) { return ErrorNotifyMVOrView, chErrorInfo } case chproto.ErrCannotInsertNullInOrdinaryColumn: - // A NULL value from a nullable source column is being inserted into a non-Nullable - // destination column, e.g. "Cannot convert NULL value to non-Nullable type ... - // while pushing to view ...". PeerDB always creates Nullable destination columns, so - // this only happens through a user-defined MV/view that casts the column to a - // non-Nullable type. return ErrorNotifyMVOrView, chErrorInfo case chproto.ErrMemoryLimitExceeded: return ErrorNotifyOOM, chErrorInfo From 58310d226a06275b4544062d516e962b96b7ecfa Mon Sep 17 00:00:00 2001 From: Dmitrii Tunikov Date: Fri, 19 Jun 2026 10:07:05 +0200 Subject: [PATCH 3/3] Classify serialized NULL-to-non-Nullable errors by message The mid-CDC failure surfaces at the classifier as a Temporal-serialized string with the underlying *clickhouse.Exception type stripped, so it fails every typed check and falls through to ErrorOther / "UNKNOWN" (matching the reported errorClass=OTHER / errorCode=UNKNOWN). The typed switch case for code 349 added earlier only fires when the cause is still typed, which is not the production path. Match the distinctive message "Cannot convert NULL value to non-Nullable type" on the raw error string and classify it as NOTIFY_MV_OR_VIEW (source clickhouse, code 349). PeerDB always creates Nullable destination columns, so this only arises through a user-defined MV/view casting a nullable source column to a non-Nullable type. Add a test covering the serialized (string-only) production path. Co-Authored-By: Claude Opus 4.8 (1M context) --- flow/alerting/classifier.go | 19 +++++++++++++++++++ flow/alerting/classifier_test.go | 17 ++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/flow/alerting/classifier.go b/flow/alerting/classifier.go index ec10514aa..b161de4fa 100644 --- a/flow/alerting/classifier.go +++ b/flow/alerting/classifier.go @@ -49,6 +49,14 @@ const ( // go-geos library when a LinearRing's points do not close. Used to give a more specific code // once we already know the error came from MySQL geometry parsing. mysqlGeometryLinearRingNotClosedError = "Points of LinearRing do not form a closed linestring" + + // clickHouseCannotConvertNullToNonNullable is the message ClickHouse raises (error code 349, + // CANNOT_INSERT_NULL_IN_ORDINARY_COLUMN) when a NULL value is inserted into a non-Nullable + // column. By the time this surfaces from a CDC failure the underlying *clickhouse.Exception + // type is usually stripped (Temporal serializes it to a plain string), so we match on the + // message. PeerDB always creates Nullable destination columns, so this only happens through a + // user-defined MV/view that casts a nullable source column to a non-Nullable type. + clickHouseCannotConvertNullToNonNullable = "Cannot convert NULL value to non-Nullable type" ) var ( @@ -468,6 +476,17 @@ func GetErrorClass(ctx context.Context, err error) (ErrorClass, ErrorInfo) { } } + // A NULL value being inserted into a non-Nullable ClickHouse column (error code 349). This + // reaches us as a serialized string with the *clickhouse.Exception type stripped, so the typed + // switch below never sees it; match on the message instead. See the constant for why this is + // always an MV/view problem. + if strings.Contains(err.Error(), clickHouseCannotConvertNullToNonNullable) { + return ErrorNotifyMVOrView, ErrorInfo{ + Source: ErrorSourceClickHouse, + Code: strconv.Itoa(int(chproto.ErrCannotInsertNullInOrdinaryColumn)), + } + } + if temporalErr, ok := errors.AsType[*temporal.ApplicationError](err); ok { switch exceptions.ApplicationErrorType(temporalErr.Type()) { case exceptions.ApplicationErrorTypeIrrecoverableInvalidSnapshot: diff --git a/flow/alerting/classifier_test.go b/flow/alerting/classifier_test.go index f1832bc85..1d95ce093 100644 --- a/flow/alerting/classifier_test.go +++ b/flow/alerting/classifier_test.go @@ -692,7 +692,8 @@ func TestNonClassifiedNonNormalizeErrorShouldBeOtherWithSourceClickHouse(t *test func TestCannotInsertNullInOrdinaryColumnShouldBeNotifyMV(t *testing.T) { // A nullable source column cast to a non-Nullable type by a user MV/view, e.g. // "code: 349, Cannot convert NULL value to non-Nullable type ... while pushing to view ...". - // This is not wrapped as a ViewError/NormalizationError, so it must be classified by code. + // The typed *clickhouse.Exception path (e.g. when the cause is still typed at the activity + // level) must be classified by code. err := &clickhouse.Exception{ Code: int32(chproto.ErrCannotInsertNullInOrdinaryColumn), //nolint:lll @@ -706,6 +707,20 @@ func TestCannotInsertNullInOrdinaryColumnShouldBeNotifyMV(t *testing.T) { }, errInfo, "Unexpected error info") } +func TestCannotInsertNullInOrdinaryColumnSerializedShouldBeNotifyMV(t *testing.T) { + // The production case: by the time the error reaches the classifier the *clickhouse.Exception + // type has been stripped (Temporal serialized it to a plain string), so it must be classified + // by message. Without the message match this surfaces as ErrorOther / "UNKNOWN". + //nolint:lll + serialized := errors.New("failed to normalize records: ClickHouse view error: code: 349, message: Cannot convert NULL value to non-Nullable type: while pushing to view cdc_otc_api.stg_cdc_otc_api__otc_otc_trade_mv (some-uuid-here)") + errorClass, errInfo := GetErrorClass(t.Context(), serialized) + assert.Equal(t, ErrorNotifyMVOrView, errorClass, "Unexpected error class") + assert.Equal(t, ErrorInfo{ + Source: ErrorSourceClickHouse, + Code: strconv.Itoa(int(chproto.ErrCannotInsertNullInOrdinaryColumn)), + }, errInfo, "Unexpected error info") +} + func TestNumericTruncateOrOutOfRangeWarningShouldBeLossyConversion(t *testing.T) { for code, err := range map[string]error{ "NUMERIC_TRUNCATED": exceptions.NewNumericTruncatedError(errors.New("testing numeric truncated warning"), "tableA1", "columnB2"),