Skip to content

Commit

Permalink
fix(graphiql): better quota management (#764)
Browse files Browse the repository at this point in the history
  • Loading branch information
jbblanchet authored and benjie committed Oct 15, 2019
1 parent 0c8b7ad commit 7efed6c
Show file tree
Hide file tree
Showing 5 changed files with 317 additions and 38 deletions.
17 changes: 10 additions & 7 deletions packages/graphiql/src/components/QueryHistory.js
Expand Up @@ -11,6 +11,9 @@ import PropTypes from 'prop-types';
import QueryStore from '../utility/QueryStore';
import HistoryQuery from './HistoryQuery';

const MAX_QUERY_SIZE = 100000;
const MAX_HISTORY_LENGTH = 20;

const shouldSaveQuery = (nextProps, current, lastQuerySaved) => {
if (nextProps.queryID === current.queryID) {
return false;
Expand All @@ -20,6 +23,10 @@ const shouldSaveQuery = (nextProps, current, lastQuerySaved) => {
} catch (e) {
return false;
}
// Don't try to save giant queries
if (nextProps.query.length > MAX_QUERY_SIZE) {
return false;
}
if (!lastQuerySaved) {
return true;
}
Expand All @@ -39,8 +46,6 @@ const shouldSaveQuery = (nextProps, current, lastQuerySaved) => {
return true;
};

const MAX_HISTORY_LENGTH = 20;

export class QueryHistory extends React.Component {
static propTypes = {
query: PropTypes.string,
Expand All @@ -53,8 +58,9 @@ export class QueryHistory extends React.Component {

constructor(props) {
super(props);
this.historyStore = new QueryStore('queries', props.storage);
this.favoriteStore = new QueryStore('favorites', props.storage);
this.historyStore = new QueryStore('queries', props.storage, MAX_HISTORY_LENGTH);
// favorites are not automatically deleted, so there's no need for a max length
this.favoriteStore = new QueryStore('favorites', props.storage, null);
const historyQueries = this.historyStore.fetchAll();
const favoriteQueries = this.favoriteStore.fetchAll();
const queries = historyQueries.concat(favoriteQueries);
Expand All @@ -71,9 +77,6 @@ export class QueryHistory extends React.Component {
operationName: nextProps.operationName,
};
this.historyStore.push(item);
if (this.historyStore.length > MAX_HISTORY_LENGTH) {
this.historyStore.shift();
}
const historyQueries = this.historyStore.items;
const favoriteQueries = this.favoriteStore.items;
const queries = historyQueries.concat(favoriteQueries);
Expand Down
24 changes: 17 additions & 7 deletions packages/graphiql/src/utility/QueryStore.js
Expand Up @@ -6,9 +6,10 @@
*/

export default class QueryStore {
constructor(key, storage) {
constructor(key, storage, maxSize = null) {
this.key = key;
this.storage = storage;
this.maxSize = maxSize;
this.items = this.fetchAll();
}

Expand Down Expand Up @@ -64,13 +65,22 @@ export default class QueryStore {
}

push(item) {
this.items.push(item);
this.save();
}
const items = [...this.items, item];

if (this.maxSize && items.length > this.maxSize) {
items.shift();
}

shift() {
this.items.shift();
this.save();
for (let attempts = 0; attempts < 5; attempts++) {
const response = this.storage.set(this.key, JSON.stringify({ [this.key]: items }));;
if (!response || !response.error) {
this.items = items;
} else if (response.isQuotaError && this.maxSize) { // Only try to delete last items on LRU stores
items.shift();
} else {
return; // We don't know what happened in this case, so just bailing out
}
}
}

save() {
Expand Down
57 changes: 33 additions & 24 deletions packages/graphiql/src/utility/StorageAPI.js
Expand Up @@ -5,6 +5,23 @@
* LICENSE file in the root directory of this source tree.
*/

function isQuotaError (storage, e) {
return (
e instanceof DOMException &&
// everything except Firefox
(e.code === 22 ||
// Firefox
e.code === 1014 ||
// test name field too, because code might not be present
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
// acknowledge QuotaExceededError only if there's something already stored
storage.length !== 0
);
}

export default class StorageAPI {
constructor(storage) {
this.storage =
Expand All @@ -17,45 +34,37 @@ export default class StorageAPI {
// Clean up any inadvertently saved null/undefined values.
if (value === 'null' || value === 'undefined') {
this.storage.removeItem('graphiql:' + name);
} else {
return value;
return null;
}

return value;
}

return null
}

set(name, value) {
let quotaError = false;
let error = null;

if (this.storage) {
const key = `graphiql:${name}`;
if (value) {
if (isStorageAvailable(this.storage, key, value)) {
try {
this.storage.setItem(key, value);
} catch(e) {
error = e;
quotaError = isQuotaError(this.storage, e);
}
} else {
// Clean up by removing the item if there's no value to set
this.storage.removeItem(key);
}
}
}
}

function isStorageAvailable(storage, key, value) {
try {
storage.setItem(key, value);
return true;
} catch (e) {
return (
e instanceof DOMException &&
// everything except Firefox
(e.code === 22 ||
// Firefox
e.code === 1014 ||
// test name field too, because code might not be present
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
// acknowledge QuotaExceededError only if there's something already stored
storage.length !== 0
);
return {
isQuotaError: quotaError,
error
};
}
}
159 changes: 159 additions & 0 deletions packages/graphiql/src/utility/__tests__/QueryStore.spec.js
@@ -0,0 +1,159 @@
/**
* Copyright (c) 2019 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import QueryStore from '../QueryStore';
import StorageAPI from '../StorageAPI';

class StorageMock {
constructor(shouldThrow) {
this.shouldThrow = shouldThrow;
this.map = {};
}

set (key, value) {
this.count++;

if (this.shouldThrow()) {
return {
error: {},
isQuotaError: true,
storageAvailable: true
};
}

this.map[key] = value;

return {
error: null,
isQuotaError: false,
storageAvailable: true
};
}

get (key) {
return this.map[key] || null;
}
}

describe('QueryStore', () => {
describe('with no max items', () => {
it('can push multiple items', () => {
const store = new QueryStore(
'normal',
new StorageAPI()
);

for (let i = 0; i < 100; i++) {
store.push(`item${i}`);
}

expect(store.items.length).toBe(100);
});

it('will fail silently on quota error', () => {
let i = 0;
const store = new QueryStore(
'normal',
new StorageMock(() => i > 4)
);

for (; i < 10; i++) {
store.push(`item${i}`);
}

expect(store.items.length).toBe(5);
expect(store.items[0]).toBe('item0');
expect(store.items[4]).toBe('item4');
});
});

describe('with max items', () => {
it('can push a limited number of items', () => {
const store = new QueryStore(
'limited',
new StorageAPI(),
20
);

for (let i = 0; i < 100; i++) {
store.push(`item${i}`);
}

expect(store.items.length).toBe(20);
// keeps the more recent items
expect(store.items[0]).toBe('item80');
expect(store.items[19]).toBe('item99');
});

it('tries to remove on quota error until it succeeds', () => {
let shouldThrow;
let retryCounter = 0;
const store = new QueryStore(
'normal',
new StorageMock(() => {
retryCounter++;
return shouldThrow();
}),
10
);

for (let i = 0; i < 20; i++) {
shouldThrow = () => false;
store.push(`item${i}`);
}

expect(store.items.length).toBe(10);
// keeps the more recent items
expect(store.items[0]).toBe('item10');
expect(store.items[9]).toBe('item19');

// tries to add an item, succeeds on 3rd try
retryCounter = 0;
shouldThrow = () => retryCounter < 3;
store.push(`finalItem`);

expect(store.items.length).toBe(8);
expect(store.items[0]).toBe('item13');
expect(store.items[7]).toBe('finalItem');
});

it('tries to remove a maximum of 5 times', () => {
let shouldTrow;
let retryCounter = 0;
const store = new QueryStore(
'normal',
new StorageMock(() => {
retryCounter++;
return shouldTrow();
}),
10
);

for (let i = 0; i < 20; i++) {
shouldTrow = () => false;
store.push(`item${i}`);
}

expect(store.items.length).toBe(10);
// keeps the more recent items
expect(store.items[0]).toBe('item10');
expect(store.items[9]).toBe('item19');

// tries to add an item, keeps failing
retryCounter = 0;
shouldTrow = () => true;
store.push(`finalItem`);

expect(store.items.length).toBe(10);
// kept the items
expect(store.items[0]).toBe('item10');
expect(store.items[9]).toBe('item19');
// retried 5 times
expect(retryCounter).toBe(5);
});
});
});

0 comments on commit 7efed6c

Please sign in to comment.