From d4e08df14a8a9cf4f148609a7a5a74e1244af012 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:58:10 -0400 Subject: [PATCH 1/2] fix: saving a role clears the entire cache instead of only the cached roles --- spec/CacheController.spec.js | 33 ++++++++++++++++++++ spec/InMemoryCacheAdapter.spec.js | 14 +++++++++ spec/RedisCacheAdapter.spec.js | 36 ++++++++++++++++++++++ src/Adapters/Cache/CacheAdapter.js | 7 ++++- src/Adapters/Cache/InMemoryCacheAdapter.js | 4 +-- src/Adapters/Cache/LRUCache.js | 14 +++++++-- src/Adapters/Cache/RedisCacheAdapter.js | 32 +++++++++++++++++-- src/Controllers/CacheController.js | 18 +++++++++-- 8 files changed, 147 insertions(+), 11 deletions(-) diff --git a/spec/CacheController.spec.js b/spec/CacheController.spec.js index de07126214..38bb23cfb1 100644 --- a/spec/CacheController.spec.js +++ b/spec/CacheController.spec.js @@ -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(); diff --git a/spec/InMemoryCacheAdapter.spec.js b/spec/InMemoryCacheAdapter.spec.js index add976fbc9..48dd4c4167 100644 --- a/spec/InMemoryCacheAdapter.spec.js +++ b/spec/InMemoryCacheAdapter.spec.js @@ -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, diff --git a/spec/RedisCacheAdapter.spec.js b/spec/RedisCacheAdapter.spec.js index 9b88e857c4..b5cc0cc726 100644 --- a/spec/RedisCacheAdapter.spec.js +++ b/spec/RedisCacheAdapter.spec.js @@ -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) diff --git a/src/Adapters/Cache/CacheAdapter.js b/src/Adapters/Cache/CacheAdapter.js index 9a84f89ec6..00f92241d1 100644 --- a/src/Adapters/Cache/CacheAdapter.js +++ b/src/Adapters/Cache/CacheAdapter.js @@ -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 `:*`. 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) {} } diff --git a/src/Adapters/Cache/InMemoryCacheAdapter.js b/src/Adapters/Cache/InMemoryCacheAdapter.js index e8036c51da..dfe53b3913 100644 --- a/src/Adapters/Cache/InMemoryCacheAdapter.js +++ b/src/Adapters/Cache/InMemoryCacheAdapter.js @@ -23,8 +23,8 @@ export class InMemoryCacheAdapter { return Promise.resolve(); } - clear() { - this.cache.clear(); + clear(prefix) { + this.cache.clear(prefix); return Promise.resolve(); } } diff --git a/src/Adapters/Cache/LRUCache.js b/src/Adapters/Cache/LRUCache.js index 129a006376..589efcc75f 100644 --- a/src/Adapters/Cache/LRUCache.js +++ b/src/Adapters/Cache/LRUCache.js @@ -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); + } + } } } diff --git a/src/Adapters/Cache/RedisCacheAdapter.js b/src/Adapters/Cache/RedisCacheAdapter.js index 7acab7fecf..5d8fc5ce95 100644 --- a/src/Adapters/Cache/RedisCacheAdapter.js +++ b/src/Adapters/Cache/RedisCacheAdapter.js @@ -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)); @@ -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 + * `:*` 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 diff --git a/src/Controllers/CacheController.js b/src/Controllers/CacheController.js index 0c645c5236..1556272509 100644 --- a/src/Controllers/CacheController.js +++ b/src/Controllers/CacheController.js @@ -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() { - return this.cache.clear(); + return this.cache.clear(this.prefix); } } @@ -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() { From 607bb56bb596cf8ab12e7b5e4558632c2a752ce0 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:51:08 -0400 Subject: [PATCH 2/2] Adjust comments per copilot --- src/Controllers/CacheController.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Controllers/CacheController.js b/src/Controllers/CacheController.js index 1556272509..083631951a 100644 --- a/src/Controllers/CacheController.js +++ b/src/Controllers/CacheController.js @@ -35,8 +35,11 @@ export class SubCache { } /** - * Empty this sub-cache, leaving keys owned by other sub-caches, other Parse - * apps, and other consumers of the same cache backend untouched. + * 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(this.prefix); @@ -68,8 +71,10 @@ export class CacheController extends AdaptableController { } /** - * Empty this app's cache. Keys belonging to other Parse apps sharing the - * same cache backend are left untouched. + * 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`.