@@ -54,6 +54,15 @@ import {
5454// query fails with a real message rather than an opaque 504.
5555const QUERY_TIMEOUT_SECONDS = 240 ;
5656
57+ // CSV export re-runs the query with no display cap, so it needs its own bound.
58+ // Without one, an unbounded SELECT streams every row into a JS array, a CSV
59+ // string, and a Blob, and the equivalent unbounded read on the backend loads
60+ // the whole result set into memory before responding — large enough result
61+ // sets can exhaust the backend heap for every tenant on that instance, not
62+ // just the exporter. 100k rows is generous for a CSV download while staying
63+ // well short of that failure mode.
64+ const EXPORT_ROW_LIMIT = 100000 ;
65+
5766// Constants for diagram layout
5867const DIAGRAM_NODE_WIDTH = 240 ;
5968const DIAGRAM_NODE_HEIGHT = 120 ;
@@ -298,6 +307,12 @@ export default function SqlRunnerTab({ connectionId }) {
298307 const dbObjectsRef = useRef ( [ ] ) ;
299308 const abortControllerRef = useRef ( null ) ;
300309 const executionIdRef = useRef ( null ) ;
310+ // Guards against a second run starting while one is already in flight (e.g. a
311+ // held-down Cmd+Enter): without it, whichever response lands last wins the
312+ // results panel regardless of which run the user actually meant to see last,
313+ // and the earlier run's abort/cancel handle gets silently discarded.
314+ const isRunningRef = useRef ( false ) ;
315+ const runSeqRef = useRef ( 0 ) ;
301316 // Note: savedQueriesPanelRef kept for potential future use, but panel UI is now in modal
302317 const savedQueriesPanelRef = useRef ( null ) ;
303318 const hasRowCount = ( value ) => value !== null && value !== undefined ;
@@ -1194,6 +1209,9 @@ export default function SqlRunnerTab({ connectionId }) {
11941209 } ;
11951210
11961211 const handleRunQuery = async ( queryToRun = null , options = { } ) => {
1212+ if ( isRunningRef . current ) {
1213+ return ;
1214+ }
11971215 const mutationConfirmed = options . mutationConfirmed === true ;
11981216 let queryText = null ;
11991217
@@ -1252,12 +1270,14 @@ export default function SqlRunnerTab({ connectionId }) {
12521270 return ;
12531271 }
12541272
1273+ isRunningRef . current = true ;
12551274 setIsRunning ( true ) ;
12561275 setError ( null ) ;
12571276 setResults ( null ) ;
12581277 setExplainResults ( null ) ; // Clear explain results when running query
12591278 setOptimizeResult ( null ) ;
12601279
1280+ const seq = ++ runSeqRef . current ;
12611281 const abortController = new AbortController ( ) ;
12621282 abortControllerRef . current = abortController ;
12631283 // Identifies this run so cancelling can terminate it on the database.
@@ -1284,6 +1304,12 @@ export default function SqlRunnerTab({ connectionId }) {
12841304 } ,
12851305 ) ;
12861306
1307+ if ( seq !== runSeqRef . current ) {
1308+ // Superseded by a later run — drop this response rather than let a
1309+ // stale result overwrite what the user is now looking at.
1310+ return ;
1311+ }
1312+
12871313 if ( response . success ) {
12881314 setPendingMutationConfirmation ( null ) ;
12891315 const DISPLAY_LIMIT = 1000 ;
@@ -1355,9 +1381,12 @@ export default function SqlRunnerTab({ connectionId }) {
13551381 setError ( err . message || "Failed to execute query" ) ;
13561382 }
13571383 } finally {
1358- abortControllerRef . current = null ;
1359- executionIdRef . current = null ;
1360- setIsRunning ( false ) ;
1384+ if ( seq === runSeqRef . current ) {
1385+ abortControllerRef . current = null ;
1386+ executionIdRef . current = null ;
1387+ isRunningRef . current = false ;
1388+ setIsRunning ( false ) ;
1389+ }
13611390 }
13621391 } ;
13631392
@@ -1366,6 +1395,7 @@ export default function SqlRunnerTab({ connectionId }) {
13661395 if ( abortControllerRef . current ) {
13671396 abortControllerRef . current . abort ( ) ;
13681397 }
1398+ isRunningRef . current = false ;
13691399 setIsRunning ( false ) ;
13701400 setError ( null ) ;
13711401 // Aborting above only drops the HTTP response; the statement keeps running
@@ -1406,23 +1436,37 @@ export default function SqlRunnerTab({ connectionId }) {
14061436
14071437 const handleExportResults = async ( ) => {
14081438 if ( ! results ) return ;
1409-
1410- // If the result was limited, always re-fetch the full result set for download.
1411- // This ensures the CSV contains all rows, not just the displayed page.
1439+ // Export re-runs the query, so it must respect the same in-flight guard as
1440+ // Run. Today `handleRunQuery` clears `results`, which hides the Export
1441+ // button for the duration of a run and makes this unreachable — but that is
1442+ // an incidental consequence of unrelated state handling, not a guarantee.
1443+ // Without this check, any change that keeps results on screen during a run
1444+ // silently reintroduces two concurrent queries from one tab.
1445+ if ( isRunningRef . current || isExporting ) return ;
1446+
1447+ // If the result was limited, always re-fetch for download so the CSV isn't
1448+ // just the displayed page — but still capped at EXPORT_ROW_LIMIT, not
1449+ // unbounded, and on the same timeout budget as Run so it fails with a real
1450+ // error instead of an opaque 504 from the nginx proxy.
14121451 if ( results . isLimited && results . query ) {
14131452 setIsExporting ( true ) ;
14141453 // Strip trailing semicolon so backends don't reject the re-executed query
14151454 const queryForExport = results . query . trim ( ) . replace ( / ; + $ / , "" ) ;
1455+ const exportAbortController = new AbortController ( ) ;
1456+ const exportExecutionId =
1457+ globalThis . crypto ?. randomUUID ?. ( ) ??
1458+ `exec-${ Date . now ( ) } -${ Math . random ( ) . toString ( 16 ) . slice ( 2 ) } ` ;
14161459 try {
14171460 const response = await queryAPI . executeQuery (
14181461 connectionId ,
14191462 queryForExport ,
1420- null , // no limit — fetch all rows
1421- 600 ,
1422- null ,
1463+ EXPORT_ROW_LIMIT ,
1464+ QUERY_TIMEOUT_SECONDS ,
1465+ exportAbortController . signal ,
14231466 {
14241467 executionOrigin : "EDITOR" ,
14251468 mutationConfirmed : false ,
1469+ executionId : exportExecutionId ,
14261470 } ,
14271471 ) ;
14281472 if ( response . success ) {
@@ -2590,9 +2634,9 @@ export default function SqlRunnerTab({ connectionId }) {
25902634 disabled = { isExporting }
25912635 title = {
25922636 results ?. isLimited
2593- ? results ?. totalRowCount != null
2594- ? `Download all ${ results . totalRowCount . toLocaleString ( ) } rows as CSV`
2595- : " Download full result set as CSV"
2637+ ? results ?. totalRowCount != null && results . totalRowCount > EXPORT_ROW_LIMIT
2638+ ? `Download first ${ EXPORT_ROW_LIMIT . toLocaleString ( ) } of ${ results . totalRowCount . toLocaleString ( ) } rows as CSV`
2639+ : ` Download up to ${ EXPORT_ROW_LIMIT . toLocaleString ( ) } rows as CSV`
25962640 : "Export CSV"
25972641 }
25982642 >
0 commit comments