feat(data modeling): merge records sync and sync_resume into a chunk-yielding generator - #2755
feat(data modeling): merge records sync and sync_resume into a chunk-yielding generator#2755evertoncolling wants to merge 18 commits into
Conversation
…ial page RecordsAPI._sync delegated to the generic _list paging helper, which stops on an exhausted cursor. The sync endpoint always returns a nextCursor (that is what makes the change feed resumable) and signals the end of the feed with hasNext, which the paging loop never reads. Any page holding fewer records than 'limit' therefore made the SDK issue requests in a loop until the process was killed. Issue a single POST instead, matching the endpoint's documented contract of one page per call. This also stops 'initializeCursor' being sent alongside 'cursor' on subsequent requests. Adds unit tests for partial, empty and resumed pages, and a new integration test module for Records and Streams.
There was a problem hiding this comment.
Code Review
This pull request refactors the record synchronization logic in _sync to use a direct POST request instead of the generic paging helper, preventing infinite loops when receiving partial pages. It also adds comprehensive integration and unit tests for the sync functionality. The reviewer suggests adding an explicit check to ensure that initialize_cursor and cursor are not provided simultaneously, as they are mutually exclusive parameters.
The records integration tests created their own stream when none existed under their own external ID. A project may only hold three active streams and a stream can neither be deleted nor have its external ID reused, so that call always failed with "Stream quota exceeded" and errored every test in the module at setup. Reuse sdk_test_mutable_stream instead, which already exists in the test project, and skip when the project has no mutable stream at all. Records are still created per test, uniquely tagged and cleaned up afterwards. Scope the partial-page sync regression test to its own tag, since a shared stream may carry records written by other suites. Also reject passing both initialize_cursor and cursor to the internal _sync helper.
TimeRange forwards its bounds to the API untouched, so the SDK's "1h-ago" relative shorthand reached the endpoint verbatim and was rejected with a 400: lastUpdatedTime only accepts ISO-8601. This failed the filter, aggregate and upsert integration tests. Derive the lower bound as a UTC ISO-8601 string instead.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #2755 +/- ##
==========================================
- Coverage 93.81% 93.80% -0.01%
==========================================
Files 513 514 +1
Lines 52620 52806 +186
==========================================
+ Hits 49364 49536 +172
- Misses 3256 3270 +14
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Left two comments to make the test suite more robust. @asosnovski and I were just talking about the sync endpoint so this was a nice change to see!
haakonvt
left a comment
There was a problem hiding this comment.
Great fix, I fully agree to move away from the existing helper method 👌 Some comments:
| def an_hour_ago() -> str: | ||
| """A lower bound for 'lastUpdatedTime'. | ||
|
|
||
| TimeRange forwards its bounds to the API untouched, and the API only accepts ISO-8601 there - | ||
| not the "1h-ago" shorthand that the rest of the SDK understands. | ||
| """ | ||
| return (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") |
There was a problem hiding this comment.
The TimeRange class should be taught how to deal with relative time strings 🚀
There was a problem hiding this comment.
That's fair! I was discussing something around time anchors in the API like what data points support ("1h-ago"), but until that lands we could handle something like this in the SDK. That would be a future dedicated PR though.
Co-authored-by: Håkon V. Treider <haakonvt@gmail.com>
Review feedback: falling back to an arbitrary mutable stream could send test writes to a stream the project owner cares about. Always use the dedicated sdk_test_mutable_stream, creating it if it does not exist yet.
Streams are soft deleted, and their external IDs stay reserved for a couple of weeks rather than forever.
Records are indexed asynchronously, and a fixed two-second sleep is both slower than needed on a good day and flaky on a bad one. Add a small assert_eventually helper - a low, fixed number of retries with Backoff - and use it after ingest and after upsert to query until the backend state is what the test expects.
The review suggestion applied via GitHub edited the async records module without regenerating, which fails verify-sync-codegen and the lint job's codegen hook.
…nitedata/cognite-sdk-python into fix/records-sync-single-page
Master renamed StreamTemplateWriteSettings to StreamWriteSettings in #2753 without a back-compat alias, which failed collection of the records integration tests on the merge result CI builds.
Review feedback: five lines of why-not-the-helpers is more than the decision needs. The full analysis lives in the PR and in the unit test covering the partial-page regression.
Review feedback: the non-positive limit test duplicates verify_limit's own test coverage, and the resumed-body test repeats the exact-equality assertion test_sync_resume_sends_cursor already makes.
Review feedback: 'load' is public API that external consumers and _sync now always constructs the list through _load_raw_api_response. Lock in that plain item lists still load, with the page-level attributes defaulting to cursor=None, has_next=False.
… the feed A finite limit (1-1000, the API page cap) returns a single page, giving the caller full control of cursoring. Passing None, -1 or math.inf now keeps requesting pages of 1000 until hasNext is False and returns everything as one list, mirroring the fetch-everything semantics these values carry across the SDK's list methods. The returned list holds the final cursor either way, so an exhausted sync remains resumable. Out-of-range finite limits now raise a ValueError in the SDK instead of surfacing as an API 400.
| break | ||
| body.pop("initializeCursor", None) | ||
| body["cursor"] = response["nextCursor"] | ||
| return SyncRecordList._load_raw_api_response(responses) |
There was a problem hiding this comment.
should we consider a generator to reduce memory?
There was a problem hiding this comment.
As in continuously yielding chunks back to the user? I’m all in favor
There was a problem hiding this comment.
Then maybe we should add it via a separate method that has this pattern, no? We can refactor the existing method to sync a single page (either start the sync or resume) with manual control and add a dedicated method that exhausts the cursor until hasNext is false with the ability to yield chunks. Kind of like iterate_data in datapoints. WDYT?
Also, since we are changing a lot here, we could also merge sync and sync_resume as suggested. I guess we can do some overload so a single method accepts the two signatures.
There was a problem hiding this comment.
Or just a single "generator" method that handles everything? Both manual pagination control by yeidling one page at the time and iterating over the whole dataset?
There was a problem hiding this comment.
Ended up testing out the latter :)
…ngle-page # Conflicts: # tests/tests_unit/test_api/test_data_modeling/test_records.py
Description
client.data_modeling.records.sync()andsync_resume()never returned whenever a page held fewer records thanlimit.RecordsAPI._syncdelegated to the generic_listpaging helper, whose loop terminates on:Neither condition can be reached for this endpoint:
nextCursor— that is precisely what makes the change feed resumable. It signals end-of-feed withhasNext: false, which the paging loop never read.The new API
Following the review discussion, the fix grew into a redesign:
syncandsync_resumeare merged into a single generator method that issues one POST per chunk and yields eachSyncRecordListas it arrives, until the feed is exhausted (hasNext: false). Nothing is accumulated in memory.initialize_cursor/cursoris required;@overloadstubs enforce this at type-check time (a lazy runtimeValueErrorbackstops untyped callers).chunk_size(1–1000, default 1000) replaceslimitas the per-request page size. It is deliberately not namedlimit: SDK-wide,limitmeans "total items,Nonefetches everything", whilechunk_sizeis the established name for per-yielded-chunk size (see the__call__iterators). There is nochunk_size=Nonesingle-record mode since each chunk carries the cursor needed to resume.sync(cursor=...)again later.Iterator[SyncRecordList]via codegen ("sync"added toSYNC_METHODS_TO_KEEPbecause mypy requires async-generator overload stubs to be plaindef).Breaking changes
Records is an alpha feature behind
FeaturePreviewWarning.sync_resume()is removed — passcursor=tosync()instead.sync()now returns a generator ofSyncRecordListchunks instead of a single list.limitparameter is replaced bychunk_size; the unlimited (limit=None) accumulate-everything mode is gone — iterate the generator instead.Checklist:
If a new method has been added it should be referenced in cognite.rst in order to generate docs based on its docstring.