Compare commits

...

2 Commits

Author SHA1 Message Date
Elizabeth Thompson
7d5e8ad785 fix(sqllab): roll back session before retrying get_query after a broken transaction
`get_query` catches any exception from the ORM lookup and relies on the
`backoff` decorator to retry up to 5 times. When the underlying failure is
(or causes) a SQLAlchemy `PendingRollbackError` - e.g. a `PendingRollbackError`
chained under an `OperationalError`/`QueryCanceled` from a dropped connection
or statement timeout - the session is left in a broken state that SQLAlchemy
refuses to use again until `.rollback()` is called explicitly. Since the
session was never rolled back, every one of the 5 retries reused the same
poisoned session and failed identically, so the retry loop never had a
chance to recover from what may be a transient connection blip.

Roll back the session in the except block before raising `SqlLabException`
so each `backoff` retry starts from a clean session. The exception raised
and logged is unchanged.

Fixes SUPERSET-PYTHON-WDZ

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 15:12:43 +00:00
Amin Ghadersohi
22c305f758 fix(dataset): retry metadata after OAuth2 authorization (#42581) 2026-07-31 20:41:42 -04:00
14 changed files with 420 additions and 137 deletions

View File

@@ -62,6 +62,7 @@ export function ErrorMessageWithStackTrace({
fallback,
compact,
closable = true,
errorMitigationFunction,
}: Props) {
// Check if a custom error message component was registered for this message
if (error) {
@@ -77,6 +78,7 @@ export function ErrorMessageWithStackTrace({
error={error}
source={source}
subtitle={subtitle}
errorMitigationFunction={errorMitigationFunction}
/>
);
}

View File

@@ -20,7 +20,7 @@
import * as reduxHooks from 'react-redux';
import { Provider } from 'react-redux';
import { createStore, Store } from 'redux';
import { render, waitFor } from 'spec/helpers/testing-library';
import { act, render, waitFor } from 'spec/helpers/testing-library';
import { ErrorLevel, ErrorSource, ErrorTypeEnum } from '@superset-ui/core';
import { reRunQuery } from 'src/SqlLab/actions/sqlLab';
import { triggerQuery } from 'src/components/Chart/chartAction';
@@ -166,15 +166,55 @@ describe('OAuth2RedirectMessage Component', () => {
render(setup());
simulateBroadcastMessage({ tabId: 'tabId' });
simulateStorageMessage({ tabId: 'tabId' });
await waitFor(() => {
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
});
expect(reRunQuery).toHaveBeenCalledTimes(1);
});
test('dispatches reRunQuery action when storage event has matching tab ID', async () => {
render(setup());
simulateStorageMessage({ tabId: 'tabId' });
await waitFor(() => {
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
});
});
test('dispatches reRunQuery action when storage event has matching tab ID', async () => {
render(setup());
test('waits for the SQL Lab query before consuming the completion', async () => {
const initialState = {
sqlLab: {
queries: {},
queryEditors: [{ id: 'editor-id', latestQueryId: 'query-id' }],
tabHistory: ['editor-id'],
},
explore: { slice: null },
charts: {},
dashboardInfo: {},
};
const delayedQueryStore = createStore(
(state: typeof initialState = initialState, action) =>
action.type === 'load-query'
? {
...state,
sqlLab: {
...state.sqlLab,
queries: { 'query-id': { sql: 'SELECT * FROM table' } },
},
}
: state,
);
render(setup({}, delayedQueryStore));
simulateBroadcastMessage({ tabId: 'tabId' });
expect(reRunQuery).not.toHaveBeenCalled();
act(() => {
delayedQueryStore.dispatch({ type: 'load-query' });
});
simulateStorageMessage({ tabId: 'tabId' });
await waitFor(() => {
@@ -234,4 +274,22 @@ describe('OAuth2RedirectMessage Component', () => {
]);
});
});
test('runs scoped mitigation once instead of CRUD invalidation', async () => {
const errorMitigationFunction = jest.fn();
render(
setup({
source: 'crud' as ErrorSource,
errorMitigationFunction,
}),
);
simulateBroadcastMessage({ tabId: 'tabId' });
simulateStorageMessage({ tabId: 'tabId' });
await waitFor(() => {
expect(errorMitigationFunction).toHaveBeenCalledTimes(1);
});
expect(api.util.invalidateTags).not.toHaveBeenCalled();
});
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
@@ -58,15 +58,16 @@ interface OAuth2RedirectExtra {
*
* After the token has been stored, the opened tab will broadcast a message to the
* original tab and close itself. This component, running on the original tab, listens
* on a same-origin BroadcastChannel and re-runs the query for the user once it
* receives the success message — be it in SQL Lab, Explore, or a dashboard. Both tabs
* share a "tab ID" (a UUID generated by the backend) which is echoed back through the
* channel so the original tab only reacts to its own OAuth2 flow.
* for same-origin BroadcastChannel and storage notifications and re-runs the query
* for the user once it receives the success message — be it in SQL Lab, Explore, or
* a dashboard. Both tabs share a "tab ID" (a UUID generated by the backend) which is
* echoed back so the original tab only reacts to its own OAuth2 flow.
*/
export function OAuth2RedirectMessage({
error,
source,
closable,
errorMitigationFunction,
}: ErrorMessageComponentProps<OAuth2RedirectExtra>) {
const { extra, level } = error;
@@ -103,13 +104,17 @@ export function OAuth2RedirectMessage({
);
const dispatch = useDispatch();
const lastHandledTabIdRef = useRef<string>();
useEffect(() => {
const handleOAuthComplete = (tabId?: string) => {
if (tabId !== extra.tab_id) {
if (tabId !== extra.tab_id || tabId === lastHandledTabIdRef.current) {
return;
}
if (source === 'sqllab' && query) {
if (errorMitigationFunction) {
errorMitigationFunction();
} else if (source === 'sqllab' && query) {
dispatch(reRunQuery(query));
} else if (source === 'explore') {
dispatch(triggerQuery(true, chartId));
@@ -123,7 +128,11 @@ export function OAuth2RedirectMessage({
'Tables',
]),
);
} else {
return;
}
lastHandledTabIdRef.current = tabId;
};
const channel =
@@ -156,7 +165,16 @@ export function OAuth2RedirectMessage({
window.removeEventListener('storage', handleStorage);
channel?.close();
};
}, [source, extra.tab_id, dispatch, query, chartId, chartList, dashboardId]);
}, [
source,
extra.tab_id,
dispatch,
query,
chartId,
chartList,
dashboardId,
errorMitigationFunction,
]);
const body = (
<p>

View File

@@ -27,6 +27,7 @@ export type ErrorMessageComponentProps<ExtraType = Record<string, any> | null> =
subtitle?: ReactNode;
compact?: boolean;
closable?: boolean;
errorMitigationFunction?: () => void;
};
export type ErrorMessageComponent = ComponentType<ErrorMessageComponentProps>;

View File

@@ -35,6 +35,5 @@ export const Basic: StoryFn<typeof DatasetPanel> = args => (
Basic.args = {
tableName: 'example_table',
loading: false,
hasError: false,
columnList: exampleColumns,
};

View File

@@ -77,7 +77,6 @@ test('View Dataset opens a single-prefixed URL under a subdirectory deployment',
render(
<DatasetPanel
tableName="example_table"
hasError={false}
columnList={exampleColumns}
loading={false}
datasets={datasetWith(`${APP_ROOT}/explore/?datasource=1__table`)}
@@ -97,7 +96,6 @@ test('View Dataset passes an external explore_url through unprefixed', async ()
render(
<DatasetPanel
tableName="example_table"
hasError={false}
columnList={exampleColumns}
loading={false}
datasets={datasetWith('https://external.example.com/custom-endpoint')}

View File

@@ -17,10 +17,12 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import { ErrorTypeEnum } from '@superset-ui/core';
import DatasetPanel, {
REFRESHING,
tableColumnDefinition,
COLUMN_TITLE,
ERROR_TITLE,
} from 'src/features/datasets/AddDataset/DatasetPanel/DatasetPanel';
import { exampleColumns, exampleDataset } from './fixtures';
import { ITableColumn } from './types';
@@ -31,8 +33,6 @@ import {
SELECT_TABLE_TITLE,
NO_COLUMNS_TITLE,
NO_COLUMNS_DESCRIPTION,
ERROR_TITLE,
ERROR_DESCRIPTION,
} from './MessageContent';
jest.mock(
@@ -47,7 +47,7 @@ jest.mock(
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DatasetPanel', () => {
test('renders a blank state DatasetPanel', () => {
render(<DatasetPanel hasError={false} columnList={[]} loading={false} />, {
render(<DatasetPanel columnList={[]} loading={false} />, {
useRouter: true,
});
@@ -70,17 +70,9 @@ describe('DatasetPanel', () => {
});
test('renders a no columns screen', () => {
render(
<DatasetPanel
tableName="Name"
hasError={false}
columnList={[]}
loading={false}
/>,
{
useRouter: true,
},
);
render(<DatasetPanel tableName="Name" columnList={[]} loading={false} />, {
useRouter: true,
});
const blankDatasetImg = screen.getByRole('img', { name: /empty/i });
expect(blankDatasetImg).toBeVisible();
@@ -91,17 +83,9 @@ describe('DatasetPanel', () => {
});
test('renders a loading screen', () => {
render(
<DatasetPanel
tableName="Name"
hasError={false}
columnList={[]}
loading
/>,
{
useRouter: true,
},
);
render(<DatasetPanel tableName="Name" columnList={[]} loading />, {
useRouter: true,
});
const loadingIndicator = screen.getByTestId('loading-indicator');
expect(loadingIndicator).toBeVisible();
@@ -113,7 +97,12 @@ describe('DatasetPanel', () => {
render(
<DatasetPanel
tableName="Name"
hasError
error={{
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
extra: null,
level: 'error',
message: 'Structured backend failure',
}}
columnList={[]}
loading={false}
/>,
@@ -124,8 +113,9 @@ describe('DatasetPanel', () => {
const errorTitle = screen.getByText(ERROR_TITLE);
expect(errorTitle).toBeVisible();
const errorDescription = screen.getByText(ERROR_DESCRIPTION);
const errorDescription = screen.getByText('Structured backend failure');
expect(errorDescription).toBeVisible();
expect(screen.getByTitle('Name')).toHaveStyle({ position: 'relative' });
});
test('renders a table with columns displayed', async () => {
@@ -133,7 +123,6 @@ describe('DatasetPanel', () => {
render(
<DatasetPanel
tableName={tableName}
hasError={false}
columnList={exampleColumns}
loading={false}
/>,
@@ -159,7 +148,6 @@ describe('DatasetPanel', () => {
render(
<DatasetPanel
tableName="example_table"
hasError={false}
columnList={exampleColumns}
loading={false}
datasets={exampleDataset}

View File

@@ -21,11 +21,13 @@ import { Alert } from '@apache-superset/core/components';
import { css, styled } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import { Loading } from '@superset-ui/core/components';
import type { SupersetError } from '@superset-ui/core';
import Table, {
ColumnsType,
TableSize,
} from '@superset-ui/core/components/Table';
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
import { ErrorMessageWithStackTrace } from 'src/components';
import { openInNewTab, stripAppRoot } from 'src/utils/navigationUtils';
import { ITableColumn } from './types';
import MessageContent from './MessageContent';
@@ -146,6 +148,10 @@ const TableScrollContainer = styled.div`
right: 0;
`;
const ErrorContainer = styled.div`
padding: 0 ${({ theme }) => theme.sizeUnit * 6}px;
`;
const StyledAlert = styled(Alert)`
${({ theme }) => `
border: 1px solid ${theme.colorInfoText};
@@ -167,6 +173,7 @@ const StyledAlert = styled(Alert)`
export const REFRESHING = t('Refreshing columns');
export const COLUMN_TITLE = t('Table columns');
export const ERROR_TITLE = t('An Error Occurred');
const pageSizeOptions = ['5', '10', '15', '25'];
const DEFAULT_PAGE_SIZE = 25;
@@ -201,9 +208,13 @@ export interface IDatasetPanelProps {
*/
columnList: ITableColumn[];
/**
* Boolean indicating if there is an error state
* Error returned while loading the table metadata
*/
hasError: boolean;
error?: SupersetError;
/**
* Function used to retry loading the table metadata after error mitigation
*/
errorMitigationFunction?: () => void;
/**
* Boolean indicating if the component is in a loading state
*/
@@ -256,11 +267,11 @@ const DatasetPanel = ({
tableName,
columnList,
loading,
hasError,
error,
errorMitigationFunction,
datasets,
}: IDatasetPanelProps) => {
const hasColumns = Boolean(columnList?.length > 0);
const datasetNames = datasets?.map(dataset => dataset.table_name);
const hasColumns = columnList.length > 0;
const tableWithDataset = datasets?.find(
dataset => dataset.table_name === tableName,
);
@@ -278,7 +289,19 @@ const DatasetPanel = ({
);
}
if (!loading) {
if (!loading && tableName && hasColumns && !hasError) {
if (error) {
component = (
<ErrorContainer>
<ErrorMessageWithStackTrace
error={error}
errorMitigationFunction={errorMitigationFunction}
source="crud"
subtitle={error.message}
title={ERROR_TITLE}
/>
</ErrorContainer>
);
} else if (tableName && hasColumns) {
component = (
<>
<StyledTitle title={COLUMN_TITLE}>{COLUMN_TITLE}</StyledTitle>
@@ -312,13 +335,7 @@ const DatasetPanel = ({
</>
);
} else {
component = (
<MessageContent
hasColumns={hasColumns}
hasError={hasError}
tableName={tableName}
/>
);
component = <MessageContent tableName={tableName} />;
}
}
@@ -326,11 +343,12 @@ const DatasetPanel = ({
<>
{tableName && (
<>
{datasetNames?.includes(tableName) &&
renderExistingDatasetAlert(tableWithDataset)}
{tableWithDataset && renderExistingDatasetAlert(tableWithDataset)}
<StyledHeader
position={
!loading && hasColumns ? EPosition.RELATIVE : EPosition.ABSOLUTE
!loading && (hasColumns || error)
? EPosition.RELATIVE
: EPosition.ABSOLUTE
}
title={tableName || ''}
>

View File

@@ -16,8 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from 'spec/helpers/testing-library';
import { SupersetClient } from '@superset-ui/core';
import { act, render, screen, waitFor } from 'spec/helpers/testing-library';
import { ErrorTypeEnum, SupersetClient } from '@superset-ui/core';
import type { SupersetClientResponse } from '@superset-ui/core';
import {
DatabaseErrorMessage,
getErrorMessageComponentRegistry,
OAuth2RedirectMessage,
} from 'src/components/ErrorMessage';
import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel';
jest.mock(
@@ -29,17 +35,29 @@ jest.mock(
),
);
const errorMessageRegistry = getErrorMessageComponentRegistry();
afterEach(() => {
errorMessageRegistry.remove(ErrorTypeEnum.GENERIC_BACKEND_ERROR);
errorMessageRegistry.remove(ErrorTypeEnum.OAUTH2_REDIRECT);
jest.restoreAllMocks();
});
const tableMetadataResponse = (
name: string,
columnName: string,
): SupersetClientResponse => ({
response: new Response(),
json: {
name,
columns: [{ name: columnName, type: 'INTEGER', longType: 'INTEGER' }],
},
});
test('fetches table metadata for schema-less database without schema', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {
name: 'my_table',
columns: [{ name: 'id', type: 'INTEGER', longType: 'INTEGER' }],
},
} as any);
const getSpy = jest
.spyOn(SupersetClient, 'get')
.mockResolvedValue(tableMetadataResponse('my_table', 'id'));
render(
<DatasetPanelWrapper
@@ -58,3 +76,99 @@ test('fetches table metadata for schema-less database without schema', async ()
);
});
});
test('renders a fallback message for an unstructured metadata error', async () => {
jest.spyOn(SupersetClient, 'get').mockRejectedValue({
response: new Response('{}', {
status: 500,
headers: { 'Content-Type': 'application/json' },
}),
});
errorMessageRegistry.registerValue(
ErrorTypeEnum.GENERIC_BACKEND_ERROR,
DatabaseErrorMessage,
);
render(
<DatasetPanelWrapper
tableName="broken_table"
dbId={1}
database={{ supports_schemas: false }}
/>,
{ useRouter: true },
);
expect(
await screen.findByText('Unable to load columns for the selected table.'),
).toBeVisible();
});
test('retries only table metadata after matching OAuth completion', async () => {
const oauthError = {
error_type: ErrorTypeEnum.OAUTH2_REDIRECT,
message: 'OAuth authorization is required.',
extra: {
url: 'https://example.com/authorize',
tab_id: 'dataset-oauth-tab',
},
level: 'warning',
};
const getSpy = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValueOnce({
response: new Response(JSON.stringify({ errors: [oauthError] }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
}),
})
.mockResolvedValueOnce(tableMetadataResponse('oauth_table', 'oauth_id'));
errorMessageRegistry.registerValue(
ErrorTypeEnum.OAUTH2_REDIRECT,
OAuth2RedirectMessage,
);
render(
<DatasetPanelWrapper
tableName="oauth_table"
dbId={1}
database={{ supports_schemas: false }}
/>,
{
initialState: {
charts: {},
dashboardInfo: {},
explore: {},
sqlLab: {
queries: {},
queryEditors: [],
tabHistory: [],
},
},
useRedux: true,
useRouter: true,
},
);
const authorizationLink = await screen.findByRole('link', {
name: /provide authorization/i,
});
expect(authorizationLink).toHaveAttribute(
'href',
'https://example.com/authorize',
);
expect(getSpy).toHaveBeenCalledTimes(1);
act(() => {
window.dispatchEvent(
new StorageEvent('storage', {
key: 'oauth2_auth_complete',
newValue: JSON.stringify({ tabId: 'dataset-oauth-tab' }),
}),
);
});
expect(await screen.findByText('oauth_id')).toBeVisible();
expect(getSpy).toHaveBeenCalledTimes(2);
expect(getSpy.mock.calls[1]).toEqual(getSpy.mock.calls[0]);
});

View File

@@ -65,27 +65,17 @@ export const NO_COLUMNS_TITLE = t('No table columns');
export const NO_COLUMNS_DESCRIPTION = t(
'This database table does not contain any data. Please select a different table.',
);
export const ERROR_TITLE = t('An Error Occurred');
export const ERROR_DESCRIPTION = t(
'Unable to load columns for the selected table. Please select a different table.',
);
interface MessageContentProps {
hasError: boolean;
tableName?: string | null;
hasColumns: boolean;
}
export const MessageContent = (props: MessageContentProps) => {
const { hasError, tableName, hasColumns } = props;
let currentImage: string | undefined = 'empty-dataset.svg';
const { tableName } = props;
let currentImage = 'empty-dataset.svg';
let currentTitle = SELECT_TABLE_TITLE;
let currentDescription = renderEmptyDescription();
if (hasError) {
currentTitle = ERROR_TITLE;
currentDescription = <>{ERROR_DESCRIPTION}</>;
currentImage = undefined;
} else if (tableName && !hasColumns) {
if (tableName) {
currentImage = 'no-columns.svg';
currentTitle = NO_COLUMNS_TITLE;
currentDescription = <>{NO_COLUMNS_DESCRIPTION}</>;

View File

@@ -16,9 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { SupersetClient } from '@superset-ui/core';
import {
ErrorTypeEnum,
getClientErrorObject,
SupersetClient,
} from '@superset-ui/core';
import type { SupersetError } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
import { addDangerToast } from 'src/components/MessageToasts/actions';
@@ -30,7 +35,7 @@ import { ITableColumn, IDatabaseTable, isIDatabaseTable } from './types';
/**
* Interface for the getTableMetadata API call
*/
interface IColumnProps {
interface TableMetadataRequest {
/**
* Unique id of the database
*/
@@ -43,6 +48,10 @@ interface IColumnProps {
* Name of the schema (optional for databases that don't support schemas)
*/
schema?: string | null;
/**
* Name of the catalog (optional for databases that don't support catalogs)
*/
catalog?: string | null;
}
export interface IDatasetPanelWrapperProps {
@@ -63,7 +72,7 @@ export interface IDatasetPanelWrapperProps {
* The selected database object (used to check engine capabilities)
*/
database?: Partial<DatabaseObject> | null;
setHasColumns?: Function;
setHasColumns?: (hasColumns: boolean) => void;
datasets?: DatasetObject[] | undefined;
}
@@ -78,74 +87,131 @@ const DatasetPanelWrapper = ({
}: IDatasetPanelWrapperProps) => {
const [columnList, setColumnList] = useState<ITableColumn[]>([]);
const [loading, setLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const tableNameRef = useRef(tableName);
const [error, setError] = useState<SupersetError>();
const requestIdRef = useRef(0);
const currentRequestRef = useRef<TableMetadataRequest>();
const supportsSchemas = database?.supports_schemas;
const getTableMetadata = async (props: IColumnProps) => {
const { dbId, tableName, schema } = props;
setLoading(true);
setHasColumns?.(false);
const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({
name: tableName,
catalog,
schema,
})}`;
try {
const response = await SupersetClient.get({
endpoint: path,
});
const getTableMetadata = useCallback(
async (props: TableMetadataRequest) => {
const { dbId, tableName, catalog, schema } = props;
requestIdRef.current += 1;
const requestId = requestIdRef.current;
setLoading(true);
setColumnList([]);
setError(undefined);
setHasColumns?.(false);
const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({
name: tableName,
catalog,
schema,
})}`;
try {
const response = await SupersetClient.get({
endpoint: path,
});
if (isIDatabaseTable(response?.json)) {
const table: IDatabaseTable = response.json as IDatabaseTable;
/**
* The user is able to click other table columns while the http call for last selected table column is made
* This check ensures we process the response that matches the last selected table name and ignore the others
*/
if (table.name === tableNameRef.current) {
if (requestId !== requestIdRef.current) {
return;
}
const table = isIDatabaseTable(response?.json)
? (response.json as IDatabaseTable)
: undefined;
if (table?.name === tableName) {
setColumnList(table.columns);
setHasColumns?.(table.columns.length > 0);
setHasError(false);
setError(undefined);
} else {
const message = t(
'The API response from %s does not match the IDatabaseTable interface.',
path,
);
setColumnList([]);
setHasColumns?.(false);
setError({
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
extra: null,
level: 'error',
message,
});
addDangerToast(message);
logging.error(message);
}
} else {
setColumnList([]);
setHasColumns?.(false);
setHasError(true);
addDangerToast(
t(
'The API response from %s does not match the IDatabaseTable interface.',
path,
),
);
logging.error(
t(
'The API response from %s does not match the IDatabaseTable interface.',
path,
),
} catch (caughtError) {
const clientError = await getClientErrorObject(
caughtError as Parameters<typeof getClientErrorObject>[0],
);
if (requestId === requestIdRef.current) {
const parsedError = clientError.errors?.[0] ?? {
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
extra: null,
level: 'error' as const,
message:
clientError.error ||
clientError.message ||
clientError.statusText ||
t('Unable to load columns for the selected table.'),
};
setColumnList([]);
setHasColumns?.(false);
setError(parsedError);
}
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
}
}
} catch (error) {
setColumnList([]);
setHasColumns?.(false);
setHasError(true);
} finally {
setLoading(false);
},
[setHasColumns],
);
const retryGetTableMetadata = useCallback(() => {
if (currentRequestRef.current) {
getTableMetadata(currentRequestRef.current);
}
};
}, [getTableMetadata]);
useEffect(() => {
tableNameRef.current = tableName;
const schemaRequired = database?.supports_schemas !== false;
const schemaRequired = supportsSchemas !== false;
if (tableName && dbId && (schema || !schemaRequired)) {
getTableMetadata({ tableName, dbId, schema: schema || undefined });
const request = {
tableName,
dbId,
catalog,
schema: schema || undefined,
};
currentRequestRef.current = request;
getTableMetadata(request);
} else if (currentRequestRef.current) {
currentRequestRef.current = undefined;
requestIdRef.current += 1;
setColumnList([]);
setError(undefined);
setHasColumns?.(false);
setLoading(false);
}
// getTableMetadata is a const and should not be in dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, dbId, schema, database]);
return () => {
requestIdRef.current += 1;
};
}, [
tableName,
dbId,
catalog,
schema,
supportsSchemas,
getTableMetadata,
setHasColumns,
]);
return (
<DatasetPanel
columnList={columnList}
hasError={hasError}
error={error}
errorMitigationFunction={retryGetTableMetadata}
loading={loading}
tableName={tableName}
datasets={datasets}

View File

@@ -138,7 +138,6 @@ describe('DatasetLayout', () => {
<DatasetPanelComponent
tableName="large_table"
columnList={manyColumns}
hasError={false}
loading={false}
/>
}

View File

@@ -161,6 +161,9 @@ def get_query(query_id: int) -> Query:
try:
return db.session.query(Query).filter_by(id=query_id).one()
except Exception as ex:
# roll back so a poisoned session (e.g. PendingRollbackError after a
# failed flush) doesn't fail every subsequent backoff retry identically
db.session.rollback()
raise SqlLabException("Failed at getting query") from ex

View File

@@ -35,6 +35,7 @@ from superset.sql.parse import SQLStatement, Table
from superset.sql_lab import (
execute_query,
execute_sql_statements,
get_query,
get_sql_results,
)
from superset.utils.rls import apply_rls, get_predicates_for_table
@@ -71,6 +72,34 @@ def test_execute_query(mocker: MockerFixture, app: None) -> None:
SupersetResultSet.assert_called_with([(42,)], cursor.description, db_engine_spec)
def test_get_query_rolls_back_session_before_retrying(
mocker: MockerFixture, app: SupersetApp
) -> None:
"""
A broken transaction (e.g. `PendingRollbackError` following a failed flush)
leaves the session unusable until `session.rollback()` is called, so without
it every `backoff` retry would reuse the same poisoned session and fail
identically. `get_query` must roll back on failure so each retry gets a
clean session and has a real chance to succeed.
"""
# avoid actually sleeping through the `backoff` decorator's retry interval
mocker.patch("backoff._sync.time.sleep")
expected_query = mocker.MagicMock()
mock_one = mocker.patch("superset.sql_lab.db.session.query")
mock_one.return_value.filter_by.return_value.one.side_effect = [
Exception("session is broken"),
expected_query,
]
mock_rollback = mocker.patch("superset.sql_lab.db.session.rollback")
result = get_query(query_id=1)
assert result is expected_query
assert mock_one.return_value.filter_by.return_value.one.call_count == 2
mock_rollback.assert_called_once()
@with_config(
{
"SQLLAB_PAYLOAD_MAX_MB": 50,