Skip to content

[OPIK-8005] [FE] perf: cut per-cell store subscriptions 5x, skip no-op dataset hydration - #7934

Open
Nimrod007 wants to merge 2 commits into
mainfrom
Nimrod007/OPIK-8005/playground-perf-quick-wins
Open

[OPIK-8005] [FE] perf: cut per-cell store subscriptions 5x, skip no-op dataset hydration#7934
Nimrod007 wants to merge 2 commits into
mainfrom
Nimrod007/OPIK-8005/playground-perf-quick-wins

Conversation

@Nimrod007

@Nimrod007 Nimrod007 commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Details

Two contained, behaviour-preserving performance changes for the Playground large-dataset slowdown, plus a cancellation-correctness fix from review. Neither perf change touches layout.

  • PlaygroundOutputCell subscribed to the store five times per cell. All five hooks (value, isLoading, stale, traceId, selectedRuleIds) delegate to the same useOutputByPromptDatasetItemId selector, and the field access happens outside the selector — so zustand was comparing the same output object five times over. Every streaming token ran that selector (dataset items x prompts x 5) times: roughly 4,600 evaluations per token on a 458-row dataset with two prompt variants. Now read the output object once and pick the fields off it.
  • useIncrementalDatasetHydration looped every row and rebuilt the whole array per iteration, but hydrateDatasetItemData only fetches when an item carries truncated media. On a text/JSON dataset every iteration was a no-op, so a 1000-row dataset did ~1M array copies to arrive back at the data it started with. Now the media-bearing indexes are selected up front and only those are looped.
  • Third commit fixes a cancellation bug the second commit exposed — see Review follow-up below.

Scope caution: this is partial relief, not a fix. The unvirtualized output grid — thousands of live MarkdownPreview subtrees at 500+ rows — remains the structural problem and is not addressed here. Please do not close OPIK-8005 on this PR.

Worth flagging for whoever picks up the structural work: the fix originally proposed on that ticket ("virtualize by passing TableBody={DataTableVirtualBody}") will not work as written. DataTableVirtualBody virtualizes against a page-level scroll container from usePageBodyScrollContainer(), and PlaygroundPage is not inside that provider. The context default is non-null, so nothing throws — the virtualizer just receives a null scroll element and renders zero rows. StickyScrollTable also exists specifically because overflow-x:auto breaks position:sticky, the same conflict that blocks naive virtualization.

Review follow-up

Baz flagged a stale-fetch bug on the no-media early return, and it was a correct catch worth stating precisely.

cancelledRef was a single ref shared across every effect run, and the effect body reset it to false on entry. React runs the previous run's cleanup before the next body, so: run 1 starts hydrating dataset A and installs cleanup → dataset changes → cleanup sets cancelled = true → run 2 enters and resets it to false, un-cancelling run 1's still-pending loop → run 1's continuation passes its post-await check and calls setHydratedItems on the new array.

Before this PR the only early return sat after setHydratedItems([]), so the stale continuation's prev.map() ran over an empty array and produced nothing — harmless. The no-media early return added here sits after setHydratedItems(datasetItems) with a populated array, so the same continuation writes dataset A's data into dataset B's array at a positional index. Silent cross-dataset corruption, reachable by switching dataset or page size while a media-bearing dataset is still hydrating.

Fixed by scoping the flag to each effect run (let cancelled = false) instead of sharing a ref. Each run observes only its own flag, so early-returning without installing cleanup can no longer resurrect an older run's loop. This also fixes the pre-existing empty-dataset path and the setState-after-unmount case.

Change checklist

  • User facing
  • Documentation update

Issues

  • Resolves #
  • OPIK-8005

Partial relief only — OPIK-8005 should stay open for the virtualization work. Related but not resolved: CUST_6878 (customer report), AI_546 (investigation record).

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 5 (1M context)
  • Scope: Full authorship — investigation, all three commits, commit messages and this PR description. The agent also ran every verification command below.
  • Human verification: The operator scoped the work, reviewed the summarized diffs and rationale, and approved opening this PR and pushing the review fix. A human line-by-line review of the diff has not yet happened, and no browser profiling was done — see Testing. Treat this as needing normal review before merge.

Testing

Commands run locally in apps/opik-frontend (deps via npm ci), re-run after the review fix:

npx tsc --project tsconfig.json --noEmit           # exit 0, no output
npx eslint --max-warnings=0 <changed files>        # exit 0
npx prettier --check <changed files>               # all matched files use Prettier code style
CI=true npx vitest run                             # 150 files passed, 2229 tests passed

Equivalence reasoning for the subscription change (why it is safe, not merely green):

  • The five removed hooks each applied a default; those exact defaults are reproduced inline (?? null, ?? false, ?? null, and no default for selectedRuleIds).
  • The selector already returned the stored leaf object reference, and updateOutput replaces only the target leaf with a new object. Unrelated cell updates therefore still return an identical reference and still do not re-render this cell. Re-render behaviour is unchanged; the only difference is 5x fewer subscriptions.
  • The five hooks remain exported because PlaygroundPromptOutput.tsx still consumes three of them (single-instance, not per-row, so no benefit in changing it).

Equivalence reasoning for the hydration change:

  • DatasetItem.data is a required field, so dropping the old defensive ?? {} path changes nothing.
  • Items without truncated media keep their original data object, which is exactly what hydrateDatasetItemData returned for them.
  • The only consumer, PlaygroundOutputTable, reads hydratedItems and ignores isHydrating.

Not run / not done, with reasons:

  • No browser profiling, so no before/after frame-time numbers. These changes remove measurable work from the hot path, but I cannot claim they make the UI feel fixed at 500+ rows. A React-profiler recording on a 500-row dataset is the natural next step and is what would size the remaining structural gap.
  • No automated test for the cancellation fix. The bug needs an interleaving of an in-flight media fetch with a dataset swap; the hook has no existing test harness, and I did not add one. Reasoned through against React's cleanup ordering rather than proven by test — worth a reviewer's eye.
  • No video evidence. No visual change is expected; all three changes are internal.
  • A fourth candidate change (throttling updateOutput during streaming to coalesce per-token store writes) was deliberately left out. It is the only one that is not behaviour-neutral: it needs a flush-on-completion path, and if that is wrong a dropped final chunk silently corrupts a cell's output. It deserves its own PR with a measurement to justify it.

Documentation

No documentation change. All changes are internal; no user-facing API, behaviour or UI surface change.

…p dataset hydration

Two contained changes for the Playground large-dataset slowdown. Both are
behaviour-preserving; neither touches layout. This is partial relief, not a fix
for OPIK_8005 — the unvirtualized output grid remains the structural problem.

PlaygroundOutputCell subscribed to the store five times per cell (value,
isLoading, stale, traceId, selectedRuleIds). All five of those hooks delegate to
the same useOutputByPromptDatasetItemId selector, and the field access happens
outside the selector, so zustand was comparing the same output object five times
over. Every streaming token therefore ran that selector
(dataset items x prompts x 5) times — roughly 4,600 evaluations per token on a
458-row dataset with two prompt variants. Read the output object once and pick
the fields off it instead. The defaults applied here are the ones those hooks
applied, so re-render behaviour is unchanged; there are simply 5x fewer
subscriptions. The five hooks stay exported, as PlaygroundPromptOutput still
uses three of them.

useIncrementalDatasetHydration looped every row and rebuilt the whole array per
iteration, but hydrateDatasetItemData only fetches when the item carries
truncated media. On a text/JSON dataset every iteration was a no-op, so a
1000-row dataset did ~1M array copies to arrive back at the data it started
with. Select the media-bearing indexes up front and loop only over those.

Sized against a real production workspace: datasets there run to 1078 items and
carry zero truncated media, so the hydration loop was pure waste.

Refs: OPIK_8005, CUST_6878, AI_546

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🌐 typecheck — frontend Whole-project tsc type check 30.96s
⚓ helm-docs Regenerate Helm chart README 4.52s
🌐 eslint — frontend Lint + autofix JS/TS 2.27s
⚙️ actionlint — github workflows Lint GitHub Actions workflows 0.87s
🌈 zizmor — github workflows security Security-scan GitHub Actions workflows 0.05s
Total (5 ran) 38.67s
⏭️ 38 skipped (no matching files changed)
Hook Description Result
🐍 trim trailing whitespace — python sdk Strip trailing whitespace ⏭️
🐍 fix end of files — python sdk Ensure files end in a newline ⏭️
🐍 ruff — python sdk Lint + autofix Python (ruff) ⏭️
🐍 ruff-format — python sdk Format Python code (ruff) ⏭️
🐍 mypy — python sdk Static type check ⏭️
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
☕ spotless — java backend Format Java code ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
🧪 rebaseline script tests Self-test the changelog re-baseline script ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️

@CometActions

CometActions commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

No test needed here.

Read both files and this is behaviour-preserving. In PlaygroundOutputCell the five hooks you collapsed are each literally useOutputByPromptDatasetItemId(...)?.<field> ?? <default> in PlaygroundStore.ts, and the inlined defaults match them one for one. In useIncrementalDatasetHydration the skipped items are exactly the ones where hydrateDatasetItemData never fetched (containsTruncatedMedia gates its only round-trip), and isHydrating has no consumer — PlaygroundOutputTable destructures hydratedItems only — so the earlier false isn't visible either. The path you touched is already walked by playground/playground-smoke.spec.ts, which loads a dataset into the output table and waits for the cells to leave "No runs yet", so a regression here would fail an existing test. Nothing to add.

Run

Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review.

Re-checked after a push on 20 Aug 13:18 UTC — nothing the verdict depends on changed.

Comment on lines +42 to +45
if (indexesToHydrate.length === 0) {
setIsHydrating(false);
return;
}

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.

Stale media fetch overwrites new dataset

The no-media early-return path resets the shared cancelledRef.current to false without installing a replacement cleanup, so an earlier hydrateItems continuation can pass its post-await cancellation check and call setHydratedItems on the new array, overwriting it with stale data or updating state after unmount — should we use a generation-specific cancellation token, or install cleanup on every effect path?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-frontend/src/v2/pages/PlaygroundPage/useIncrementalDatasetHydration.ts`
around lines 42-45, fix the `useEffect` early-return path for datasets with no media.
Resetting the shared `cancelledRef` allows an older `hydrateItems` continuation to
update the replacement dataset or call setters after unmount; use a generation-specific
cancellation token/version, or install cleanup for every effect path, so stale async
work is always invalidated.

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.

Commit fb17f8e addressed this comment by replacing the shared cancellation ref with an effect-scoped cancellation token. Previous async hydrations are invalidated by cleanup before a new effect run, including no-media early returns and unmounts.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correct catch, and worth being precise about why it matters: this was a latent flaw that the no-media early return turned into a dangerous one.

cancelledRef is one ref shared across every effect run, and the effect body reset it to false on entry. React runs the previous run cleanup before the next body, so the sequence is: run 1 starts hydrating dataset A and installs cleanup -> dataset changes -> cleanup sets cancelled = true -> run 2 enters and resets it to false, un-cancelling run 1 still-pending loop -> run 1 continuation passes its post-await check and calls setHydratedItems on the new array.

Before this PR the only early return sat after setHydratedItems([]), so that stale continuation prev.map() ran over an empty array and produced nothing. Harmless, which is why it went unnoticed. The early return I added sits after setHydratedItems(datasetItems) with a populated array, so the same continuation writes dataset A data into dataset B array at a positional index — silent cross-dataset corruption, reachable by switching dataset or page size while a media-bearing dataset is still hydrating.

Fixed in fb17f8e by taking your second suggestion rather than a generation token: the flag is now scoped to each effect run (let cancelled = false) instead of being a shared ref. Each run observes only its own flag, so early-returning without installing cleanup cannot resurrect an older run loop, and no path needs its own cleanup to stay correct. That also fixes the pre-existing empty-dataset path and the setState-after-unmount case, and drops the useRef import.

Preferred it over a generation counter because it removes the shared mutable state entirely rather than versioning it, so the whole class of bug goes away instead of being guarded against.

One thing I did not do: there is no regression test. Reproducing it needs an in-flight media fetch interleaved with a dataset swap, and the hook has no existing test harness. The fix is reasoned against React cleanup ordering, not proven by test, so it is worth a human eye.

…(Baz review)

Baz caught a real bug in the previous commit, and it is worth being precise that
the new early-return path made a latent flaw dangerous rather than merely
inelegant.

cancelledRef is a single ref shared across every effect run, and the effect body
reset it to false on entry. React runs the previous run's cleanup before the next
body, so the sequence was: run 1 starts hydrating dataset A and installs cleanup
-> dataset changes -> cleanup sets cancelled = true -> run 2 enters and resets it
to false, un-cancelling run 1's still-pending loop -> run 1's continuation passes
its post-await check and calls setHydratedItems on the new array.

Before this PR the only early return sat after setHydratedItems([]), so that
stale continuation's prev.map() ran over an empty array and produced nothing —
harmless. The no-media early return added in the previous commit sits after
setHydratedItems(datasetItems) with a populated array, so the same continuation
writes dataset A's data into dataset B's array at a positional index. That is
silent cross-dataset corruption, reachable by switching dataset or page size
while a media-bearing dataset is still hydrating.

Replace the shared ref with a `cancelled` flag scoped to each effect run. Each
run observes only its own flag, so early-returning without installing cleanup
cannot resurrect an older run's loop. This also fixes the pre-existing empty-
dataset path and the setState-after-unmount case, and removes the useRef import.

Verified: tsc, eslint --max-warnings=0, prettier, and the full vitest suite
(150 files, 2229 tests) all pass.

Refs: OPIK-8005, CUST_6878, AI_546

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Nimrod007 Nimrod007 changed the title [OPIK_8005] [FE] perf: cut per-cell store subscriptions 5x, skip no-op dataset hydration [OPIK-8005] [FE] perf: cut per-cell store subscriptions 5x, skip no-op dataset hydration Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants