Repository navigation
perf(dashboard): Skip rebuilding unchanged conversation detail on polls - #2655
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
7 screenshot changes — 7 changed · 0 added · 0 removed Review screenshots in Frameshift |
|
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. |
|
@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:
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>
|
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.
…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>
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>
…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>
…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>
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
junior_conversations.detail_versioncolumn (migration 0075). Writes to a table the report reads advance it:juniorConversationswrites setdetailVersion: nextDetailVersioninline.db.with(conversationDetailTouch(db, ids)), a data-modifying CTE that bumps in the same statement.touchConversationDetail(db, ids)after the write, because their ids come from the write itself.readConversationDetailEtaghashes the subtreesum/countofdetail_versionwith the state that is per viewer or owned by other Conversations: access, viewer marks (archive and pin), read position, forks, agent tree,limit, andJUNIOR_VERSION. Child agents bump only their own row. A new child changes the count.readConversationDetailwhenIf-None-Matchmatches, else 200 with the ETag.Lint rule
junior/touch-conversation-detail(oxlint JS plugin intools/oxlint/junior, on forpackages/junior/src) reports:insert/update/deleteon a detail table, or a rawsqlwrite to one, that is not built on.with(conversationDetailTouch(...))and has no latertouchConversationDetailin the same function.juniorConversationsupdate or upsert that does not setdetailVersion. Plain inserts pass, because the row count covers them.RuleTester cases run in
pnpm oxlint-rules:check, which is now part ofpnpm 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_versionhas 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