Skip to content

Commit 05e1bf5

Browse files
committed
fix(azure-data-explorer): only read partial-failure status from the QueryStatus table
Scanning every returned table for Severity and StatusDescription columns misread an ordinary query as a failed request whenever the user's own result selected columns of those names — a common shape for a log table. Failure detection now consults only the table the response's table of contents names as QueryStatus, and primary-result selection reuses the same index instead of re-reading it.
1 parent 374574c commit 05e1bf5

2 files changed

Lines changed: 84 additions & 32 deletions

File tree

apps/sim/app/api/tools/azure_data_explorer/proxy/route.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,29 @@ describe('POST /api/tools/azure_data_explorer/proxy', () => {
134134
expect(data.error).toBe('Query execution has exceeded')
135135
})
136136

137+
it('does not mistake a result column named Severity for a failed query', async () => {
138+
const shadowed = queryResponse({
139+
severity: 4,
140+
statusDescription: 'Query completed successfully',
141+
})
142+
shadowed.Tables[1] = {
143+
TableName: 'Table_1',
144+
Columns: [
145+
{ ColumnName: 'Severity', DataType: 'Int32', ColumnType: 'int' },
146+
{ ColumnName: 'StatusDescription', DataType: 'String', ColumnType: 'string' },
147+
],
148+
Rows: [[1, 'disk almost full']],
149+
}
150+
mockCluster(shadowed)
151+
152+
const response = await post(baseBody)
153+
const data = await response.json()
154+
155+
expect(response.status).toBe(200)
156+
expect(data.success).toBe(true)
157+
expect(data.output.records).toEqual([{ Severity: 1, StatusDescription: 'disk almost full' }])
158+
})
159+
137160
it('returns the first table for a management command, which has no table of contents', async () => {
138161
mockCluster({
139162
Tables: [

apps/sim/app/api/tools/azure_data_explorer/proxy/route.ts

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -130,53 +130,80 @@ function columnNames(table: KustoTable): string[] {
130130
return (table.Columns ?? []).map((column) => column.ColumnName ?? '')
131131
}
132132

133+
interface TableOfContents {
134+
primaryOrdinal: number | null
135+
statusOrdinal: number | null
136+
}
137+
133138
/**
134-
* Picks the table holding the query's own results.
135-
*
136-
* A v1 query response carries several tables plus a trailing table of contents
137-
* that maps each ordinal to a kind; the first `QueryResult` ordinal is the
138-
* primary result. Management commands return a single table with no table of
139-
* contents, so the first table is the answer.
139+
* Reads the trailing table of contents, which maps each ordinal in the response
140+
* to a kind. It is the only thing that identifies which table holds the query's
141+
* results and which holds its status — a management command has no table of
142+
* contents, and returns `null` here.
140143
*/
141-
function selectPrimaryTable(tables: KustoTable[]): KustoTable | null {
144+
function readTableOfContents(tables: KustoTable[]): TableOfContents | null {
142145
if (tables.length === 0) return null
143146

144147
const contents = tables[tables.length - 1]
145148
const names = columnNames(contents)
146149
const ordinalIndex = names.indexOf('Ordinal')
147150
const kindIndex = names.indexOf('Kind')
148-
149-
if (ordinalIndex >= 0 && kindIndex >= 0) {
150-
for (const row of contents.Rows ?? []) {
151-
if (row[kindIndex] !== 'QueryResult') continue
152-
const ordinal = Number(row[ordinalIndex])
153-
if (Number.isInteger(ordinal) && tables[ordinal]) return tables[ordinal]
154-
}
151+
if (ordinalIndex < 0 || kindIndex < 0) return null
152+
153+
let primaryOrdinal: number | null = null
154+
let statusOrdinal: number | null = null
155+
for (const row of contents.Rows ?? []) {
156+
const ordinal = Number(row[ordinalIndex])
157+
if (!Number.isInteger(ordinal) || !tables[ordinal]) continue
158+
if (row[kindIndex] === 'QueryResult' && primaryOrdinal === null) primaryOrdinal = ordinal
159+
if (row[kindIndex] === 'QueryStatus' && statusOrdinal === null) statusOrdinal = ordinal
155160
}
161+
return { primaryOrdinal, statusOrdinal }
162+
}
156163

164+
/**
165+
* Picks the table holding the query's own results — the first `QueryResult`
166+
* ordinal the table of contents names. A management command returns a single
167+
* table with no table of contents, so the first table is the answer.
168+
*/
169+
function selectPrimaryTable(
170+
tables: KustoTable[],
171+
contents: TableOfContents | null
172+
): KustoTable | null {
173+
if (tables.length === 0) return null
174+
if (contents?.primaryOrdinal !== null && contents !== null) {
175+
return tables[contents.primaryOrdinal] ?? tables[0]
176+
}
157177
return tables[0]
158178
}
159179

160180
/**
161181
* Finds a partial query failure. Kusto answers 200 as soon as it starts
162-
* processing, then reports later failures through a QueryStatus table where a
182+
* processing, then reports later failures through the QueryStatus table, where a
163183
* severity of 2 or lower means the request did not succeed.
184+
*
185+
* Only the table the table of contents names as `QueryStatus` is inspected.
186+
* Scanning every table for `Severity`/`StatusDescription` columns would
187+
* misread an ordinary log query that happens to select columns of those names
188+
* as a failed request.
164189
*/
165-
function findQueryFailure(tables: KustoTable[]): string | null {
166-
for (const table of tables) {
167-
const names = columnNames(table)
168-
const severityIndex = names.indexOf('Severity')
169-
const descriptionIndex = names.indexOf('StatusDescription')
170-
if (severityIndex < 0 || descriptionIndex < 0) continue
171-
172-
for (const row of table.Rows ?? []) {
173-
const severity = Number(row[severityIndex])
174-
if (!Number.isFinite(severity) || severity > 2) continue
175-
const description = row[descriptionIndex]
176-
return typeof description === 'string' && description.length > 0
177-
? description
178-
: 'Kusto reported a query failure'
179-
}
190+
function findQueryFailure(tables: KustoTable[], contents: TableOfContents | null): string | null {
191+
if (contents?.statusOrdinal == null) return null
192+
const table = tables[contents.statusOrdinal]
193+
if (!table) return null
194+
195+
const names = columnNames(table)
196+
const severityIndex = names.indexOf('Severity')
197+
const descriptionIndex = names.indexOf('StatusDescription')
198+
if (severityIndex < 0 || descriptionIndex < 0) return null
199+
200+
for (const row of table.Rows ?? []) {
201+
const severity = Number(row[severityIndex])
202+
if (!Number.isFinite(severity) || severity > 2) continue
203+
const description = row[descriptionIndex]
204+
return typeof description === 'string' && description.length > 0
205+
? description
206+
: 'Kusto reported a query failure'
180207
}
181208
return null
182209
}
@@ -339,7 +366,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
339366
? ((body as { Tables: KustoTable[] }).Tables ?? [])
340367
: []
341368

342-
const failure = findQueryFailure(tables)
369+
const contents = readTableOfContents(tables)
370+
371+
const failure = findQueryFailure(tables, contents)
343372
if (failure) {
344373
logger.warn(`[${requestId}] Azure Data Explorer partial query failure: ${failure}`)
345374
return NextResponse.json(
@@ -350,7 +379,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
350379

351380
return NextResponse.json({
352381
success: true,
353-
output: projectTable(selectPrimaryTable(tables)),
382+
output: projectTable(selectPrimaryTable(tables, contents)),
354383
})
355384
} catch (error) {
356385
if (isPayloadSizeLimitError(error)) {

0 commit comments

Comments
 (0)