perf(dashboard): take the last blocking transcript reads off the event loop - #7469
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of The claims check out end-to-end: Design-Verdict: PASS Loop-stall cause is deleted at its root — [DESIGN-REVIEWED] b00488b |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified. The description's audit holds up: First-Principles-Verdict: PASS Five loop-stalling reads fixed at cause level — the fallback that invited them is deleted, and every new surface has counted consumers. What this change shipsIntent: stop large-transcript file reads from freezing every dashboard session's frames — a FIX (issue #7408, measured 100–300 ms reads, logged 6.1 s loop lag).
Subtractions
[FIRST-PRINCIPLES-REVIEWED] b00488b |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
|
Disposition of the one advisory residue First Principles raised (the two inline Both of those sites -- It is worth doing, just not here. Whoever picks up the |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
7cfa861 to
de08472
Compare
|
Backend Tests (Windows) shard 3 came back with two failures on Mine -- fixed. Main-owned -- filed as #7490, not fixed here. That test does not exist at this PR's base ( |
de08472 to
0e13d6d
Compare
|
First Principles' subtraction 1 -- adopted in
What that cost, precisely:
The spec line shrank accordingly: it now states the parameter is required and why, instead of asking callers not to reach a fallback that no longer exists. Gates re-run clean on the new head (mypy 1220 files, black gate, flake8, isort, docs-lint, sync-io-in-async), 260 tests pass across the cron and gateway files. Subtraction 2 (folding the two remaining inline prefetch spellings onto |
|
The one remaining red is covered by open PR #7492 ("re-measure the files.py log-site census after #7293"), which makes exactly the 3 -> 1 edit this needs. It is the only failure left on |
ConversationLog.read_messages / read_messages_chained parse a whole transcript from disk (100-300 ms on a large store). Five call sites still ran that parse on the event loop, where it stalls every other session: - chat_runner: the prompt-submit re-injection probe read the transcript inline; now via asyncio.to_thread. - handlers/messaging api_send_message and slack/gateway _deliver_script_result called the SYNCHRONOUS _rehydrate_slot_from_history from async functions; both now await rehydrate_slot_from_history_async, which prefetches the same reads in a worker thread and re-checks the close/deletion races on return. - slack/gateway _cron_callback's dedup-suppressed and silent injection paths passed no history=, so inject_cron_result_to_dashboard took its own fallback read on the loop. Both now await the new cron_inject prefetch_cron_history, which reads off the loop and skips the read entirely when the slot is already linked (the state in which the injection does not consume history), so no path pays for a discarded transcript. The cause of that last pair was the fallback itself: a synchronous function with an optional history= let an async caller leave the parse to it. The fallback is deleted and history is now a required keyword parameter, so a forgotten prefetch is a TypeError at the call rather than a stall in production. All four in-repo callers already pass it; the two test files that drove the function synchronously read the transcript in a local helper instead, which is where a blocking read belongs. The four other lines listed in the issue are already off the loop through their callers: session_transfer via asyncio.to_thread(_read_and_assemble), chat_persistence through _prefetched_messages, channel_slots through loop.run_in_executor(_load_messages), handlers/artifacts through _run_off_loop. api_session_detail is handled by #7404. Tests assert the read's THREAD, not the name of the function called, so a future rename cannot quietly reopen the defect. All three new off-loop tests fail on base. Closes #7408
0e13d6d to
b00488b
Compare
bolichen97
left a comment
There was a problem hiding this comment.
CI green, no blocking bot findings, diff matches description. Approved.
Problem / Motivation
ConversationLog.read_messages/read_messages_chainedopen a transcript,read it whole and JSON-parse every row: 100-300 ms on a large store. Five call
sites still did that on the asyncio event loop, so while one of them ran, every
other session's frames waited. A gateway log on an 810-session, 644 MB store
shows
event-loop heartbeat: lag 6.1s (loop was blocked).Issue #7408 lists six lines. Verified against main
bb01943c1, four of thesix are already off the loop -- the offload lives at their callers, which is
exactly why the lines look un-offloaded when read on their own -- and the real
remaining exposure includes four caller sites the issue does not list. The set
this PR fixes is therefore five sites, not six:
dashboard/chat_runner.py:5877async def _run_chatdashboard/handlers/messaging.py:2135async def api_send_messagecalled the sync_rehydrate_slot_from_historyslack/gateway.py:3239async def _deliver_script_result, same substitutionslack/gateway.py:4581history=, socron_inject.py:98reads on the loopslack/gateway.py:4605dashboard/session_transfer.py:173_read_and_assembleis dispatched byawait asyncio.to_thread(...)dashboard/chat_persistence.py:1009elsearm of a_prefetched_messagesternary;rehydrate_slot_from_history_asyncprefetches off-loopdashboard/channel_slots.py:751await loop.run_in_executor(None, _load_messages)dashboard/handlers/artifacts.py:755_run_off_loop(run_in_executor(subprocess_executor(), ...))dashboard/cron_inject.py:98history=that let the two gateway callers leave the read here; now deletedapi_session_detailis covered by #7404 and deliberately untouched here.Why it matters
Every affected path is one a user is waiting on. The prompt-submit read runs on
the hottest path there is -- the keystroke-to-first-token path of every new
turn. The two rehydrate sites fire when a cron or an MCP
send_messagedeliversinto a session whose tab is cold, which is precisely when the transcript is
large enough to be slow. A stall long enough to cross
dashboard.loop_stall_exit_after_secs(25s) does not merely feel slow: theloop-stall watchdog kills the gateway.
What changed (motivation -> approach -> change)
The convention this codebase already uses for the sync/async boundary is
neither "async-ify the helper" nor "wrap the helper's own line": it is
prefetch in the async caller and pass the data in, leaving the sync
signature and its sync callers untouched. That is what
_prefetched_messages=on
_rehydrate_slot_from_historyandhistory=oninject_cron_result_to_dashboardexist for. No signature changes were neededfor the rehydrate half, so the ripple through
slack/gateway.pyand the testsurface the issue worried about does not arise.
chat_runner: the re-injection probe'sread_messagesnow goes throughasyncio.to_thread.mem_countis counted after the hop, so both sides ofthe comparison read post-await state.
handlers/messaging.pyandslack/gateway.py: both cold-slot lookups nowawait rehydrate_slot_from_history_async, which already exists, prefetchesthe same three reads in a thread, and re-checks the tab-close and deletion
races on return (returning
None, which both callers already handle). Thenow-unused sync imports are dropped from both modules.
cron_inject.pygainsprefetch_cron_history, and the two silent /dedup-suppressed gateway sites await it. It reads off the loop and skips
the read entirely when the slot is already linked -- the state in which the
injection does not consume
history. That guard matters: these two sites fireon every suppressed and every silent run, and copying the third site's
unconditional prefetch would have added a whole-transcript parse whose result
is discarded.
omit
history=only becauseinject_cron_result_to_dashboard-- asynchronous function whose every caller is async -- read the transcript for
itself when the kwarg was absent. That fallback is deleted and
historyisnow a required keyword parameter, so a forgotten prefetch is a
TypeErroratthe call site instead of a silent loop stall in production. Adopted from the
First Principles lane's subtraction; it replaces a spec sentence with a
signature. All four in-repo callers already passed the kwarg, so no production
call site changed; the two test files that drove the function synchronously
now do the read in a local
_injecthelper, which is where a blocking readlegitimately belongs.
gateway.py:4643andhandlers/cron.py:635keep their own inline prefetch:they are already off the loop, and reworking a correct path is not this PR's
business (see the deferral comment on this PR).
Tests
Every new test asserts the thread the read ran on, not the name of the
function called, so a later rename cannot quietly reopen the defect. All three
off-loop tests were confirmed red with only the source changes reverted.
test_chat_runner_coverage.py::TestPromptSubmitTranscriptRead-- drives areal turn with a non-empty in-memory window (what arms the probe) and asserts
the transcript read happened on some thread other than the loop's. Reverted,
it fails with "the re-injection probe read the transcript on the event-loop
thread".
test_dashboard_file_io.py::...rehydrate_reads_off_the_loop-- POSTs/api/send-messagewithsession=originagainst a cold slot and asserts thesame for the rehydration read.
test_slack_gateway_cron_exec_coverage.py::...rehydration_reads_the_transcript_off_the_loop-- same assertion for the script-cron delivery path.
prefetch_cron_historytests: reads off-loop for an unlinked slot, readswhen no slot exists yet, skips the read for a linked slot (locking in the
no-added-cost property), and returns
Nonewith no conversation log.test_dashboard_cron_to_chat.py::...test_history_is_required_so_the_read_cannot_land_on_the_loop-- pins the enforcement the deletion buys: calling the injection without
history=raisesTypeError.seam now name the async one,
test_silent_cron_injects_to_existing_slotasserts the new
history=ANYkwarg like its three sibling assertions alreadydid, and the 22 synchronous injection call sites in two files route through a
local
_injecthelper that performs the read the fallback used to.Targeted runs (CI owns the full suite): 260 passed across the cron and gateway
files after the subtraction, 435 across the six originally touched test files,
plus
test_slack_gateway.py,test_session_restore.py,test_rehydrate_async.py,test_send_message_targeted.py,test_cron_locking_regression.pyandtest_chat_turn_timeout_consistency.py.Gates: black (repo gate), isort, flake8,
mypy src/kiro_crew/(1220 filesclean), docs-lint, scrub-lint, check_sync_io_in_async,
check_subprocess_encoding, check_agent_sdk_boundary, check_loop_bound_locks,
check_changelog_history, check_harness_parity, check_brand_name,
check_focus_cue.
Manual verification
N/A -- no user-visible surface changes, and the property under test (which
thread a read runs on) is asserted directly rather than inferred from behavior.
Related Issues
Closes #7408
Pattern harvest
Rule candidate: lint --
scripts/check_sync_io_in_async.pyis the gate forexactly this class, and it did not see any of these five sites. Its detection
surface is primitives:
MODULE_FUNCS(os.*,shutil.*, ...),NAME_FUNCS,HTTP verbs, sync client constructors and
DB_METHODSbehind a receiver regex.A domain method that wraps
open()+json.loadsis invisible to it, so atranscript parse on the loop reads as clean code. The rule candidate is a small
receiver-matched table of domain readers --
.read_messages(/.read_messages_chained(on aconversation_log-shaped receiver to start --carried under the same shrink-only baseline discipline the gate already uses.
There is also a design half worth stating, because it is what actually
prevents the recurrence here: an optional argument on a synchronous function
whose every caller is async is a loop-stall waiting to happen. The default is
what makes the omission silent. Requiring the argument moves the failure from
production to the call site, and needs no detector at all.
Two auxiliary notes for whoever picks up the lint rule, both learned here:
channel_slots.py:751is offloaded vialoop.run_in_executor, notasyncio.to_thread; a detector keyed on one spelling would have "found" it asan offender. Main carries both idioms in bulk.
reason four of the six listed lines needed no change: under the
prefetch-at-the-caller convention the offload is not at the read. A useful
detector has to resolve the enclosing function's callers to the nearest async
boundary, or it will report exactly this issue's false positives.