Skip to content

perf(sources): read_rows uses the response body when it is the whole result - #81

Merged
anoop-narang merged 4 commits into
mainfrom
feat/hybrid-dict-read
Aug 19, 2026
Merged

perf(sources): read_rows uses the response body when it is the whole result#81
anoop-narang merged 4 commits into
mainfrom
feat/hybrid-dict-read

Conversation

@anoop-narang

Copy link
Copy Markdown
Contributor

read_rows always fetched the query's persisted result: four requests plus a readiness wait, for rows the first response had usually already returned in full.

truncated: false is the API's guarantee that "the result fit entirely in this response" — so completeness is already established there, and the extra round trips buy nothing.

Measured against a live workspace

10,000-row pages, steady state (first read discarded as cold), same query and environment:

direct inline read (baseline)        median 0.84s
read_rows before this change        median 2.17s
read_rows after                     median 0.80s     <- parity with inline

The persisted path is still taken where it earns its cost

The inline cap is a byte budget, not a row count. So the same query returns ten thousand rows against a narrow table and can cap at a few hundred against a wide one. When the body is a bounded preview, read_rows goes to the persisted result, which is not capped at all — so a workspace where the cap bites gets full pages instead of a silent throughput ceiling.

Completeness is checked on both routes: total_row_count against the body, X-Total-Row-Count against the persisted result. A body that disagrees with its own count raises IncompleteReadError exactly as a short persisted read does.

The trade, made deliberately

The body is JSON and the persisted result is Arrow, so a timestamp arrives as text on one route and a datetime on the other — and which route runs depends on how large the answer happened to be.

That is documented rather than hidden, and it is scoped: dicts are already a lossier representation than Arrow, and a caller asking for them has accepted that. read_arrow and read_arrow_batches are unchanged — they always use the persisted result, so the Arrow types they yield never vary. hotdata_table and hotdata_query are built on those, so the dlt resources are unaffected.

If the inconsistency turns out to matter in practice, the fix is to point read_rows back at the persisted result — one line, at the cost of the latency above.

Also

_submit is split into _query (the request) and _resolve (the readiness wait), so the fast path can reach the persisted result without submitting the query twice.

245 tests pass. New coverage for both routes, for a body that contradicts its own count, and for the Arrow methods keeping the strict path.

…result

`read_rows` always fetched the persisted result: four requests plus a readiness
wait, for rows the first response had usually already returned in full.
`truncated: false` is the API's guarantee that "the result fit entirely in this
response", so completeness is established there and the extra round trips buy
nothing. Measured against a live workspace, ~0.8s per 10,000 rows either way
versus ~2.2s always taking the persisted path -- parity with a direct inline
read.

The persisted path is still taken when the body is a bounded preview, which is
where it earns its cost: the inline cap is a BYTE budget, so a wide table can cap
at a few hundred rows where a narrow one returns ten thousand, and the persisted
result is not capped at all. Row counts are checked on both routes --
`total_row_count` against the body, `X-Total-Row-Count` against the result.

The trade this makes deliberately: the body is JSON and the persisted result is
Arrow, so a timestamp arrives as text on one route and a datetime on the other.
Dicts are already lossier than Arrow and a caller asking for them has accepted
that; `read_arrow` / `read_arrow_batches` always use the persisted result and are
unchanged, so anything needing one stable set of types has a path that never
varies.

`_submit` is split into `_query` (the request) and `_resolve` (the readiness
wait) so the fast path can reach the persisted result without submitting twice.
@anoop-narang
anoop-narang requested a review from a team as a code owner August 19, 2026 15:01
@anoop-narang
anoop-narang requested review from eddietejeda and removed request for a team August 19, 2026 15:01
Comment thread src/hotdata_dlt_destination/source_client.py Outdated
Comment thread src/hotdata_dlt_destination/source_client.py Outdated
Comment thread src/hotdata_dlt_destination/source_client.py
Comment thread src/hotdata_dlt_destination/source_client.py Outdated
Comment thread src/hotdata_dlt_destination/source_client.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review

The split of _submit into _query/_resolve is clean, and I confirmed the scoping claim in the description: sources.py:145 uses only read_arrow_batches, so the JSON-vs-Arrow type divergence really is confined to read_rows callers.

The blocking issue is that the new fast path establishes completeness by absence of a negative rather than by an affirmative check, in a module whose stated reason for existing is refusing the opposite.

Blocking Issues

  1. src/hotdata_dlt_destination/source_client.py:208not response.truncated fails open. A truncated that is absent or None takes the fast path, returning a capped preview as the whole result. truncated was added as a field in hotdata 0.4.1 (CHANGELOG:384-385), so an optional/nullable model field is the likely shape; response.truncated is False removes the dependency on that question entirely. I could not inspect the SDK model — hotdata is not installed in this checkout and it is not vendored — so I am flagging the fail-open direction, not a confirmed None in the pinned version.

  2. src/hotdata_dlt_destination/source_client.py:507-513 — an absent total_row_count skips the row-count check. This compounds with (1): a response with neither field returns whatever the body held, as complete. The repo's own fixtures construct total_row_count: None (tests/test_source_client.py:581, :737), so the case is modelled, not hypothetical. Unlike _total_from_headers, which raises UnverifiableReadError when it cannot verify, read_rows has a correct route available here — the persisted result — so falling back is strictly better than trusting the body.

Action Required

  • Branch on response.truncated is False.
  • Fall back to the persisted result when total_row_count is absent, instead of returning an unverified body.
  • Extend the tests to cover both: truncated=None and total_row_count=None must not reach the fast path. FakeSourceClient already has the knobs for the second one.

Non-blocking comments inline: the untested positional-row branch and strict=False in _rows_from_response, the module docstring at lines 7-14/33-41 now contradicting read_rows, the result_id-vs-truncated justification in the method docstring, and the duplicated read_arrow tail.

Note on CI: CI / Test (Python 3.12) was not reported as passing when this review started (still queued or running), so I have not seen the result of the 245 tests — I have not run anything myself.

Three ways the body route could fail open, all closed:

- `truncated` was tested for falsiness, so an absent or null value read as "not
  truncated" and a capped preview would have been returned as the whole result.
  Now `is False` -- the API affirmatively saying the body is everything.
- An absent `total_row_count` skipped the row check entirely. Combined with the
  above, a response carrying no rows became a successful empty read, which under
  `write_disposition="replace"` truncates a destination table and reports success.
  `_rows_from_response` now reports `None` when it cannot verify, and `read_rows`
  takes the persisted result instead of trusting the boolean alone. A count that is
  present and disagrees still raises: the response contradicts itself and no other
  route resolves that.
- `zip(..., strict=False)` silently dropped the tail of a short row, producing rows
  missing columns -- which the row count cannot detect, since it counts rows and
  not fields. Now `strict=True`.

The positional branch was also the untested one: the SDK types `rows` as a list of
lists, and a live check confirms the API sends `rows[0]` as a list, so the branch
running in production was the one CI never exercised. The fake now emits the
positional shape by default, with the pre-zipped form opt-in.

Also extracts `_read_ready` so the fetch-check-normalise tail is not duplicated
between `read_arrow` and `read_rows`, with the retry-boundary reasoning in one
place, and corrects the module docstring, which still claimed every read goes to
the persisted result.
Comment thread tests/test_source_client.py Outdated
Comment thread tests/test_source_client.py Outdated
Comment thread src/hotdata_dlt_destination/source_client.py
Comment thread src/hotdata_dlt_destination/source_client.py Outdated
claude[bot]
claude Bot previously approved these changes Aug 19, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both blocking issues from cycle 1 are fixed in code: the fast path now opens on truncated is False (source_client.py:239) and an absent total_row_count falls back to the persisted result rather than trusting the boolean alone (_rows_from_response returns None). The positional-row branch is now covered, strict=True replaces strict=False, the duplicated tail is factored into _read_ready, and the module docstring no longer claims every read goes to the persisted result.

Remaining comments are nits — two new tests do not exercise what their docstrings claim, and read_rows now returns rows for an unpersisted result where it used to raise, undocumented. Note CI's Test job had not reported when this review ran, so I have not seen the suite result.

Two of the tests added with the body route asserted nothing about it. The
fallback test passed `body_total=None`, which the fake read as "use the body's own
count" rather than "omit the field", so the response was fully verifiable and the
rows came back via the BODY -- the assertion would have held with the fallback
deleted. The null-`truncated` test asserted `None is not False`, a property of
`model_construct` that holds regardless of what the source does.

Both now turn on a `fetch_calls` counter, so a test says which route produced its
rows instead of inferring it from a row count both routes satisfy, and the fake
gained a sentinel that distinguishes "omit the count" from "count is None".

Also pins the one case where `read_rows` deliberately diverges from `read_arrow`:
a result that fit inline but could not be persisted has no file for the Arrow path
to read, so that raises, while the body is the answer here and has been verified.
The same for a verified zero -- dicts carry no schema for a destination to key
off, which is the reason the Arrow path refuses it and this one does not. Stated
in the docstring so the neighbouring comment arguing the other way cannot be read
as covering both.

Collapses two tests that duplicated earlier ones, and fixes a dedented docstring
heading.
Comment thread tests/test_source_client.py Outdated
claude[bot]
claude Bot previously approved these changes Aug 19, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All prior threads resolved in e11d9f8 and ce9fbfd: truncated is False, unverifiable bodies falling back rather than being trusted, strict=True on the zip, _read_ready extracted, the module docstring rewritten to match the new default path, and the route tests now asserting on fetch_calls instead of a row count both routes satisfy. The unpersisted-but-verified divergence from read_arrow is pinned by tests in both directions, including the zero-row corner.

One non-blocking test-duplication nit inline. Note CI (Test 3.12) was still in progress when this review ran, so I have not seen the suite result.

Removing two duplicates in the previous commit introduced a third: same
construction and the same two assertions as the existing body-route test, which
grew the `fetch_calls == 0` assertion in that commit. The counter stays
falsifiable through the `== 1` assertions in the fallback tests, which is where
that property actually lives.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All prior threads resolved in code. The change since the last review is the duplicate-test deletion plus a comment noting that the fetch_calls == 1 assertions in the fallback tests are what make the == 0 assertion falsifiable — which is the right place for it.

No new findings. CI (CI / Test (Python 3.12)) had not reported passing as of this review, so the test run is unverified from here.

@anoop-narang
anoop-narang merged commit 6c54a92 into main Aug 19, 2026
3 checks passed
anoop-narang added a commit that referenced this pull request Aug 19, 2026
`update_changelog.py` only preserves the guide comment when it is the FIRST thing
in the [Unreleased] body (`_GUIDE_RE` is anchored with \A). The notes for #80 and
#81 were inserted above it, so `prepare` treated it as part of the notes and moved
it down into the released 0.14.0 section -- leaving [Unreleased] bare and putting
the authoring guide inside the payload `extract-changelog.py` hands to the GitHub
Release.

Moved back under [Unreleased]. Verified both ways: extracting 0.14.0 no longer
carries the guide, and a simulated 0.15.0 prepare preserves it.
anoop-narang added a commit that referenced this pull request Aug 19, 2026
* chore: release v0.14.0

* chore: keep the changelog style guide under [Unreleased]

`update_changelog.py` only preserves the guide comment when it is the FIRST thing
in the [Unreleased] body (`_GUIDE_RE` is anchored with \A). The notes for #80 and
#81 were inserted above it, so `prepare` treated it as part of the notes and moved
it down into the released 0.14.0 section -- leaving [Unreleased] bare and putting
the authoring guide inside the payload `extract-changelog.py` hands to the GitHub
Release.

Moved back under [Unreleased]. Verified both ways: extracting 0.14.0 no longer
carries the guide, and a simulated 0.15.0 prepare preserves it.

* chore: collapse a stray blank line left by moving the changelog guide
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.

1 participant