Skip to content

Commit 1a2dfd1

Browse files
authored
Return structured gRPC errors on source table missing (#4194)
Helps to unify the handling across connectors and consume on the caller without string matching. Also clean up unused api error and move string constants to pkg. Would be nicer to have everything in protos but string constants are not supported there and not all our constants are uppercase to be enums.
1 parent 3a036db commit 1a2dfd1

12 files changed

Lines changed: 409 additions & 58 deletions

File tree

flow/cmd/api_error.go

Lines changed: 56 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ package cmd
22

33
import (
44
"errors"
5+
"fmt"
56
"log/slog"
67

78
"github.com/gogo/googleapis/google/rpc"
9+
"google.golang.org/genproto/googleapis/rpc/errdetails"
810
"google.golang.org/grpc/codes"
911
"google.golang.org/grpc/status"
1012
"google.golang.org/protobuf/protoadapt"
1113

1214
"github.com/PeerDB-io/peerdb/flow/generated/grpc_handler"
15+
"github.com/PeerDB-io/peerdb/flow/pkg/common"
1316
)
1417

1518
// APIError is a strongly-typed error that must be a gRPC status error.
@@ -24,31 +27,31 @@ func newAPIError(s *status.Status) *apiError {
2427
return &apiError{status: s}
2528
}
2629

27-
func NewInvalidArgumentApiError(err error, details ...*rpc.ErrorInfo) *apiError {
30+
func NewInvalidArgumentApiError(err error, details ...protoadapt.MessageV1) *apiError {
2831
return newAPIError(convertToStatus(codes.InvalidArgument, err, details...))
2932
}
3033

31-
func NewFailedPreconditionApiError(err error, details ...*rpc.ErrorInfo) *apiError {
34+
func NewFailedPreconditionApiError(err error, details ...protoadapt.MessageV1) *apiError {
3235
return newAPIError(convertToStatus(codes.FailedPrecondition, err, details...))
3336
}
3437

35-
func NewInternalApiError(err error, details ...*rpc.ErrorInfo) *apiError {
38+
func NewInternalApiError(err error, details ...protoadapt.MessageV1) *apiError {
3639
return newAPIError(convertToStatus(codes.Internal, err, details...))
3740
}
3841

39-
func NewUnavailableApiError(err error, details ...*rpc.ErrorInfo) *apiError {
42+
func NewUnavailableApiError(err error, details ...protoadapt.MessageV1) *apiError {
4043
return newAPIError(convertToStatus(codes.Unavailable, err, details...))
4144
}
4245

43-
func NewUnimplementedApiError(err error, details ...*rpc.ErrorInfo) *apiError {
46+
func NewUnimplementedApiError(err error, details ...protoadapt.MessageV1) *apiError {
4447
return newAPIError(convertToStatus(codes.Unimplemented, err, details...))
4548
}
4649

47-
func NewAlreadyExistsApiError(err error, details ...*rpc.ErrorInfo) *apiError {
50+
func NewAlreadyExistsApiError(err error, details ...protoadapt.MessageV1) *apiError {
4851
return newAPIError(convertToStatus(codes.AlreadyExists, err, details...))
4952
}
5053

51-
func NewNotFoundApiError(err error, details ...*rpc.ErrorInfo) *apiError {
54+
func NewNotFoundApiError(err error, details ...protoadapt.MessageV1) *apiError {
5255
return newAPIError(convertToStatus(codes.NotFound, err, details...))
5356
}
5457

@@ -70,18 +73,14 @@ func (e *apiError) Code() codes.Code {
7073
return e.status.Code()
7174
}
7275

73-
func convertToStatus(code codes.Code, err error, details ...*rpc.ErrorInfo) *status.Status {
76+
func convertToStatus(code codes.Code, err error, details ...protoadapt.MessageV1) *status.Status {
7477
errorStatus := status.New(code, err.Error())
7578
if len(details) == 0 {
7679
return errorStatus
7780
}
78-
convertedDetails := make([]protoadapt.MessageV1, len(details))
79-
for i, detail := range details {
80-
convertedDetails[i] = detail
81-
}
82-
richStatus, err := errorStatus.WithDetails(convertedDetails...)
81+
richStatus, err := errorStatus.WithDetails(details...)
8382
if err != nil {
84-
// This cannot happen because we control all calls to convertToStatus and only pass code != OK and allow only rpc.ErrorInfo in details
83+
// This cannot happen because we control all calls to convertToStatus and only pass code != OK and valid proto details
8584
slog.Error("Failed to convert to grpc proto error", slog.Any("error", err)) //nolint:sloglint // No context in conversion helper
8685
return errorStatus
8786
}
@@ -107,34 +106,57 @@ func AsAPIError(err error) APIError {
107106
return NewInternalApiError(err)
108107
}
109108

110-
const (
111-
ErrorInfoReasonClickHousePeer = "CLICKHOUSE_PEER"
112-
ErrorInfoReasonMirror = "MIRROR"
113-
)
109+
func NewMirrorErrorInfo(metadata map[string]string) *rpc.ErrorInfo {
110+
return &rpc.ErrorInfo{
111+
Reason: common.ErrorInfoReasonMirror,
112+
Domain: common.ErrorInfoDomain,
113+
Metadata: metadata,
114+
}
115+
}
114116

115-
const (
116-
ErrorInfoDomain = "peerdb.io"
117-
)
117+
func NewSourceTableMissingErrorInfo() *rpc.ErrorInfo {
118+
return &rpc.ErrorInfo{
119+
Reason: common.ErrorInfoReasonSourceTableMissing,
120+
Domain: common.ErrorInfoDomain,
121+
}
122+
}
118123

119-
const (
120-
ErrorMetadataDownstreamErrorCode = "downstreamErrorCode"
121-
ErrorMetadataOffendingField = "offendingField"
122-
)
124+
// NewSourceTableMissingPreconditionFailure builds the per-table breakdown detail
125+
// that accompanies the SOURCE_TABLE_MISSING ErrorInfo on FailedPrecondition.
126+
func NewSourceTableMissingPreconditionFailure(tables []common.QualifiedTable) protoadapt.MessageV1 {
127+
violations := make([]*errdetails.PreconditionFailure_Violation, len(tables))
128+
for i, t := range tables {
129+
subject := fmt.Sprintf("%s.%s", t.Namespace, t.Table)
130+
violations[i] = &errdetails.PreconditionFailure_Violation{
131+
Type: common.ErrorInfoReasonSourceTableMissing,
132+
Subject: subject,
133+
Description: fmt.Sprintf("source table %s does not exist", subject),
134+
}
135+
}
136+
return protoadapt.MessageV1Of(&errdetails.PreconditionFailure{Violations: violations})
137+
}
123138

124-
func NewClickHousePeerErrorInfo(metadata map[string]string) *rpc.ErrorInfo {
139+
func NewTablesNotInPublicationErrorInfo(publication string) *rpc.ErrorInfo {
125140
return &rpc.ErrorInfo{
126-
Reason: ErrorInfoReasonClickHousePeer,
127-
Domain: ErrorInfoDomain,
128-
Metadata: metadata,
141+
Reason: common.ErrorInfoReasonTablesNotInPublication,
142+
Domain: common.ErrorInfoDomain,
143+
Metadata: map[string]string{common.ErrorMetadataPublication: publication},
129144
}
130145
}
131146

132-
func NewMirrorErrorInfo(metadata map[string]string) *rpc.ErrorInfo {
133-
return &rpc.ErrorInfo{
134-
Reason: ErrorInfoReasonMirror,
135-
Domain: ErrorInfoDomain,
136-
Metadata: metadata,
147+
// NewTablesNotInPublicationPreconditionFailure builds the per-table breakdown detail
148+
// that accompanies the TABLES_NOT_IN_PUBLICATION ErrorInfo on FailedPrecondition.
149+
func NewTablesNotInPublicationPreconditionFailure(publication string, tables []common.QualifiedTable) protoadapt.MessageV1 {
150+
violations := make([]*errdetails.PreconditionFailure_Violation, len(tables))
151+
for i, t := range tables {
152+
subject := fmt.Sprintf("%s.%s", t.Namespace, t.Table)
153+
violations[i] = &errdetails.PreconditionFailure_Violation{
154+
Type: common.ErrorInfoReasonTablesNotInPublication,
155+
Subject: subject,
156+
Description: fmt.Sprintf("table %s is not in publication %q", subject, publication),
157+
}
137158
}
159+
return protoadapt.MessageV1Of(&errdetails.PreconditionFailure{Violations: violations})
138160
}
139161

140162
var ErrUnderMaintenance = errors.New("PeerDB is under maintenance. Please retry in a few minutes")

flow/cmd/handler.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ func (h *FlowRequestHandler) FlowStateChange(
509509
if _, err := h.ValidateCDCMirror(ctx, &protos.CreateCDCFlowRequest{
510510
ConnectionConfigs: config,
511511
}); err != nil {
512-
return nil, NewFailedPreconditionApiError(fmt.Errorf("invalid mirror: %w", err))
512+
return nil, err
513513
}
514514
changeErr = model.FlowSignalStateChange.SignalClientWorkflow(ctx, h.temporalClient, workflowID, "", req)
515515
if changeErr == nil {
@@ -540,6 +540,10 @@ func (h *FlowRequestHandler) FlowStateChange(
540540
}
541541
if changeErr != nil {
542542
slog.ErrorContext(ctx, "unable to signal workflow", logs, slog.Any("error", changeErr))
543+
// Preserve typed API errors
544+
if apiErr, ok := changeErr.(APIError); ok {
545+
return nil, apiErr
546+
}
543547
return nil, NewInternalApiError(fmt.Errorf("unable to signal workflow: %w", changeErr))
544548
}
545549
}

flow/cmd/validate_mirror.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/PeerDB-io/peerdb/flow/generated/proto_conversions"
1313
"github.com/PeerDB-io/peerdb/flow/generated/protos"
1414
"github.com/PeerDB-io/peerdb/flow/internal"
15+
"github.com/PeerDB-io/peerdb/flow/pkg/common"
1516
"github.com/PeerDB-io/peerdb/flow/shared"
1617
"github.com/PeerDB-io/peerdb/flow/shared/types"
1718
)
@@ -65,7 +66,7 @@ func (h *FlowRequestHandler) validateCDCMirrorImpl(
6566
return nil, NewAlreadyExistsApiError(
6667
fmt.Errorf("mirror with name %s already exists", connectionConfigs.FlowJobName),
6768
NewMirrorErrorInfo(map[string]string{
68-
ErrorMetadataOffendingField: "flow_job_name",
69+
common.ErrorMetadataOffendingField: "flow_job_name",
6970
}))
7071
}
7172
}
@@ -106,6 +107,18 @@ func (h *FlowRequestHandler) validateCDCMirrorImpl(
106107
defer srcClose(ctx)
107108

108109
if err := srcConn.ValidateMirrorSource(ctx, connectionConfigs); err != nil {
110+
if missing, ok := errors.AsType[*common.SourceTablesMissingError](err); ok {
111+
return nil, NewFailedPreconditionApiError(
112+
missing,
113+
NewSourceTableMissingErrorInfo(),
114+
NewSourceTableMissingPreconditionFailure(missing.Tables))
115+
}
116+
if notInPub, ok := errors.AsType[*common.TablesNotInPublicationError](err); ok {
117+
return nil, NewFailedPreconditionApiError(
118+
notInPub,
119+
NewTablesNotInPublicationErrorInfo(notInPub.Publication),
120+
NewTablesNotInPublicationPreconditionFailure(notInPub.Publication, notInPub.Tables))
121+
}
109122
return nil, NewFailedPreconditionApiError(
110123
fmt.Errorf("failed to validate source connector %s: %w", connectionConfigs.SourceName, err))
111124
}

flow/connectors/bigquery/source.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,21 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"net/http"
78

89
"cloud.google.com/go/storage"
910
"google.golang.org/api/iterator"
1011

1112
"github.com/PeerDB-io/peerdb/flow/generated/protos"
13+
"github.com/PeerDB-io/peerdb/flow/pkg/common"
1214
)
1315

1416
func (c *BigQueryConnector) ValidateMirrorSource(ctx context.Context, cfg *protos.FlowConnectionConfigsCore) error {
1517
if !cfg.InitialSnapshotOnly || !cfg.DoInitialSnapshot {
1618
return fmt.Errorf("BigQuery source connector only supports initial snapshot flows. CDC is not supported")
1719
}
1820

21+
var missingTables []common.QualifiedTable
1922
for _, tableMapping := range cfg.TableMappings {
2023
dstDatasetTable, err := c.convertToDatasetTable(tableMapping.SourceTableIdentifier)
2124
if err != nil {
@@ -25,9 +28,19 @@ func (c *BigQueryConnector) ValidateMirrorSource(ctx context.Context, cfg *proto
2528
table := c.client.DatasetInProject(c.projectID, dstDatasetTable.dataset).Table(dstDatasetTable.table)
2629

2730
if _, err := table.Metadata(ctx); err != nil {
31+
if c.isApiErrorWithStatusCode(err, http.StatusNotFound) {
32+
missingTables = append(missingTables, common.QualifiedTable{
33+
Namespace: dstDatasetTable.dataset,
34+
Table: dstDatasetTable.table,
35+
})
36+
continue
37+
}
2838
return fmt.Errorf("failed to get metadata for table %s: %w", tableMapping.DestinationTableIdentifier, err)
2939
}
3040
}
41+
if len(missingTables) > 0 {
42+
return common.NewSourceTablesMissingError(missingTables)
43+
}
3144

3245
if cfg.SnapshotStagingPath == "" {
3346
return fmt.Errorf("snapshot bucket is required for BigQuery source connector")

flow/connectors/core.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ type ValidationConnector interface {
4444
type MirrorSourceValidationConnector interface {
4545
GetTableSchemaConnector
4646

47+
// ValidateMirrorSource checks that the source is ready to replicate the configured tables.
48+
// MUST return *common.SourceTablesMissingError when a mapped source table is absent.
4749
ValidateMirrorSource(context.Context, *protos.FlowConnectionConfigsCore) error
4850
}
4951

flow/connectors/mongo/validate.go

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,6 @@ func (c *MongoConnector) ValidateCheck(ctx context.Context) error {
2121
}
2222

2323
func (c *MongoConnector) ValidateMirrorSource(ctx context.Context, cfg *protos.FlowConnectionConfigsCore) error {
24-
if cfg.DoInitialSnapshot && cfg.InitialSnapshotOnly {
25-
return nil
26-
}
27-
28-
if err := shared_mongo.ValidateOplogRetention(ctx, c.client); err != nil {
29-
return err
30-
}
31-
3224
tables := make([]*common.QualifiedTable, 0, len(cfg.TableMappings))
3325
for _, tm := range cfg.TableMappings {
3426
t, err := common.ParseTableIdentifier(tm.SourceTableIdentifier)
@@ -41,5 +33,10 @@ func (c *MongoConnector) ValidateMirrorSource(ctx context.Context, cfg *protos.F
4133
return err
4234
}
4335

44-
return nil
36+
// no need to check oplog retention for initial-snapshot-only mirrors
37+
if cfg.DoInitialSnapshot && cfg.InitialSnapshotOnly {
38+
return nil
39+
}
40+
41+
return shared_mongo.ValidateOplogRetention(ctx, c.client)
4542
}

flow/connectors/mysql/validate.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,19 @@ import (
1515
)
1616

1717
func (c *MySqlConnector) CheckSourceTables(ctx context.Context, tableNames []*common.QualifiedTable) error {
18+
var missingTables []common.QualifiedTable
1819
for _, parsedTable := range tableNames {
1920
if _, err := c.Execute(ctx, fmt.Sprintf("SELECT * FROM %s LIMIT 0", parsedTable.MySQL())); err != nil {
21+
if mErr, ok := errors.AsType[*mysql.MyError](err); ok && mErr.Code == mysql.ER_NO_SUCH_TABLE {
22+
missingTables = append(missingTables, *parsedTable)
23+
continue
24+
}
2025
return fmt.Errorf("error checking table %s: %w", parsedTable.MySQL(), err)
2126
}
2227
}
28+
if len(missingTables) > 0 {
29+
return common.NewSourceTablesMissingError(missingTables)
30+
}
2331
return nil
2432
}
2533

flow/connectors/postgres/validate.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import (
77
"slices"
88
"strings"
99

10+
"github.com/jackc/pgerrcode"
1011
"github.com/jackc/pgx/v5"
12+
"github.com/jackc/pgx/v5/pgconn"
1113

1214
"github.com/PeerDB-io/peerdb/flow/connectors/utils"
1315
"github.com/PeerDB-io/peerdb/flow/generated/protos"
@@ -41,6 +43,7 @@ func (c *PostgresConnector) CheckSourceTables(
4143

4244
// Check that we can select from all tables
4345
tableArr := make([]string, 0, len(tableNames))
46+
var missingTables []common.QualifiedTable
4447
for idx, parsedTable := range tableNames {
4548
var row pgx.Row
4649
tableArr = append(tableArr, fmt.Sprintf(`(%s::text,%s::text)`,
@@ -62,9 +65,16 @@ func (c *PostgresConnector) CheckSourceTables(
6265
if err := c.conn.QueryRow(ctx,
6366
fmt.Sprintf("SELECT %s FROM %s LIMIT 0", selectedColumnsStr, parsedTable),
6467
).Scan(&row); err != nil && !errors.Is(err, pgx.ErrNoRows) {
68+
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == pgerrcode.UndefinedTable {
69+
missingTables = append(missingTables, *parsedTable)
70+
continue
71+
}
6572
return fmt.Errorf("failed to select from table %s: %w", parsedTable, err)
6673
}
6774
}
75+
if len(missingTables) > 0 {
76+
return common.NewSourceTablesMissingError(missingTables)
77+
}
6878

6979
if pubName != "" && !noCDC {
7080
// Check if publication exists
@@ -93,20 +103,19 @@ func (c *PostgresConnector) CheckSourceTables(
93103
if err != nil {
94104
return err
95105
}
96-
missing, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (string, error) {
97-
var schema string
98-
var table string
99-
if err := row.Scan(&schema, &table); err != nil {
100-
return "", err
106+
missing, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (common.QualifiedTable, error) {
107+
var qt common.QualifiedTable
108+
if err := row.Scan(&qt.Namespace, &qt.Table); err != nil {
109+
return common.QualifiedTable{}, err
101110
}
102-
return fmt.Sprintf("%s.%s", common.QuoteIdentifier(schema), common.QuoteIdentifier(table)), nil
111+
return qt, nil
103112
})
104113
if err != nil {
105114
return err
106115
}
107116

108117
if len(missing) != 0 {
109-
return fmt.Errorf("some tables missing from publication: %s", strings.Join(missing, ", "))
118+
return common.NewTablesNotInPublicationError(pubName, missing)
110119
}
111120
}
112121
}
@@ -241,6 +250,11 @@ func (c *PostgresConnector) ValidateMirrorSource(ctx context.Context, cfg *proto
241250
}
242251

243252
pubName := cfg.PublicationName
253+
// Check source tables before the publication, for better errors
254+
if err := c.CheckSourceTables(ctx, sourceTables, cfg.TableMappings, pubName, noCDC); err != nil {
255+
return fmt.Errorf("provided source tables invalidated: %w", err)
256+
}
257+
244258
if pubName == "" && !noCDC {
245259
srcTableNames := make([]string, 0, len(sourceTables))
246260
for _, srcTable := range sourceTables {
@@ -252,10 +266,6 @@ func (c *PostgresConnector) ValidateMirrorSource(ctx context.Context, cfg *proto
252266
}
253267
}
254268

255-
if err := c.CheckSourceTables(ctx, sourceTables, cfg.TableMappings, pubName, noCDC); err != nil {
256-
return fmt.Errorf("provided source tables invalidated: %w", err)
257-
}
258-
259269
return nil
260270
}
261271

0 commit comments

Comments
 (0)