feat: add CombinedExtractor for combining multiple record extractors - #1162
Anatolii Yatsuk (tolik0) wants to merge 5 commits into
Conversation
A DpathExtractor can only describe one path into a response. Three connectors need more than that and hand-roll it in a custom components.py today, which keeps them out of the Connector Builder and out of manifest-only packaging: - source-google-analytics-data-api ships its own class literally named CombinedExtractor (components.py:17) that zip-merges the i-th record of each sub-extractor - the `zip_merge` mode here. - source-monday's MondayIncrementalItemsExtractor (components.py:75) falls back to a second field path only when the first one produced nothing - the `first_match` mode here. - source-github's in-progress GraphQL extractors read records from several sibling paths of one document - the `union` mode here. Their deep traversal stays out of scope; a separate paginator PR covers it. `zip_merge` truncates at the shortest sub-extractor instead of padding, matching GA4's existing zip() so that connector can drop its custom class without a data change. `first_match` decides "produced at least one record" by peeking a single element and chaining it back, so no sub-extractor is materialized and the peeked record is still emitted. All sub-extractors read the same requests.Response. That is safe for the buffered decoders (JsonDecoder, XmlDecoder, ZipfileDecoder) and not for the streaming ones (CompositeRawDecoder with stream_response=True, i.e. CsvDecoder, JsonlDecoder, JsonItemsDecoder, GzipDecoder outside the Connector Builder). The limitation is documented in the docstring and in the schema, and pinned by a test. The generated pydantic model was updated by hand on purpose: a local `poe assemble` on arm64 rewrites ~340 unrelated lines relative to main (conint/ confloat where main uses Field(..., ge=N), plus unrelated class reordering), so only the delta this schema change implies was applied. A maintainer should run /poe build to regenerate the models authoritatively before merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
👋 Greetings, Airbyte Team Member!Here are some helpful tips and reminders for your convenience. 💡 Show Tips and TricksTesting This CDK VersionYou can test this version of the CDK using the following: # Run the CLI from this branch:
uvx 'git+https://github.com/airbytehq/airbyte-python-cdk.git@tolik0/cdk/combined-extractor#egg=airbyte-python-cdk[dev]' --help
# Update a connector to use the CDK from this branch ref:
cd airbyte-integrations/connectors/source-example
poe use-cdk-branch tolik0/cdk/combined-extractorPR Slash CommandsAirbyte Maintainers can execute the following slash commands on your PR:
|
…address review The streaming-decoder hazard was documented as a loud `ValueError`, but that is only what `io.BytesIO` does. In production `requests` puts a `urllib3.HTTPResponse` in `response.raw`, whose read on a closed body returns `b""`, so a `CombinedExtractor` over `JsonlDecoder` or `GzipDecoder` silently drops records: `union` emits only the first sub-extractor's output and `first_match` emits nothing when the first path misses. The Connector Builder downgrades those decoders to `stream_response=False`, so the manifest test-reads green and loses data once published, with the cursor advancing regardless. - `create_combined_extractor` now raises an `AirbyteTracedException` (`config_error`) when the stream's decoder streams the response, naming the decoders and the buffered alternatives. It also rejects the downgraded `CompositeRawDecoder` instances the Builder substitutes, so the Builder rejects exactly what production rejects, and it fires for nested `CombinedExtractor`s, which inherit the retriever's decoder. - `test_streaming_decoder_is_a_known_limitation` is replaced by two tests built on a real `urllib3.HTTPResponse`, pinning the silent loss in `union` and the silent wrong answer in `first_match` against a buffered control, plus parametrized factory tests for the parse-time rejection. `minItems: 1` is added to `extractors`. The stated blocker did not exist: datamodel-codegen silently drops `minItems` on this self-referencing construct at the pinned 0.26.3, so pydantic never sees it, while manifest validation is jsonschema and does enforce it. The `__post_init__` guard stays as the backstop. The hand-written generated enum is renamed `CombineMode` -> `Mode` to match what the pinned generator emits (it derives an inline property enum's name from the property name, not from `title`), so a `/poe build` regen is not a surprise diff. The runtime enum stays `CombineMode`. Also from the review: - Three manifest-level tests through `ConcurrentDeclarativeSource` so the 59 new schema lines are covered by jsonschema validation rather than `parse_obj`. - `zip_merge` logs a warning when the sub-extractors have different lengths, and raises an error naming the mode and sub-extractor index when one yields a non-object instead of a bare `dictionary update sequence` built-in error. - Docstring and schema descriptions rewritten: the limitation is silent, not loud, and is now rejected; the per-sub-extractor decode cost and the wrong paginator record count are documented. The docstring uses headings so pdoc no longer renders the warning as a code block. - `AsyncHttpJobRepository.status_extractor` / `download_target_extractor` and the three factory `extractor_model` parameters widened to what they receive. - `CombinedExtractor` and `CombineMode` exported from `airbyte_cdk`. - Coverage for the four widened unions outside `RecordSelector` and for `CombinedExtractor` behind an `OffsetIncrement` paginator. Backward compatibility re-verified: 531 monorepo manifests validate identically against the base and head schemas, and 5,818 extractor payloads resolve identically through the base and head model modules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Behaviour
- A `null` inside the collection is now skipped instead of aborting the sync.
A null in an array is ordinary API payload, and `MondayTransformation` — the
Python code this component replaces — skips it, so raising was a regression.
Any other non-object element still raises, but as `system_error` rather than
`config_error`: a mis-pointed `field_path` is a connector-development fault the
end user cannot fix from their connection configuration, and sibling decoders
already classify read-time payload-shape faults that way.
- Every collection a glob expands to is resolved and validated before any element
is transformed, so the documented "a bad element leaves the record untouched"
contract now holds per record, not just per collection.
- A `field_path` segment that interpolates to something other than a non-empty
string is now a `config_error` instead of silently turning the whole
transformation into a no-op for the entire sync.
- The error message no longer promises a stream name. `name` is a
`DeclarativeStream` field, not a `$parameter`, so `self._parameters.get("name")`
was always empty for a modern manifest. Threading the real name through
`_create_component_from_model` was rejected: the kwarg would reach
`create_custom_component`, which merges unknown kwargs into the custom class's
own fields and would override a manifest-declared `name` on any existing custom
transformation. The message still names the `field_path` and the element type.
- Messages shortened and de-duplicated per the error-message guidelines.
Performance
- `dpath.get` resolves by folding over the entire object graph, so it costs
O(total nodes in the record) per record: 17.7 ms measured on a record with a
10,000-element *unrelated* sibling. Paths without a glob segment now use a plain
walk — 0.4 us on the same record, ~45,000x faster. `dpath.values` is used for
every glob form, which also removes the bare `ValueError: globs must match only
one leaf` that `**`, `?` and `a[ab]` used to raise.
Manifest contract
- `"ForEach.transformations": "CustomTransformation"` added to
`CUSTOM_COMPONENTS_MAPPING`. Without it a `class_name`-only custom
transformation — the style used in six production manifests — gets no `type`
injected when nested in a `ForEach` and fails schema validation.
- `field_path` declares `interpolation_context: [config, parameters]`, matching
the examples the block advertises. The generated model mirrors the reworded
description exactly; `interpolation_context` is not emitted by codegen.
- The redundant `__eq__` override is gone. The base class body is byte-identical,
and the dataclass-generated one correctly returns False for foreign types
instead of raising `AttributeError`.
Tests
- Null elements skipped; non-object elements raise `system_error`; no stream name
in the message; nothing mutated when a later collection under a glob is invalid
(the previous assertion was vacuous — every element in its fixture was a
non-dict, so nothing could have mutated). Both new guards were confirmed to fail
against the pre-fix implementation.
- `field_path: []`, `$parameters` interpolation of `field_path`, `stream_state`
forwarding, non-`*` globs, equality, and a deep copy in the no-op test.
- `dpath` is not called for a path without a glob, plus a wall-clock guard that a
large unrelated sibling does not dominate the lookup.
- A `ForEach` manifest fragment is validated against the JSON schema, including
all four `$ref` sites — `DeclarativeStream.transformations`,
`DynamicSchemaLoader.schema_transformations`,
`JsonSchemaPropertySelector.transformations` and `ForEach.transformations`.
Nothing validated against the schema before, so dropping `ForEach` from one of
those `anyOf` lists would only have surfaced in a real connector.
- A factory-built nested `class_name`-only `CustomTransformation`.
Notes for the PR body
- Do not run `/poe build` on this branch. The hand-applied model delta was
reproduced against pinned codegen and is byte-identical to it; `main`'s
generated file is itself ~240 lines divergent from codegen (including an
`OAuthScope` -> `Scope`/`OptionalScope` public rename), so regenerating here
would import that drift and would make #1162 conflict. The drift belongs in a
separate re-sync PR against `main`.
- The earlier `ForwardRef` rationale for the class placement was wrong. Codegen
also emits `ForEach` before `JsonSchemaPropertySelector`; there is no hazard and
CI regeneration would not reintroduce the failure.
- The `source-github` `ReleasesRecordTransformation` citation is from the unmerged
`tolik0/source-github/graphql-streams` branch, not monorepo `main`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Review of 5c7510c37244cdcb1a695d8cebc7dee205253327
Everything below was executed locally on this SHA (branch checked out, Poetry env from the lockfile). Findings are tagged P0–P3; no P0.
Breaking-change call: NON_BREAKING
git grep on airbytehq/airbyte origin/master (583b2dee3a1e):
| Query | Count |
|---|---|
type: CombinedExtractor under airbyte-integrations/ |
0 |
CombinedExtractor anywhere |
4 — all source-google-analytics-data-api (AGENTS.md, CONTRIBUTING.md, components.py, manifest.yaml). It is a connector-local custom component (class_name: source_declarative_manifest.components.CombinedExtractor), resolved via CustomRecordExtractor, so it does not collide with the new declarative type. |
CombineMode |
0 |
from airbyte_cdk import * |
0 |
class Mode / import Mode / Mode as from airbyte_cdk.sources.declarative.models in connector .py |
0 (class Mode is new on this branch, absent on origin/main) |
.py referencing status_extractor / download_target_extractor / AsyncHttpJobRepository |
1 — source-salesforce/source_salesforce/streams.py (constructs AsyncHttpJobRepository; the annotation widening DpathExtractor → RecordExtractor is contravariant-safe for callers) |
manifests with type: RecordSelector |
533 (all unaffected: schema changes are additive anyOf members) |
manifests with first_match / zip_merge |
0 |
All changes are additive (new schema type, new anyOf members, widened type hints, new exports). No existing manifest or Python consumer is affected.
Codegen (checked against clean main, per the known ~350-line drift)
Ran bin/generate_component_manifest_files.py (datamodel-codegen 0.26.3 in Dagger) in a clean origin/main worktree and in this branch's worktree, then diffed generator-output vs generator-output. The regen delta is 147 lines and contains exactly this PR's additions (class Mode, class CombinedExtractor, the union widenings, CombinedExtractor.update_forward_refs()) — the committed hand-edit matches generator output modulo line wrapping. min_items=1 for extractors is absent in both committed and regenerated output: datamodel-codegen 0.26.3 drops minItems when items is an anyOf (same as partition_routers), so it's a generator limitation, not a hand-edit deviation. JSON-schema validation still enforces minItems (the new test_concurrent_declarative_source test covers it).
Local checks
poetry run ruff check .— pass;poetry run ruff format --check .— pass;poetry run mypy --config-file mypy.ini airbyte_cdk— pass (459 files).unit_tests/sources/declarative/{extractors,decoders}+unit_tests/connector_builder— 236 passed.extractors/test_combined_extractor.py,parsers/test_model_to_component_factory.py,test_concurrent_declarative_source.py,requesters/— 728 passed, 1 skipped, 2 failed:test_read_with_concurrent_and_synchronous_streams_with_{concurrent,sequential}_state(sqlite3.OperationalError: database table is locked: responsesfromrequests_cache). Same two fail identically on a cleanorigin/main(96a7c0ac) worktree in this environment, so I'm not attributing them to this PR; flagging for awareness since PR CI reports them green.
CI snapshot at review time
25 passed, destination-motherduck failed, source-shopify pending — matches the stated baseline. As noted, no connector in the matrix declares CombinedExtractor, so CI doesn't exercise this component; the findings below come from a local end-to-end read.
Findings
- P1 —
unionmode +OffsetIncrementsilently skips pages (data loss). Reproduced end-to-end; details inline oncombined_extractor.py. - P2 — schema
modedescription states the wrong pagination caveat (blamesfirst_match/zip_merge, omitsunion, which is the harmful one). Inline ondeclarative_component_schema.yaml. - P2 —
create_offset_incrementacceptsCombinedExtractorModelwithout guarding theunioncase. Inline onmodel_to_component_factory.py. - P3 — Builder-mode guard rejects any
CompositeRawDecodersubclass regardless ofstream_response. Inline. - P3 — generated enum name
Modeis a very generic new top-level export ofdeclarative_component_schema.py. Inline.
Probe script used for the P1 repro: end-to-end ConcurrentDeclarativeSource.read against requests_mock, manifest with CombinedExtractor(union, [a, b]) + OffsetIncrement(page_size=2), API returning 2 items per path per page for 6 items each.
| Note that an `OffsetIncrement` or `PageIncrement` paginator builds its own copy of the | ||
| extractor to count the records of a page, which doubles that cost, and the count it obtains is | ||
| the combined count: the winner's record count under `first_match` and the shortest | ||
| sub-extractor's count under `zip_merge`, neither of which is the API's page size. |
There was a problem hiding this comment.
P1 — union + OffsetIncrement skips records. This note covers first_match and zip_merge but omits union, which is the mode where the mismatch actually loses data: OffsetIncrement.next_page_token does len(list(self.extractor.extract_records(response))) and advances the offset by that count. Under union that's the sum across sub-extractors, so the offset overshoots.
Reproduced end-to-end on this SHA (ConcurrentDeclarativeSource.read, requests_mock, CombinedExtractor(union, [a, b]) + OffsetIncrement(page_size=2), API returning 2 items for a and 2 for b per page, 6 each):
requests (qs): [{'offset': ['0'], 'limit': ['2']}, {'offset': ['4'], 'limit': ['2']}, {'offset': ['8'], 'limit': ['2']}]
records: ['a0', 'a1', 'b0', 'b1', 'a4', 'a5', 'b4', 'b5']
expected 12 records; got 8
a2, a3, b2, b3 are silently dropped and there is no warning. first_match behaves correctly in the same harness (offsets 0, 2, 4; all 5 b records). PageIncrement is not lossy (it only uses the count for the < page_size stop check) but will issue one extra request when the combined last page happens to equal page_size.
Suggest either (a) rejecting mode: union under an OffsetIncrement paginator in the factory with a config_error, or (b) documenting here and in the schema that union must not be combined with OffsetIncrement, and making the existing test_combined_extractor_is_accepted_by_an_offset_increment_paginator assert behaviour rather than just construction.
There was a problem hiding this comment.
Confirmed and fixed in 02f07c6 — I went with option (a), the parse-time rejection.
ModelToComponentFactory.create_offset_increment now raises an AirbyteTracedException with FailureType.config_error when its extractor_model is a CombinedExtractor whose tree contains a union node at any depth (a nested union inflates the count of the node above it, so the whole tree is walked). first_match and zip_merge are untouched.
PageIncrement is left accepting union, since it only compares the count against page_size: the cost is the one extra request you describe, which is now documented on the component rather than rejected.
Tests replacing test_combined_extractor_is_accepted_by_an_offset_increment_paginator:
test_union_combined_extractor_is_rejected_by_an_offset_increment_paginator, parametrized over the default mode, an explicitmode: union, and aunionnested under afirst_match.test_first_match_combined_extractor_counts_only_the_winning_sub_extractor_for_the_offset— asserts behaviour, not construction: withaempty andbholding two records,next_page_tokenreturns offset 2, not 4.test_zip_merge_combined_extractor_counts_the_shortest_sub_extractor_for_the_offset— two merged records out of four extracted, offset advances by 2.test_union_combined_extractor_is_accepted_by_a_page_increment_paginator.
| - "$ref": "#/definitions/CombinedExtractor" | ||
| mode: | ||
| title: Combine Mode | ||
| description: 'How the records of the sub-extractors are combined. "union" (default) yields every record of every sub-extractor, in the order the extractors are declared. "first_match" yields the records of the first sub-extractor that produces at least one record and skips the remaining ones; nothing is yielded if none of them produces a record. "zip_merge" merges the i-th record of every sub-extractor into a single record, with later sub-extractors overwriting the fields set by earlier ones, and stops at the shortest sub-extractor, discarding the trailing records of the longer ones. Note that an OffsetIncrement or PageIncrement paginator counts the combined records, which under "first_match" and "zip_merge" is not the number of records the API returned for the page.' |
There was a problem hiding this comment.
P2 — pagination caveat is inverted. "under first_match and zip_merge is not the number of records the API returned" — in practice first_match returns the winner's count, which is the API count for the path that had data (verified: offsets 0/2/4 in the repro), and zip_merge returns the shortest count, which is usually the API count too. The mode that misreports is union (sum of all sub-extractors), and with OffsetIncrement that skips pages (see the P1 comment on combined_extractor.py). This text is what Builder users will read; it should name union + OffsetIncrement as the unsupported combination.
There was a problem hiding this comment.
You are right, the caveat was backwards. Rewritten in 02f07c6; the mode description now reads:
Note that a paginator which counts the records of a page counts the combined records: under "union" that is the sum over all sub-extractors, which overshoots the API page size, so "union" is rejected with an OffsetIncrement paginator because the offset would skip records. Under "first_match" the count is the count of the winning sub-extractor and under "zip_merge" the count of the shortest one, which are usually the number of records the API returned for the page.
The same correction is in the component docstring, under a new "Record-counting paginators" section that also records the extra PageIncrement request.
| url_base: str, | ||
| extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, | ||
| extractor_model: Optional[ | ||
| Union[CustomRecordExtractorModel, DpathExtractorModel, CombinedExtractorModel] |
There was a problem hiding this comment.
P2 — accepting CombinedExtractorModel here without a guard is what enables the P1 data loss. create_offset_increment builds a second CombinedExtractor whose extract_records count is fed to OffsetIncrement.next_page_token. For mode: union that count is sum(len(sub_i)), not the API page size. Since this PR already rejects unsupported decoder combinations at construction (_reject_combined_extractor_over_streaming_decoder), the same pattern fits here: if isinstance(extractor_model, CombinedExtractorModel) and (extractor_model.mode or Mode.union) == Mode.union, raise an AirbyteTracedException(failure_type=config_error) explaining that union cannot drive offset pagination. Nested CombinedExtractors with a union anywhere in the tree have the same problem.
There was a problem hiding this comment.
Done in 02f07c6, using exactly the pattern you suggest: create_offset_increment calls a new _reject_union_combined_extractor_for_offset_increment(extractor_model) right after the decoder check, which raises AirbyteTracedException(failure_type=FailureType.config_error) when the extractor model is a CombinedExtractorModel whose tree contains a union.
The nested case is covered by _combined_extractor_tree_contains_union, which recurses through the sub-extractors, so a union under a first_match is rejected too. create_page_increment deliberately keeps accepting union — it only uses the count for the < page_size stop check, so the count inflation costs a request rather than records.
| streams_in_production = inner_decoder.is_stream_response() or ( | ||
| # The four decoders the Builder downgrades are the only bare `CompositeRawDecoder`s | ||
| # this factory builds; every buffered decoder is a distinct class. | ||
| self._emit_connector_builder_messages and isinstance(inner_decoder, CompositeRawDecoder) |
There was a problem hiding this comment.
P3 — In Builder mode this rejects every CompositeRawDecoder instance, including ones constructed with stream_response=False (e.g. a CustomDecoder subclassing CompositeRawDecoder, or JsonDecoder-style wrappers if they are ever refactored to subclass it). inner_decoder.is_stream_response() already covers the production case; the Builder branch exists only because create_*_decoder forces stream_response=False when _emit_connector_builder_messages is set. Consider checking the model type (CsvDecoderModel, JsonlDecoderModel, GzipDecoderModel, IterableDecoderModel, …) rather than the runtime class, so the rejection matches the message text exactly. Low impact today: 0 connectors on origin/master use CombinedExtractor.
There was a problem hiding this comment.
Narrowed in 02f07c6. The Builder half of the guard is now _is_decoder_downgraded_by_connector_builder, which matches on the exact class plus the parser rather than on isinstance:
return type(decoder) is CompositeRawDecoder and isinstance(
decoder.parser, (CsvParser, JsonLineParser, JsonItemsParser, GzipParser)
)That is exactly the set create_csv_decoder / create_jsonl_decoder / create_json_items_decoder / create_gzip_decoder build with stream_response=False in Builder mode, so the rejection matches the message text. A CustomDecoder subclassing CompositeRawDecoder, or a buffered CompositeRawDecoder around a JsonParser, is no longer rejected — both read response.content in production too. IterableDecoder still lands in the first branch, since is_stream_response() is True for it in the Builder as well.
I kept the runtime check rather than threading decoder models down to create_combined_extractor: the decoder arrives there as an instance through create_record_selector, and passing the model through every call site would be a much larger change than the guard warrants.
New test: test_combined_extractor_over_a_buffered_composite_raw_decoder_is_accepted_in_the_builder, parametrized over a CompositeRawDecoder(JsonParser(), stream_response=False) and a subclass of it.
| parameters: Optional[Dict[str, Any]] = Field(None, alias="$parameters") | ||
|
|
||
|
|
||
| class Mode(Enum): |
There was a problem hiding this comment.
P3 — This is what datamodel-codegen 0.26.3 emits for the inline mode enum (verified by regenerating from clean main and from this branch — the regen delta matches the committed file), so it's not a hand-edit issue. But Mode is a very generic new top-level name in airbyte_cdk.sources.declarative.models.declarative_component_schema, which several connectors import names from (source-github, source-klaviyo, source-stripe, source-sftp-bulk); today no connector imports or defines Mode from it (0 hits on origin/master), so this is not breaking. If you want a stable, descriptive name, give the enum its own definitions entry (e.g. CombineMode) and $ref it — codegen will then emit CombineMode, matching the runtime enum in combined_extractor.py.
There was a problem hiding this comment.
Took the suggestion in 02f07c6: mode is now a $ref to a new CombineMode definition in declarative_component_schema.yaml (alongside the existing enum-only definitions such as SchemaNormalization and ValueType), so the generated name matches the runtime enum in combined_extractor.py.
Verified with the pinned generator rather than by hand-editing the name: running datamodel-codegen==0.26.3 with the flags from bin/generate_component_manifest_files.py emits
class CombineMode(Enum):
union = "union"
first_match = "first_match"
zip_merge = "zip_merge"immediately before ResponseToFileExtractor, and mode: Optional[CombineMode] = Field(CombineMode.union, ...). The committed generated block is that output verbatim (the per-field examples list moves onto the definition, which is why it is gone from the field). class Mode is gone from the module. Schema validation is unchanged: the mode: concatenate and extractors: [] ValidationError tests in test_concurrent_declarative_source.py still pass through the $ref.
There was a problem hiding this comment.
Re-review of 02f07c66d0f8b7bb27262de692ebb0c3e48ab355 (follow-up to my review of 5c7510c3)
Everything below was executed locally on this SHA.
Previous findings
| Finding | Status |
|---|---|
P1 union + OffsetIncrement skips pages |
Fixed. Re-ran the same end-to-end repro (ConcurrentDeclarativeSource.read + requests_mock, union[a,b] + OffsetIncrement(page_size=2)): construction now raises AirbyteTracedException (config_error) before any request is sent. first_match + OffsetIncrement still reads correctly (offsets 0/2/4, all 5 records). OffsetIncrement( is constructed at exactly one site in the factory, so the guard covers every declarative path, including nested unions (_combined_extractor_tree_contains_union). |
P2 schema mode description inverted |
Fixed — now names union as the rejected combination. |
P2 create_offset_increment unguarded |
Fixed — _reject_union_combined_extractor_for_offset_increment, 3 rejection cases + first_match/zip_merge/PageIncrement acceptance tests with real next_page_token assertions. |
P3 Builder guard rejects any CompositeRawDecoder |
Fixed — _is_decoder_downgraded_by_connector_builder matches type(...) is CompositeRawDecoder + one of the four parsers, which is exactly what create_{csv,jsonl,json_items,gzip}_decoder build under _emit_connector_builder_messages (lines 2839/2847/2855/2875). |
P3 generic Mode enum name |
Fixed — CombineMode is now a definitions entry; regenerated with datamodel-codegen 0.26.3 from clean main and from this SHA, and the regen delta (class CombineMode(Enum) before ResponseToFileExtractor, mode: Optional[CombineMode], no examples=, update_forward_refs position) matches the committed file exactly. |
New findings
-
P1 — a 62 MB, 2,000,001-line
unit_tests/sources/declarative/extractors/test_response.csvis committed in this commit (git log --diff-filter=A→02f07c66). GitHub won't render a diff for it, so no inline anchor is possible:This file (2,000,001 lines, 62,000,016 bytes) is the artifact that
large_event_response_fixtureintest_response_to_file_extractor.pywrites and thenos.removes; it survived here because a@pytest.mark.slowrun was interrupted before teardown. Once merged it stays in every clone's history forever (a squash merge only helps if the file is gone from the final tree), andunit_tests/sources/declarative/extractors/has no.gitignorecovering it (the only related rule isdecoders/.gitignore: test_response.txt).Please
git rmit in a follow-up commit, and consider addingunit_tests/sources/declarative/extractors/.gitignorewithtest_response.csv(mirroring thedecoders/one) so the fixture can't be committed again. -
P3 — the new
AirbyteTracedException.messageembeds remediation ("Use thefirst_matchorzip_mergemode, …"); per the CDK error-message guidelines the user-facingmessageshould state the one violated condition and remediation goes in docs/internal_message. Same pattern as the pre-existing streaming-decoder message from5c7510c3, so treat as a nit for both. Inline.
Breaking-change call: NON_BREAKING (unchanged)
The new commit only touches the new component, its schema entry, the factory guard, and tests. The Mode → CombineMode rename affects a symbol that was never released (introduced in this PR; 0 references on airbytehq/airbyte origin/master). Counts from the first review stand (0 type: CombinedExtractor, 0 CombineMode, 0 first_match/zip_merge in manifests, 0 star imports).
Local checks
ruff check/ruff format --check/mypy --config-file mypy.ini airbyte_cdk— all pass (459 files).test_combined_extractor.py+test_model_to_component_factory.py+test_concurrent_declarative_source.py: no failures in the first two.test_concurrent_declarative_source.pyfailed 14 tests in one run and 2 in an immediate rerun — every failure issqlite3.OperationalError: database table is locked: responsesfromrequests_cacheunder concurrent reads (no assertion errors). The same 2 fail identically on a cleanorigin/mainworktree in this environment, so I'm not attributing them to the PR, but flagging since CI reports the file green.
CI
Not re-checked for this SHA beyond the snapshot in my previous review; connector matrix still doesn't exercise CombinedExtractor.
| if not ModelToComponentFactory._combined_extractor_tree_contains_union(extractor_model): | ||
| return | ||
| raise AirbyteTracedException( | ||
| message=( |
There was a problem hiding this comment.
P3 (nit) — Per the CDK error-message guidelines, the user-facing message should be one violated condition, ≤~120 chars, no remediation (that belongs in the docs/schema description, which now already have it); internal_message carries the mechanism. Something like message="CombinedExtractor mode \"union\" is not supported with an OffsetIncrement paginator." with the current text moved into internal_message. Same nit applies to the pre-existing _reject_combined_extractor_over_streaming_decoder message — fine to address both or neither together.
There was a problem hiding this comment.
Taken, for both messages, in 28da58e:
message='CombinedExtractor mode "union" is not supported with an OffsetIncrement paginator.'message="CombinedExtractor is not supported with a streaming decoder."
The mechanism and the remediation (which mode/paginator or which buffered decoder to use instead) moved into internal_message in both cases; the schema description and the component docstring already carry the guidance for users.
…de enum - Reject a `union` CombinedExtractor (nested ones included) under an `OffsetIncrement` paginator with a config error: the offset is advanced by the summed record count of all sub-extractors, which overshoots the API page size and silently skips records. - Correct the pagination caveat in the `mode` schema description and in the component docstring: `union` is the harmful mode, `first_match` and `zip_merge` report the winning/shortest sub-extractor's count. - Narrow the Connector Builder half of the streaming-decoder guard to the four decoders the Builder actually downgrades (exact `CompositeRawDecoder` class with a streaming parser), so a buffered subclass is no longer rejected. - Move the `mode` enum into its own `CombineMode` schema definition so codegen emits `CombineMode` instead of the very generic top-level `Mode`; the generated model matches `datamodel_code_generator==0.26.3` output. - Keep both new user-facing messages to the violated condition and move the remediation into `internal_message`, per the error-message guidelines. - Ignore the `test_response.csv` fixture that `test_response_to_file_extractor.py` writes under `unit_tests/sources/declarative/extractors/`, mirroring the `decoders/` rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
02f07c6 to
28da58e
Compare
|
Both findings from the re-review of P1 — the 62 MB P3 — error-message remediation. Applied to both messages, the new one and the pre-existing streaming-decoder one; details in the inline reply. Local checks on |
There was a problem hiding this comment.
Re-review of 28da58ef18e964e123a670bbcb9f2e87a93455db
Executed locally on this SHA (git diff 02f07c66 28da58ef: 4 files, +13/−2,000,016).
Previous findings
| Finding | Status |
|---|---|
P1 62 MB unit_tests/sources/declarative/extractors/test_response.csv committed |
Fixed. File is gone from the tree; new unit_tests/sources/declarative/extractors/.gitignore (test_response.csv) — git check-ignore confirms the fixture path is now ignored. Note the blob still exists in the branch's history (added in 02f07c66); a squash merge drops it from main, a merge commit would keep it. |
P3 error message embedded remediation |
Fixed. Both AirbyteTracedException.messages are now a single condition (CombinedExtractor is not supported with a streaming decoder. / CombinedExtractor mode "union" is not supported with an OffsetIncrement paginator.); remediation and decoder lists moved to internal_message. |
All findings from 5c7510c3 and 02f07c66 remain fixed — re-ran the end-to-end repro on this SHA: union + OffsetIncrement is rejected at construction before any request; first_match + OffsetIncrement reads offsets 0/2/4 and all 5 records.
Local checks
poetry run ruff check .— pass;poetry run ruff format --check .— pass;poetry run mypy --config-file mypy.ini airbyte_cdk— pass (459 files).test_combined_extractor.py+test_model_to_component_factory.py— 206 passed, 0 failed (the message-text assertions"union" in message/"OffsetIncrement" in messagestill hold with the shortened strings).- Codegen: this commit doesn't touch the schema YAML or generated model, so the
02f07c66regen comparison (committed block == generator output) still applies.
Breaking-change call: NON_BREAKING (unchanged)
Only error strings, a docstring, a .gitignore and a fixture deletion changed. Monorepo counts from the first review stand: 0 type: CombinedExtractor, 0 CombineMode, 0 first_match/zip_merge manifests, 0 from airbyte_cdk import * on airbytehq/airbyte origin/master.
No open findings. Approving; the only thing to keep in mind is the squash-merge note above so the 62 MB blob doesn't land in main history.
…n fall through a page of nulls
`source-monday`'s `MondayIncrementalItemsExtractor` was described as a motivating
connector for this component but was not a drop-in adopter, for one reason: it
drops falsy entries before deciding whether the primary path produced anything
(`components.py:106-124`). A GraphQL partial response puts `null` in the record
list and explains itself in a sibling `errors` field, and monday has to fall
through to `field_path_pagination` when a page is all nulls.
`first_match` counted a `None` as a record, so the primary path won with nothing
usable and the fallback was never tried. `skip_empty_records` drops falsy records
- `None`, `{}`, `[]`, `""` - before any mode sees them, which both keeps them out
of the record stream and takes them out of the match decision. It is off by
default, so existing manifests are unaffected.
The flag applies to all three modes. Under `zip_merge` it shifts the alignment of
the merged records, because dropping the i-th record of one sub-extractor pairs
its next record with the i-th record of the others; the schema description and a
test say so rather than leaving it to be discovered.
One divergence from monday remains and is deliberate: monday's warning names
`body.get("errors")`, which assumes a GraphQL error envelope. The component logs
the dropped count and the sub-extractor that produced them instead.
The generated model was hand-applied and then verified against the pinned
generator: the `CombinedExtractor` block is byte-identical to `poe assemble`
output once `ruff format` is applied.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The architecture review of this PR found one adopter for `zip_merge` (source-google-analytics-data-api), and that connector keeps its `components.py` in every scenario because both of its inner extractors are a custom `KeyValueExtractor`. `union` and `first_match` each have two adopters, so they stay; `zip_merge` was fitted to a single connector that it does not free. Removes the mode from the enum, the schema, the generated models, the component and its tests, along with the shortest-wins truncation warning, the Mapping-only merge check and the `skip_empty_records` alignment caveat that only applied to it. The mode can come back when a second columnar API turns up, ideally alongside a key/value-list-to-object transformation that would free GA4's inner extractor too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
Adds
CombinedExtractor, a declarativeRecordExtractorthat combines the output of severalsub-extractors. A single
DpathExtractorcan only describe one path into a response; connectorsthat need more than that drop into a custom
components.py, which makes them ineligible for theConnector Builder and for manifest-only packaging.
Why - connectors hand-roll this today
Every claim below was checked against the connector's
components.pyin the monorepo, and the replacement was run against fixtures where it mattered rather than compared by shape. Two of the claims in the first revision of this description were too strong and are corrected here; a third mode,zip_merge, was dropped after that check (see below).source-monday-components.py:75-124,MondayIncrementalItemsExtractor. It does not concatenate: it triesfield_path, trackshas_records, and only falls back tofield_path_paginationwhen the first path producednothing (lines 113-124). That is
first_match, and it is the reason that mode exists.The first revision of this description said monday was not a drop-in adopter, because its
_try_extract_records(lines 106-111) also drops falsy entries and does so before decidingwhether the primary path produced anything, while
DpathExtractoryields list items verbatim.Migrating it as-is would have started emitting
Nonerecords and - on a page where every itemis
None- counted those as records, locked in the primary extractor and never fallen back.skip_empty_recordscloses that gap, so monday is a drop-in adopter with the flag on. One divergencestays, deliberately: monday's warning names
body.get("errors"), which assumes a GraphQL errorenvelope, so the component logs the dropped count and the sub-extractor instead. monday keeps
six other custom classes, so it does not become manifest-only.
source-zendesk-chat-components.py:19,ZendeskChatBansRecordExtractor, concatenatesip_addressandvisitorfrom one response.That is
union. It then sorts bycreated_at, whichuniondoes not do; the sort exists becausethe
banspaginator uses{{ last_record['id'] + 1 }}assince_id, so the connector needs thelast emitted record to carry the highest id. A
unionadoption therefore comes with a manifestchange to
cursor_value: "{{ (response.ip_address + response.visitor) | map(attribute='id') | max + 1 }}",which is also more correct than the sort, since it does not assume
created_atorder equalsidorder.With that change zendesk-chat loses its
components.py.source-github-unionis the wrapper that picks between the listing document(
data.repository.pullRequests.nodes[*]) and the drill-down document (data.repository.pullRequest,or
data.node) of the GraphQL streams on the unmergedtolik0/source-github/graphql-streamsbranch.It cannot express those streams on its own, because the children also need fields copied from their
parent; that half is
DpathExtractor.record_expanderwith theparent_fieldsoption from feat(RecordExpander): addparent_fieldsto copy named parent values onto expanded items #1165.unionoverDpathExtractors with aRecordExpanderreproduces bothNestedGraphQLRecordExtractorand
DeepNestedGraphQLRecordExtractorrecord-for-record on seven fixture shapes, including thefour-level
pull_request_comment_reactionstraversal. The connector still keeps two custompagination strategies and a transformation, so it does not become manifest-only either.
Checked and found not to be adopters:
source-google-analytics-data-apiCombinedExtractor(components.py:17-44) waszip_mergecharacter for character, but both of its sub-extractors are a custom
KeyValueExtractorthiscomponent cannot express, so only the outer layer could ever migrate and the connector keeps its
components.pyregardless. It was the onlyzip_mergeadopter in the fleet, which is why the modewas dropped.
source-zendesk-supportZendeskSupportAttributeDefinitionsExtractor(components.py:32-46)reads two paths and stamps a per-path constant (
condition: all/any). After aunionnothingtells
AddFieldswhich branch a record came from.source-pinterestAdAccountRecordExtractor(components.py:60-82) returnsitemswhen thekey is present and otherwise wraps the whole response as one record.
first_matchfalls throughwhen
itemsis present but empty, where pinterest returns nothing, and the wrap is aDpathExtractorgap, not a combine mode.Count:
union2 adopters,first_match1, connectors that losecomponents.py1 (zendesk-chat).Dropped:
zip_mergeAn earlier revision had a third mode,
zip_merge, merging the i-th record of every sub-extractorinto one record and stopping at the shortest sub-extractor. It had exactly one adopter
(google-analytics-data-api, above), that connector stays Python either way, and the mode needed
more documentation of its failure modes - shortest-wins truncation, alignment shift under
skip_empty_records, object-only records - than of its behaviour. Five connectors do zipcolumn headers with row values (amplitude, google-analytics-data-api, google-search-console,
google-sheets, us-census), but four of them zip inside a single response array, which sub-extractor
zipping does not express. If a columnar extractor is wanted later it should be its own component
over one array, not a combine mode. Commit
bbfd864cremoves the mode, its nine tests, and theOffsetIncrementcounting rule for it.skip_empty_recordsOff by default; existing manifests are unaffected. When on, falsy records -
None,{},[],""- are dropped before any mode sees them, so they neither reach the record stream nor counttowards the
first_matchdecision. A sub-extractor whose records are all empty therefore loses,and the next one is tried. That is what makes
source-mondaya drop-in adopter, and it is alsowhat a paginator counts: under
first_matchthe count is the winning sub-extractor's count afterthe empty records were dropped.
A warning naming the dropped count and the sub-extractor is logged once per extraction, and only
when something was actually dropped.
Record-counting paginators:
unionis rejected underOffsetIncrementOffsetIncrement.next_page_tokenadvances the offset by the number of records its extractorreturns for the page. Under
unionthat is the sum over all sub-extractors, so the offsetovershoots the page the API returned: two sub-extractors returning two records each with
page_size: 2request offsets 0, 4, 8 instead of 0, 2, 4 and silently lose two thirds of therecords.
ModelToComponentFactory.create_offset_incrementtherefore rejects aunionCombinedExtractor- including aunionnested anywhere in the tree - with anAirbyteTracedException(failure_type=config_error).first_match(the winning sub-extractor's count) does not inflate the count and is accepted.PageIncrementacceptsuniontoo: it onlycompares the count against
page_sizeto decide whether to stop, so an inflated count costs oneextra request on the last page rather than records. Both facts are in the docstring and in the
modeschema description.Modes
union(default) - every record of every sub-extractor, in the order the extractors aredeclared. The GraphQL case: one document, records under two sibling paths.
first_match- the records of the first sub-extractor that produces at least one record;the remaining sub-extractors are never touched. Nothing is yielded if none of them produces a
record. The monday case.
"Produced at least one record" is decided by peeking a single element and chaining it back with
itertools.chain, never bylist()-ing a sub-extractor - large responses stay lazy and the peekedrecord is still emitted.
Backward compatibility: additive only - no existing manifest changes behavior
CombinedExtractordefinition, andCombinedExtractorappended to five existinganyOflists. Appending a member to ananyOfonly widens what validates; every manifest that validated before still validates, and it still
resolves to the same member because each extractor definition pins a distinct
typeliteral.jsonschema-validated against the base and head schemas with identical verdicts, and all 5,818
extractor payloads inside them were parsed through both the base and the head pydantic model
modules with zero resolution changes (5,677
DpathExtractor, 84CustomRecordExtractor, thesame 57 non-literal payloads failing on both sides).
model_to_component_factorygains one registry entry and two methods. Nothing dispatchesdifferently:
_create_component_from_modelkeys on the pydantic model type, and no existingmanifest can produce a
CombinedExtractormodel.Streaming decoders are rejected at parse time
Every sub-extractor is handed the same
requests.Response, so the body must be readable more thanonce. That holds for the buffering decoders, which is the common case:
JsonDecoderand anyCompositeRawDecoder(stream_response=False)readresponse.content(cached byrequests),XmlDecoderreadsresponse.text,ZipfileDecoderreadsresponse.content.It does not hold for streaming decoders -
CompositeRawDecoder(stream_response=True), which iswhat
CsvDecoder,JsonlDecoder,JsonItemsDecoderandGzipDecoderresolve to outside theConnector Builder, plus
IterableDecoder. An earlier revision of this description claimed thefailure was loud. It is not.
requestsputs aurllib3.HTTPResponseinresponse.raw, andreading a closed
urllib3.HTTPResponsereturns an empty body rather than raising, so:unionemits only the first sub-extractor's records, with no error;first_matchemits nothing at all when the first path misses - which is precisely the case thatmode exists for.
The old
test_streaming_decoder_is_a_known_limitationonly passed because it put anio.BytesIOin
response.raw, andio.BytesIOdoes raise on a read after close. Since the Connector Builderforces those decoders to
stream_response=False, the manifest would have test-read correctly andlost records once published, with the cursor advancing over the lost window.
Both halves are fixed here:
ModelToComponentFactory.create_combined_extractornow raises anAirbyteTracedExceptionwithFailureType.config_errorwhen the stream's decoder streams the response. The user-facingmessagestates the violated condition only; the decoder that was configured and the bufferedalternatives are in
internal_message. It fires for nestedCombinedExtractors too, sincesub-extractors inherit the retriever's decoder, and it fires in the Connector Builder as well:
_is_decoder_downgraded_by_connector_buildermatches the exactCompositeRawDecoderclasscarrying one of the four streaming parsers the Builder downgrades, so the Builder rejects exactly
what production rejects while a buffered
CompositeRawDecoder(or a custom subclass of it) isleft alone.
urllib3.HTTPResponseand renamed to what it pins:test_union_over_a_streaming_decoder_silently_drops_the_later_sub_extractorsandtest_first_match_over_a_streaming_decoder_silently_returns_the_wrong_answer, with a bufferedcontrol. A parametrized factory test asserts the parse-time rejection for all five decoders, in
and out of the Builder.
minItems: 1is now in the schemaAn earlier revision left
extractorswithoutminItems: 1, on the grounds that pydantic v1 cannotenforce a length constraint through the
ForwardRefcreated by the self-referencing list. Thepydantic behaviour is real in isolation, but the constraint never reaches pydantic:
datamodel-codegen silently drops
minItemson this construct (verified by regenerating the modelswith the pinned
datamodel_code_generator==0.26.3; the generated file is byte-identical with andwithout it, while a non-self-referencing list such as
RateLimitedMultipleTokenAuthenticator.quotasdoes get
min_items=1). Manifest validation isjsonschema, not pydantic, so the constraint isenforced exactly where a configuration error belongs. The
__post_init__guard stays as thebackstop for direct Python construction.
Other notes
type. I deliberately did not add"CombinedExtractor.extractors": "DpathExtractor"toDEFAULT_MODEL_TYPES: inside aheterogeneous list, silently defaulting a typo'd entry to
DpathExtractorhides errors.file_extractoronFileUploaderwas left alone, deliberately. It is the one recordextractor reference site that did not get
CombinedExtractor.git grep file_extractor -- airbyte_cdkreturns only the schema line and the model line:create_file_uploadernever readsit, nothing under
retrievers/file_uploader/reads it, and no connector in the monorepo sets it.Widening a dead field would be noise; deleting it belongs in a separate PR. The other five sites
all got it:
RecordSelector.extractor,FileUploader.download_target_extractor, andAsyncRetriever'sstatus_extractor,download_target_extractoranddownload_extractor.CompositeRawDecoderre-parsesthe cached body on each
decode()), so a response is parsed once per sub-extractor. AnOffsetIncrementorPageIncrementpaginator builds its own copy of the extractor to count apage, doubling that.
AsyncHttpJobRepository.status_extractor/download_target_extractorare now typedRecordExtractorrather thanDpathExtractor, and thethree
extractor_modelparameters in the factory now includeCombinedExtractorModel. Noruntime change; the old annotations simply understated what those sites already receive.
unit_tests/sources/declarative/extractors/.gitignorenow ignorestest_response.csv, the 62 MB artifactlarge_event_response_fixturewrites and removes inteardown, mirroring the existing
decoders/test_response.txtrule.CombinedExtractorandCombineModeare exported fromairbyte_cdk, matchingDpathExtractorand theUnionPartitionRouterprecedent.Generated models
airbyte_cdk/sources/declarative/models/declarative_component_schema.pywas updated by hand tomatch the schema, on purpose. A local
poe assembleon this machine does not reproduce what iscommitted on
main: it rewrites hundreds of unrelated lines, emittingconint(ge=1)/confloat(ge=0.0)where the committed file usesField(..., ge=N)(maincontains zeroconint/confloat; a fresh local run emits nine), renamesOAuthScopetoScope/OptionalScope,and reorders unrelated generated classes. Rather than bury this component under that churn, the
generated file carries only the delta the schema change implies.
The delta is byte-faithful to what the pinned generator produces for the new block, verified by
regenerating with
datamodel_code_generator==0.26.3:class CombineMode(Enum)immediately beforeResponseToFileExtractor,mode: Optional[CombineMode] = Field(CombineMode.union, ...)with noper-field
examples, andCombinedExtractor.update_forward_refs()afterCompositeErrorHandler's.modeis a$refto aCombineModedefinition rather than an inline enum for that reason:datamodel-codegen derives an inline property enum's class name from the property name and not from
title, sotitle: Combine Modeproduced the very generic top-levelMode. Adefinitionsentrygives the generated enum the same name as the runtime enum in
extractors/combined_extractor.py,and moves the
exampleslist from the field onto the definition. A maintainer should still run/poe buildon this PR to regenerate the models authoritatively before merge.Tests
unit_tests/sources/declarative/extractors/test_combined_extractor.py(22 tests):unionyields every record and preserves sub-extractor order;unionis the default mode;unionskips empty sub-extractors.first_matchfalls through an empty first extractor; does not drop the peeked record; nevertouches the extractors after the winner; yields nothing when every sub-extractor is empty.
extractorslist raises aValueErrornaming the field.modeaccepts the raw string from a manifest; an unknown mode is rejected.records, pinned over a real
urllib3.HTTPResponsein bothunionandfirst_match.unit_tests/sources/declarative/parsers/test_model_to_component_factory.py:RecordSelectorwhose extractor is aCombinedExtractorcontaining a nested
CombinedExtractor, exercising the schema, the generated model, thePYDANTIC_MODEL_TO_CONSTRUCTORregistration,$parameterspropagation and the extracted records.modedefaults tounionthrough the factory.CsvDecoder,JsonlDecoder,JsonItemsDecoder,GzipDecoderandIterableDecoder, with and withoutemit_connector_builder_messages; plus thenested case and a full
SimpleRetrievermanifest, and the negative case forJsonDecoder/XmlDecoder.union+OffsetIncrementrejection, parametrized over the default mode, an explicitmode: unionand aunionnested under afirst_match; plus behavioural assertions thatfirst_matchadvances the offset by the winning sub-extractor's count and thatPageIncrementstill accepts
union.CompositeRawDecoderand a subclass of it are accepted in Builder mode, since theBuilder guard matches only the four decoders the Builder downgrades.
CombinedExtractorat all threeAsyncRetrieverextractor sites and atFileUploader.download_target_extractor.unit_tests/sources/declarative/test_concurrent_declarative_source.py:jsonschemavalidation: a valid nestedCombinedExtractormanifest constructs,mode: concatenateraisesValidationError, andextractors: []raisesValidationErrorwith theminItemsmessage in the error tree.No new markers; nothing is marked
slow,flaky,super_sloworlinting.CI at this head
bbfd864c: Pytest (Fast) and Pytest (All) on Python 3.10–3.13, MyPy, Ruff Lint and Ruff Format all pass.Check: destination-motherduckfails and is unrelated; it is red on #1149 and #1165 at the same time.🤖 Generated with Claude Code