Skip to content

[Bug]: one unparseable record silently truncates an entire paginated stream read (search_traces, search_spans, experiments, migrate) #8407

Description

@feiiiiii5

Bug description

A paginated stream read stops early and reports a short answer as if it were complete whenever the backend serves a record the SDK cannot parse. One unreadable record in the middle of a stream costs every page after it, silently.

read_and_parse_full_stream() decides whether the backend has run out of data by comparing the page size it asked for against the number of items it got back:

# sdks/python/src/opik/api_objects/rest_stream_parser.py:80 (main f81eed1b4)
if current_batch_size > len(parsed_items):
    break

len(parsed_items) is the count of records that survived parsing. _parse_stream_line() returns None for anything it cannot decode or validate (rest_stream_parser.py:121-137), and read_and_parse_stream() only appends non-None items (:101-104). So a page where the backend sent a full 50 records and 1 failed validation looks, to the loop, exactly like a page where the backend sent 49 records and there is nothing left. The remaining pages are never requested.

The two things the test at line 80 is trying to distinguish are different events:

  • the backend sent fewer records than we asked for — end of data;
  • the backend sent a full page, some of which we could not read — data continues past this page.

Affected user-facing paths

Every caller of read_and_parse_full_stream(), i.e. any read that can span more than one page:

  • Opik.search_traces() — sdks/python/src/opik/api_objects/opik_client.py:2126 → api_objects/search_helpers.py:79
  • Opik.search_spans() — opik_client.py:2236 → search_helpers.py:41
  • Opik.search_threads() — search_helpers.py:113, api_objects/threads/threads_client.py:111
  • experiment reads: evaluate_experiment() → evaluation/rest_operations.py:15 → api_objects/experiment/rest_operations.py:35
  • opik migrate — cli/migrate/datasets/experiments.py:827, cli/migrate/datasets/version_replay.py:606

The experiment path is the worst case: if every record on the first page happens to be unreadable, parsed_items is empty, the loop breaks, and experiment/rest_operations.py:47-50 raises ExperimentNotFound for an experiment that does exist. A migration/rollback tool then reports missing data that is present.

Steps to reproduce

Backend/SDK field skew is what makes a record unreadable in practice (the generated model requires start_time, sdks/python/src/opik/rest_api/types/span_public.py:28), so the reproducer feeds the parser a record with that field absent. Three pages of two records each; the last page is genuinely short.

import json
from opik.api_objects import rest_stream_parser
from opik.rest_api.types import span_public

RECORD = {
    "id": "0195f6f1-9da2-7630-b285-60cf5580372f",
    "project_id": "0195f6f1-9c82-751f-a1ad-54fec4b5c7d8",
    "trace_id": "0195f6f1-9c3f-7b65-a334-56173d19bc00",
    "name": "query", "type": "general",
    "start_time": "2025-01-03T11:03:17.505783Z",
    "end_time": "2025-01-03T11:03:18.591897Z",
    "created_at": "2025-04-02T14:39:44.412550Z",
    "last_updated_at": "2025-04-02T14:39:44.412550Z",
    "created_by": "admin", "last_updated_by": "admin",
}

def line(span_id, unreadable=False):
    record = dict(RECORD, id=span_id)
    if unreadable:
        del record["start_time"]
    return (json.dumps(record) + "\r\n").encode("utf-8")

PAGES = [
    [line("bad-1", unreadable=True), line("span-a")],   # backend sent 2 (a full page)
    [line("span-b"), line("span-c")],                    # backend sent 2
    [line("span-d")],                                     # backend sent 1 -> real end
]
requests_made = []

def read_source(current_batch_size, last_retrieved_id):
    requests_made.append((current_batch_size, last_retrieved_id))
    return PAGES.pop(0)

spans = rest_stream_parser.read_and_parse_full_stream(
    read_source=read_source, parsed_item_class=span_public.SpanPublic,
    max_results=None, max_endpoint_batch_size=2,
)
print("requests:", requests_made)
print("records :", [s.id for s in spans])

Output on main (f81eed1b4), measured — python3 repro_stream_truncation.py, cwd sdks/python:

ERROR opik.api_objects.rest_stream_parser: Error parsing SpanPublic, reason: 1 validation error for SpanPublic  Field required [type=missing, input_value={'id': 'bad-1', ...}]
requests: [(2, None)]
records : ['span-a']

One request instead of three; span-b, span-c and span-d are never fetched. The only trace is a per-record ERROR line — nothing says the read stopped early, and the return value is indistinguishable from "there were only 1 record".

Expected behaviour

A dropped record counts as a record the backend sent, so the read continues to the next page and stops when a page is genuinely short. When a read did drop records, the fact should be visible in one log line rather than only in per-record errors.

One case cannot be recovered by skipping: a page whose records all fail to parse advances neither the result nor the last_retrieved_id cursor, so the next request would ask for exactly the same page forever. Whatever is decided there should be a deliberate one — today it stops only by accident, because an empty page also reads as "short".

What I checked before filing

Searched issues and PRs, open and closed, for this root cause (read_and_parse_full_stream, rest_stream_parser, incomplete chunked read, pagination stopped early, malformed record, skip unparseable, search_traces missing results). Closest prior art is in the same file but a different cause: #7330 / #7289 (page-size adaptive shrink, merged) and #7479 (guard against an unbounded full-project read). Neither addresses the parsed-count-vs-page-size comparison. No open PR touches rest_stream_parser.py.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions