Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: cancel queries on navigation-ish #13414

Merged
merged 19 commits into from
Dec 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 0 additions & 1 deletion frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ export const FEATURE_FLAGS = {
KAFKA_INSPECTOR: 'kafka-inspector', // owner: @yakkomajuri
INSIGHT_EDITOR_PANELS: '8929-insight-editor-panels', // owner: @mariusandra
BILLING_LOCK_EVERYTHING: 'billing-lock-everything', // owner @timgl
CANCEL_RUNNING_QUERIES: 'cancel-running-queries', // owner @timgl
HISTORICAL_EXPORTS_V2: 'historical-exports-v2', // owner @macobo
PERSON_ON_EVENTS_ENABLED: 'person-on-events-enabled', //owner: @EDsCODE
REGION_SELECT: 'region-select', //owner: @kappa90
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/scenes/dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,15 @@ function DashboardScene(): JSX.Element {
dashboardMode,
receivedErrorsFromAPI,
} = useValues(dashboardLogic)
const { setDashboardMode, setDates, reportDashboardViewed, setProperties } = useActions(dashboardLogic)
const { setDashboardMode, setDates, reportDashboardViewed, setProperties, abortAnyRunningQuery } =
useActions(dashboardLogic)

useEffect(() => {
reportDashboardViewed()
return () => {
Copy link
Contributor

@macobo macobo Dec 20, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: create a useQueryCancellation(fn) wrapping useEffect.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem with wrapping (or at least the problem I've not figured out the solution to) is the insight and dashboard cancellation cases are still really different

// request cancellation of any running queries when this component is no longer in the dom
abortAnyRunningQuery()
}
}, [])

useKeyboardHotkeys(
Expand Down
178 changes: 178 additions & 0 deletions frontend/src/scenes/dashboard/dashboardLogic.queryCancellation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { expectLogic, partial } from 'kea-test-utils'
import { initKeaTests } from '~/test/init'
import { dashboardLogic } from 'scenes/dashboard/dashboardLogic'
import { dashboardsModel } from '~/models/dashboardsModel'
import { insightsModel } from '~/models/insightsModel'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { DashboardType, InsightModel, InsightShortId } from '~/types'
import { useMocks } from '~/mocks/jest'
import { now } from 'lib/dayjs'
import { MOCK_TEAM_ID } from 'lib/api.mock'
import api from 'lib/api'
import { boxToString, dashboardResult, insightOnDashboard, tileFromInsight } from 'scenes/dashboard/dashboardLogic.test'

const seenQueryIDs: string[] = []

describe('dashboardLogic query cancellation', () => {
let logic: ReturnType<typeof dashboardLogic.build>

let dashboards: Record<number, DashboardType> = {}

let dashboardTwelveInsightLoadedCount = 0

beforeEach(() => {
jest.spyOn(api, 'update')

const insights: Record<number, InsightModel> = {
2040: {
...insightOnDashboard(2040, [12]),
id: 2040,
short_id: '2040' as InsightShortId,
last_refresh: now().toISOString(),
},
}
dashboards = {
12: {
...dashboardResult(12, [tileFromInsight(insights['2040'])]),
},
}
useMocks({
get: {
'/api/projects/:team/dashboards/12/': { ...dashboards['12'] },
'/api/projects/:team/dashboards/': {
count: 1,
next: null,
previous: null,
results: [{ ...dashboards['12'] }],
},
'/api/projects/:team/insights/:id/': (req) => {
const clientQueryId = req.url.searchParams.get('client_query_id')
if (clientQueryId !== null) {
seenQueryIDs.push(clientQueryId)
}

const dashboard = req.url.searchParams.get('from_dashboard')
if (!dashboard) {
throw new Error('the logic must always add this param')
}
const matched = insights[boxToString(req.params['id'])]
if (!matched) {
return [404, null]
}
if (dashboard === '12') {
dashboardTwelveInsightLoadedCount++
// delay for 2 seconds before response without pausing
// but only the first time that dashboard 12 refreshes its results
if (dashboardTwelveInsightLoadedCount === 1) {
return new Promise((resolve) =>
setTimeout(() => {
resolve([200, matched])
}, 2000)
)
}
}
return [200, matched]
},
},
post: {
'/api/projects/:team/insights/cancel/': [201],
},
patch: {
'/api/projects/:team/dashboards/:id/': async (req) => {
const dashboardId = typeof req.params['id'] === 'string' ? req.params['id'] : req.params['id'][0]
const payload = await req.json()
return [200, { ...dashboards[dashboardId], ...payload }]
},
'/api/projects/:team/insights/:id/': async (req) => {
try {
const updates = await req.json()
if (typeof updates !== 'object') {
return [500, `this update should receive an object body not ${JSON.stringify(updates)}`]
}
const insightId = boxToString(req.params.id)

const starting: InsightModel = insights[insightId]
insights[insightId] = {
...starting,
...updates,
}

starting.dashboards?.forEach((dashboardId) => {
// remove this insight from any dashboard it is already on
dashboards[dashboardId].tiles = dashboards[dashboardId].tiles.filter(
(t) => !!t.insight && t.insight.id !== starting.id
)
})

insights[insightId].dashboards?.forEach((dashboardId: number) => {
// then add it to any it new references
dashboards[dashboardId].tiles.push(tileFromInsight(insights[insightId]))
})

return [200, insights[insightId]]
} catch (e) {
return [500, e]
}
},
},
})
initKeaTests()
dashboardsModel.mount()
insightsModel.mount()
})

describe('cancelling queries', () => {
beforeEach(async () => {
logic = dashboardLogic({ id: 12 })
logic.mount()
await expectLogic(logic).toFinishAllListeners()
})

it('cancels a running query', async () => {
jest.spyOn(api, 'create')

setTimeout(() => {
// this change of filters will dispatch cancellation on the first query
// will run while the -180d query is still running
logic.actions.setDates('-90d', null)
}, 200)
// dispatches an artificially slow data request
// takes 3000 milliseconds to return
logic.actions.setDates('-180d', null)

await expectLogic(logic)
.toDispatchActions([
'setDates',
'updateFilters',
'abortAnyRunningQuery',
'refreshAllDashboardItems',
'abortAnyRunningQuery',
'setDates',
'updateFilters',
'abortAnyRunningQuery',
'abortQuery',
'refreshAllDashboardItems',
eventUsageLogic.actionTypes.reportDashboardRefreshed,
])
.toMatchValues({
filters: partial({ date_from: '-90d' }),
})

const mockCreateCalls = (api.create as jest.Mock).mock.calls
// there will be at least two used client query ids
// the most recent has not been cancelled
// the one before that has been
// the query IDs are made of `${dashboard query ID}::{insight query ID}`
// only the dashboard query ID is sent to the API
const cancelledQueryID = seenQueryIDs[seenQueryIDs.length - 2].split('::')[0]
expect(mockCreateCalls).toEqual([
[
`api/projects/${MOCK_TEAM_ID}/insights/cancel`,
{
client_query_id: cancelledQueryID,
},
],
])
})
})
})
20 changes: 11 additions & 9 deletions frontend/src/scenes/dashboard/dashboardLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import api from 'lib/api'

const dashboardJson = _dashboardJson as any as DashboardType

function insightOnDashboard(
export function insightOnDashboard(
insightId: number,
dashboardsRelation: number[],
insight: Partial<InsightModel> = {}
Expand All @@ -54,7 +54,7 @@ const TEXT_TILE: DashboardTile = {
}

let tileId = 0
const tileFromInsight = (insight: InsightModel, id: number = tileId++): DashboardTile => ({
export const tileFromInsight = (insight: InsightModel, id: number = tileId++): DashboardTile => ({
id: id,
layouts: {},
color: null,
Expand All @@ -64,7 +64,7 @@ const tileFromInsight = (insight: InsightModel, id: number = tileId++): Dashboar
refreshing: false,
})

const dashboardResult = (
export const dashboardResult = (
dashboardId: number,
tiles: DashboardTile[],
filters: Partial<Pick<FilterType, 'date_from' | 'date_to' | 'properties'>> = {}
Expand All @@ -79,12 +79,12 @@ const dashboardResult = (

const uncached = (insight: InsightModel): InsightModel => ({ ...insight, result: null, last_refresh: null })

const boxToString = (param: string | readonly string[]): string => {
export const boxToString = (param: string | readonly string[]): string => {
//path params from msw can be a string or an array
if (typeof param === 'string') {
return param
} else {
throw new Error("this shouldn't be an arry")
throw new Error("this shouldn't be an array")
}
}

Expand Down Expand Up @@ -198,16 +198,18 @@ describe('dashboardLogic', () => {
throw new Error('the logic must always add this param')
}
const matched = insights[boxToString(req.params['id'])]
if (matched) {
return [200, matched]
} else {
if (!matched) {
return [404, null]
}
return [200, matched]
},
},
post: {
'/api/projects/:team/insights/cancel/': [201],
},
patch: {
'/api/projects/:team/dashboards/:id/': async (req) => {
const dashboardId = req.params['id'][0]
const dashboardId = typeof req.params['id'] === 'string' ? req.params['id'] : req.params['id'][0]
const payload = await req.json()
return [200, { ...dashboards[dashboardId], ...payload }]
},
Expand Down
60 changes: 27 additions & 33 deletions frontend/src/scenes/dashboard/dashboardLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { insightsModel } from '~/models/insightsModel'
import {
AUTO_REFRESH_DASHBOARD_THRESHOLD_HOURS,
DashboardPrivilegeLevel,
FEATURE_FLAGS,
OrganizationMembershipLevel,
} from 'lib/constants'
import { DashboardEventSource, eventUsageLogic } from 'lib/utils/eventUsageLogic'
Expand Down Expand Up @@ -187,9 +186,10 @@ export const dashboardLogic = kea<dashboardLogicType>([
loadingDashboardItemsStarted: (action: string, dashboardQueryId: string) => ({ action, dashboardQueryId }),
setInitialLoadResponseBytes: (responseBytes: number) => ({ responseBytes }),
abortQuery: (payload: { dashboardQueryId: string; queryId: string; queryStartTime: number }) => payload,
abortAnyRunningQuery: true,
}),

loaders(({ actions, props, values, cache }) => ({
loaders(({ actions, props, values }) => ({
// TODO this is a terrible name... it is "dashboard" but there's a "dashboard" reducer ¯\_(ツ)_/¯
allItems: [
null as DashboardType | null,
Expand Down Expand Up @@ -225,10 +225,7 @@ export const dashboardLogic = kea<dashboardLogicType>([
return values.allItems
}

if (cache.abortController) {
cache.abortController.abort()
}
cache.abortController = null
actions.abortAnyRunningQuery()

try {
return await api.update(`api/projects/${values.currentTeamId}/dashboards/${props.id}`, {
Expand Down Expand Up @@ -778,7 +775,6 @@ export const dashboardLogic = kea<dashboardLogicType>([
}
},
beforeUnmount: () => {
cache.abortController?.abort()
if (cache.autoRefreshInterval) {
window.clearInterval(cache.autoRefreshInterval)
cache.autoRefreshInterval = null
Expand Down Expand Up @@ -926,11 +922,8 @@ export const dashboardLogic = kea<dashboardLogicType>([
true
)

// If a query is in progress, kill that query
// we will use one abort controller for all insight queries for this dashboard
if (cache.abortController) {
cache.abortController.abort()
}
actions.abortAnyRunningQuery()
cache.abortController = new AbortController()
const methodOptions: ApiMethodOptions = {
signal: cache.abortController.signal,
Expand Down Expand Up @@ -980,7 +973,6 @@ export const dashboardLogic = kea<dashboardLogicType>([
})
totalResponseBytes += getResponseBytes(refreshedInsightResponse)
} catch (e: any) {
console.error(e)
if (isBreakpoint(e)) {
cancelled = true
} else if (e.name === 'AbortError' || e.message?.name === 'AbortError') {
Expand All @@ -995,10 +987,7 @@ export const dashboardLogic = kea<dashboardLogicType>([
}

refreshesFinished += 1
if (refreshesFinished === insights.length) {
breakpoint()
cache.abortController = null

if (!cancelled && refreshesFinished === insights.length) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@macobo one last thing to check now I have the tests passing

I've had to add a !cancelled gate here because sometimes this conditional was executed after tests had finished running.

I think it's OK not to report time_to_see data for the dashboard load here since we'd be reporting on the cancellation but....

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the heads up.

const payload: TimeToSeeDataPayload = {
type: 'dashboard_load',
context: 'dashboard',
Expand Down Expand Up @@ -1162,25 +1151,30 @@ export const dashboardLogic = kea<dashboardLogicType>([
actions.setShouldReportOnAPILoad(true)
}
},
abortAnyRunningQuery: () => {
if (cache.abortController) {
cache.abortController.abort()
cache.abortController = null
}
},
abortQuery: async ({ dashboardQueryId, queryId, queryStartTime }) => {
const { currentTeamId } = values
if (values.featureFlags[FEATURE_FLAGS.CANCEL_RUNNING_QUERIES]) {
await api.create(`api/projects/${currentTeamId}/insights/cancel`, { client_query_id: dashboardQueryId })

// TRICKY: we cancel just once using the dashboard query id.
// we can record the queryId that happened to capture the AbortError exception
// and request the cancellation, but it is probably not particularly relevant
await captureTimeToSeeData(values.currentTeamId, {
type: 'insight_load',
context: 'dashboard',
dashboard_query_id: dashboardQueryId,
query_id: queryId,
status: 'cancelled',
time_to_see_data_ms: Math.floor(performance.now() - queryStartTime),
insights_fetched: 0,
insights_fetched_cached: 0,
})
}

await api.create(`api/projects/${currentTeamId}/insights/cancel`, { client_query_id: dashboardQueryId })

// TRICKY: we cancel just once using the dashboard query id.
// we can record the queryId that happened to capture the AbortError exception
// and request the cancellation, but it is probably not particularly relevant
await captureTimeToSeeData(values.currentTeamId, {
type: 'insight_load',
context: 'dashboard',
dashboard_query_id: dashboardQueryId,
query_id: queryId,
status: 'cancelled',
time_to_see_data_ms: Math.floor(performance.now() - queryStartTime),
insights_fetched: 0,
insights_fetched_cached: 0,
})
},
})),

Expand Down