Skip to content

fix(trace_management): delete never terminated above one page - #63

Open
smiller-comet wants to merge 2 commits into
mainfrom
smiller-comet/fix-trace-pagination
Open

fix(trace_management): delete never terminated above one page#63
smiller-comet wants to merge 2 commits into
mainfrom
smiller-comet/fix-trace-pagination

Conversation

@smiller-comet

Copy link
Copy Markdown

What & why

delete never terminated on any project with more than 1,000 matching traces — which is
essentially every real retention cleanup, including the 1,743-trace example in this
folder's own README.

collect_trace_ids() hardcoded page=1 in both of its fetch calls and exited only on a
short page. Deletion ran after collection finished, so nothing shrank the result set
during the walk and page 1 returned identical rows forever. The loop re-added the same
IDs until it ran out of memory, and hammered the API hard enough to exhaust the
workspace rate limit — after which the next run hit a 429, printed it, reported
Grand total: 0 traces, and exited 0.

The loop's own docstring argued that incrementing pages was unsafe because offsets go
stale once you start deleting. That is true — but only for a delete-as-you-go design,
which this wasn't. It inherited the workaround's cost without the condition that
motivated it.

test_manage_traces.py seeds ~70 traces, so it never reached the failing path.

The fix

Rewritten on the opik SDK's REST client:

  • Deletion reads a page and deletes it, then repeats. The result set genuinely
    shrinks, so it terminates; memory is bounded by one page rather than the full match
    set; an interrupted run resumes naturally. A guard aborts if a page comes back
    unchanged after a delete, instead of spinning.
  • Non-mutating reads use the API's last_retrieved_id cursor, not offsets.
  • Reads carry only id + start_time. Trace bodies, aggregates and attachments are
    excluded server-side rather than transferred and discarded. strip_attachments also
    stops the backend downloading attachment blobs for a response we throw away.
  • --dry-run no longer enumerates. It reports the count, the date window actually
    matched, and the batch plan — so previews are fast at any volume.
  • Rate limits are waited out per the server's Retry-After.
  • Runtime API errors now exit 1 instead of reporting success, so a scheduled run can
    be alerted on. A count that failed prints ERROR rather than 0.

Which date "older than" means

Date bounds went to to_time, which bounds the trace id — ingestion time — while
--after separately filtered start_time. The two bounds used different clocks.

Both now share one clock, defaulting to ingestion: the same basis --before already
used, and the right one for a retention commitment, since "we don't keep customer data
longer than 90 days"
is a claim about custody. It is also the only clock a client cannot
influence — start_time is supplied by whatever wrote the trace and accepted as given,
so keying deletion on it means a skewed clock or a stamped future date keeps data past
its deletion date while the sweep still reports success.

--time-field start_time selects the activity-age policy where that is what's meant.
The two are never combined; being AND-ed would restore the exclusion. --dry-run names
the clock it used, so a mistaken cutoff is visible before anything is deleted.

Verification

Offlinetest_pagination.py (new; no credentials, no network, milliseconds).
20/20: termination above one page, exactly-once deletion, boundaries (0/1/999/1000/1001/4001),
the no-progress guard, and that date bounds land on exactly one clock.

Live, read-only — a 1,860-trace project: list 2.3s, delete --dry-run 3.1s. The
same dry-run on the previous revision ran 45s without terminating and exhausted the
workspace rate limit.

Live, destructive — a seeded throwaway project of 1,250 backfilled traces (deleted
afterwards): 100 removed by tag (1250 → 1150 exactly), then 1,150 removed across two
pages
(1000 + 150) → 0. Error paths (unknown project, no filters, unreadable config)
exit 1.

Also caught during the rewrite: the paginated endpoint rejects the exclude field names
the streaming endpoint accepts (400 Invalid query param exclude 'input'), so the two
size=1 probes don't send it — they fetch one row, where pruning saves nothing anyway.

Notes for the reviewer

  • No tracking issue. Deliberate — happy to open one retroactively if you'd rather
    have the record.
  • test_manage_traces.py now forces --time-field start_time. Its fixtures are
    created now but backdated via start_time, so on the default clock every
    date-based scenario would match nothing while still printing tidy output — it asserts
    nothing. It also now warns on exit 1 rather than tolerating it.
  • No run.sh, so pr-test.yml skips this folder — as it does today, and as it does
    for 4 of the 6 scripts/ folders. Worth a separate issue: the live-run job executes
    run.sh with real credentials, so opting a trace-deletion tool into it needs a
    deliberately read-only run.sh, and the DRY_RUN convention fits a tool that requires
    credentials poorly.
  • Formatting is its own commit (style: apply ruff format) — the folder was not
    format-clean before this branch. Read the fix commit alone to skip that noise.
  • OPIK_BASE_URL is left as-is rather than renamed to the documented
    OPIK_URL_OVERRIDE; changing it would break existing users.
  • Longer-term, none of this would be needed if Opik had a server-side per-project
    retention policy. Deletion is ID-only (BatchDeleteByProject, capped at 1,000), so
    every retention job must enumerate client-side first.

Checklist

  • Example is in the right bucket (scripts)
  • Folder name is lowercase_with_underscores
  • README.md has all required sections; no examples added/renamed/removed, so index tables unchanged
  • uv run ruff check . and uv run ruff format --check . are clean
  • No credentials or .env files committed
  • Dependencies declared in pyproject.toml (uv project); no requirements.txt, no committed uv.lock
  • run.sh / dry-run CI job — not applicable, see notes above
  • litellm / OPIK_EXAMPLES_MODEL — not applicable, this script makes no LLM calls

collect_trace_ids() hardcoded page=1 in both of its fetch calls and exited
only on a short page. Deletion runs *after* collection completes, so nothing
shrinks the result set during the walk and page 1 returns identical rows
forever. Any project with more than BATCH_SIZE (1,000) matching traces looped
indefinitely, re-adding the same IDs until it ran out of memory — and hammered
the API hard enough to exhaust the workspace rate limit, after which the next
run reported "0 traces" and exited 0. The README's own example (1,743 matches)
is above the threshold. test_manage_traces.py seeds ~70 traces, so it never
reached the failing path.

Rewritten on the opik SDK's REST client:

- Deletion reads a page and deletes it, then repeats. The result set genuinely
  shrinks, so it terminates; memory is bounded by one page; an interrupted run
  resumes naturally. A guard aborts if a page is unchanged after a delete.
- Non-mutating reads use the API's last_retrieved_id cursor, not offsets.
- Reads carry only id + start_time. Trace bodies, aggregates and attachments
  are excluded server-side rather than transferred and discarded.
- --dry-run no longer enumerates: it reports the count, the date window it
  actually matched, and the batch plan, so previews are fast at any volume.
- Rate limits (429) are waited out per the server's Retry-After.
- Runtime API errors now set exit 1 instead of printing and reporting success,
  so a scheduled run can be alerted on. A count that failed prints ERROR
  rather than 0.

Also fixes which date "older than" meant. Date bounds went to to_time, which
bounds the trace *id* — i.e. ingestion time — while --after separately
filtered start_time, so the two bounds used different clocks. For traces
logged as they happen the clocks agree, but for backfilled data they do not:
1,250 traces imported today carrying start_time ~100 days ago are a year old on one
clock and seconds old on the other, so which is meant must be explicit rather than
incidental. Both bounds now share one
clock, defaulting to ingestion — which is what the original --before already used,
and the right basis for a retention commitment, since "we do not keep data longer
than 90 days" is a claim about custody. It is also the only clock a client cannot
influence: start_time is supplied by whatever wrote the trace and accepted as
given, so keying deletion on it means a skewed clock or a stamped future date keeps
data past its deletion date while the sweep still reports success. --time-field
start_time selects the activity-age policy where that is what is meant. The two are
never combined; being AND-ed would restore the exclusion.

Adds test_pagination.py — offline, no credentials: termination above one page,
exactly-once deletion, page boundaries, the no-progress guard, and that date
bounds land on exactly one clock, never both.

test_manage_traces.py seeds backdated fixtures, so it now forces --time-field
start_time; it also warns on exit 1 rather than tolerating it.

README documents the two clocks and why the default is custody-based, the OR-within-a-TTL-rule
tag semantics, that TTL counts are an upper bound, the exit codes, and a
flock+timeout cron recipe. Corrects the stated delete batch size (200, not
1,000).

Verified live against a workspace project of 1,860 traces (read-only) and a
seeded throwaway project of 1,250 backfilled traces (since removed):
list and --dry-run return in ~3s where the previous dry-run never terminated;
delete removed 100 by tag then 1,150 across two pages, leaving 0; error paths
exit 1; offline suite is 20/20.
Mechanical only — `uv run ruff format .`, no behaviour change. The folder was not
format-clean before this branch, and CONTRIBUTING's PR checklist asks for both
`ruff check` and `ruff format --check` to pass. Kept as its own commit so the
preceding fix reads without reformatting noise.
@LeoRoccoBreedt

Copy link
Copy Markdown
Collaborator

Strong, well-reasoned fix — approving. The delete-as-you-go rewrite is the right shape: the result set genuinely shrinks so the loop terminates, memory is bounded to one page, and interrupted runs resume. Fixing the exit-0-on-failure behavior and the split-clock date bounds are both real correctness wins on their own.

Verified against the pinned floor (opik 2.2.42): all SDK symbols the PR relies on exist — rest_helpers.ensure_rest_api_call_respecting_rate_limit, rest_stream_parser.read_and_parse_stream, TraceFilterPublic, and Opik(host=…, _show_misconfiguration_message=…). Method signatures line up exactly: search_traces has last_retrieved_id, delete_traces(ids, project_id), and get_traces_by_project accepts the from_time/to_time/sorting kwargs passed. Passing datetime objects for from_time/to_time while using the formatted string for the start_time predicate is consistent with the SDK types.

Two non-blocking notes for the record:

  1. Progress guard vs. deletion latency. The loop re-reads from the top each round, so correctness of the no-progress guard assumes delete_traces is synchronous. Confirmed offline that the design holds if it is, and the live destructive test (1,150 → 0 across two pages, landing exactly at 0, second read returning the next page) is strong evidence it is. Noting it only so a future reader knows the assumption. No change requested.

  2. ingestion clock rests on to_time/from_time bounding the trace id. check_time_field locks the param-construction half; the server-semantics half (that to_time really keys on the id, not start_time) is the one thing not covered by a test. Worth a mental confirmation, not a code change.

Minor/optional: cmd_list and _preview share near-identical counting loops (fine to leave); test_pagination.py stubs _search_page so EXCLUDE_FIELDS/to_api_kwargs wiring is only exercised live (acceptable seam). README additions — the clock explainer and the flock/timeout/mail-on-fail cron recipe — are genuinely good operator docs.

Good to merge.

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.

2 participants