Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Util): add SweptCollection for auto sweeping of caches #6110

Merged
merged 23 commits into from Jul 30, 2021
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ca51111
feat(Util): add SweptCollection for auto sweeping of caches
ckohen Jul 13, 2021
693839d
refactor: remove methods that no longer exist
ckohen Jul 13, 2021
72c0ba4
refactor: from pr review
ckohen Jul 13, 2021
f393e5f
refactor: general cleaniness and better interface
ckohen Jul 14, 2021
79d2a65
fix: typos and better default
ckohen Jul 14, 2021
49d7f4e
refactor: single function for caching with a different collection
ckohen Jul 14, 2021
6116192
types: fix type checks
ckohen Jul 14, 2021
3fd1589
refactor: remove LimitedCollection likes
ckohen Jul 14, 2021
a4c684c
refactor: pr comments and deprecation
ckohen Jul 18, 2021
f8a66b5
types: cleaner typings
ckohen Jul 22, 2021
c07aca5
fix: test for sweepInterval properly
ckohen Jul 22, 2021
b5a718e
refactor: use slice over spread
ckohen Jul 27, 2021
d93f6cd
feat(SweptCollection): clear intervals on client destroy
ckohen Jul 28, 2021
3341d04
feat(Managers): add garbage collection to managers
ckohen Jul 28, 2021
1cd1957
refactor(Collection): better import
ckohen Jul 28, 2021
20d38de
refactor: use symbols and functions instead of hardcoding
ckohen Jul 29, 2021
ccc79c6
refactor: better interface and non-global symbol
ckohen Jul 29, 2021
6061ce6
refactor: rename to be lib wide
ckohen Jul 29, 2021
1f9c5d6
refactor: remove weakref
ckohen Jul 29, 2021
76770f3
fix: typo
ckohen Jul 29, 2021
123a508
revert: revert spread to slice (iterator not array)
ckohen Jul 30, 2021
f0463d9
fix: better success message after gc
ckohen Jul 30, 2021
dfb0f3f
revert: cacheWithLimits to cacheSome
ckohen Jul 30, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions .eslintrc.json
Expand Up @@ -2,10 +2,10 @@
"extends": ["eslint:recommended", "plugin:prettier/recommended"],
"plugins": ["import"],
"parserOptions": {
"ecmaVersion": 2020
"ecmaVersion": 2021
},
"env": {
"es2020": true,
"es2021": true,
"node": true
},
"rules": {
Expand Down
19 changes: 11 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -46,7 +46,7 @@
"homepage": "https://github.com/discordjs/discord.js#readme",
"dependencies": {
"@discordjs/builders": "^0.2.0",
"@discordjs/collection": "^0.1.6",
"@discordjs/collection": "^0.2.0",
"@discordjs/form-data": "^3.0.1",
"@sapphire/async-queue": "^1.1.4",
"@types/ws": "^7.4.5",
Expand Down Expand Up @@ -77,7 +77,7 @@
"typescript": "^4.3.4"
},
"engines": {
"node": ">=14.0.0",
"node": ">=14.6.0",
"npm": ">=7.0.0"
}
}
39 changes: 39 additions & 0 deletions src/client/Client.js
Expand Up @@ -72,6 +72,20 @@ class Client extends BaseClient {

this._validateOptions();

/**
* Functions called when a cache is garbage collected or the Client is destroyed
* @type {Set<Function>}
* @private
*/
this._cleanups = new Set();

/**
* The finalizers used to cleanup items.
* @type {FinalizationRegistry}
* @private
*/
this._finalizers = new FinalizationRegistry(this._finalize.bind(this));

/**
* The WebSocket manager of the client
* @type {WebSocketManager}
Expand Down Expand Up @@ -161,6 +175,10 @@ class Client extends BaseClient {
this.readyAt = null;

if (this.options.messageSweepInterval > 0) {
process.emitWarning(
'The message sweeping client options are deprecated, use the makeCache option with a SweptCollection instead.',
'DeprecationWarning',
);
this.sweepMessageInterval = setInterval(
this.sweepMessages.bind(this),
this.options.messageSweepInterval * 1000,
Expand Down Expand Up @@ -247,6 +265,10 @@ class Client extends BaseClient {
*/
destroy() {
super.destroy();

for (const fn of this._cleanups) fn();
this._cleanups.clear();

if (this.sweepMessageInterval) clearInterval(this.sweepMessageInterval);

this.ws.destroy();
Expand Down Expand Up @@ -346,6 +368,23 @@ class Client extends BaseClient {
const data = await this.api('sticker-packs').get();
return new Collection(data.sticker_packs.map(p => [p.id, new StickerPack(this, p)]));
}
/**
* A last ditch cleanup function for garbage collection.
* @param {Function} options.cleanup The function called to GC
* @param {string} [options.message] The message to send after a successful GC
* @param {string} [options.name] The name of the item being GCed
*/
_finalize({ cleanup, message, name }) {
try {
cleanup();
this._cleanups.delete(cleanup);
if (message) {
this.emit(Events.DEBUG, message);
}
} catch {
this.emit(Events.DEBUG, `Garbage collection failed on ${name ?? 'an unknown item'}.`);
}
}

/**
* Sweeps all text-based channels' messages and removes the ones older than the max message lifetime.
Expand Down
2 changes: 2 additions & 0 deletions src/errors/Messages.js
Expand Up @@ -143,6 +143,8 @@ const Messages = {
INVITE_MISSING_SCOPES: 'At least one valid scope must be provided for the invite',

NOT_IMPLEMENTED: (what, name) => `Method ${what} not implemented on ${name}.`,

SWEEP_FILTER_RETURN: 'The return value of the sweepFilter function was not false or a Function',
};

for (const [name, message] of Object.entries(Messages)) register(name, message);
1 change: 1 addition & 0 deletions src/index.js
Expand Up @@ -27,6 +27,7 @@ module.exports = {
Permissions: require('./util/Permissions'),
RateLimitError: require('./rest/RateLimitError'),
SnowflakeUtil: require('./util/SnowflakeUtil'),
SweptCollection: require('./util/SweptCollection'),
SystemChannelFlags: require('./util/SystemChannelFlags'),
ThreadMemberFlags: require('./util/ThreadMemberFlags'),
UserFlags: require('./util/UserFlags'),
Expand Down
1 change: 1 addition & 0 deletions src/managers/ApplicationCommandPermissionsManager.js
Expand Up @@ -16,6 +16,7 @@ class ApplicationCommandPermissionsManager extends BaseManager {
/**
* The manager or command that this manager belongs to
* @type {ApplicationCommandManager|ApplicationCommand}
* @private
*/
this.manager = manager;

Expand Down
13 changes: 13 additions & 0 deletions src/managers/CachedManager.js
@@ -1,6 +1,7 @@
'use strict';

const DataManager = require('./DataManager');
const { _cleanupSymbol } = require('../util/Constants');

/**
* Manages the API methods of a data model with a mutable cache of instances.
Expand All @@ -13,6 +14,18 @@ class CachedManager extends DataManager {

Object.defineProperty(this, '_cache', { value: this.client.options.makeCache(this.constructor, this.holds) });

let cleanup = this._cache[_cleanupSymbol];
const cacheType = this._cache.constructor.name;
iCrawl marked this conversation as resolved.
Show resolved Hide resolved
if (cleanup) {
cleanup = cleanup.bind(this._cache);
client._cleanups.add(cleanup);
client._finalizers.register(this, {
cleanup,
message: `Garbage Collection completed on ${this.constructor.name}, which held a ${cacheType}.`,
name: this.constructor.name,
});
}

if (iterable) {
for (const item of iterable) {
this._add(item);
Expand Down
4 changes: 2 additions & 2 deletions src/managers/GuildEmojiRoleManager.js
Expand Up @@ -72,7 +72,7 @@ class GuildEmojiRoleManager extends DataManager {
resolvedRoleIds.push(roleId);
}

const newRoles = this.cache.keyArray().filter(id => !resolvedRoleIds.includes(id));
const newRoles = [...this.cache.keys()].filter(id => !resolvedRoleIds.includes(id));
return this.set(newRoles);
}

Expand All @@ -97,7 +97,7 @@ class GuildEmojiRoleManager extends DataManager {

clone() {
const clone = new this.constructor(this.emoji);
clone._patch(this.cache.keyArray().slice());
clone._patch([...this.cache.keys()]);
return clone;
}

Expand Down
2 changes: 1 addition & 1 deletion src/managers/GuildMemberRoleManager.js
Expand Up @@ -172,7 +172,7 @@ class GuildMemberRoleManager extends DataManager {

clone() {
const clone = new this.constructor(this.member);
clone.member._roles = [...this.cache.keyArray()];
clone.member._roles = [...this.cache.keys()];
return clone;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/util/Collection.js
@@ -1,6 +1,6 @@
'use strict';

const BaseCollection = require('@discordjs/collection');
const { Collection: BaseCollection } = require('@discordjs/collection');
const Util = require('./Util');

class Collection extends BaseCollection {
Expand Down
2 changes: 2 additions & 0 deletions src/util/Constants.js
Expand Up @@ -971,6 +971,8 @@ exports.PrivacyLevels = createEnum([null, 'PUBLIC', 'GUILD_ONLY']);
*/
exports.PremiumTiers = createEnum(['NONE', 'TIER_1', 'TIER_2', 'TIER_3']);

exports._cleanupSymbol = Symbol('djsCleanup');

function keyMirror(arr) {
let tmp = Object.create(null);
for (const value of arr) tmp[value] = value;
Expand Down
63 changes: 52 additions & 11 deletions src/util/Options.js
Expand Up @@ -35,10 +35,11 @@
* (e.g. recommended shard count, shard count of the ShardingManager)
* @property {CacheFactory} [makeCache] Function to create a cache.
* You can use your own function, or the {@link Options} class to customize the Collection used for the cache.
* @property {number} [messageCacheLifetime=0] How long a message should stay in the cache until it is considered
* sweepable (in seconds, 0 for forever)
* @property {number} [messageSweepInterval=0] How frequently to remove messages from the cache that are older than
* the message cache lifetime (in seconds, 0 for never)
* @property {number} [messageCacheLifetime=0] DEPRECATED: Use `makeCache` with a `SweptCollection` instead.
* How long a message should stay in the cache until it is considered sweepable (in seconds, 0 for forever)
* @property {number} [messageSweepInterval=0] DEPRECATED: Use `makeCache` with a `SweptCollection` instead.
* How frequently to remove messages from the cache that are older than the message cache lifetime
* (in seconds, 0 for never)
* @property {MessageMentionOptions} [allowedMentions] Default value for {@link MessageOptions#allowedMentions}
* @property {number} [invalidRequestWarningInterval=0] The number of invalid REST requests (those that return
* 401, 403, or 429) in a 10 minute window between emitted warnings (0 for no warnings). That is, if set to 500,
Expand Down Expand Up @@ -99,7 +100,15 @@ class Options extends null {
static createDefault() {
return {
shardCount: 1,
makeCache: this.cacheWithLimits({ MessageManager: 200 }),
makeCache: this.cacheSome({
MessageManager: 200,
ThreadManager: {
sweepFilter: require('./SweptCollection').filterByLifetime({
getComparisonTimestamp: e => e.archiveTimestamp,
excludeFromSweep: e => !e.archived,
}),
},
}),
messageCacheLifetime: 0,
messageSweepInterval: 0,
invalidRequestWarningInterval: 0,
Expand Down Expand Up @@ -134,20 +143,52 @@ class Options extends null {
}

/**
* Create a cache factory using predefined limits.
* @param {Record<string, number>} [limits={}] Limits for structures.
* Create a cache factory using predefined settings to sweep or limit.
* @param {Object<string, SweptCollectionOptions|number>} [settings={}] Settings passed to the relevant constructor.
* If no setting is provided for a manager, it uses Collection.
* If SweptCollectionOptions are provided for a manager, it uses those settings to form a SweptCollection
* If a number is provided for a manager, it uses that number as the max size for a LimitedCollection
* @returns {CacheFactory}
* @example
* // Store up to 200 messages per channel and discard archived threads if they were archived more than 4 hours ago.
* Options.cacheSome({
* MessageManager: 200,
* ThreadManager: {
* sweepFilter: SweptCollection.filterByLifetime({
* getComparisonTimestamp: e => e.archiveTimestamp,
* excludeFromSweep: e => !e.archived,
* }),
* },
* });
* @example
* // Sweep messages every 5 minutes, removing messages that have not been edited or created in the last 30 minutes
* Options.cacheSome({
* MessageManager: {
* sweepInterval: 300,
* sweepFilter: SweptCollection.filterByLifetime({
* lifetime: 1800,
* getComparisonTimestamp: e => e.editedTimestamp ?? e.createdTimestamp,
* })
* }
* });
*/
static cacheWithLimits(limits = {}) {
static cacheSome(settings = {}) {
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved
const Collection = require('./Collection');
const LimitedCollection = require('./LimitedCollection');
const SweptCollection = require('./SweptCollection');

return manager => {
const limit = limits[manager.name];
if (limit === null || limit === undefined || limit === Infinity) {
const setting = settings[manager.name];
if (typeof setting === 'number' && setting !== Infinity) return new LimitedCollection(setting);
if (
/* eslint-disable-next-line eqeqeq */
(setting?.sweepInterval == null && setting?.sweepFilter == null) ||
setting.sweepInterval <= 0 ||
setting.sweepInterval === Infinity
) {
return new Collection();
}
return new LimitedCollection(limit);
return new SweptCollection(setting);
};
}

Expand Down