Commit cb2a0573 authored by Tiger Oakes's avatar Tiger Oakes Committed by Commit Bot

Supersize: Improving performance by storing fetch results

We now cache the data file as binary data, which can be cached in
memory without crashing the page. When streaming, we build the final
cache by tracking the total size of the bytes streamed in. This
cache allows us to reuse the data on subsequent runs
(such as when filters change) rather than calling fetch again.

Bug: 847599
Change-Id: I09fb9f45c992984ed56c1270c1c015d274c4c167
Reviewed-on: https://chromium-review.googlesource.com/1136852
Commit-Queue: Tiger Oakes <tigero@google.com>
Reviewed-by: default avataragrieve <agrieve@chromium.org>
Cr-Commit-Position: refs/heads/master@{#576490}
parent dcc14ad6
...@@ -67,7 +67,6 @@ ...@@ -67,7 +67,6 @@
} }
.options { .options {
display: none;
grid-area: options; grid-area: options;
} }
......
...@@ -84,7 +84,7 @@ const _CONTAINER_TYPES = { ...@@ -84,7 +84,7 @@ const _CONTAINER_TYPES = {
const _CONTAINER_TYPE_SET = new Set(Object.values(_CONTAINER_TYPES)); const _CONTAINER_TYPE_SET = new Set(Object.values(_CONTAINER_TYPES));
/** Type for a dex method symbol */ /** Type for a dex method symbol */
const _DEX_METHOD_SYMBOL_TYPE = 'm' const _DEX_METHOD_SYMBOL_TYPE = 'm';
/** Type for an 'other' symbol */ /** Type for an 'other' symbol */
const _OTHER_SYMBOL_TYPE = 'o'; const _OTHER_SYMBOL_TYPE = 'o';
...@@ -122,3 +122,20 @@ function* types(typesList) { ...@@ -122,3 +122,20 @@ function* types(typesList) {
} }
} }
} }
/**
* Limit how frequently `func` is called.
* @template {T}
* @param {T & Function} func
* @param {number} wait Time to wait before func can be called again (ms).
* @returns {T}
*/
function debounce(func, wait) {
/** @type {number} */
let timeoutId;
function debounced (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), wait);
};
return /** @type {any} */ (debounced);
}
...@@ -322,16 +322,36 @@ ...@@ -322,16 +322,36 @@
} }
{ {
class ProgressBar {
/** @param {string} id */
constructor(id) {
/** @type {HTMLProgressElement} */
this._element = document.getElementById(id);
this.lastValue = this._element.value;
}
setValue(val) {
if (val === 0 || val >= this.lastValue) {
this._element.value = val;
this.lastValue = val;
} else {
// Reset to 0 so the progress bar doesn't animate backwards.
this.setValue(0);
requestAnimationFrame(() => this.setValue(val));
}
}
}
/** @type {HTMLUListElement} */ /** @type {HTMLUListElement} */
const _symbolTree = document.getElementById('symboltree'); const _symbolTree = document.getElementById('symboltree');
/** @type {HTMLProgressElement} */ const _progress = new ProgressBar('progress');
const _progress = document.getElementById('progress');
/** /**
* Displays the given data as a tree view * Displays the given data as a tree view
* @param {TreeProgress} param0 * @param {TreeProgress} message
*/ */
function displayTree({root, percent, diffMode, error}) { function displayTree(message) {
const {root, percent, diffMode, error} = message;
/** @type {DocumentFragment | null} */ /** @type {DocumentFragment | null} */
let rootElement = null; let rootElement = null;
if (root) { if (root) {
...@@ -344,21 +364,25 @@ ...@@ -344,21 +364,25 @@
} }
state.set('diff_mode', diffMode ? 'on' : null); state.set('diff_mode', diffMode ? 'on' : null);
requestAnimationFrame(() => { // Double requestAnimationFrame ensures that the code inside executes in a
_progress.value = percent; // different frame than the above tree element creation.
if (error) { requestAnimationFrame(() =>
document.body.classList.add('error'); requestAnimationFrame(() => {
} else { _progress.setValue(percent);
document.body.classList.remove('error'); if (error) {
} document.body.classList.add('error');
if (diffMode) { } else {
document.body.classList.add('diff'); document.body.classList.remove('error');
} else { }
document.body.classList.remove('diff'); if (diffMode) {
} document.body.classList.add('diff');
} else {
document.body.classList.remove('diff');
}
dom.replace(_symbolTree, rootElement); dom.replace(_symbolTree, rootElement);
}); })
);
} }
treeReady.then(displayTree); treeReady.then(displayTree);
...@@ -366,13 +390,13 @@ ...@@ -366,13 +390,13 @@
form.addEventListener('change', event => { form.addEventListener('change', event => {
if (event.target.dataset.dynamic == null) { if (event.target.dataset.dynamic == null) {
_progress.value = 0; _progress.setValue(0);
worker.loadTree().then(displayTree); worker.loadTree().then(displayTree);
} }
}); });
form.addEventListener('submit', event => { form.addEventListener('submit', event => {
event.preventDefault(); event.preventDefault();
_progress.value = 0; _progress.setValue(0);
worker.loadTree().then(displayTree); worker.loadTree().then(displayTree);
}); });
} }
...@@ -37,29 +37,32 @@ importScripts('./shared.js'); ...@@ -37,29 +37,32 @@ importScripts('./shared.js');
const _PATH_SEP = '/'; const _PATH_SEP = '/';
/** @param {FileEntry} fileEntry */
function getSourcePath(fileEntry) {
return fileEntry[_KEYS.SOURCE_PATH];
}
/** /**
* Return the basename of the pathname 'path'. In a file path, this is the name * Find the last index of either '/' or `sep` in the given path.
* of the file and its extension. In a folder path, this is the name of the * @param {string} path
* folder. * @param {string} sep
* @param {string} path Path to find basename of.
* @param {string} sep Path seperator, such as '/'.
*/ */
function basename(path, sep) { function lastIndexOf(path, sep) {
const sepIndex = path.lastIndexOf(sep); if (sep === _PATH_SEP) {
const pathIndex = path.lastIndexOf(_PATH_SEP); return path.lastIndexOf(_PATH_SEP);
return path.substring(Math.max(sepIndex, pathIndex) + 1); } else {
return Math.max(path.lastIndexOf(sep), path.lastIndexOf(_PATH_SEP));
}
} }
/** /**
* Return the basename of the pathname 'path'. In a file path, this is the * Return the dirname of the pathname 'path'. In a file path, this is the
* full path of its folder. * full path of its folder.
* @param {string} path Path to find dirname of. * @param {string} path Path to find dirname of.
* @param {string} sep Path seperator, such as '/'. * @param {string} sep Path seperator, such as '/'.
*/ */
function dirname(path, sep) { function dirname(path, sep) {
const sepIndex = path.lastIndexOf(sep); return path.substring(0, lastIndexOf(path, sep));
const pathIndex = path.lastIndexOf(_PATH_SEP);
return path.substring(0, Math.max(sepIndex, pathIndex));
} }
/** /**
...@@ -73,19 +76,19 @@ function _compareFunc(a, b) { ...@@ -73,19 +76,19 @@ function _compareFunc(a, b) {
/** /**
* Make a node with some default arguments * Make a node with some default arguments
* @param {Partial<TreeNode> & {shortName:string}} options * @param {Partial<TreeNode>} options
* Values to use for the node. If a value is * Values to use for the node. If a value is
* omitted, a default will be used instead. * omitted, a default will be used instead.
* @returns {TreeNode} * @returns {TreeNode}
*/ */
function createNode(options) { function createNode(options) {
const {idPath, type, shortName, size = 0, childStats = {}} = options; const {idPath, type, shortNameIndex, size = 0, childStats = {}} = options;
return { return {
children: [], children: [],
parent: null, parent: null,
childStats, childStats,
idPath, idPath,
shortNameIndex: idPath.lastIndexOf(shortName), shortNameIndex,
size, size,
type, type,
}; };
...@@ -114,7 +117,7 @@ class TreeBuilder { ...@@ -114,7 +117,7 @@ class TreeBuilder {
this.rootNode = createNode({ this.rootNode = createNode({
idPath: this._sep, idPath: this._sep,
shortName: this._sep, shortNameIndex: 0,
type: this._containerType(this._sep), type: this._containerType(this._sep),
}); });
/** @type {Map<string, TreeNode>} Cache for directory nodes */ /** @type {Map<string, TreeNode>} Cache for directory nodes */
...@@ -131,37 +134,38 @@ class TreeBuilder { ...@@ -131,37 +134,38 @@ class TreeBuilder {
* Link a node to a new parent. Will go up the tree to update parent sizes to * Link a node to a new parent. Will go up the tree to update parent sizes to
* include the new child. * include the new child.
* @param {TreeNode} node Child node. * @param {TreeNode} node Child node.
* @param {TreeNode} parent New parent node. * @param {TreeNode} directParent New parent node.
*/ */
static _attachToParent(node, parent) { static _attachToParent(node, directParent) {
// Link the nodes together // Link the nodes together
parent.children.push(node); directParent.children.push(node);
node.parent = parent; node.parent = directParent;
const additionalSize = node.size; const additionalSize = node.size;
const additionalStats = Object.entries(node.childStats); const additionalStats = Object.entries(node.childStats);
// Update the size and childStats of all ancestors // Update the size and childStats of all ancestors
while (node != null && node.parent != null) { while (node.parent != null) {
const [containerType, lastBiggestType] = node.parent.type; const {parent} = node;
const [containerType, lastBiggestType] = parent.type;
let {size: lastBiggestSize = 0} = let {size: lastBiggestSize = 0} =
node.parent.childStats[lastBiggestType] || {}; parent.childStats[lastBiggestType] || {};
for (const [type, stat] of additionalStats) { for (const [type, stat] of additionalStats) {
const parentStat = node.parent.childStats[type] || {size: 0, count: 0}; const parentStat = parent.childStats[type] || {size: 0, count: 0};
parentStat.size += stat.size; parentStat.size += stat.size;
parentStat.count += stat.count; parentStat.count += stat.count;
node.parent.childStats[type] = parentStat; parent.childStats[type] = parentStat;
const absSize = Math.abs(parentStat.size); const absSize = Math.abs(parentStat.size);
if (absSize > lastBiggestSize) { if (absSize > lastBiggestSize) {
node.parent.type = `${containerType}${type}`; parent.type = `${containerType}${type}`;
lastBiggestSize = absSize; lastBiggestSize = absSize;
} }
} }
node.parent.size += additionalSize; parent.size += additionalSize;
node = node.parent; node = parent;
} }
} }
...@@ -197,7 +201,7 @@ class TreeBuilder { ...@@ -197,7 +201,7 @@ class TreeBuilder {
if (classNode == null) { if (classNode == null) {
classNode = createNode({ classNode = createNode({
idPath: classIdPath, idPath: classIdPath,
shortName: classIdPath.slice(childNode.shortNameIndex), shortNameIndex: childNode.shortNameIndex,
type: _CONTAINER_TYPES.JAVA_CLASS, type: _CONTAINER_TYPES.JAVA_CLASS,
}); });
javaClassContainers.set(classIdPath, classNode); javaClassContainers.set(classIdPath, classNode);
...@@ -303,7 +307,7 @@ class TreeBuilder { ...@@ -303,7 +307,7 @@ class TreeBuilder {
if (parentNode == null) { if (parentNode == null) {
parentNode = createNode({ parentNode = createNode({
idPath: parentPath, idPath: parentPath,
shortName: basename(parentPath, this._sep), shortNameIndex: lastIndexOf(parentPath, this._sep) + 1,
type: this._containerType(childNode.idPath), type: this._containerType(childNode.idPath),
}); });
this._parents.set(parentPath, parentNode); this._parents.set(parentPath, parentNode);
...@@ -323,15 +327,11 @@ class TreeBuilder { ...@@ -323,15 +327,11 @@ class TreeBuilder {
* @param {FileEntry} fileEntry File entry from data file * @param {FileEntry} fileEntry File entry from data file
*/ */
addFileEntry(fileEntry) { addFileEntry(fileEntry) {
// make path for this
const filePath = fileEntry[_KEYS.SOURCE_PATH];
// insert zero-width spaces after certain characters to indicate to the
// browser it could add a line break there on small screen sizes.
const idPath = this._getPath(fileEntry); const idPath = this._getPath(fileEntry);
// make node for this // make node for this
const fileNode = createNode({ const fileNode = createNode({
idPath, idPath,
shortName: basename(filePath, this._sep), shortNameIndex: lastIndexOf(idPath, this._sep) + 1,
type: _CONTAINER_TYPES.FILE, type: _CONTAINER_TYPES.FILE,
}); });
// build child nodes for this file's symbols and attach to self // build child nodes for this file's symbols and attach to self
...@@ -342,7 +342,7 @@ class TreeBuilder { ...@@ -342,7 +342,7 @@ class TreeBuilder {
const symbolNode = createNode({ const symbolNode = createNode({
// Join file path to symbol name with a ":" // Join file path to symbol name with a ":"
idPath: `${idPath}:${symbol[_KEYS.SYMBOL_NAME]}`, idPath: `${idPath}:${symbol[_KEYS.SYMBOL_NAME]}`,
shortName: symbol[_KEYS.SYMBOL_NAME], shortNameIndex: idPath.length + 1,
size, size,
type: symbol[_KEYS.TYPE], type: symbol[_KEYS.TYPE],
childStats: {[type]: {size, count}}, childStats: {[type]: {size, count}},
...@@ -366,6 +366,9 @@ class TreeBuilder { ...@@ -366,6 +366,9 @@ class TreeBuilder {
* Finalize the creation of the tree and return the root node. * Finalize the creation of the tree and return the root node.
*/ */
build() { build() {
this._getPath = () => '';
this._filterTest = () => false;
this._parents.clear();
return this.rootNode; return this.rootNode;
} }
...@@ -431,6 +434,8 @@ class DataFetcher { ...@@ -431,6 +434,8 @@ class DataFetcher {
constructor(url) { constructor(url) {
this._controller = new AbortController(); this._controller = new AbortController();
this._url = url; this._url = url;
/** @type {Uint8Array | null} */
this._cache = null;
} }
/** /**
...@@ -446,41 +451,76 @@ class DataFetcher { ...@@ -446,41 +451,76 @@ class DataFetcher {
} }
/** /**
* Transforms a binary stream into a newline delimited JSON (.ndjson) stream. * Yields binary chunks as Uint8Arrays. After a complete run, the bytes are
* Each yielded value corresponds to one line in the stream. * cached and future calls will yield the cached Uint8Array instead.
* @returns {AsyncIterable<Meta | FileEntry>}
*/ */
async *newlineDelimtedJsonStream() { async *arrayBufferStream() {
if (this._cache) {
yield this._cache;
return;
}
const response = await this.fetch(); const response = await this.fetch();
// Are streams supported? let result;
// Use streams, if supported, so that we can show in-progress data instead
// of waiting for the entire data file to download. The file can be >100 MB,
// so on streams ensure slow connections still see some data.
if (response.body) { if (response.body) {
const decoder = new TextDecoder();
const decodeOptions = {stream: true};
const reader = response.body.getReader(); const reader = response.body.getReader();
let buffer = ''; /** @type {Uint8Array[]} Store received bytes to merge later */
let buffer = [];
/** Total size of received bytes */
let byteSize = 0;
while (true) { while (true) {
// Read values from the stream // Read values from the stream
const {done, value} = await reader.read(); const {done, value} = await reader.read();
if (done) break; if (done) break;
// Convert binary values to text chunks. const chunk = new Uint8Array(value, 0, value.length);
const chunk = decoder.decode(value, decodeOptions); yield chunk;
buffer += chunk; buffer.push(chunk);
// Split the chunk base on newlines, byteSize += chunk.length;
// and turn each complete line into JSON }
const lines = buffer.split('\n');
[buffer] = lines.splice(lines.length - 1, 1);
for (const line of lines) { // We just cache a single typed array to save some memory and make future
yield JSON.parse(line); // runs consistent with the no streams mode.
} result = new Uint8Array(byteSize);
let i = 0;
for (const chunk of buffer) {
result.set(chunk, i);
i += chunk.length;
} }
} else { } else {
// In-memory version for browsers without stream support // In-memory version for browsers without stream support
const text = await response.text(); result = new Uint8Array(await response.arrayBuffer());
for (const line of text.split('\n')) { yield result;
if (line) yield JSON.parse(line); }
this._cache = result;
}
/**
* Transforms a binary stream into a newline delimited JSON (.ndjson) stream.
* Each yielded value corresponds to one line in the stream.
* @returns {AsyncIterable<Meta | FileEntry>}
*/
async *newlineDelimtedJsonStream() {
const decoder = new TextDecoder();
const decoderArgs = {stream: true};
let textBuffer = '';
for await (const bytes of this.arrayBufferStream()) {
if (this._controller.signal.aborted) {
throw new DOMException('Request was aborted', 'AbortError');
}
textBuffer += decoder.decode(bytes, decoderArgs);
const lines = textBuffer.split('\n');
[textBuffer] = lines.splice(lines.length - 1, 1);
for (const line of lines) {
yield JSON.parse(line);
} }
} }
} }
...@@ -517,11 +557,18 @@ function parseOptions(options) { ...@@ -517,11 +557,18 @@ function parseOptions(options) {
} }
} }
/** @type {Array<(symbolNode: TreeNode) => boolean>} */
const filters = [];
/** Ensure symbol size is past the minimum */ /** Ensure symbol size is past the minimum */
const checkSize = s => Math.abs(s.size) >= minSymbolSize; if (minSymbolSize > 0) {
filters.push(s => Math.abs(s.size) >= minSymbolSize);
}
/** Ensure the symbol size wasn't filtered out */ /** Ensure the symbol size wasn't filtered out */
const checkType = s => typeFilter.has(s.type); if (typeFilter.size < _SYMBOL_TYPE_SET.size) {
const filters = [checkSize, checkType]; filters.push(s => typeFilter.has(s.type));
}
if (includeRegex) { if (includeRegex) {
const regex = new RegExp(includeRegex); const regex = new RegExp(includeRegex);
...@@ -551,6 +598,7 @@ const fetcher = new DataFetcher('data.ndjson'); ...@@ -551,6 +598,7 @@ const fetcher = new DataFetcher('data.ndjson');
* Assemble a tree when this worker receives a message. * Assemble a tree when this worker receives a message.
* @param {string} options Query string containing options for the builder. * @param {string} options Query string containing options for the builder.
* @param {(msg: TreeProgress) => void} onProgress * @param {(msg: TreeProgress) => void} onProgress
* @returns {Promise<TreeProgress>}
*/ */
async function buildTree(options, onProgress) { async function buildTree(options, onProgress) {
const {groupBy, filterTest} = parseOptions(options); const {groupBy, filterTest} = parseOptions(options);
...@@ -562,19 +610,14 @@ async function buildTree(options, onProgress) { ...@@ -562,19 +610,14 @@ async function buildTree(options, onProgress) {
const getPathMap = { const getPathMap = {
component(fileEntry) { component(fileEntry) {
const component = meta.components[fileEntry[_KEYS.COMPONENT_INDEX]]; const component = meta.components[fileEntry[_KEYS.COMPONENT_INDEX]];
const path = getPathMap.source_path(fileEntry); const path = getSourcePath(fileEntry);
return (component || '(No component)') + '>' + path; return `${component || '(No component)'}>${path}`;
},
source_path(fileEntry) {
return fileEntry[_KEYS.SOURCE_PATH];
}, },
}; source_path: getSourcePath,
const sepMap = {
component: '>',
}; };
builder = new TreeBuilder({ builder = new TreeBuilder({
sep: sepMap[groupBy], sep: groupBy === 'component' ? '>' : _PATH_SEP,
getPath: getPathMap[groupBy], getPath: getPathMap[groupBy],
filterTest, filterTest,
}); });
...@@ -616,11 +659,9 @@ async function buildTree(options, onProgress) { ...@@ -616,11 +659,9 @@ async function buildTree(options, onProgress) {
onProgress(message); onProgress(message);
} }
/** @type {number} ID from setInterval */
let interval = null;
try { try {
// Post partial state every second // Post partial state every second
interval = setInterval(postToUi, 1000); let lastBatchSent = Date.now();
for await (const dataObj of fetcher.newlineDelimtedJsonStream()) { for await (const dataObj of fetcher.newlineDelimtedJsonStream()) {
if (meta == null) { if (meta == null) {
// First line of data is used to store meta information. // First line of data is used to store meta information.
...@@ -628,22 +669,26 @@ async function buildTree(options, onProgress) { ...@@ -628,22 +669,26 @@ async function buildTree(options, onProgress) {
postToUi(); postToUi();
} else { } else {
builder.addFileEntry(/** @type {FileEntry} */ (dataObj)); builder.addFileEntry(/** @type {FileEntry} */ (dataObj));
const currentTime = Date.now();
if (currentTime - lastBatchSent > 500) {
postToUi();
await Promise.resolve(); // Pause loop to check for worker messages
lastBatchSent = currentTime;
}
} }
} }
clearInterval(interval);
return createProgressMessage({ return createProgressMessage({
root: builder.build(), root: builder.build(),
percent: 1, percent: 1,
}); });
} catch (error) { } catch (error) {
if (interval != null) clearInterval(interval);
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
console.info(error.message); console.info(error.message);
} else { } else {
console.error(error); console.error(error);
return createProgressMessage({error});
} }
return createProgressMessage({error});
} }
} }
...@@ -665,10 +710,11 @@ const actions = { ...@@ -665,10 +710,11 @@ const actions = {
/** /**
* Call the requested action function with the given data. If an error is thrown * Call the requested action function with the given data. If an error is thrown
* or rejected, post the error message to the UI thread. * or rejected, post the error message to the UI thread.
* @param {MessageEvent} event Event for when this worker receives a message. * @param {number} id Unique message ID.
* @param {string} action Action type, corresponding to a key in `actions.`
* @param {any} data Data to supply to the action function.
*/ */
self.onmessage = async event => { async function runAction(id, action, data) {
const {id, action, data} = event.data;
try { try {
const result = await actions[action](data); const result = await actions[action](data);
// @ts-ignore // @ts-ignore
...@@ -678,4 +724,22 @@ self.onmessage = async event => { ...@@ -678,4 +724,22 @@ self.onmessage = async event => {
self.postMessage({id, error: err.message}); self.postMessage({id, error: err.message});
throw err; throw err;
} }
}
const runActionDebounced = debounce(runAction, 0);
/**
* @param {MessageEvent} event Event for when this worker receives a message.
*/
self.onmessage = async event => {
const {id, action, data} = event.data;
if (action === 'load') {
// Loading large files will block the worker thread until complete or when
// an await statement is reached. During this time, multiple load messages
// can pile up due to filters being adjusted. We debounce the load call
// so that only the last message is read (the current set of filters).
runActionDebounced(id, action, data);
} else {
runAction(id, action, data);
}
}; };
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment