Repository navigation
feat: add postgres toast column resolution when unavailable during cdc #1264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from all commits
a9051a9
bec939b
cfb2f07
6bb59ce
d99b9f0
74b5fd6
ea33c6a
e4706c4
a859d79
9412881
86b7ec7
54b33f1
23a240a
1ffdb04
d05011c
1fd8a2d
3550009
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,7 @@ type Iceberg struct { | |
| server *serverInstance // shared Java server instance (per-process singleton) | ||
| schema map[string]string // schema for current thread associated with Java writer (col -> type) | ||
| writer Writer // writer instance | ||
| toast *toastResolver // recovers values the source could not send; nil unless the stream keeps a row index (pos or dv) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should not name things in destination based on postgres only terminology.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. changed it to unavailableValues and unavailableValuesResolver |
||
| // Why Schema On Thread Level? | ||
| // Schema on thread level is identical to the writer instance available in the Java server. | ||
| // It defines when to complete the Java writer and when schema evolution is required. | ||
|
|
@@ -143,13 +144,22 @@ func (i *Iceberg) Setup(ctx context.Context, stream types.StreamInterface, _ any | |
| return schema, nil, err | ||
| } | ||
|
|
||
| // nil in equality mode; shared by the writer, which buffers this thread's row locations | ||
| // in it, and the toast resolver, which looks rows up in it | ||
| indexThread := types.NewStreamIndexThread(options.TableIndex) | ||
| if i.config.UseArrowWrites { | ||
| i.writer, err = arrowwriter.New(ctx, i.options, i.partitionInfo, i.schema, i.stream, i.server, upsertMode) | ||
| i.writer, err = arrowwriter.New(ctx, i.options, i.partitionInfo, i.schema, i.stream, i.server, upsertMode, indexThread) | ||
| if err != nil { | ||
| return nil, nil, destination.WriteFailure(fmt.Errorf("failed to create arrow writer: %w", err)) | ||
| } | ||
| } else { | ||
| i.writer = legacywriter.New(i.options, i.schema, i.stream, i.server, upsertMode) | ||
| i.writer = legacywriter.New(i.options, i.schema, i.stream, i.server, upsertMode, indexThread) | ||
| } | ||
|
|
||
| // Recovery needs the row's previous location, which only an upsert thread with a | ||
| // stream index has. Backfill and equality mode keep the placeholder. | ||
| if indexThread != nil && upsertMode { | ||
| i.toast = newToastResolver(options.ThreadID, i.stream, i.writer, indexThread, i.server.tableIndexClient) | ||
| } | ||
|
|
||
| return schema, &metadataState, nil | ||
|
|
@@ -180,6 +190,10 @@ func (i *Iceberg) Close(ctx context.Context, finalMetadataState any) (err error) | |
| } | ||
| }() | ||
|
|
||
| if i.toast != nil { | ||
| i.toast.Close() | ||
| } | ||
|
|
||
| select { | ||
| case <-ctx.Done(): | ||
| // skip commit in case of context cancellation | ||
|
|
@@ -261,6 +275,14 @@ func (i *Iceberg) Check(ctx context.Context) error { | |
|
|
||
| // validate schema change & evolution and removes null records | ||
| func (i *Iceberg) FlattenAndCleanData(ctx context.Context, records []types.RawRecord) (bool, []types.RawRecord, any, error) { | ||
| // Resolve first, so schema detection, filters, partition values and the JSON row | ||
| // (normalization off) all see the recovered values. | ||
| if i.toast != nil { | ||
| if err := i.toast.Resolve(ctx, records); err != nil { | ||
| return false, nil, nil, fmt.Errorf("failed to recover unavailable column values: %w", err) | ||
| } | ||
| } | ||
|
|
||
| // extractSchemaFromRecords detects difference in current thread schema and the batch that being received | ||
| // Also extracts current batch schema | ||
| extractSchemaFromRecords := func(ctx context.Context, records []types.RawRecord) (bool, map[string]string, error) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,4 +13,8 @@ type Writer interface { | |
| // Abort discards whatever the writer staged outside Iceberg, for the paths | ||
| // that give up before Close can commit. | ||
| Abort() | ||
| // EnsureReadable closes any file in paths the Go side still has open, without | ||
| // committing it, so its rows can be read (an open file has no footer). Files written | ||
| // by Java are closed by Java itself when ReadRows asks for them. | ||
| EnsureReadable(ctx context.Context, paths []string) error | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't really set with Writer definition
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. removed from here, created a interface in unavailable values resolver for open files handling |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,9 @@ | |
| <version.googlebigdataoss>2.2.28</version.googlebigdataoss> | ||
| <version.testcontainers>1.20.4</version.testcontainers> | ||
| <version.protobuf>3.25.5</version.protobuf> | ||
| <!-- matches the parquet iceberg-parquet pulls in; needed at compile time to read | ||
| individual rows back by position (see tableIndex/RowReader) --> | ||
| <version.parquet>1.16.0</version.parquet> | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This hand pin overrides Iceberg's Parquet on a future bump, silently mixing versions. |
||
| <version.log4j2>2.26.1</version.log4j2> | ||
| <!-- deliberate family pins; see DEPENDENCY POLICY above --> | ||
| <version.netty>4.1.137.Final</version.netty> | ||
|
|
@@ -563,6 +566,12 @@ | |
| <version>3.12.14</version> | ||
| <scope>test</scope> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>org.apache.parquet</groupId> | ||
| <artifactId>parquet-hadoop</artifactId> | ||
| <version>${version.parquet}</version> | ||
| </dependency> | ||
|
|
||
| <dependency> | ||
| <groupId>com.google.protobuf</groupId> | ||
| <artifactId>protobuf-java</artifactId> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
UnavailableValue is a plain, user-representable string used as an in-band marker: the toast resolver distinguishes markers from real data purely by string equality (isUnavailable in destination/iceberg/toast.go). With post_read enabled, a genuine TEXT/JSON source value exactly equal to this constant is misclassified, and I traced a concrete silent-corruption path: (1) a row is written with a normal value, which is remembered into carry; (2) the row is updated so this column's real new value equals the constant — the resolver's remember path skips it because isUnavailable(value) matches, so carry keeps the stale value; (3) a later UPDATE leaves the column as unchanged TOAST, and the resolver recovers the stale carry value, permanently replacing the true data in the destination. Prefer discriminating via the record's UnavailableColumns metadata (slices.Contains) wherever record context exists (Resolve/remember), and/or make the marker collision-proof (e.g., embed a UUID component). If the constant is changed, note destinations already written by earlier versions contain the old literal, which then stops being recognized as a placeholder.