feat(structured-ingestion) DBI-1096: Generic flattening schema projection for structured ingestion - #4781
Conversation
…on so it can be re-used
…ort to be ingested along with correct values
… a JSON or a BSON object) into a flat array of column QValues including malformed data according to the schema
…data". The column name suffices.
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
|
@fable do a deep review with explanations of the changes. |
|
Claude finished @pfcoperez's task in 4m 6s —— View job Deep review: generic flattening schema projection for structured ingestion
What the PR doesThe PR introduces a source-agnostic pipeline for ingesting dynamic-schema documents into a fixed columnar schema, in three layers:
Nothing outside the tests calls Findings1. Behavior change hidden in the "pure refactor": 2. Non-finite floats nested inside compound values fail the whole record (medium) — 3. 4. Duplicate record fields produce contradictory output (low) — BSON legally allows repeated keys and JSON tolerates them, and 5. Drift risk in the string mapping (nit) — 6. Pre-existing quirks now shared (nits, no action needed) — the 7. NaN downgrade discards the original reason (design nit) — What's good
None of the findings are blockers for merging infrastructure-only code; 2 and 3 are the ones I'd resolve (or explicitly rule intentional) before the MongoDB walker starts feeding real data through this path. |
…or destination_type overrides in normalize (#4783) With a table mapping column setting both a `destination_type` override and nullability (table- or column-level `nullable_enabled`), the DDL generator creates the destination column as Nullable(<type>), but the normalize query still extracted it as plain <type>. JSONExtract to a non-nullable type turns JSON nulls into the type's default, so NULL values silently landed as `0`, `" "`, etc. instead of NULL. This PR makes the normalize query generator mirror the DDL: - Wraps the override in Nullable(...) under the same conditions. - Guards both generators against double wrapping when the override is already spelled Nullable(...), which previously produced invalid Nullable(Nullable(<type>)) DDL. Part of: https://linear.app/clickhouse/issue/DBI-1096 Related to: - #4781 - #4774
Co-authored-by: Pablo Francisco Pérez Hidalgo <273379+pfcoperez@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🔄 Flaky Test DetectedAnalysis: TestApiMy/TestResyncWithSnapshotConfigOnRunningPipe timed out only on the trailing "wait for flow dropped" cleanup (3-min WaitFor) in one of three matrix legs, after all of its real assertions passed and while 8 sibling tests using the same drop-wait helper succeeded — a load-sensitive timeout rather than a logic failure. ✅ Automatically retrying the workflow |
…entable values by wrapping as strings.
This is a valid concern and has been addressed in bf8a9a8 |
🔄 Flaky Test DetectedAnalysis: Infrastructure flake: the pg18 matrix job timed out pulling the imresamu/postgis:18-3.5-alpine image from Docker Hub, so ✅ Automatically retrying the workflow |
… off `shouldRecordValues`
This was an implementation bug, the intention was always to avoid including unexpected values if the flag sets it. Fixed in: 1d03fde |
This is a protection against deeply broken upstream data sources or bad connectors implementation (around the iterator). Included in 368a9fa |
…_field' Co-authored-by: Pablo Francisco Pérez Hidalgo <273379+pfcoperez@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| field["value"] = json.RawMessage(marshaledValue) | ||
| } | ||
| } else { | ||
| fields[name] = map[string]any{ReasonNaN.String(): true} |
There was a problem hiding this comment.
i think claude already mentioned this but here it's overriding the default reason and iiuc is only set because json.Marshal does not support it. could instead convert the NaN/-Inf/+Inf value as string and then the original reason can be used and reasonNaN can be removed (since ClickHouse supports NaN/Inf/-Inf for floats)
There was a problem hiding this comment.
Yes Claude flagged it too. I disregarded its comment for this reason: In these cases, the data could be malformed for two different reasons: The out of domain value and the potentially being an unexpected or wrong type column.
I want to keep the malformed column simple and avoid the situation were we record or need to detect all the possible ways in which a value is broken. So I decided that it was better to flag the completely out of domain value because:
- Unexpected type is a super-category of this problem. Bad domain value implies bad value type -> Unexpected type.
- Unexpected field is less disruptive than broken data.
There was a problem hiding this comment.
maybe I am misunderstanding something here so want to clarify.
my understanding is that ReasonNaN here does not mean that the data itself cannot be replicated due to NaN (because clickhouse supports it for floats), but it's set to NaN because _peerdb_malformed_data column is JSON type and that does not support NaN if the data is deemed incompatible for some other reason (e.g. unexpected field, or type mismatch, but it shouldn't be ambiguous here since unexpected field means the field is not expected from the schema, or type mismatch which means the actual type does not match the inferred type). if my understanding is correct, then i think this code here is hiding that root cause, and overriding with NaN instead.
I can't think of a valid scenario where NaN is a valid reason on during inferencing itself: if it's a float, clickhouse can handle it; if it's not a float, then it's a typeMismatch error of some sort.
Does this match your understanding or did I miss something here
There was a problem hiding this comment.
@jgao54 You understood it perfectly!
There is a wrong premise in this comment of mine:
Unexpected type is a super-category of this problem. Bad domain value implies bad value type -> Unexpected type.
I assumed we didn't accept these float values for ingestion but you clarified that they do, thank you!
Corrected in #4798
… ClickHouse connector Now that it lives in the flow module it returns types.QValueKind directly (using the kind constants), removing the string round-trip and the defaultCHSchemaToQKind wrapper in the structured package. Co-authored-by: Pablo Francisco Pérez Hidalgo <273379+pfcoperez@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aligns the reserved malformed-data column with the existing _peerdb_* column naming convention, reducing the chance of collision with real source fields. Co-authored-by: Pablo Francisco Pérez Hidalgo <273379+pfcoperez@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This PR adds the generic tools to implement structured ingestion (using QValue system) for any unstructured (dynamic schema) data source.
It provided two abstractions for this purpose:
SchemaProjector(db9ce41): Upon initialization, it receives the target schema ([]*protos.ColumnSetting) and a function to interpret these raw mappings column settings as an ordered array ofQValue. ThroughProjectRecordmethod it transforms generic dynamic schema records (abstracted behind a walk iterator so it could be JSON, BSON, or anything that a lazy walker function can take) into an ordered array ofQValueinstances matching the schema plus an extraQValueJSONcolumn (malformed_data) reporting records not matching the schema (see next point). In the ouput QValues record all fields are nullable as missing fields are consideredNULLfor structured logging.One example of lazy iterator applied for MongDB document flattening using
SchemaProjectoris:peerdb/flow/connectors/mongo/qvalue_convert.go
Lines 246 to 273 in 859e699
MalformedData(898e5d0): While processing each individual unstructured document, it is the tracker of schema violations. Its JSON marshalling method implementation generates a JSON structure that makes it possible and easy to query data that failed to fit into the schema at destination CH table:e.g:
{ "year": { "unexpected": true, "value": 2019 } }{ "year": { "type_mismatch": true, "value": "two thousand nineteen" } }Query example at destination CH table:
Given that it provides a default CH types to QKind schema interpreter (Used for MongDB structured ingestion) and that it matches the equivalence function already present in the ClickHouse target connector implementation, this PR also factors out the conversion table so it's shared: 60a80b9
Part of: https://linear.app/clickhouse/issue/DBI-1096