Skip to content

feat(data modeling): merge records sync and sync_resume into a chunk-yielding generator - #2755

Open
evertoncolling wants to merge 18 commits into
masterfrom
fix/records-sync-single-page
Open

feat(data modeling): merge records sync and sync_resume into a chunk-yielding generator#2755
evertoncolling wants to merge 18 commits into
masterfrom
fix/records-sync-single-page

Conversation

@evertoncolling

@evertoncolling evertoncolling commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Description

client.data_modeling.records.sync() and sync_resume() never returned whenever a page held fewer records than limit.

RecordsAPI._sync delegated to the generic _list paging helper, whose loop terminates on:

# cognite/client/_api_client.py:328
if total_retrieved == limit or next_cursor is None:
    break

Neither condition can be reached for this endpoint:

  • The sync endpoint always returns a nextCursor — that is precisely what makes the change feed resumable. It signals end-of-feed with hasNext: false, which the paging loop never read.
  • So the loop could only exit when the feed happened to return exactly as many records as were asked for.

The new API

Following the review discussion, the fix grew into a redesign: sync and sync_resume are merged into a single generator method that issues one POST per chunk and yields each SyncRecordList as it arrives, until the feed is exhausted (hasNext: false). Nothing is accumulated in memory.

# Start from a relative time, iterate the whole feed:
for chunk in client.data_modeling.records.sync(stream_id="my-stream", initialize_cursor="7d-ago"):
    process(chunk)
    last_cursor = chunk.cursor  # persist after processing each chunk

# Later, resume from the stored cursor:
for chunk in client.data_modeling.records.sync(stream_id="my-stream", cursor=last_cursor):
    ...

# Manual single-chunk control:
chunk = next(client.data_modeling.records.sync(stream_id="my-stream", cursor=last_cursor))
  • Exactly one of initialize_cursor / cursor is required; @overload stubs enforce this at type-check time (a lazy runtime ValueError backstops untyped callers).
  • chunk_size (1–1000, default 1000) replaces limit as the per-request page size. It is deliberately not named limit: SDK-wide, limit means "total items, None fetches everything", while chunk_size is the established name for per-yielded-chunk size (see the __call__ iterators). There is no chunk_size=None single-record mode since each chunk carries the cursor needed to resume.
  • The generator terminates at end-of-feed rather than polling forever — the endpoint has no server-side poll timeout (unlike datapoint subscriptions), so tailing is done by calling sync(cursor=...) again later.
  • The sync client gets the equivalent Iterator[SyncRecordList] via codegen ("sync" added to SYNC_METHODS_TO_KEEP because mypy requires async-generator overload stubs to be plain def).

Breaking changes

Records is an alpha feature behind FeaturePreviewWarning.

  • sync_resume() is removed — pass cursor= to sync() instead.
  • sync() now returns a generator of SyncRecordList chunks instead of a single list.
  • The limit parameter is replaced by chunk_size; the unlimited (limit=None) accumulate-everything mode is gone — iterate the generator instead.

Checklist:

  • Tests added/updated.
  • Documentation updated. Documentation is generated from docstrings - these must be updated according to your change.
    If a new method has been added it should be referenced in cognite.rst in order to generate docs based on its docstring.
  • The PR title follows the Conventional Commit spec.

…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.
@evertoncolling
evertoncolling requested review from a team as code owners August 7, 2026 14:29

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cognite/client/_api/data_modeling/records.py Outdated
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

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.09091% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.80%. Comparing base (2ea6d56) to head (87b639b).

Files with missing lines Patch % Lines
...ration/test_api/test_data_modeling/test_records.py 98.07% 2 Missing ⚠️
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     
Files with missing lines Coverage Δ
cognite/client/_api/data_modeling/records.py 98.96% <100.00%> (+1.43%) ⬆️
cognite/client/_sync_api/data_modeling/records.py 100.00% <100.00%> (ø)
...gnite/client/data_classes/data_modeling/records.py 98.22% <ø> (ø)
...s_unit/test_api/test_data_modeling/test_records.py 100.00% <100.00%> (ø)
...ration/test_api/test_data_modeling/test_records.py 98.07% <98.07%> (ø)

... and 10 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread tests/tests_integration/test_api/test_data_modeling/test_records.py Outdated
Comment thread tests/tests_integration/test_api/test_data_modeling/test_records.py Outdated

@andersfylling andersfylling left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 haakonvt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great fix, I fully agree to move away from the existing helper method 👌 Some comments:

Comment thread cognite/client/_api/data_modeling/records.py Outdated
Comment thread cognite/client/_api/data_modeling/records.py Outdated
Comment thread cognite/client/_api/data_modeling/records.py Outdated
Comment thread tests/tests_integration/test_api/test_data_modeling/test_records.py Outdated
Comment on lines +36 to +42
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TimeRange class should be taught how to deal with relative time strings 🚀

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/tests_integration/test_api/test_data_modeling/test_records.py Outdated
Comment thread tests/tests_integration/test_api/test_data_modeling/test_records.py Outdated
Comment thread tests/tests_unit/test_api/test_data_modeling/test_records.py Outdated
Comment thread tests/tests_unit/test_api/test_data_modeling/test_records.py Outdated
Comment thread tests/tests_unit/test_api/test_data_modeling/test_records.py Outdated
evertoncolling and others added 13 commits August 10, 2026 21:37
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.
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we consider a generator to reduce memory?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As in continuously yielding chunks back to the user? I’m all in favor

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ended up testing out the latter :)

…ngle-page

# Conflicts:
#	tests/tests_unit/test_api/test_data_modeling/test_records.py
@evertoncolling evertoncolling changed the title fix(data modeling): records sync no longer requests forever on a partial page feat(data modeling)!: merge records sync and sync_resume into a chunk-yielding generator Aug 12, 2026
@evertoncolling evertoncolling changed the title feat(data modeling)!: merge records sync and sync_resume into a chunk-yielding generator feat(data modeling): merge records sync and sync_resume into a chunk-yielding generator Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants