Skip to content
Open
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
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
23 changes: 20 additions & 3 deletions src/Controllers/CacheController.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@ export class SubCache {
return this.cache.del(cacheKey);
}

/**
* Empty this sub-cache by asking the adapter to clear only this sub-cache's
* key scope. Adapters that implement scoped clearing, which includes the
* built-in Redis and in-memory adapters, leave keys owned by other
* sub-caches, other Parse apps, and other consumers of the same backend
* untouched. An adapter that ignores the prefix empties the whole cache.
*/
clear() {
return this.cache.clear();
return this.cache.clear(this.prefix);
}
}

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

clear() {
return this.adapter.clear();
/**
* Empty this app's cache by asking the adapter to clear only this app's key
* scope. Adapters that implement scoped clearing leave keys belonging to
* other Parse apps sharing the same backend untouched. An adapter that
* ignores the prefix empties the whole cache.
*
* @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