Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
33 changes: 33 additions & 0 deletions spec/CacheController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,39 @@ describe('CacheController', function () {
expect(FakeCacheAdapter.clear.calls.count()).toEqual(3);
});

it('should scope clear to the app', () => {
const cache = new CacheController(FakeCacheAdapter, FakeAppID);

cache.clear();
expect(FakeCacheAdapter.clear.calls.first().args[0]).toEqual(FakeAppID);
});

['role', 'user', 'graphQL'].forEach(cacheName => {
it('should scope clear of the ' + cacheName + ' cache to its prefix', () => {
const cache = new CacheController(FakeCacheAdapter, FakeAppID);

cache[cacheName].clear();
expect(FakeCacheAdapter.clear.calls.first().args[0]).toEqual(
[FakeAppID, cacheName].join(':')
);
});
});

it('should not evict cached users when a _Role is saved', async () => {
const cacheController = Parse.Server.cacheController;
await cacheController.user.put('r:someSessionToken', { objectId: 'someUser' });
await cacheController.role.put('someUser', ['role:Admin']);

await new Parse.Role('Admin', new Parse.ACL()).save(null, { useMasterKey: true });
// The role cache is cleared without being awaited by RestWrite.
await new Promise(resolve => setTimeout(resolve, 200));

expect(await cacheController.role.get('someUser')).toEqual(null);
expect(await cacheController.user.get('r:someSessionToken')).toEqual({
objectId: 'someUser',
});
});

it('should handle cache rejections', done => {
FakeCacheAdapter.get = () => Promise.reject();

Expand Down
14 changes: 14 additions & 0 deletions spec/InMemoryCacheAdapter.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ describe('InMemoryCacheAdapter', function () {
.then(done);
});

it('should only clear the given prefix', async () => {
const cache = new InMemoryCacheAdapter({ ttl: NaN });

await cache.put('myAppId:role:someUser', VALUE);
await cache.put('myAppId:user:someToken', VALUE);
await cache.put('otherAppId:role:someUser', VALUE);

await cache.clear('myAppId:role');

expect(await cache.get('myAppId:role:someUser')).toEqual(null);
expect(await cache.get('myAppId:user:someToken')).toEqual(VALUE);
expect(await cache.get('otherAppId:role:someUser')).toEqual(VALUE);
});

it('should expire after ttl', done => {
const cache = new InMemoryCacheAdapter({
ttl: 10,
Expand Down
36 changes: 36 additions & 0 deletions spec/RedisCacheAdapter.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,42 @@ describe_only(() => {
await cacheNaN.clear();
});

it('should only clear the given prefix', async () => {
const scoped = new RedisCacheAdapter(null, 5000);
await scoped.connect();

await scoped.put('myAppId:role:someUser', VALUE);
await scoped.put('myAppId:user:someToken', VALUE);
await scoped.put('otherAppId:role:someUser', VALUE);
// A key owned by an unrelated consumer of the same Redis database.
await scoped.put('queue:default', VALUE);

await scoped.clear('myAppId:role');

expect(await scoped.get('myAppId:role:someUser')).toEqual(null);
expect(await scoped.get('myAppId:user:someToken')).toEqual(VALUE);
expect(await scoped.get('otherAppId:role:someUser')).toEqual(VALUE);
expect(await scoped.get('queue:default')).toEqual(VALUE);

await scoped.clear();
expect(await scoped.get('queue:default')).toEqual(null);
});

it('should not treat glob characters in the prefix as wildcards', async () => {
const scoped = new RedisCacheAdapter(null, 5000);
await scoped.connect();

await scoped.put('a*:someKey', VALUE);
await scoped.put('ab:someKey', VALUE);

await scoped.clear('a*');

expect(await scoped.get('a*:someKey')).toEqual(null);
expect(await scoped.get('ab:someKey')).toEqual(VALUE);

await scoped.clear();
});

it('should expire after ttl', done => {
cache
.put(KEY, VALUE)
Expand Down
7 changes: 6 additions & 1 deletion src/Adapters/Cache/CacheAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export class CacheAdapter {

/**
* Empty a cache
* @param {String} prefix Optional key prefix limiting the scope of the
* operation to keys of the form `<prefix>:*`. When omitted, the whole cache
* is emptied. Implementing scoped clearing is optional: an adapter that
* ignores this parameter empties the whole cache, which remains correct as
* long as the adapter is the sole owner of its storage.
*/
clear() {}
clear(prefix) {}
}
4 changes: 2 additions & 2 deletions src/Adapters/Cache/InMemoryCacheAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export class InMemoryCacheAdapter {
return Promise.resolve();
}

clear() {
this.cache.clear();
clear(prefix) {
this.cache.clear(prefix);
return Promise.resolve();
}
}
Expand Down
14 changes: 12 additions & 2 deletions src/Adapters/Cache/LRUCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,18 @@ export class LRUCache {
this.cache.delete(key);
}

clear() {
this.cache.clear();
clear(prefix) {
if (prefix == null) {
this.cache.clear();
return;
}
const scope = `${prefix}:`;
// Materialize the keys first, deleting while iterating the LRU is unsafe.
for (const key of [...this.cache.keys()]) {
if (typeof key === 'string' && key.startsWith(scope)) {
this.cache.delete(key);
}
}
}
}

Expand Down
32 changes: 29 additions & 3 deletions src/Adapters/Cache/RedisCacheAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import { KeyPromiseQueue } from '../../KeyPromiseQueue';

const DEFAULT_REDIS_TTL = 30 * 1000; // 30 seconds in milliseconds
const FLUSH_DB_KEY = '__flush_db__';
// Number of keys SCAN is asked to examine per iteration when clearing a scope.
const SCAN_COUNT = 100;
// Characters that carry meaning in a Redis glob pattern and therefore have to
// be escaped before a caller-supplied prefix is used as a SCAN MATCH pattern.
const GLOB_SPECIAL_CHARS = /[?*[\]^\\]/g;

function escapeGlob(value) {
return String(value).replace(GLOB_SPECIAL_CHARS, char => `\\${char}`);
}

function debug(...args: any) {
const message = ['RedisCacheAdapter: ' + arguments[0]].concat(args.slice(1, args.length));
Expand Down Expand Up @@ -80,10 +89,27 @@ export class RedisCacheAdapter {
return this.client.del(key);
}

async clear() {
debug('clear');
/**
* Empty the cache. When a `prefix` is given, only keys of the form
* `<prefix>:*` are removed, using SCAN and UNLINK so that keys belonging to
* other Parse apps or to other consumers of the same Redis database survive.
* Without a `prefix` the whole database is flushed.
*/
async clear(prefix) {
debug('clear', { prefix });
await this.queue.enqueue(FLUSH_DB_KEY);
return this.client.sendCommand(['FLUSHDB']);
if (prefix == null) {
return this.client.sendCommand(['FLUSHDB']);
}
const match = `${escapeGlob(prefix)}:*`;
let cursor = '0';
do {
const reply = await this.client.scan(cursor, { MATCH: match, COUNT: SCAN_COUNT });
cursor = String(reply.cursor);
if (reply.keys.length) {
await this.client.unlink(reply.keys);
}
} while (cursor !== '0');
}

// Used for testing
Expand Down
18 changes: 15 additions & 3 deletions src/Controllers/CacheController.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ export class SubCache {
return this.cache.del(cacheKey);
}

/**
* Empty this sub-cache, leaving keys owned by other sub-caches, other Parse
* apps, and other consumers of the same cache backend untouched.
*/
clear() {
Comment on lines +37 to 44
return this.cache.clear();
return this.cache.clear(this.prefix);
}
}

Expand Down Expand Up @@ -63,8 +67,16 @@ export class CacheController extends AdaptableController {
return this.adapter.del(cacheKey);
}

clear() {
return this.adapter.clear();
/**
* Empty this app's cache. Keys belonging to other Parse apps sharing the
* same cache backend are left untouched.
*
* @param {String} prefix Optional sub-cache prefix to narrow the scope
* further, for example `role`.
*/
clear(prefix) {
const scope = prefix == null ? this.appId : joinKeys(this.appId, prefix);
return this.adapter.clear(scope);
}

expectedAdapterType() {
Expand Down