Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion e2e/support/start-mock-trino.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ const routes: [string, object][] = [
'SHOULD_ERROR',
{
id: 'q-err',
error: { message: 'syntax error at position 7', errorCode: 1 },
error: {
message: 'syntax error at position 7',
errorCode: 1,
errorName: 'SYNTAX_ERROR',
errorType: 'USER_ERROR'
},
stats: { state: 'FAILED' }
}
],
Expand Down Expand Up @@ -113,6 +118,8 @@ const DEFAULT_RESPONSE = {
// FROM exact_limit_table exactly MAX_CLIENT_ROWS rows, then an empty trailing page
// FAILS_SILENTLY first response FAILED without an error object
// FAILS_LATER one page of rows, then FAILED without an error object
// FAILS_INTERNALLY first response FAILED with an INTERNAL_ERROR
// POLL_UNAVAILABLE QUEUED, then GET answers HTTP 502 with an HTML body
// HOLD:<id> RUNNING until released via POST /__mock/release/<id>
// GATE:<id> submit response withheld until released (combinable)
//
Expand All @@ -133,6 +140,8 @@ interface MockQuery {
/** Release id for HOLD queries, which stay RUNNING until released. */
hold?: string;
cancelled?: boolean;
/** GET nextUri answers HTTP 502, as a proxy in front of an unavailable Trino. */
pollUnavailable?: boolean;
}

const queries = new Map<string, MockQuery>();
Expand Down Expand Up @@ -249,6 +258,21 @@ function startPagedQuery(id: string, sql: string): Page | null {
if (sql.includes('FAILS_SILENTLY')) {
return { id, stats: { state: 'FAILED' } };
}
if (sql.includes('FAILS_INTERNALLY')) {
return {
id,
error: {
message: 'java.lang.IllegalStateException: metastore thrift://hive.internal:9083',
errorName: 'GENERIC_INTERNAL_ERROR',
errorType: 'INTERNAL_ERROR'
},
stats: { state: 'FAILED' }
};
}
if (sql.includes('POLL_UNAVAILABLE')) {
queries.set(id, { sql, pages: [], pollUnavailable: true });
return { id, nextUri: nextUri(id, 1), stats: { state: 'QUEUED' } };
}
if (sql.includes('FAILS_LATER')) {
queries.set(id, {
sql,
Expand Down Expand Up @@ -333,6 +357,11 @@ http
return;
}
if (req.method === 'GET' && executing) {
if (queries.get(executing[1])?.pollUnavailable) {
res.writeHead(502, { 'Content-Type': 'text/html' });
res.end('<html>502 Bad Gateway from proxy.internal</html>');
return;
}
pollQuery(executing[1], Number(executing[2])).then((page) => sendJson(res, page));
return;
}
Expand Down
30 changes: 29 additions & 1 deletion e2e/trino/query-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mockMarker, mockTrinoStatements, releaseMockTrino } from '../support/mo

/**
* Query lifecycle against the multi-page mock Trino protocol: polling, running
* state, cancellation and failures reported without error details.
* state, cancellation and failures, including which error details reach the user.
*/
test.describe('Trino query lifecycle', () => {
test.use({ locale: 'en-US' });
Expand Down Expand Up @@ -110,6 +110,34 @@ test.describe('Trino query lifecycle', () => {
await expect(page.getByText('Query failed')).toBeVisible();
});

test('withholds the message of a Trino internal error', async ({ page }) => {
await openEditor(page, 'SELECT FAILS_INTERNALLY');

await runButton(page).click();
await waitForQueryComplete(page);

await expect(statusBadge(page)).toHaveAttribute('data-query-state', 'FAILED');
await expect(
page.getByText(
'Trino could not run the query because of a server-side error (GENERIC_INTERNAL_ERROR).'
)
).toBeVisible();
await expect(page.getByText('hive.internal')).toHaveCount(0);
});

test('withholds the response body when Trino is unavailable during polling', async ({ page }) => {
await openEditor(page, 'SELECT POLL_UNAVAILABLE');

await runButton(page).click();
await waitForQueryComplete(page);

await expect(statusBadge(page)).toHaveAttribute('data-query-state', 'FAILED');
await expect(
page.getByText('Trino could not be reached or sent an invalid response.')
).toBeVisible();
await expect(page.getByText('proxy.internal')).toHaveCount(0);
});

test('re-running between statements stops the previous script', async ({ page }) => {
const marker = mockMarker();
const gate = releaseId();
Expand Down
1 change: 1 addition & 0 deletions e2e/trino/trino.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ test.describe('Trino query editor', () => {
// Monaco also renders role="alert" nodes for its own accessibility — filter by content.
const alert = page.getByRole('alert').filter({ hasText: 'Query error' });
await expect(alert).toBeVisible();
// Errors in the user's query keep Trino's message.
await expect(alert.getByText('syntax error at position 7')).toBeVisible();
});

Expand Down
4 changes: 4 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
"header_sign_out": "Abmelden",
"trino_results_empty": "Keine Ergebnisse",
"trino_query_error": "Abfragefehler",
"trino_query_error_server": "Trino konnte die Abfrage wegen eines serverseitigen Fehlers nicht ausführen.",
"trino_query_error_server_named": "Trino konnte die Abfrage wegen eines serverseitigen Fehlers nicht ausführen ({name}).",
"trino_query_error_unavailable": "Trino ist nicht erreichbar oder hat eine ungültige Antwort gesendet.",
"trino_query_error_unknown": "Abfrage fehlgeschlagen",
"trino_query_connection_lost": "Verbindung zum Server verloren.",
"trino_running": "Wird ausgeführt...",
"trino_connection_url_placeholder": "https://trino.example.com:8443",
Expand Down
4 changes: 4 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
"header_sign_out": "Sign out",
"trino_results_empty": "No results",
"trino_query_error": "Query error",
"trino_query_error_server": "Trino could not run the query because of a server-side error.",
"trino_query_error_server_named": "Trino could not run the query because of a server-side error ({name}).",
"trino_query_error_unavailable": "Trino could not be reached or sent an invalid response.",
"trino_query_error_unknown": "Query failed",
"trino_query_connection_lost": "Lost connection to the server.",
"trino_running": "Running...",
"trino_connection_url_placeholder": "https://trino.example.com:8443",
Expand Down
19 changes: 17 additions & 2 deletions src/lib/components/trino/StatementResult.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import IconExpandMore from 'virtual:icons/material-symbols/expand-more';
import IconOpenInNew from 'virtual:icons/material-symbols/open-in-new';
import IconDownload from 'virtual:icons/material-symbols/download';
import { MAX_CLIENT_ROWS, type QuerySnapshot } from '$lib/types/query';
import { MAX_CLIENT_ROWS, type QueryError, type QuerySnapshot } from '$lib/types/query';
import { initPageSize, type PageSize } from '$lib/types/pagination.js';
import Pagination from '$lib/components/Pagination.svelte';

Expand Down Expand Up @@ -35,14 +35,29 @@
const lastPage = $derived(totalPages - 1);
const rowStart = $derived(currentPage * pageSize + 1);
const rowEnd = $derived(currentPage * pageSize + displayedRows.length);
const stmtError = $derived(result.error);
const stmtError = $derived(result.error ? formatQueryError(result.error) : null);
const showHeader = $derived(totalStatements > 1);
const rowLimitWarning = $derived(
showHeader && result.rowLimitReached
? m.trino_row_limit_reached({ limit: MAX_CLIENT_ROWS.toLocaleString(getLocale()) })
: null
);

function formatQueryError(error: QueryError): string {
switch (error.kind) {
case 'query':
return error.message;
case 'server':
return error.name
? m.trino_query_error_server_named({ name: error.name })
: m.trino_query_error_server();
case 'unavailable':
return m.trino_query_error_unavailable();
case 'unknown':
return m.trino_query_error_unknown();
}
}

const CSV_HEADERS_KEY = 'trino_csv_include_headers';
let includeHeaders = $state(browser ? localStorage.getItem(CSV_HEADERS_KEY) === 'true' : false);

Expand Down
18 changes: 17 additions & 1 deletion src/lib/server/trino/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ vi.mock('$env/dynamic/private', () => ({ env: mockEnv }));
vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js'));

import { TrinoClient, trinoMetadataQuery } from './client.js';
import { TrinoHttpError, TrinoQueryFailure } from './errors.js';

describe('TrinoClient X-Trino-User header', () => {
let fetchMock: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -151,9 +152,24 @@ describe('trinoMetadataQuery (connection test path)', () => {
vi.stubGlobal('fetch', fetchMock);
const client = new TrinoClient({ serverUrl: 'http://trino:8080' });
await expect(trinoMetadataQuery(client, 'SELECT 1', { user: 'alice' })).rejects.toThrow(
'Access Denied'
expect.objectContaining({
constructor: TrinoQueryFailure,
trinoError: { message: 'Access Denied' }
})
);
});

it('throws an HTTP error without the response body when polling fails', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'q1', nextUri: 'http://n/1' }) })
.mockResolvedValueOnce({ ok: false, status: 502, text: async () => '<html>proxy</html>' });
vi.stubGlobal('fetch', fetchMock);
const client = new TrinoClient({ serverUrl: 'http://trino:8080' });
const result = trinoMetadataQuery(client, 'SELECT 1', { user: 'alice' });
await expect(result).rejects.toBeInstanceOf(TrinoHttpError);
await expect(result).rejects.toThrow('Trino poll failed (502)');
});
});

describe('resolveTrinoPublicUrl', () => {
Expand Down
20 changes: 7 additions & 13 deletions src/lib/server/trino/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { env } from '$env/dynamic/private';
import { readFileSync } from 'node:fs';
import { Agent } from 'undici';
import { logger } from '$lib/server/logging';
import { TrinoHttpError, TrinoQueryFailure, type TrinoQueryError } from './errors.js';
import { getUserTrinoClient, getUserTrinoUrl } from './user-clients.js';

const log = logger.child({ module: 'trino-client' });
Expand All @@ -20,13 +21,6 @@ export interface TrinoQueryStats {
elapsedTimeMillis?: number;
}

interface TrinoQueryError {
message: string;
errorCode?: number;
errorName?: string;
errorType?: string;
}

export interface TrinoQueryResult {
id: string;
nextUri?: string;
Expand Down Expand Up @@ -91,7 +85,7 @@ export class TrinoClient {

if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Trino POST /v1/statement failed (${res.status}): ${text}`);
throw new TrinoHttpError('POST /v1/statement', res.status, text);
}

return res.json() as Promise<TrinoQueryResult>;
Expand All @@ -112,7 +106,7 @@ export class TrinoClient {

if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Trino poll failed (${res.status}): ${text}`);
throw new TrinoHttpError('poll', res.status, text);
}

return res.json() as Promise<TrinoQueryResult>;
Expand All @@ -139,7 +133,7 @@ export class TrinoClient {
// 404 is expected — the query may have already finished and been cleaned up.
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => '');
throw new Error(`Trino DELETE /v1/query failed (${res.status}): ${text}`);
throw new TrinoHttpError('DELETE /v1/query', res.status, text);
}
}

Expand All @@ -154,7 +148,7 @@ export class TrinoClient {

if (!res.ok && res.status !== 404 && res.status !== 410) {
const text = await res.text().catch(() => '');
throw new Error(`Trino DELETE nextUri failed (${res.status}): ${text}`);
throw new TrinoHttpError('DELETE nextUri', res.status, text);
}
}
}
Expand Down Expand Up @@ -267,14 +261,14 @@ export async function trinoMetadataQuery(
const rows: unknown[][] = result.data ? [...result.data] : [];

if (result.error) {
throw new Error(result.error.message);
throw new TrinoQueryFailure(result.error);
}

while (result.nextUri) {
result = await client.poll(result.nextUri, { user: options.user, signal });

if (result.error) {
throw new Error(result.error.message);
throw new TrinoQueryFailure(result.error);
}
if (result.columns && columns.length === 0) {
columns = result.columns;
Expand Down
56 changes: 56 additions & 0 deletions src/lib/server/trino/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from 'vitest';

vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js'));

import { TrinoHttpError, TrinoQueryFailure, classifyTrinoError, toQueryError } from './errors.js';

describe('toQueryError', () => {
it.each(['USER_ERROR', 'INSUFFICIENT_RESOURCES'])('passes on the message of %s', (errorType) => {
expect(toQueryError({ message: 'line 1:8: Column x cannot be resolved', errorType })).toEqual({
kind: 'query',
message: 'line 1:8: Column x cannot be resolved'
});
});

it.each(['INTERNAL_ERROR', 'EXTERNAL', undefined])(
'withholds the message of %s and keeps the error name',
(errorType) => {
expect(
toQueryError({
message: 'Failed connecting to metastore thrift://hive.internal:9083',
errorName: 'HIVE_METASTORE_ERROR',
errorType
})
).toEqual({ kind: 'server', name: 'HIVE_METASTORE_ERROR' });
}
);

it.each([undefined, {}, { errorType: 'USER_ERROR' }])(
'reports a failure without a message as unknown (%o)',
(trinoError) => {
expect(toQueryError(trinoError)).toEqual({ kind: 'unknown' });
}
);
});

describe('classifyTrinoError', () => {
it('classifies a Trino query failure by its error type', () => {
const err = new TrinoQueryFailure({ message: 'Access Denied', errorType: 'USER_ERROR' });
expect(classifyTrinoError(err)).toEqual({ kind: 'query', message: 'Access Denied' });
});

it.each([
['an HTTP error', new TrinoHttpError('poll', 502, '<html>proxy.internal</html>')],
['a network failure', new TypeError('fetch failed')]
])('reports %s as unavailable', (_, err) => {
expect(classifyTrinoError(err)).toEqual({ kind: 'unavailable' });
});
});

describe('TrinoHttpError', () => {
it('keeps the response body out of the message and caps it', () => {
const err = new TrinoHttpError('poll', 502, 'x'.repeat(5000));
expect(err.message).toBe('Trino poll failed (502)');
expect(err.body).toHaveLength(2000);
});
});
Loading
Loading