Skip to content

perf(dashboard): Skip rebuilding unchanged conversation detail on polls - #2655

Merged
thomasjiangcy merged 3 commits into
mainfrom
feat/conversation-detail-version
Nov 4, 2026
Merged

thomasjiangcy merged 3 commits into
mainfrom
feat/conversation-detail-version

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Open detail pages poll GET /api/conversations/:id. The old ETag (#1954) hashed the response, so every poll still built the whole report (p50 814ms, p95 1.5s in prod) only to return 304. This compares a cheap version token first and skips the build when nothing changed. Follow-up to #2650.

Design

  • New junior_conversations.detail_version column (migration 0075). Writes to a table the report reads advance it:
    • juniorConversations writes set detailVersion: nextDetailVersion inline.
    • Event, attachment, annotation, Brief, and archive writes use db.with(conversationDetailTouch(db, ids)), a data-modifying CTE that bumps in the same statement.
    • Purge and attachment GC call touchConversationDetail(db, ids) after the write, because their ids come from the write itself.
    • Participant upserts do not bump. Each caller already bumps the Conversation in the same transaction, and the root sums its subtree.
    • Pin writes do not bump. A pin is a viewer mark, which the ETag reads on each request.
  • readConversationDetailEtag hashes the subtree sum/count of detail_version with the state that is per viewer or owned by other Conversations: access, viewer marks (archive and pin), read position, forks, agent tree, limit, and JUNIOR_VERSION. Child agents bump only their own row. A new child changes the count.
  • The route returns 304 before readConversationDetail when If-None-Match matches, else 200 with the ETag.
  • The dashboard content-hash middleware now runs only for mock reporting, which has no stored version.

Lint rule

junior/touch-conversation-detail (oxlint JS plugin in tools/oxlint/junior, on for packages/junior/src) reports:

  • insert/update/delete on a detail table, or a raw sql write to one, that is not built on .with(conversationDetailTouch(...)) and has no later touchConversationDetail in the same function.
  • A juniorConversations update or upsert that does not set detailVersion. Plain inserts pass, because the row count covers them.

RuleTester cases run in pnpm oxlint-rules:check, which is now part of pnpm lint.

Not covered by the version

Identity names/avatars, destination names, Automation titles/source task, Slack team domains, and plugin sidebar hook output. These update with the next detail change or a reload.

Cost

The bump rides in the write statement, so it adds no round trip. It costs one primary-key update of the Conversation row in Postgres. detail_version has no index, so Postgres can usually update the row in place without touching indexes. Event appends already hold the event lock.

--

View Session in Sentry

Add a detail_version column that every write to a table the detail
report reads advances. The detail route hashes the subtree version
with the per-viewer state and answers a matching If-None-Match with
304 before it builds the report.

A new oxlint rule, junior/touch-conversation-detail, fails when a
detail table write does not call touchConversationDetail afterwards
or set detailVersion itself.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
@vercel

vercel Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
junior-docs Ready Ready Preview Nov 4, 2026 4:49pm UTC

Request Review

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

7 screenshot changes — 7 changed · 0 added · 0 removed

Review screenshots in Frameshift

Automation Page · Desktop
Automation Page · Desktop
Changed
Automation Run Details · Desktop
Automation Run Details · Desktop
Changed
Automation Run Details Light · Desktop
Automation Run Details Light · Desktop
Changed
Conversation Attachment · Desktop
Conversation Attachment · Desktop
Changed
People · Desktop
People · Desktop
Changed
System Cache Missing · Desktop
System Cache Missing · Desktop
Changed
Show 1 more changed screenshot
System Cache Share · Desktop
System Cache Share · Desktop
Changed

@thomasjiangcy

Copy link
Copy Markdown
Contributor

I wonder if it's worthwhile to consider moving to SSE instead. Initial GET to hydrate + SSE for subsequent events.

Feels like the polling is leading to a lot of unnecessary complexity here.

@sentry-junior

sentry-junior Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@thomasjiangcy thanks. We talked it through with @BYK and want to keep this PR as the fix for poll cost. SSE can come later as a separate change if we want lower latency.

Why SSE doesn't replace this:

  • SSE still needs a change signal. The server can only push when it knows the detail changed. It would need this same detail_version bump (or LISTEN/NOTIFY), or it would poll the database inside each stream. The write-side bumps and the lint rule carry over to SSE.
  • SSE adds new infrastructure. Nothing in the dashboard streams today. On Vercel functions, each stream holds a long-lived invocation and needs reconnects, a duration cap, and resume after a drop.
  • This PR is what removes the cost. Unchanged polls go from building the whole report (p50 814ms) to one small version query plus per-viewer reads, with no report build.

Most of the complexity here is the write-side bookkeeping, and any push design needs that too.

Carry the detail_version bump as a data-modifying CTE on the write it
covers, so it needs no extra round trip. touchConversationDetail stays
for the purge transaction and attachment GC, which learn their ids from
the write.

Participant upserts no longer bump. Each caller already bumps the
Conversation in the same transaction, and the root sums its subtree.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
Co-Authored-By: BYK <burak.kaya@sentry.io>
@thomasjiangcy

Copy link
Copy Markdown
Contributor

Oh yea fair push back, i forgot that the agent loop is handled on the worker side so that'll make sse much harder in the first place.

we just merged another pr that had migrations so you're going to need to refresh yours.

Renumber the detail_version migration to 0075 after 0074_conversation_participant_pins.
Hash viewer marks (archive and pin) in the detail ETag, since main replaced
readConversationArchivedAt with readConversationViewerMarks. Pin writes are
viewer marks, so they skip the detail version bump.
@thomasjiangcy
thomasjiangcy merged commit 2a63a4c into main Nov 4, 2026
46 checks passed
@thomasjiangcy
thomasjiangcy deleted the feat/conversation-detail-version branch November 4, 2026 16:58
thomasjiangcy added a commit that referenced this pull request Nov 4, 2026
…anges (#2667)

Follow-up to #2655.

The details panel fell behind the transcript. Subagents stayed "running"
after the transcript showed them Completed, and new Review items took a
long time to reach "Needs you". Only the detail report polls. The
panel's child agents, Watches, memories, and Review items load from
their own endpoints and refreshed only on remount or window focus.

Now, when a detail poll returns a new ETag, the detail `queryFn`
invalidates those reads. Only mounted reads refetch, so a closed panel
adds no requests. Since #2655, unchanged polls are 304s, so the panel
refetches only when the Conversation changed. The new ETag comes after
the data the panel reads: `recordSubagentEnded` writes `subagent_ended`
after the invocation is terminal, and Review items and Watches exist
before their tool result events are written.

The panel query keys now come from one `conversationSideReadQueryKey`
helper, so the refresh list and the hooks cannot drift. The archive
mutation moved to `conversationArchive.ts` with no change, because
`queries.ts` went over the 1,000-line limit.

<!-- junior-request-attribution:start -->
via @thomasjiangcy.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:slack%3AC0CCUMX7ET0%3A1793809940.432169 -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/slack%3AC0CCUMX7ET0%3A1793809940.432169)
[[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AC0CCUMX7ET0%3A1793809940.432169/?project=4510944073809921)

<!-- junior-session-footer:end -->

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: Thomas Jiang <20501235+thomasjiangcy@users.noreply.github.com>
BYK added a commit that referenced this pull request Nov 4, 2026
Follow-up to #2655. The version-gated 304 never fires in production.

Evidence (Sentry project `junior`, http.server `GET
/api/conversations/<id>` detail reads):
- 0.276.0 (24h): 3,150 server responses, all 200. 0.277.0 (first ~20
min): 160 server responses, all 200.
- 7 days: the only server-side 304s came from local dev
(`sentry-cli-local`).
- Browser `http.client` spans for the same URLs do show 304s: 614 of
~1,580 between 17:00 and 20:15Z. Their p50 was ~500ms, slower than the
200s (~340ms).
- The usage query (`cab9c71d57e8180d`) ran about once per detail read.

So the function builds the full report on every poll. Then something in
front of it, most likely the Vercel CDN, answers `ETag` /
`If-None-Match` itself. That is an inference: the CDN behavior is not
confirmed from Vercel docs. Either way, the route never sees a validator
that matches.

This change sends the token in `x-junior-detail-version`, both on the
response and on the next poll. The route compares that header before it
builds the report. Mock reporting uses the same header. The token is
unchanged except for the `W/"…"` wrapper, which no longer applies.

To verify after deploy, server-side `http.response.status_code:304`
should appear on detail reads, and usage query spans per detail read
should drop below 1.

<!-- junior-request-attribution:start -->
via @BYK.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:local%3Aweb%3A17b6d7f555623680001f2a90 -->

--

[View Session in
Sentry](https://sentry.sentry.io/explore/conversations/local%3Aweb%3A17b6d7f555623680001f2a90/?project=4510944073809921)

<!-- junior-session-footer:end -->

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <126780+BYK@users.noreply.github.com>
thomasjiangcy added a commit that referenced this pull request Nov 5, 2026
…2716)

When a conversation opens with no cached copy, the transcript waits for
two requests in a row: `/pending-messages`, then
`/api/conversations/:id`. #1932 added this order on purpose. Workers
commit input before they acknowledge it, so reading the mailbox first
means a message that moves into history between the two reads still
appears in one of them.

In prod traces, the detail request started only after the mailbox
request returned:

-
[`3cffecfa…`](https://sentry.sentry.io/explore/traces/trace/3cffecfa6ae6460f8677a8fc57ea128e/)
(navigation): mailbox 47→383ms, then detail from 385ms.
-
[`0d26d374…`](https://sentry.sentry.io/explore/traces/trace/0d26d3741d5d477793c326ccfbed4f4b/)
(pageload): mailbox 713→856ms, then detail from 861ms.
-
[`19019d5f…`](https://sentry.sentry.io/explore/traces/trace/19019d5f769341a2916e865e93267baa/)
and
[`40a68dfc…`](https://sentry.sentry.io/explore/traces/trace/40a68dfc3ed248dca589f837bd109bb8/):
the mailbox took 0.86s and 1.8s in the browser, and the detail waited
for it.

Change:

- `GET /api/conversations/:id?mailbox=true` reads the mailbox first and
then history, in one request. It returns the `/pending-messages` report
as `mailbox`. A request with `mailbox=true` never returns 304.
- The client uses it only for a first read with no cached copy. Polls
and cached reopens keep the two reads, so the detail can still return
304 (#2655).
- The mock reporting API and its hash-based 304 middleware support the
flag. A shared e2e helper, `withRequestedMailbox`, adds the mailbox to
mocked detail reports.

Expected impact (inference): opening a conversation saves one round trip
plus the server's mailbox read, about 150–350ms in typical traces and up
to about 1.8s in the slow ones. Polling cost does not change.

Checks: `pnpm typecheck` (junior, junior-dashboard), oxlint, prettier,
`file-length:check`, junior `pending-messages` and `detail` integration
tests, dashboard vitest, and the whole dashboard Playwright suite (87
passed). `tests/dashboard-output.test.ts` fails in my sandbox on `main`
too, during its Nitro build, so it is not related to this change.

Overlaps: #2657, #2676, #2677, #2678, and #2428 change the conversation
page UI. This PR changes only `queries.ts` data loading and the detail
route, so I expect few conflicts. #2657 (catch up on unread activity)
may touch the same detail options. This branch is based on `main`. #2712
(Neon pool size) is independent, and it fixes most of the server-side
wait in the slow traces.

<!-- junior-request-attribution:start -->
via @thomasjiangcy.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A34a570431e2cdbd57aba2d8c15a0c1ac -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/agent%3A34a570431e2cdbd57aba2d8c15a0c1ac)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A34a570431e2cdbd57aba2d8c15a0c1ac/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: Thomas Jiang <20501235+thomasjiangcy@users.noreply.github.com>
BYK pushed a commit that referenced this pull request Nov 5, 2026
…ersation read (#2720)

Opening a conversation built the whole detail report twice.
`readConversationDetailEtag`, added in #2655, hashed the viewer's read
position, and the transcript sends `PUT /read-position` soon after it
opens and again as the reader scrolls. Each write changed the version
token. So the next poll got a 200 with the full report, not a 304. That
new token also made `conversationDetailQueryOptions` refresh agents,
memories, watches and Review items.

A prod browser capture of one conversation open showed it:
`pending-messages` → detail (200, 77.8 kB, 1.41s) → `read-position` →
`pending-messages` → detail (200 again, 77.9 kB, 1.44s) → `memories` and
`spend` again.

Change:

- The read position is no longer part of the detail ETag, and that query
no longer runs on each poll. `useMarkConversationRead` already puts the
stored position into the cached detail, so the reader's own client stays
correct.
- Polls now answer 304 after a read, so the cache keeps the
`unreadCursor` from the last full build. `useMarkConversationRead` now
drops `unreadCursor` and `unreadPage` when the new position reaches the
latest page. It uses the same rule as the server (`lastReadSeq + 1 <
firstSeq`). Without this, a second visit in the same session would open
at rows the reader already read. The current visit is not affected,
because `useReadPositionAtOpen` keeps the values from when the
conversation opened.
- The read-position integration scenario now checks that a poll after a
read returns 304.

Trade-off: another tab or device does not see the new read position
until the detail changes or the page reloads. A new page load has no
cache, so it always gets the current position. If the reader is still
behind the latest page after a write, the cached cursor stays at the
older position until the next full build. A reopen in that tab then
loads from slightly earlier rows.

Not checked: the dashboard Playwright read-position spec, because Chrome
is not installed in my sandbox. CI runs it. Mock reporting uses a
content-hash ETag, so the e2e path still rebuilds after a read either
way. `tests/dashboard-output.test.ts` also fails on `main` in my
sandbox.

Overlaps: #2716 changes the first detail read (mailbox in the same
request) in `queries.ts` and the detail route, but not the ETag. #2657
(unread catch-up) touches read-position e2e and the unread flow.
Whichever merges second may need a small rebase.

<!-- junior-request-attribution:start -->
via @thomasjiangcy.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3Ab48dee8440f2faa8ee84518a2b9ac612 -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/agent%3Ab48dee8440f2faa8ee84518a2b9ac612)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3Ab48dee8440f2faa8ee84518a2b9ac612/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: Thomas Jiang <20501235+thomasjiangcy@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview – junior-docs — 9657d8fe Deployed Nov 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: high PR risk score: high

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants