Skip to content

Commit 3c4134b

Browse files
authored
Edit Mirror: don't strip shared destination on table removal (#4414)
Multiple source tables mapping to one destination works by keying on source. In raw table we only have destination table. Removing one such source previously cleaned based on destination, breaking surviving source cdc_flow: compute exclusivelyRemovedTables, removed mappings whose destination is not fed by any surviving mapping, & scope cleanup to those. Publication alteration keeps full RemovedTables set: keyed on source, so every removed source must leave regardless of shared destination RemoveTablesFromRawTable: sort+compact destination names & skip when none remain or syncBatchID <= normBatchID. The DELETE predicate is _peerdb_batch_id in (normBatchID, syncBatchID]; an empty range makes it a no-op, so skipping avoids connecting to destination for nothing clickhouse: delete in 100 table batches to group mutations Adds e2e Test_Removal_Shared_Destination covering surviving-source replication into a shared destination after removal. This also adds coverage for N:1 mirrors
1 parent 0709374 commit 3c4134b

5 files changed

Lines changed: 133 additions & 23 deletions

File tree

flow/activities/flowable.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1876,6 +1876,19 @@ func (a *FlowableActivity) RemoveTablesFromRawTable(
18761876
return a.Alerter.LogFlowError(ctx, cfg.FlowJobName, err)
18771877
}
18781878

1879+
tableNames := make([]string, 0, len(tablesToRemove))
1880+
for _, table := range tablesToRemove {
1881+
tableNames = append(tableNames, table.DestinationTableIdentifier)
1882+
}
1883+
slices.Sort(tableNames)
1884+
tableNames = slices.Compact(tableNames)
1885+
if len(tableNames) == 0 || syncBatchID <= normBatchID {
1886+
logger.Info("[RemoveTablesFromRawTable] no pending raw rows to remove, skipping",
1887+
slog.Int64("syncBatchID", syncBatchID), slog.Int64("normalizeBatchID", normBatchID),
1888+
slog.Int("tables", len(tableNames)))
1889+
return nil
1890+
}
1891+
18791892
dstConn, dstClose, err := connectors.GetByNameAs[connectors.RawTableConnector](ctx, cfg.Env, a.CatalogPool, cfg.DestinationName)
18801893
if err != nil {
18811894
if errors.Is(err, errors.ErrUnsupported) {
@@ -1889,10 +1902,6 @@ func (a *FlowableActivity) RemoveTablesFromRawTable(
18891902
}
18901903
defer dstClose(ctx)
18911904

1892-
tableNames := make([]string, 0, len(tablesToRemove))
1893-
for _, table := range tablesToRemove {
1894-
tableNames = append(tableNames, table.DestinationTableIdentifier)
1895-
}
18961905
if err := dstConn.RemoveTableEntriesFromRawTable(ctx, &protos.RemoveTablesFromRawTableInput{
18971906
FlowJobName: cfg.FlowJobName,
18981907
DestinationTableNames: tableNames,

flow/connectors/clickhouse/cdc.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"log/slog"
7+
"slices"
78
"strconv"
89
"strings"
910

@@ -410,17 +411,22 @@ func (c *ClickHouseConnector) RemoveTableEntriesFromRawTable(
410411
return nil
411412
}
412413

413-
for _, tableName := range req.DestinationTableNames {
414+
// chunk to bound statement size, one mutation covers many tables instead of one mutation each
415+
for chunk := range slices.Chunk(req.DestinationTableNames, 100) {
416+
quoted := make([]string, len(chunk))
417+
for i, tableName := range chunk {
418+
quoted[i] = peerdb_clickhouse.QuoteLiteral(tableName)
419+
}
414420
// Better to use lightweight deletes here as the main goal is to not have
415421
// rows in the table be visible by the NormalizeRecords' INSERT INTO SELECT queries
416-
if err := c.execWithLogging(ctx, fmt.Sprintf("DELETE FROM %s WHERE _peerdb_destination_table_name = %s"+
422+
if err := c.execWithLogging(ctx, fmt.Sprintf("DELETE FROM %s WHERE _peerdb_destination_table_name IN (%s)"+
417423
" AND _peerdb_batch_id > %d AND _peerdb_batch_id <= %d",
418-
c.GetRawTableName(req.FlowJobName), peerdb_clickhouse.QuoteLiteral(tableName), req.NormalizeBatchId, req.SyncBatchId),
424+
c.GetRawTableName(req.FlowJobName), strings.Join(quoted, ","), req.NormalizeBatchId, req.SyncBatchId),
419425
); err != nil {
420-
return fmt.Errorf("unable to remove table %s from raw table: %w", tableName, err)
426+
return fmt.Errorf("unable to remove tables from raw table: %w", err)
421427
}
422428

423-
c.logger.Info("successfully removed entries for table from raw table", slog.String("table", tableName))
429+
c.logger.Info("successfully removed entries for tables from raw table", slog.Any("tables", chunk))
424430
}
425431

426432
return nil

flow/e2e/clickhouse_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,98 @@ func (s ClickHouseSuite) Test_Addition_Removal() {
207207
RequireEnvCanceled(s.t, env)
208208
}
209209

210+
// Removing one of several source tables feeding a shared destination must keep
211+
// that destination's raw rows & catalog schema mapping intact, otherwise the
212+
// surviving source can no longer normalize into it. Disjoint id ranges keep the
213+
// two sources' rows distinct under the destination's ReplacingMergeTree.
214+
func (s ClickHouseSuite) Test_Removal_Shared_Destination() {
215+
tc := NewTemporalClient(s.t)
216+
217+
srcTableA := s.attachSchemaSuffix("test_shared_dst_a")
218+
srcTableB := s.attachSchemaSuffix("test_shared_dst_b")
219+
dstTableName := "test_shared_dst_target"
220+
221+
for _, srcTableName := range []string{srcTableA, srcTableB} {
222+
require.NoError(s.t, s.source.Exec(s.t.Context(), fmt.Sprintf(`
223+
CREATE TABLE IF NOT EXISTS %s (
224+
id INT PRIMARY KEY,
225+
"key" TEXT NOT NULL
226+
);
227+
`, srcTableName)))
228+
}
229+
230+
connectionGen := FlowConnectionGenerationConfig{
231+
FlowJobName: s.attachSuffix("clickhousesharedremoval"),
232+
TableNameMapping: map[string]string{srcTableA: dstTableName, srcTableB: dstTableName},
233+
Destination: s.Peer().Name,
234+
}
235+
236+
flowConnConfig := connectionGen.GenerateFlowConnectionConfigs(s)
237+
flowConnConfig.MaxBatchSize = 1
238+
239+
env := ExecutePeerflow(s.t, tc, flowConnConfig)
240+
241+
SetupCDCFlowStatusQuery(s.t, env, flowConnConfig)
242+
require.NoError(s.t, s.source.Exec(s.t.Context(),
243+
fmt.Sprintf(`INSERT INTO %s (id,"key") VALUES (1,'a1')`, srcTableA)))
244+
require.NoError(s.t, s.source.Exec(s.t.Context(),
245+
fmt.Sprintf(`INSERT INTO %s (id,"key") VALUES (101,'b1')`, srcTableB)))
246+
EnvWaitForCount(env, s, "both sources reach shared destination", dstTableName, "id,\"key\"", 2)
247+
248+
SignalWorkflow(s.t.Context(), env, model.FlowSignal, model.PauseSignal)
249+
EnvWaitFor(s.t, env, 3*time.Minute, "pausing for removing shared table", func() bool {
250+
return env.GetFlowStatus(s.t) == protos.FlowStatus_STATUS_PAUSED
251+
})
252+
253+
if pgconn, ok := s.source.Connector().(*connpostgres.PostgresConnector); ok {
254+
conn := pgconn.Conn()
255+
_, err := conn.Exec(s.t.Context(),
256+
fmt.Sprintf(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
257+
WHERE query LIKE '%%START_REPLICATION%%' AND query LIKE '%%%s%%' AND backend_type='walsender'`,
258+
s.attachSuffix("clickhousesharedremoval")))
259+
require.NoError(s.t, err)
260+
261+
EnvWaitFor(s.t, env, 3*time.Minute, "waiting for replication to stop", func() bool {
262+
rows, err := conn.Query(s.t.Context(),
263+
fmt.Sprintf(`SELECT pid FROM pg_stat_activity
264+
WHERE query LIKE '%%START_REPLICATION%%' AND query LIKE '%%%s%%' AND backend_type='walsender'`,
265+
s.attachSuffix("clickhousesharedremoval")))
266+
require.NoError(s.t, err)
267+
defer rows.Close()
268+
return !rows.Next()
269+
})
270+
}
271+
272+
runID := EnvGetRunID(s.t, env)
273+
SignalWorkflow(s.t.Context(), env, model.CDCDynamicPropertiesSignal, &protos.CDCFlowConfigUpdate{
274+
RemovedTables: []*protos.TableMapping{{
275+
SourceTableIdentifier: srcTableA,
276+
DestinationTableIdentifier: dstTableName,
277+
}},
278+
})
279+
280+
EnvWaitFor(s.t, env, 4*time.Minute, "removing shared source", func() bool {
281+
return env.GetFlowStatus(s.t) == protos.FlowStatus_STATUS_RUNNING
282+
})
283+
EnvWaitFor(s.t, env, time.Minute, "ContinueAsNew", func() bool {
284+
return runID != EnvGetRunID(s.t, env)
285+
})
286+
287+
// removed source: must not replicate; surviving source: must still reach shared destination
288+
require.NoError(s.t, s.source.Exec(s.t.Context(),
289+
fmt.Sprintf(`INSERT INTO %s (id,"key") VALUES (2,'a2')`, srcTableA)))
290+
require.NoError(s.t, s.source.Exec(s.t.Context(),
291+
fmt.Sprintf(`INSERT INTO %s (id,"key") VALUES (102,'b2')`, srcTableB)))
292+
293+
EnvWaitForCount(env, s, "surviving source still reaches shared destination", dstTableName, "id,\"key\"", 3)
294+
295+
rows, err := s.GetRows(dstTableName, "id")
296+
require.NoError(s.t, err)
297+
require.Len(s.t, rows.Records, 3, "removed source must not leak into shared destination")
298+
env.Cancel(s.t.Context())
299+
RequireEnvCanceled(s.t, env)
300+
}
301+
210302
func (s ClickHouseSuite) Test_NullableMirrorSetting() {
211303
srcTableName := "test_nullable_mirror"
212304
srcFullName := s.attachSchemaSuffix(srcTableName)

flow/workflows/cdc_flow.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,10 +382,24 @@ func processTableRemovals(
382382
}
383383
logger.Info("tables removed from publication")
384384

385+
// destinations still fed by remaining mappings keep raw rows & schema mapping,
386+
// pending rows still normalize into surviving destination table
387+
// relies on TableMappings being trimmed before selector callbacks fire
388+
remainingDestinations := make(map[string]struct{}, len(state.SyncFlowOptions.TableMappings))
389+
for _, tm := range state.SyncFlowOptions.TableMappings {
390+
remainingDestinations[tm.DestinationTableIdentifier] = struct{}{}
391+
}
392+
exclusivelyRemovedTables := make([]*protos.TableMapping, 0, len(state.FlowConfigUpdate.RemovedTables))
393+
for _, tm := range state.FlowConfigUpdate.RemovedTables {
394+
if _, shared := remainingDestinations[tm.DestinationTableIdentifier]; !shared {
395+
exclusivelyRemovedTables = append(exclusivelyRemovedTables, tm)
396+
}
397+
}
398+
385399
rawTableCleanupFuture := workflow.ExecuteActivity(
386400
removeTablesCtx,
387401
flowable.RemoveTablesFromRawTable,
388-
cfg, state.FlowConfigUpdate.RemovedTables)
402+
cfg, exclusivelyRemovedTables)
389403
removeTablesSelector.AddFuture(rawTableCleanupFuture, func(f workflow.Future) {
390404
if err := f.Get(ctx, nil); err != nil {
391405
logger.Error("failed to clean up raw table for removed tables", slog.Any("error", err))
@@ -397,7 +411,7 @@ func processTableRemovals(
397411
removeTablesFromCatalogFuture := workflow.ExecuteActivity(
398412
removeTablesCtx,
399413
flowable.RemoveTablesFromCatalog,
400-
cfg, state.FlowConfigUpdate.RemovedTables)
414+
cfg, exclusivelyRemovedTables)
401415
removeTablesSelector.AddFuture(removeTablesFromCatalogFuture, func(f workflow.Future) {
402416
if err := f.Get(ctx, nil); err != nil {
403417
logger.Error("failed to clean up raw table for removed tables", slog.Any("error", err))

ui/app/mirrors/create/schema.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,7 @@ export const tableMappingSchema = z
2626
partitionKey: z.string().optional(),
2727
})
2828
)
29-
.nonempty('At least one table mapping is required')
30-
.superRefine((mappingArray, ctx) => {
31-
if (
32-
mappingArray.map((val) => val.destinationTableIdentifier).length !==
33-
new Set(mappingArray).size
34-
) {
35-
ctx.addIssue({
36-
code: 'custom',
37-
message: `Two source tables have been mapped to the same destination table`,
38-
});
39-
}
40-
});
29+
.nonempty('At least one table mapping is required');
4130

4231
export const cdcSchema = z.object({
4332
sourceName: z.string({ error: 'Source peer is required' }).min(1),

0 commit comments

Comments
 (0)