Skip to content

fix: handle drop column on clickhouse and remove from schema in clickpipes - #4609

Open
masterashu wants to merge 3 commits into
mainfrom
dbi-501-allow-users-to-drop-columns-on-ch
Open

fix: handle drop column on clickhouse and remove from schema in clickpipes#4609
masterashu wants to merge 3 commits into
mainfrom
dbi-501-allow-users-to-drop-columns-on-ch

Conversation

@masterashu

@masterashu masterashu commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Problem

When customer removes a column from table in Clickhouse, Normalize fails due to SQL query error mentioning unknown column.

In order to deal with this, we had to change the source schema for the table to remove that column.

This PR aims to make this easier by identifying if the column is deleted and retrying and updating the schema.

We check the errors in Normalization step and match if its occurring from a missing column, if it is we refresh the schema and retry normalization and also update the schema.

@github-actions

Copy link
Copy Markdown
Contributor

❌ Test Failure

Analysis: The new test Test_Column_Added_Back_After_Destination_Column_Drop timed out because the ClickHouse destination never received the re-added record (stuck at 2 vs expected 3 for the full 200s) in two suite variants, indicating a real bug in this PR's column drop/re-add handling rather than a flaky failure.
Confidence: 0.9

⚠️ This appears to be a real bug - manual intervention needed

View workflow run

@github-actions

Copy link
Copy Markdown
Contributor

❌ Test Failure

Analysis: The PR's own column-drop feature test (Test_Column_Added_Back_After_Destination_Column_Drop) fails deterministically on both ClickHouse and ClickHouse_Cluster variants with a persistent, non-converging record mismatch (source=3 vs destination=2) that never normalizes — a real regression in the PR's column-drop handling, not a timing flake.
Confidence: 0.82

⚠️ This appears to be a real bug - manual intervention needed

View workflow run

@masterashu
masterashu marked this pull request as ready for review July 23, 2026 15:56
@masterashu
masterashu requested a review from a team as a code owner July 23, 2026 15:56
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@jgao54

jgao54 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@masterashu could you add a description for the expected behavior for this PR? Thanks!

actualColSet[col.Name] = struct{}{}
}

currentSchema := gen.tableNameSchemaMapping[gen.TableName]

@jgao54 jgao54 Jul 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gen.tableNameSchemaMapping does not represent the freshest schema stored in the catalog, but instead represent the schema that was fetched from catalog earlier at the start of the startNormalize. This introduces a race condition between Sync and Normalize flow:

  1. normalize fetch schemas from catalog
  2. sync applies an add column DDL and updated the catalog
  3. normalize take this schemas from 1, removes the deleted column, and saves it back to catalog

The catalog is now stuck with a stale schema with a missing new column

Since we only care about column removal in the error handling, instead of passing back the updated schemas, pass back the removed column instead for the NormalizeResponse. Then in the readModifyLoop you can focus on just removing the deleted column from the fresh schema fetched from catalog, instead of replacing the entire schema today.

// removes any columns from the schema that no longer exist there, and returns a rebuilt normalize query
// together with the filtered schema. This is used to recover from ErrNoSuchColumnInTable when the user
// has manually dropped a column from the destination after it was dropped at the source.
func (c *ClickHouseConnector) refreshSchemaAndRebuildQuery(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the e2e tests are great; let's also add unit tests for refreshSchemaAndRebuildQuery

// If a destination column is missing (user dropped it), try to auto-recover
// by refreshing the schema and retrying the INSERT without that column.
var chEx *clickhouse.Exception
if errors.As(err, &chEx) && ch_proto.Error(chEx.Code) == ch_proto.ErrNoSuchColumnInTable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: errors.AsType[*clickhouse.Exception](err)

Comment on lines +737 to +739
c.logger.Warn("[clickhouse] auto-removing destination-dropped columns from schema",
slog.String("table", gen.TableName),
slog.Any("removedColumns", removedCols))

@jgao54 jgao54 Jul 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iiuc, since we don't check against source db schema, if the user drops the column from ClickHouse today, but does not drop it from source, we will gracefully recover in this scenario as well and continue to replicate without the dropped column; and this column essentially becomes an excluded column, unless the table gets resynced which it would reappear again.

I think this is more of a product decision on whether we should fail loudly here, vs. we should continue. My only concern here with continue is if customer accidentally dropped a column, then previously it would fail the pipe, they would have manually added it back and the pipe would recover. vs with this change manually adding back the column would do nothing, and would require a resync.

If this is intended behavior we should have an user-facing log for it

cc @morsapaes on thoughts here.

@jgao54 jgao54 Jul 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just realized that if user dropped the column and then add it back, the historical data would be lost anyways. so maybe continuing the pipe is the more graceful option here (and user will just have to resync if it was a mistake)

Comment on lines +780 to +789
); updateErr != nil {
logger.Error("failed to persist auto-refreshed table schemas to catalog",
slog.Any("error", updateErr))
}
for name, schema := range res.UpdatedSchemaMapping {
tableNameSchemaMapping[name] = schema
a.Alerter.LogFlowWarning(ctx, config.FlowJobName,
fmt.Errorf("destination column(s) were dropped from table %s and automatically removed "+
"from the mirror schema; future syncs will omit those columns", name))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If ReadModifyWriteTableSchemasToCatalog fails above, this would still add a user-facing warning that destination columns were dropped. should only surface log if update succeeds

@@ -524,6 +531,44 @@ func (c *ClickHouseConnector) NormalizeRecords(
slog.Int("parallelWorker", i))

if err := c.execWithConnection(errCtx, chConn, q.query); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can factor out exec into a helper to improve readability, currently the error handling with continue is a bit hard to follow; and there are some duplicated loggings introduced that wouldn't be needed. e.g.

func (c *ClickHouseConnector) execNormalizeQuery(
      ctx context.Context,
      logger log.Logger,
      conn clickhouse.Conn,
      q queryInfo,
) (*protos.TableSchema, error) { 
      err := c.execWithConnection(ctx, conn, q.query)
      if err == nil {
              return nil, nil
      }

      chEx, ok := errors.AsType[*clickhouse.Exception](err)
      if !ok || chEx == nil || ch_proto.Error(chEx.Code) != ch_proto.ErrNoSuchColumnInTable {
              return nil, err
      }

      logger.Warn("[clickhouse] missing column in destination, attempting schema refresh and retry",
              slog.Any("error", err))
      retryQuery, refreshedSchema, refreshErr := c.refreshSchemaAndRebuildQuery(ctx, q.generator)
      if refreshErr != nil {
              logger.Error("[clickhouse] failed to refresh schema after missing column error",
                      slog.Any("error", refreshErr))
              return nil, err
      }
      if retryErr := c.execWithConnection(ctx, conn, retryQuery); retryErr != nil {
              logger.Error("[clickhouse] retry after schema refresh failed",
                      slog.String("query", retryQuery), slog.Any("error", retryErr))
              return nil, err
      }
      logger.Warn("[clickhouse] successfully retried normalize after dropping missing destination columns")
      return refreshedSchema, nil
}

}

a.recordDeliveryLag(ctx, config.FlowJobName, res.StartBatchID, res.EndBatchID)
// If any destination columns were auto-removed during normalize (user dropped them), persist

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: better implemented as a helper as this method is getting quite long

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants