Repository navigation
fix(low-code): stop OffsetIncrement/PageIncrement re-reading pages - #1202
Merged
Baz (bazarnov) merged 1 commit intoOct 6, 2026
Merged
Conversation
Both strategies stop on the number of records the API returned for a page, counted before the record filter. The factory gave each of them a second copy of the stream's extractor, so every page was decoded and extracted twice, and `OffsetIncrement` decoded it once more to evaluate a constant `page_size`. A streamed response cannot be read twice, so the copy counted no records and pagination stopped after the first page. `RecordSelector` now counts every item its extractor yields, including the empty records a `CombinedExtractor` drops, and stores the count on the response. The strategies read it through `extracted_record_count` and fall back to `last_page_size` when no selector read the response. An extractor passed to either strategy directly still counts the page.
👋 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@bazarnov/fix-paginators-single-page-extraction#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 bazarnov/fix-paginators-single-page-extractionPR Slash CommandsAirbyte Maintainers can execute the following slash commands on your PR:
|
PyTest Results (Fast)4 990 tests +8 4 978 ✅ +8 11m 2s ⏱️ + 1m 29s Results for commit 57b58bc. ± Comparison against base commit d214b0c. This pull request removes 5 and adds 13 tests. Note that renamed tests count towards both. |
PyTest Results (Full)4 993 tests +8 4 981 ✅ +8 15m 41s ⏱️ -23s Results for commit 57b58bc. ± Comparison against base commit d214b0c. This pull request removes 5 and adds 13 tests. Note that renamed tests count towards both. |
Daryna Ishchenko (darynaishchenko)
approved these changes
Oct 6, 2026
Baz (bazarnov)
deleted the
bazarnov/fix-paginators-single-page-extraction
branch
October 6, 2026 09:34
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
👉 TL;DR: Paginating by offset or by page number no longer reads every page a second time just to count its records, and no longer stops after the first page when the response is streamed. The count still covers the records a filter drops, so filtered
SimpleRetrieverstreams declaringOffsetIncrementorPageIncrementpaginate exactly as before.Specifically,
RecordSelectorcounts the records its extractor yields and stores the count on the response, andOffsetIncrementandPageIncrementread it through the newextracted_record_countinstead of the extractor copies_create_record_counting_extractorbuilt, which is removed.Changes
RecordSelector.select_recordscounts every item its extractor yields, before the record filter and including the empty records aCombinedExtractordrops, and stores the count on the response once the page has been read. If a custom record filter stops reading early, the selector reads the rest of the page to count it.extracted_record_count(response, default)reads the count back.OffsetIncrementandPageIncrementuse that count and fall back tolast_page_sizewhen noRecordSelectorread the response. Anextractorpassed to either constructor still counts the page, andOffsetIncrement.extractornow defaults toNone.OffsetIncrementdecodes the response only to evaluate a stringpage_size, which after the factory's digit-string conversion means a template. An integer or absent page size no longer parses the page again.ModelToComponentFactoryno longer builds extractor copies:_create_record_counting_extractoris removed andcreate_page_incrementis a@staticmethodagain.create_record_selectorand theAsyncRetrieverdownload retriever setcount_dropped_empty_recordson the selector's own extractor instead. Theunionrejection forOffsetIncrementis kept.CombinedExtractoralways logs the empty records it drops, since a single extractor now reads the page, and its docstrings describe the new counting.SimpleRetriever: one extraction per page with a record filter, and pagination over a gzip-streamed body.CombinedExtractorteststest_the_counting_copy_*are renamedtest_a_drop_counting_extractor_*.Review Spotlight
Reviewers with limited time, please review first:
record_selector.py#L103-L120: the count is taken during the only extraction and kept on the response, because one retriever reads all partitions of a stream concurrently; the page is read to its end even when a record filter stops earlyrecord_selector.py#L24-L35:extracted_record_count; theisinstancecheck keeps unread responses and mocks on the fallbackoffset_increment.py#L83-L104: count precedence and the decode skipmodel_to_component_factory.py#L3319-L3330: dropped-record counting moved to the record selector's extractor, called fromcreate_record_selectorand the download retrieverRisks
GzipDecoderover JSON outside the Connector Builder is the one pagination accepts), the copy got an empty document from the pagination decoder, counted 0 records and stopped after the first page, silently. Every page is now read. 0 monorepo connectors combine a streamed decoder with these paginators: after resolving$refs, the only non-JSON decoder on such retrievers is the bufferedXmlDecoderofsource-workflowmax.mainbuilt no counting copy for custom retrievers or custom pagination strategies, so they usedlast_page_size(records emitted after filtering), and a record filter that shortened a full page ended their pagination there. A custom retriever reading throughRecordSelector.select_records, and a subclass ofOffsetIncrementorPageIncrementcallingsuper().next_page_token, now get the pre-filter count and read the following pages. ACustomPaginationStrategysubclassingOffsetIncrementalso builds now thatextractorhas a default; onmainthe factory raised aTypeError. 0 monorepo connectors are affected: no custom retriever holds these paginators, and thePageIncrementsubclasses insource-mixpanelandsource-mondayoverridenext_page_tokenwithout callingsuper.RecordSelector.select_recordsleaves no count, so the strategy useslast_page_size, as it did onmain.count_dropped_empty_recordson every record selector. The factory now sets it on anyCombinedExtractorof a record selector, not only on the paginator copy. It only matters withskip_empty_records, which no monorepo manifest sets, andRecordSelectordiscards the placeholders, so the emitted records do not change.extracted_record_countis new andOffsetIncrement.extractorgains a default. Only the private_create_record_counting_extractoris removed, andcreate_page_incrementstill acceptsdecoderandextractor_modelas keyword arguments, through**kwargs.OffsetIncrement, 162PageIncrement), counted withgit grep -l -E 'type: *"?(OffsetIncrement|PageIncrement)' origin/master -- 'airbyte-integrations/connectors/*.yaml'on airbytemasteratfc800755and deduplicated by connector.$refs, 266 use them on stream retrievers. Each saves one extraction per page, and everyOffsetIncrementalso saves one decode per page: its 878 definitions have 858 integer page sizes, 20 absent and 0 templates.record_filterin 11, client-side incremental in 5,source-tiktok-marketingin both) and paginate exactly as before.CombinedExtractor, a custom retriever or anAsyncRetrieverdownload paginator.Open PR #1074 can reuse this count
#1074 adds a third extractor copy, in
CursorPaginationStrategy, to get the same pre-filter count. It can callextracted_record_count(response, default=last_page_size)instead.Test plan
main(git checkout origin/main --the 5 changed files underairbyte_cdk/) with this branch's tests:test_record_selector.pyfails to importextracted_record_count, which stops collection, and the other 8 tests fail when run without it:assert 4 == 2for both strategies (each page extracted twice).[1, 3] == [1, 3, 4]for both strategies (stopped after the first page).None == 6andNone == 4(the record selector's count is ignored).test_a_drop_counting_extractor_warns_about_the_records_it_drops:0 == 1;test_create_offset_increment: the strategy still holds a copy.select_records, thefilter_that_stops_reading_the_pagecase of the record selector test fails with-1 == 3: the page is left uncounted.poetry run pytest unit_tests/sources/declarative unit_tests/connector_builder: 2388 passed.test_read_with_concurrent_and_synchronous_streams*(3 tests) was deselected because it can crash locally on macOS (Cached parent stream with more than one partition corrupts the shared sqlite request cache under concurrent reads (no such table: responses,file is not a database,no more rows available) #1150).poetry run pytest unit_tests/legacy unit_tests/manifest_migrations unit_tests/source_declarative_manifest: 161 passed.-p no:cacheprovider -p no:memray; the memory-limited tests intest_decoders_memory_usage.pyandtest_response_to_file_extractor.pypassed separately with both plugins on.DpathExtractor): onmain,OffsetIncrement.next_page_tokentakes 5.95 s and grows max RSS by 413 MiB, on top of the record selector's own 5.36 s pass. On this branch it takes 0.005 ms and 0 MiB. Reading 2 pages parses JSON 6 times withOffsetIncrementand 4 times withPageIncrementonmain, 2 times with either on this branch.mainvs this branch throughConcurrentDeclarativeSource, same requests and records on both:OffsetIncrement, with and without a record filter: two chunk requests per page, at no offset, then offsets 2 and 4;LazySimpleRetrieverwithOffsetIncrementandPageIncrementand a record filter: the filtered full embedded page still requests the second page.mainvs this branch with retrievers built byModelToComponentFactory:OffsetIncrement, and aPageIncrementsubclass callingsuper, each with a record filter:mainstops after the first page ([1]), this branch reads all 3 pages ([1, 3, 4, 5]).ruff format,ruff checkon the 10 changed files andmypy --config-file mypy.inion the 5 source files: clean.Fixes #1136