|
| 1 | +// TODO: This file contains temporary validation code used during the migration from v1 to v2 schema delta approaches. |
| 2 | +// Once the v2 approach is fully rolled out and v1 is removed, this entire file should be deleted. |
| 3 | +// The validation ensures that v2 produces identical results to v1 during the transition period. |
| 4 | +// |
| 5 | +// Related cleanup tasks when deleting this file: |
| 6 | +// - Remove applySchemaDeltasV1() function once v1 is deprecated |
| 7 | +// - Clean up any references to this validation logic |
| 8 | +// - Clean up `PEERDB_APPLY_SCHEMA_DELTA_TO_CATALOG` in dynconf.go |
| 9 | +package activities |
| 10 | + |
| 11 | +import ( |
| 12 | + "context" |
| 13 | + "fmt" |
| 14 | + "log/slog" |
| 15 | + "slices" |
| 16 | + |
| 17 | + "go.opentelemetry.io/otel/attribute" |
| 18 | + "go.opentelemetry.io/otel/metric" |
| 19 | + |
| 20 | + "github.com/PeerDB-io/peerdb/flow/generated/protos" |
| 21 | + "github.com/PeerDB-io/peerdb/flow/internal" |
| 22 | + "github.com/PeerDB-io/peerdb/flow/otel_metrics" |
| 23 | + "github.com/PeerDB-io/peerdb/flow/shared" |
| 24 | +) |
| 25 | + |
| 26 | +// validateV2AgainstV1 compares v1 and v2 schema delta approaches for validation. |
| 27 | +// Failures in this validation are logged but don't affect production behavior. |
| 28 | +func validateV2AgainstV1( |
| 29 | + ctx context.Context, |
| 30 | + pool shared.CatalogPool, |
| 31 | + flowName string, |
| 32 | + baseSchema map[string]*protos.TableSchema, |
| 33 | + schemaDeltas []*protos.TableSchemaDelta, |
| 34 | + affectedTables []string, |
| 35 | +) { |
| 36 | + logger := internal.LoggerFromCtx(ctx) |
| 37 | + |
| 38 | + schemaAfterV1, err := internal.LoadTableSchemasFromCatalog(ctx, pool, flowName, affectedTables) |
| 39 | + if err != nil { |
| 40 | + logger.Warn("skipping v1/v2 validation: cannot load post-v1 schemas", slog.Any("error", err)) |
| 41 | + return |
| 42 | + } |
| 43 | + |
| 44 | + schemaAfterV2, err := applySchemaDeltaV2(ctx, baseSchema, schemaDeltas) |
| 45 | + if err != nil { |
| 46 | + logger.Warn("skipping v1/v2 validation: applySchemaDeltaV2 failed", slog.Any("error", err)) |
| 47 | + return |
| 48 | + } |
| 49 | + |
| 50 | + reportUnexpectedSchemaDiffs(ctx, flowName, baseSchema, schemaAfterV1, schemaAfterV2, schemaDeltas) |
| 51 | +} |
| 52 | + |
| 53 | +func reportUnexpectedSchemaDiffs( |
| 54 | + ctx context.Context, |
| 55 | + flowName string, |
| 56 | + baseSchemas map[string]*protos.TableSchema, |
| 57 | + schemasAfterV1 map[string]*protos.TableSchema, |
| 58 | + schemasAfterV2 map[string]*protos.TableSchema, |
| 59 | + schemaDeltas []*protos.TableSchemaDelta, |
| 60 | +) { |
| 61 | + logger := internal.LoggerFromCtx(ctx) |
| 62 | + |
| 63 | + for _, schemaDelta := range schemaDeltas { |
| 64 | + tableName := schemaDelta.DstTableName |
| 65 | + baseSchema := baseSchemas[tableName] |
| 66 | + schemaV1 := schemasAfterV1[tableName] |
| 67 | + schemaV2 := schemasAfterV2[tableName] |
| 68 | + |
| 69 | + if baseSchema == nil || schemaV1 == nil || schemaV2 == nil { |
| 70 | + logger.Warn("skipping validation for table due to missing schema", |
| 71 | + slog.String("table", tableName), |
| 72 | + slog.Bool("hasBase", baseSchema != nil), |
| 73 | + slog.Bool("hasV1", schemaV1 != nil), |
| 74 | + slog.Bool("hasV2", schemaV2 != nil)) |
| 75 | + continue |
| 76 | + } |
| 77 | + |
| 78 | + var issues []string |
| 79 | + |
| 80 | + v1ColumnMap := make(map[string]*protos.FieldDescription) |
| 81 | + for _, col := range schemaV1.Columns { |
| 82 | + v1ColumnMap[col.Name] = col |
| 83 | + } |
| 84 | + |
| 85 | + v2ColumnMap := make(map[string]*protos.FieldDescription) |
| 86 | + for _, col := range schemaV2.Columns { |
| 87 | + v2ColumnMap[col.Name] = col |
| 88 | + } |
| 89 | + |
| 90 | + // Validate existing columns match in v1 and v2 |
| 91 | + for _, baseCol := range baseSchema.Columns { |
| 92 | + v1Col, existsInV1 := v1ColumnMap[baseCol.Name] |
| 93 | + v2Col, existsInV2 := v2ColumnMap[baseCol.Name] |
| 94 | + |
| 95 | + // Column missing in v1 is expected: |
| 96 | + // - in applySchemaDeltaV1, we were syncing catalog with schema from source. |
| 97 | + // DROP COLUMN DDLs are ignored and do not trigger a schema fetch, |
| 98 | + // but subsequent ADD COLUMN DDLs would fetch the latest schema from source |
| 99 | + // with dropped columns removed. |
| 100 | + // - in applySchemaDeltaV2, we never fetch schema from source, therefore it's |
| 101 | + // expected that deleted columns are in v2, but not in v1. |
| 102 | + // Only report error if column exists in v1 but not in v2. |
| 103 | + if existsInV1 && !existsInV2 { |
| 104 | + issues = append(issues, fmt.Sprintf("existing column '%s' missing in v2", baseCol.Name)) |
| 105 | + } |
| 106 | + |
| 107 | + if existsInV1 && existsInV2 && !fieldDescriptionEqual(v1Col, v2Col) { |
| 108 | + issues = append(issues, fmt.Sprintf("existing column '%s' differs: v1=%+v, v2=%+v", |
| 109 | + baseCol.Name, v1Col, v2Col)) |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + // Validate new columns match in v1 and v2 |
| 114 | + for _, newCol := range schemaDelta.AddedColumns { |
| 115 | + v1Col, existsInV1 := v1ColumnMap[newCol.Name] |
| 116 | + v2Col, existsInV2 := v2ColumnMap[newCol.Name] |
| 117 | + |
| 118 | + if !existsInV1 { |
| 119 | + issues = append(issues, fmt.Sprintf("new column '%s' missing in v1", newCol.Name)) |
| 120 | + } |
| 121 | + if !existsInV2 { |
| 122 | + issues = append(issues, fmt.Sprintf("new column '%s' missing in v2", newCol.Name)) |
| 123 | + } |
| 124 | + |
| 125 | + if existsInV1 && existsInV2 && !fieldDescriptionEqual(v1Col, v2Col) { |
| 126 | + issues = append(issues, fmt.Sprintf("new column '%s' differs: v1=%+v, v2=%+v", |
| 127 | + newCol.Name, v1Col, v2Col)) |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + // Note: v1 may have extra columns due to race condition (fetching latest schema from source) |
| 132 | + // This is expected and not considered an error. |
| 133 | + |
| 134 | + // Check all other TableSchema fields match |
| 135 | + if schemaV1.TableIdentifier != schemaV2.TableIdentifier { |
| 136 | + issues = append(issues, fmt.Sprintf("table_identifier differs: v1='%s', v2='%s'", |
| 137 | + schemaV1.TableIdentifier, schemaV2.TableIdentifier)) |
| 138 | + } |
| 139 | + if !slices.Equal(schemaV1.PrimaryKeyColumns, schemaV2.PrimaryKeyColumns) { |
| 140 | + issues = append(issues, fmt.Sprintf("primary_key_columns differs: v1=%v, v2=%v", |
| 141 | + schemaV1.PrimaryKeyColumns, schemaV2.PrimaryKeyColumns)) |
| 142 | + } |
| 143 | + if schemaV1.IsReplicaIdentityFull != schemaV2.IsReplicaIdentityFull { |
| 144 | + issues = append(issues, fmt.Sprintf("is_replica_identity_full differs: v1=%v, v2=%v", |
| 145 | + schemaV1.IsReplicaIdentityFull, schemaV2.IsReplicaIdentityFull)) |
| 146 | + } |
| 147 | + if schemaV1.System != schemaV2.System { |
| 148 | + issues = append(issues, fmt.Sprintf("system differs: v1=%v, v2=%v", |
| 149 | + schemaV1.System, schemaV2.System)) |
| 150 | + } |
| 151 | + if schemaV1.NullableEnabled != schemaV2.NullableEnabled { |
| 152 | + issues = append(issues, fmt.Sprintf("nullable_enabled differs: v1=%v, v2=%v", |
| 153 | + schemaV1.NullableEnabled, schemaV2.NullableEnabled)) |
| 154 | + } |
| 155 | + if schemaV1.TableOid != schemaV2.TableOid { |
| 156 | + issues = append(issues, fmt.Sprintf("table_oid differs: v1=%v, v2=%v", |
| 157 | + schemaV1.TableOid, schemaV2.TableOid)) |
| 158 | + } |
| 159 | + |
| 160 | + // summarize the findings in logs |
| 161 | + if len(issues) > 0 { |
| 162 | + logger.Warn("schema validation issues found: v1 and v2 differ", |
| 163 | + slog.String("flowName", flowName), |
| 164 | + slog.String("table", tableName), |
| 165 | + slog.Int("issueCount", len(issues)), |
| 166 | + slog.Any("issues", issues)) |
| 167 | + |
| 168 | + // if there is a discrepancy, report this as a metric to code_notification |
| 169 | + otel_metrics.CodeNotificationCounter.Add(ctx, 1, metric.WithAttributeSet(attribute.NewSet( |
| 170 | + attribute.String("message", fmt.Sprintf("Schema delta v2 validation failed for flow=%s table=%s: %d issues", |
| 171 | + flowName, tableName, len(issues))), |
| 172 | + attribute.String("flowName", flowName), |
| 173 | + attribute.String("tableName", tableName), |
| 174 | + ))) |
| 175 | + } |
| 176 | + } |
| 177 | +} |
| 178 | + |
| 179 | +func fieldDescriptionEqual(a, b *protos.FieldDescription) bool { |
| 180 | + if a == nil || b == nil { |
| 181 | + return a == b |
| 182 | + } |
| 183 | + return a.Name == b.Name && |
| 184 | + a.Type == b.Type && |
| 185 | + a.TypeModifier == b.TypeModifier && |
| 186 | + a.Nullable == b.Nullable |
| 187 | +} |
0 commit comments