Skip to content

Implement streaming for compare view #3771

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

Merged
merged 2 commits into from
Oct 30, 2024
Merged
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
25 changes: 25 additions & 0 deletions extensions/ql-vscode/src/common/interface-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,9 @@ interface ChangeCompareMessage {
export type ToCompareViewMessage =
| SetComparisonQueryInfoMessage
| SetComparisonsMessage
| StreamingComparisonSetupMessage
| StreamingComparisonAddResultsMessage
| StreamingComparisonCompleteMessage
| SetUserSettingsMsg;

/**
Expand Down Expand Up @@ -419,6 +422,28 @@ export type InterpretedQueryCompareResult = {
to: Result[];
};

export interface StreamingComparisonSetupMessage {
readonly t: "streamingComparisonSetup";
// The id of this streaming comparison
readonly id: string;
readonly currentResultSetName: string;
readonly message: string | undefined;
// The from and to fields will only contain a chunk of the results
readonly result: QueryCompareResult;
}

interface StreamingComparisonAddResultsMessage {
readonly t: "streamingComparisonAddResults";
readonly id: string;
// The from and to fields will only contain a chunk of the results
readonly result: QueryCompareResult;
}

interface StreamingComparisonCompleteMessage {
readonly t: "streamingComparisonComplete";
readonly id: string;
}

/**
* Extract the name of the default result. Prefer returning
* 'alerts', or '#select'. Otherwise return the first in the list.
Expand Down
87 changes: 86 additions & 1 deletion extensions/ql-vscode/src/compare/compare-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from "./result-set-names";
import { compareInterpretedResults } from "./interpreted-results";
import { isCanary } from "../config";
import { nanoid } from "nanoid";

interface ComparePair {
from: CompletedLocalQueryInfo;
Expand Down Expand Up @@ -183,13 +184,97 @@ export class CompareView extends AbstractWebview<
message = getErrorMessage(e);
}

await this.streamResults(result, currentResultSetDisplayName, message);
}
}

private async streamResults(
result: QueryCompareResult | undefined,
currentResultSetName: string,
message: string | undefined,
) {
// Since there is a string limit of 1GB in Node.js, the comparison is send as a JSON.stringified string to the webview
// and some comparisons may be larger than that, we sometimes need to stream results. This uses a heuristic of 2,000 results
// to determine if we should stream results.

if (!this.shouldStreamResults(result)) {
await this.postMessage({
t: "setComparisons",
result,
currentResultSetName: currentResultSetDisplayName,
currentResultSetName,
message,
});
return;
}

const id = nanoid();

// Streaming itself is implemented like this:
// - 1 setup message which contains the first 1,000 results
// - n "add results" messages which contain 1,000 results each
// - 1 complete message which just tells the webview that we're done

await this.postMessage({
t: "streamingComparisonSetup",
id,
result: this.chunkResults(result, 0, 1000),
currentResultSetName,
message,
});

const { from, to } = result;

const maxResults = Math.max(from.length, to.length);
for (let i = 1000; i < maxResults; i += 1000) {
const chunk = this.chunkResults(result, i, i + 1000);

await this.postMessage({
t: "streamingComparisonAddResults",
id,
result: chunk,
});
}

await this.postMessage({
t: "streamingComparisonComplete",
id,
});
}

private shouldStreamResults(
result: QueryCompareResult | undefined,
): result is QueryCompareResult {
if (result === undefined) {
return false;
}

// We probably won't run into limits if we have less than 2,000 total results
const totalResults = result.from.length + result.to.length;
return totalResults > 2000;
}

private chunkResults(
result: QueryCompareResult,
start: number,
end: number,
): QueryCompareResult {
if (result.kind === "raw") {
return {
...result,
from: result.from.slice(start, end),
to: result.to.slice(start, end),
};
}

if (result.kind === "interpreted") {
return {
...result,
from: result.from.slice(start, end),
to: result.to.slice(start, end),
};
}
Comment on lines +261 to +275
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like the bodies of these if statements are the same. Should you combine them?

Copy link
Member Author

Choose a reason for hiding this comment

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

Unfortunately that's not possible in this case because TypeScript can't determine that automatically from these overlapping types: Returned expression type {kind: "raw" | "interpreted", from: Row[] | Result[], to: Row[] | Result[]} is not assignable to type QueryCompareResult .

Copy link
Contributor

Choose a reason for hiding this comment

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

Unfortunate.


assertNever(result);
}

protected getPanelConfig(): WebviewPanelConfig {
Expand Down
91 changes: 90 additions & 1 deletion extensions/ql-vscode/src/view/compare/Compare.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { styled } from "styled-components";

import type {
ToCompareViewMessage,
SetComparisonsMessage,
SetComparisonQueryInfoMessage,
UserSettings,
StreamingComparisonSetupMessage,
QueryCompareResult,
} from "../../common/interface-types";
import { DEFAULT_USER_SETTINGS } from "../../common/interface-types";
import CompareSelector from "./CompareSelector";
Expand Down Expand Up @@ -37,6 +39,12 @@ export function Compare(_: Record<string, never>): React.JSX.Element {
DEFAULT_USER_SETTINGS,
);

// This is a ref because we don't need to re-render when we get a new streaming comparison message
// and we don't want to change the listener every time we get a new message
const streamingComparisonRef = useRef<StreamingComparisonSetupMessage | null>(
null,
);

const message = comparison?.message || "Empty comparison";
const hasRows =
comparison?.result &&
Expand All @@ -53,6 +61,87 @@ export function Compare(_: Record<string, never>): React.JSX.Element {
case "setComparisons":
setComparison(msg);
break;
case "streamingComparisonSetup":
setComparison(null);
streamingComparisonRef.current = msg;
break;
case "streamingComparisonAddResults": {
const prev = streamingComparisonRef.current;
if (prev === null) {
console.warn(
'Received "streamingComparisonAddResults" before "streamingComparisonSetup"',
);
break;
}

if (prev.id !== msg.id) {
console.warn(
'Received "streamingComparisonAddResults" with different id, ignoring',
);
break;
}

let result: QueryCompareResult;
switch (prev.result.kind) {
case "raw":
if (msg.result.kind !== "raw") {
throw new Error(
"Streaming comparison: expected raw results, got interpreted results",
);
}
Comment on lines +87 to +90
Copy link
Contributor

Choose a reason for hiding this comment

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

Any danger of getting here if you switch in the middle of rendering a very large comparison result?

Copy link
Member Author

Choose a reason for hiding this comment

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

There shouldn't be: 510a269 adds a check for that race condition by adding an id to every message so that if a different setup message is received, it will ignore all future messages from the previous stream.


result = {
...prev.result,
from: [...prev.result.from, ...msg.result.from],
to: [...prev.result.to, ...msg.result.to],
};
break;
case "interpreted":
if (msg.result.kind !== "interpreted") {
throw new Error(
"Streaming comparison: expected interpreted results, got raw results",
);
}

result = {
...prev.result,
from: [...prev.result.from, ...msg.result.from],
to: [...prev.result.to, ...msg.result.to],
};
break;
Comment on lines +106 to +110
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like this assignment is the same as the one above. Can you merge the cases?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is split out for the same reason as above: we get a TypeScript error if we don't have this.

default:
throw new Error("Unexpected comparison result kind");
}

streamingComparisonRef.current = {
...prev,
result,
};

break;
}
case "streamingComparisonComplete":
if (streamingComparisonRef.current === null) {
console.warn(
'Received "streamingComparisonComplete" before "streamingComparisonSetup"',
);
setComparison(null);
break;
}

if (streamingComparisonRef.current.id !== msg.id) {
console.warn(
'Received "streamingComparisonComplete" with different id, ignoring',
);
break;
}

setComparison({
...streamingComparisonRef.current,
t: "setComparisons",
});
streamingComparisonRef.current = null;
break;
case "setUserSettings":
setUserSettings(msg.userSettings);
break;
Expand Down
Loading