feat: 🎸 Update how we store tokens in indexed-db (#2223)

Added logic to make the list tokens stored in indexed DB more granular.
I get a hash of the query parameters that will get sent to the controller
and use that as the id for the token. This also means extra logic for when
a token is invalid as we will need to delete all tokens of the same
resource type.
This commit is contained in:
Zhihe Li committed 2024-04-12 14:55:34 -04:00
1 parent 6c59baa413
commit 5d32d987bc
13 files changed
+121 -33

No files matched your search

+1 -1
View File
@@ -283,7 +283,7 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
const baseError = {
isInvalid: true,
status,
code: payload.code,
code: payload.kind,
detail: payload.message,
source: { pointer: '/data' },
};
+31 -23
View File
@@ -3,6 +3,7 @@ import { inject as service } from '@ember/service';
import { getOwner, setOwner } from '@ember/application';
import { queryIndexedDb } from '../utils/indexed-db-query';
import { paginateResults } from '../utils/paginate-results';
import { hashCode } from '../utils/hash-code';
/**
* Handler to sit in front of the API request layer
@@ -34,9 +35,10 @@ export default class IndexedDbHandler {
}
let { page, pageSize, query: queryObj, ...remainingQuery } = query;
const tokenKey = `${type}-${hashCode(remainingQuery)}`;
if (!peekIndexedDB) {
const listToken = await indexedDb.token.get(type);
const listToken = await indexedDb.token.get(tokenKey);
const adapter = store.adapterFor(type);
const schema = store.modelFor(type);
@@ -44,35 +46,41 @@ export default class IndexedDbHandler {
try {
payload = await adapter.query(store, schema, {
...remainingQuery,
scope_id: 'global',
recursive: true,
list_token: listToken?.token,
});
} catch (e) {
// If we get an invalid list token, we'll delete the token and
// clear the DB and try again without a token.
if (
e?.errors[0].status === 400 &&
e?.errors[0].code === 'invalid list token'
) {
await indexedDb[type].clear();
// Delete all tokens of the same resource type so we don't keep clearing the DB.
// Even if other tokens may still be valid, we might have cleared data that they
// depended on so we should clear all tokens of the same type when clearing the DB.
const tokenKeys = await indexedDb.token
.where(':id')
.startsWith(type)
.primaryKeys();
await indexedDb.token.bulkDelete(tokenKeys);
payload = await adapter.query(store, schema, {
...remainingQuery,
});
} catch (e) {
// If we get any error that's not a rate limiting error,
// assume the token is not good anymore. We'll delete the token and
// clear the DB and try again without a token.
// TODO: Probably should check for 400 and bad token error specifically instead?
if (e?.errors[0].status !== 429) {
await indexedDb[type].clear();
await indexedDb.token.delete(type);
payload = await adapter.query(store, schema, {
...remainingQuery,
scope_id: 'global',
recursive: true,
list_token: null,
});
} else {
throw e;
}
} else {
throw e;
}
}
// Store the token we just got back from the payload
// Store the token we just got back from the payload if it exists
if (payload.list_token) {
await indexedDb.token.put({
id: type,
id: tokenKey,
token: payload.list_token,
});
}
// Remove any records from the DB if the API indicates they've been deleted
if (payload.removed_ids?.length > 0) {
+24
View File
@@ -0,0 +1,24 @@
/**
* Takes an object and gets the JSON stringified version of it to be converted
* to a hash code. Taken from https://stackoverflow.com/a/8076436
* @param obj
*/
export const hashCode = (obj) => {
if (!obj) {
return 0;
}
// Use a replacer function to sort the keys of the object so we get the same hashcode
// regardless of the order of the keys.
const jsonString = JSON.stringify(obj, Object.keys(obj).sort());
let hash = 0;
for (let i = 0; i < jsonString.length; i++) {
let code = jsonString.charCodeAt(i);
hash = (hash << 5) - hash + code;
hash = hash & hash; // Convert to 32bit integer
}
// Make sure the hash is positive since we're using it as an ID
return Math.abs(hash);
};
@@ -0,0 +1,36 @@
import { hashCode } from 'api/utils/hash-code';
import { module, test } from 'qunit';
module('Unit | Utility | hash-code', function () {
test('it hashes', function (assert) {
const result = hashCode({ a: 1, b: 2 });
assert.ok(result);
});
test('it hashes to same result regardless of object order', function (assert) {
const first = hashCode({ a: 1, b: 2, c: 3 });
const second = hashCode({ b: 2, c: 3, a: 1 });
assert.strictEqual(first, second);
});
test('it hashes to different result if objects are different', function (assert) {
const first = hashCode({ a: 1, b: 2, c: 3 });
const second = hashCode({ a: '1', b: '2', c: '3' });
assert.notStrictEqual(first, second);
});
test('it hashes an empty object', function (assert) {
const result = hashCode({});
assert.ok(result);
});
test('it hashes null to 0', function (assert) {
const result = hashCode(null);
assert.strictEqual(result, 0);
});
test('it hashes undefined to 0', function (assert) {
const result = hashCode(undefined);
assert.strictEqual(result, 0);
});
});
@@ -58,6 +58,7 @@ export default class ScopesScopeCredentialStoresIndexRoute extends Route {
})
) {
credentialStores = await this.store.query('credential-store', {
scope_id,
query: { search, filters },
page,
pageSize,
@@ -86,6 +87,7 @@ export default class ScopesScopeCredentialStoresIndexRoute extends Route {
const credentialStore = await this.store.query(
'credential-store',
{
scope_id,
query: { filters: { scope_id: [{ equals: scope_id }] } },
page: 1,
pageSize: 1,
@@ -43,6 +43,7 @@ export default class ScopesScopeGroupsIndexRoute extends Route {
if (this.can.can('list model', scope, { collection: 'groups' })) {
groups = await this.store.query('group', {
scope_id,
query: { filters, search },
page,
pageSize,
@@ -67,6 +68,7 @@ export default class ScopesScopeGroupsIndexRoute extends Route {
const group = await this.store.query(
'group',
{
scope_id: scope_id,
query: {
filters: {
scope_id: [{ equals: scope_id }],
@@ -48,6 +48,7 @@ export default class ScopesScopeHostCatalogsIndexRoute extends Route {
let hostCatalogsExist = false;
if (this.can.can('list model', scope, { collection: 'host-catalogs' })) {
hostCatalogs = await this.store.query('host-catalog', {
scope_id,
query: { search, filters },
page,
pageSize,
@@ -74,6 +75,7 @@ export default class ScopesScopeHostCatalogsIndexRoute extends Route {
const hostCatalogs = await this.store.query(
'host-catalog',
{
scope_id,
query: { filters: { scope_id: [{ equals: scope_id }] } },
page: 1,
pageSize: 1,
@@ -45,6 +45,7 @@ export default class ScopesScopeRolesIndexRoute extends Route {
let rolesExist = false;
if (this.can.can('list model', scope, { collection: 'roles' })) {
roles = await this.store.query('role', {
scope_id,
query: { search, filters },
page,
pageSize,
@@ -70,6 +71,7 @@ export default class ScopesScopeRolesIndexRoute extends Route {
const role = await this.store.query(
'role',
{
scope_id,
query: {
filters: {
scope_id: [{ equals: scope_id }],
@@ -67,6 +67,7 @@ export default class ScopesScopeSessionsIndexRoute extends Route {
});
const queryOptions = {
scope_id,
include_terminated: true,
query: { search, filters },
page,
@@ -84,7 +85,7 @@ export default class ScopesScopeSessionsIndexRoute extends Route {
await this.getAssociatedUsers();
}
if (!this.associatedTargets) {
await this.getAssociatedTargets();
await this.getAssociatedTargets(scope_id);
}
return {
@@ -103,6 +104,7 @@ export default class ScopesScopeSessionsIndexRoute extends Route {
*/
async getAllSessions(scope_id) {
const allSessionsQuery = {
scope_id,
include_terminated: true,
query: { filters: { scope_id: [{ equals: scope_id }] } },
};
@@ -128,6 +130,8 @@ export default class ScopesScopeSessionsIndexRoute extends Route {
filters.id.values.push({ equals: userId });
});
const associatedUsersQuery = {
scope_id: 'global',
recursive: true,
query: { filters },
};
this.associatedUsers = await this.store.query('user', associatedUsersQuery);
@@ -135,9 +139,10 @@ export default class ScopesScopeSessionsIndexRoute extends Route {
/**
* Get all the targets but only load them once when entering the route.
* @param scope_id
* @returns {Promise<void>}
*/
async getAssociatedTargets() {
async getAssociatedTargets(scope_id) {
const uniqueSessionTargetIds = new Set(
this.allSessions
.filter((session) => session.target_id)
@@ -148,6 +153,7 @@ export default class ScopesScopeSessionsIndexRoute extends Route {
filters.id.values.push({ equals: targetId });
});
const associatedTargetsQuery = {
scope_id,
query: { filters },
};
this.associatedTargets = await this.store.query(
@@ -61,6 +61,7 @@ export default class ScopesScopeTargetsIndexRoute extends Route {
});
const sessions = await this.store.query('session', {
scope_id,
query: {
filters: {
scope_id: [{ equals: scope_id }],
@@ -78,6 +79,7 @@ export default class ScopesScopeTargetsIndexRoute extends Route {
let targetsExist = false;
if (this.can.can('list model', scope, { collection: 'targets' })) {
targets = await this.store.query('target', {
scope_id,
query: { search, filters },
page,
pageSize,
@@ -46,6 +46,7 @@ export default class ScopesScopeUsersIndexRoute extends Route {
let usersExist = false;
if (this.can.can('list model', scope, { collection: 'users' })) {
users = await this.store.query('user', {
scope_id,
query: { search, filters },
page,
pageSize,
@@ -59,11 +60,11 @@ export default class ScopesScopeUsersIndexRoute extends Route {
/**
* Sets usersExist to true if there exists any users.
* @param {string} scopeId
* @param {string} scope_id
* @param {number} totalItems
* @returns
*/
async getUsersExist(scopeId, totalItems) {
async getUsersExist(scope_id, totalItems) {
if (totalItems > 0) {
return true;
}
@@ -71,9 +72,10 @@ export default class ScopesScopeUsersIndexRoute extends Route {
const user = await this.store.query(
'user',
{
scope_id,
query: {
filters: {
scope_id: [{ equals: scopeId }],
scope_id: [{ equals: scope_id }],
},
},
page: 1,
@@ -4,7 +4,6 @@
}}
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<Breadcrumbs::Container />
</page.breadcrumbs>
@@ -22,9 +21,10 @@
<page.actions>
{{#if (can 'create model' this.scope collection='targets')}}
{{#if @model.targetsExist}}
<Rose::LinkButton @route='scopes.scope.targets.new' @style='primary'>{{t
'resources.target.titles.new'
}}</Rose::LinkButton>
<Hds::Button
@route='scopes.scope.targets.new'
@text={{t 'resources.target.titles.new'}}
/>
{{/if}}
{{/if}}
</page.actions>
@@ -22,10 +22,12 @@ import {
//invalidateSession,
} from 'ember-simple-auth/test-support';
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
module('Acceptance | targets | create', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
setupIndexedDb(hooks);
let getTargetCount;
let getTCPTargetCount;