Use more robust caching implementation

* use node-cache-manager so operator has a choice of memory or redis
* BC update TTL values to be in seconds instead of milliseconds
* Count requests and misses for cache
* display cache stats in ui
This commit is contained in:
FoxxMD
2021-07-22 17:47:19 -04:00
parent 73c3052c69
commit a91b9ab146
9 changed files with 274 additions and 73 deletions

39
package-lock.json generated
View File

@@ -49,6 +49,7 @@
"@tsconfig/node14": "^1.0.0",
"@types/async": "^3.2.7",
"@types/cache-manager": "^3.4.2",
"@types/cache-manager-redis-store": "^2.0.0",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.4",
"@types/express-socket.io-session": "^1.3.6",
@@ -194,6 +195,16 @@
"integrity": "sha512-1IwA74t5ID4KWo0Kndal16MhiPSZgMe1fGc+MLT6j5r+Ab7jku36PFTl4PP6MiWw0BJscM9QpZEo00qixNQoRg==",
"dev": true
},
"node_modules/@types/cache-manager-redis-store": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/cache-manager-redis-store/-/cache-manager-redis-store-2.0.0.tgz",
"integrity": "sha512-6svzdqXFcjsBPtTjkrLinlcB/XsIY4kkVh7KgFYjJHqw0usg2nALXN9YNe7q2r7RErymoCAvnnlNqjwTd3k/sQ==",
"dev": true,
"dependencies": {
"@types/cache-manager": "*",
"@types/redis": "*"
}
},
"node_modules/@types/command-line-args": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.0.0.tgz",
@@ -395,6 +406,15 @@
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
"dev": true
},
"node_modules/@types/redis": {
"version": "2.8.31",
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.31.tgz",
"integrity": "sha512-daWrrTDYaa5iSDFbgzZ9gOOzyp2AJmYK59OlG/2KGBgYWF3lfs8GDKm1c//tik5Uc93hDD36O+qLPvzDolChbA==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.13.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz",
@@ -3652,6 +3672,16 @@
"integrity": "sha512-1IwA74t5ID4KWo0Kndal16MhiPSZgMe1fGc+MLT6j5r+Ab7jku36PFTl4PP6MiWw0BJscM9QpZEo00qixNQoRg==",
"dev": true
},
"@types/cache-manager-redis-store": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/cache-manager-redis-store/-/cache-manager-redis-store-2.0.0.tgz",
"integrity": "sha512-6svzdqXFcjsBPtTjkrLinlcB/XsIY4kkVh7KgFYjJHqw0usg2nALXN9YNe7q2r7RErymoCAvnnlNqjwTd3k/sQ==",
"dev": true,
"requires": {
"@types/cache-manager": "*",
"@types/redis": "*"
}
},
"@types/command-line-args": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.0.0.tgz",
@@ -3852,6 +3882,15 @@
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
"dev": true
},
"@types/redis": {
"version": "2.8.31",
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.31.tgz",
"integrity": "sha512-daWrrTDYaa5iSDFbgzZ9gOOzyp2AJmYK59OlG/2KGBgYWF3lfs8GDKm1c//tik5Uc93hDD36O+qLPvzDolChbA==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/serve-static": {
"version": "1.13.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz",

View File

@@ -66,6 +66,7 @@
"@tsconfig/node14": "^1.0.0",
"@types/async": "^3.2.7",
"@types/cache-manager": "^3.4.2",
"@types/cache-manager-redis-store": "^2.0.0",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.4",
"@types/express-socket.io-session": "^1.3.6",

View File

@@ -109,8 +109,7 @@ export class App {
}
} = config;
CacheManager.authorTTL = argParseInt(authorTTL);
CacheManager.enabled = store !== 'none';
CacheManager.setDefaultsFromConfig(config);
this.dryRun = parseBool(dryRun) === true ? true : undefined;
this.heartbeatInterval = heartbeatInterval;

View File

@@ -379,26 +379,28 @@ export interface PollingOptions extends PollingDefaults {
export interface SubredditCacheConfig {
/**
* Amount of time, in milliseconds, author activities (Comments/Submission) should be cached
* @examples [10000]
* @default 10000
* Amount of time, in seconds, author activities (Comments/Submission) should be cached
* @examples [60]
* @default 60
* */
authorTTL?: number;
/**
* Amount of time, in milliseconds, wiki content pages should be cached
* @examples [300000]
* @default 300000
* Amount of time, in seconds, wiki content pages should be cached
* @examples [300]
* @default 300
* */
wikiTTL?: number;
/**
* Amount of time, in milliseconds, [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) should be cached
* @examples [60000]
* @default 60000
* @examples [300]
* @default 300
* */
userNotesTTL?: number;
}
export type StrongSubredditCacheConfig = Required<SubredditCacheConfig>;
export interface Footer {
/**
* Customize the footer for Actions that send replies (Comment/Ban)
@@ -459,7 +461,7 @@ export interface ManagerOptions {
/**
* Per-subreddit config for caching TTL values. If set to `false` caching is disabled.
* */
caching?: false | SubredditCacheConfig
caching?: SubredditCacheConfig
/**
* Use this option to override the `dryRun` setting for all `Checks`
@@ -766,3 +768,12 @@ export interface OperatorConfig extends OperatorJsonConfig {
}
//export type OperatorConfig = Required<OperatorJsonConfig>;
interface CacheTypeStat {
requests: number,
miss: number,
}
export interface ResourceStats {
[key: string]: CacheTypeStat
}

View File

@@ -251,6 +251,7 @@ export const parseOpConfigFromArgs = (args: any): OperatorJsonConfig => {
clientSecret,
accessToken,
refreshToken,
redirectUri,
wikiConfig,
dryRun,
heartbeat,
@@ -278,7 +279,8 @@ export const parseOpConfigFromArgs = (args: any): OperatorJsonConfig => {
clientId,
clientSecret,
accessToken,
refreshToken
refreshToken,
redirectUri,
},
subreddits: {
names: subreddits,
@@ -342,6 +344,7 @@ export const parseOpConfigFromEnv = (): OperatorJsonConfig => {
clientSecret: process.env.CLIENT_SECRET,
accessToken: process.env.ACCESS_TOKEN,
refreshToken: process.env.REFRESH_TOKEN,
redirectUri: process.env.REDIRECT_URI,
},
subreddits: {
names: subs,
@@ -469,7 +472,7 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
} = {},
snoowrap = {},
web: {
port = 5058,
port = 8085,
sessionSecret,
maxLogs = 200,
} = {},
@@ -486,7 +489,7 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
} = data;
const cacheOptDefaults = {ttl: 60, max: 500};
const cacheDefaults = {authorTTL: 60000, userNotesTTL: 60000, wikiTTL: 300000};
const cacheDefaults = {authorTTL: 60, userNotesTTL: 300, wikiTTL: 300};
let cache = {
...cacheDefaults,

View File

@@ -27,7 +27,7 @@ import {
import {Manager} from "../Subreddit/Manager";
import {getLogger} from "../Utils/loggerFactory";
import LoggedError from "../Utils/LoggedError";
import {OperatorConfig, RUNNING, STOPPED, SYSTEM, USER} from "../Common/interfaces";
import {OperatorConfig, ResourceStats, RUNNING, STOPPED, SYSTEM, USER} from "../Common/interfaces";
const MemoryStore = createMemoryStore(session);
const app = addAsync(express());
@@ -163,7 +163,7 @@ const rcbServer = async function (options: OperatorConfig) {
});
// @ts-ignore
if (operator !== undefined && socket.handshake.session.user.toLowerCase() === operator.toLowerCase()) {
if (name !== undefined && socket.handshake.session.user.toLowerCase() === name.toLowerCase()) {
// @ts-ignore
operatorSessionId = socket.handshake.session.id;
}
@@ -249,7 +249,7 @@ const rcbServer = async function (options: OperatorConfig) {
req.session['user'] = user;
// @ts-ignore
req.session['subreddits'] = operator !== undefined && operator.toLowerCase() === user.toLowerCase() ? bot.subManagers.map(x => x.displayLabel) : subs.reduce((acc: string[], x) => {
req.session['subreddits'] = name !== undefined && name.toLowerCase() === user.toLowerCase() ? bot.subManagers.map(x => x.displayLabel) : subs.reduce((acc: string[], x) => {
const sm = bot.subManagers.find(y => y.subreddit.display_name === x.display_name);
if (sm !== undefined) {
return acc.concat(sm.displayLabel);
@@ -277,8 +277,14 @@ const rcbServer = async function (options: OperatorConfig) {
return res.render('noSubs', {operatorDisplay: display});
}
// @ts-ignore
const logs = filterLogBySubreddit(subLogMap, req.session.subreddits, {level, operator, user, sort, limit});
const logs = filterLogBySubreddit(subLogMap, req.session.subreddits, {
level,
operator: isOperator,
user,
// @ts-ignore
sort,
limit
});
const subManagerData = [];
for (const s of subreddits) {
const m = bot.subManagers.find(x => x.displayLabel === s) as Manager;
@@ -309,6 +315,18 @@ const rcbServer = async function (options: OperatorConfig) {
startedAt: 'Not Started',
startedAtHuman: 'Not Started',
delayBy: m.delayBy === undefined ? 'No' : `Delayed by ${m.delayBy} sec`,
cache: {
//currentKeyCount: await m.resources.getCacheKeyCount(),
totalRequests: Object.values(m.resources.stats.cache).reduce((acc, curr) => acc + curr.requests, 0),
types: {
...Object.keys(m.resources.stats.cache).reduce((acc, curr) => {
const per = acc[curr].miss === 0 ? 0 : formatNumber(acc[curr].miss / acc[curr].requests) * 100;
// @ts-ignore
acc[curr].missPercent = `${per}%`;
return acc;
}, m.resources.stats.cache),
},
}
};
// TODO replace indicator data with js on client page
let indicator;
@@ -355,6 +373,25 @@ const rcbServer = async function (options: OperatorConfig) {
});
const {checks, ...rest} = totalStats;
const resCum: ResourceStats = {
author: {requests: 0, miss: 0},
authorCrit: {requests: 0, miss: 0},
content: {requests: 0, miss: 0}
};
let cumRaw = subManagerData.reduce((acc, curr) => {
Object.keys(curr.cache.types as ResourceStats).forEach((k) => {
acc[k].requests += curr.cache.types[k].requests;
acc[k].miss += curr.cache.types[k].miss;
});
return acc;
}, resCum);
cumRaw = Object.keys(cumRaw).reduce((acc, curr) => {
const per = acc[curr].miss === 0 ? 0 : formatNumber(acc[curr].miss / acc[curr].requests) * 100;
// @ts-ignore
acc[curr].missPercent = `${per}%`;
return acc;
}, cumRaw);
let allManagerData: any = {
name: 'All',
botState: {
@@ -367,6 +404,14 @@ const rcbServer = async function (options: OperatorConfig) {
softLimit: bot.softLimit,
hardLimit: bot.hardLimit,
stats: rest,
cache: {
// naive
currentKeyCount: await bot.subManagers[0].resources.getCacheKeyCount(),
totalRequests: subManagerData.reduce((acc, curr) => acc + curr.cache.totalRequests, 0),
types: {
...cumRaw
}
}
};
if (allManagerData.logs === undefined) {
// this should happen but saw an edge case where potentially did

View File

@@ -295,6 +295,14 @@
<label>Actions</label>
<span><%= data.stats.actionsRunTotal %> Run</span>
<% if (data.name === 'All') { %>
<label>Cached Keys</label>
<span><%= data.cache.currentKeyCount %></span>
<% } %>
<label>Total Cache Reqs</label>
<span><%= data.cache.totalRequests %></span>
<label>Author Cache</label>
<span><%= data.cache.types.author.requests %> (<%= data.cache.types.author.missPercent %> Miss)</span>
</div>
</div>
</div>

View File

@@ -12,14 +12,21 @@ import winston, {Logger} from "winston";
import fetch from 'node-fetch';
import {mergeArr, parseExternalUrl, parseWikiContext} from "../util";
import LoggedError from "../Utils/LoggedError";
import {Footer, SubredditCacheConfig} from "../Common/interfaces";
import {
CacheOptions, CacheProvider,
Footer, OperatorConfig, ResourceStats,
StrongCache,
StrongSubredditCacheConfig,
SubredditCacheConfig
} from "../Common/interfaces";
import UserNotes from "./UserNotes";
import Mustache from "mustache";
import he from "he";
import {AuthorCriteria} from "../Author/Author";
import Poll from "snoostorm/out/util/Poll";
import {SPoll} from "./Streams";
import cacheManager from 'cache-manager';
import cacheManager, {Cache} from 'cache-manager';
import redisStore from 'cache-manager-redis-store';
export const DEFAULT_FOOTER = '\r\n*****\r\nThis action was performed by [a bot.]({{botLink}}) Mention a moderator or [send a modmail]({{modmailLink}}) if you any ideas, questions, or concerns about this action.';
@@ -29,14 +36,38 @@ export interface SubredditResourceOptions extends SubredditCacheConfig, Footer {
logger: Logger;
}
export interface SubredditResourceSetOptions extends SubredditCacheConfig, Footer {
enabled: boolean;
interface StrongSubredditResourceOptions extends SubredditResourceOptions {
cache?: Cache
}
//const memoryCache = cacheManager.caching({store: 'memory', max: 1000, ttl: 60/*seconds*/});
export interface SubredditResourceSetOptions extends SubredditCacheConfig, Footer {
enabled: boolean,
}
//
// interface ResourceStats {
// cache: {
// //keys: number,
// author: {
// requests: number,
// miss: number,
// },
// authorCrit: {
// requests: number,
// miss: number,
// }
// content: {
// requests: number,
// miss: number,
// }
// }
// }
export class SubredditResources {
enabled!: boolean;
//enabled!: boolean;
protected authorTTL!: number;
protected useSubredditAuthorCache!: boolean;
protected wikiTTL!: number;
@@ -45,15 +76,20 @@ export class SubredditResources {
userNotes: UserNotes;
footer!: false | string;
subreddit: Subreddit
cache?: Cache
constructor(name: string, options: SubredditResourceOptions) {
stats: { cache: ResourceStats };
constructor(name: string, options: StrongSubredditResourceOptions) {
const {
subreddit,
logger,
enabled = true,
userNotesTTL = 60000,
cache,
} = options || {};
this.cache = cache;
this.subreddit = subreddit;
this.name = name;
if (logger === undefined) {
@@ -63,21 +99,36 @@ export class SubredditResources {
this.logger = logger.child({labels: ['Resource Cache']}, mergeArr);
}
this.stats = {
cache: {
author: {
requests: 0,
miss: 0,
},
authorCrit: {
requests: 0,
miss: 0,
},
content: {
requests: 0,
miss: 0
}
}
};
this.userNotes = new UserNotes(enabled ? userNotesTTL : 0, this.subreddit, this.logger)
this.setOptions(options);
}
setOptions (options: SubredditResourceSetOptions) {
setOptions(options: SubredditResourceSetOptions) {
const {
enabled = true,
authorTTL = 10000,
userNotesTTL = 60000,
wikiTTL = 300000, // 5 minutes
authorTTL,
userNotesTTL,
wikiTTL,
footer = DEFAULT_FOOTER
} = options || {};
this.footer = footer;
this.enabled = manager.enabled ? enabled : false;
if (authorTTL === undefined) {
this.useSubredditAuthorCache = false;
this.authorTTL = manager.authorTTL;
@@ -85,35 +136,40 @@ export class SubredditResources {
this.useSubredditAuthorCache = true;
this.authorTTL = authorTTL;
}
this.wikiTTL = wikiTTL;
this.userNotes.notesTTL = enabled ? userNotesTTL : 0;
this.wikiTTL = wikiTTL || this.wikiTTL;
this.userNotes.notesTTL = userNotesTTL || this.userNotes.notesTTL;
}
async getCacheKeyCount() {
if (this.cache !== undefined && this.cache.store.keys !== undefined) {
return (await this.cache.store.keys()).length;
}
return 0;
}
async getAuthorActivities(user: RedditUser, options: AuthorTypedActivitiesOptions): Promise<Array<Submission | Comment>> {
const useCache = this.enabled && this.authorTTL > 0;
let hash;
if (useCache) {
if (this.cache !== undefined && this.authorTTL > 0) {
const userName = user.name;
const hashObj: any = {...options, userName};
if (this.useSubredditAuthorCache) {
hashObj.subreddit = this.name;
}
hash = objectHash.sha1({...options, userName});
const hash = objectHash.sha1({...options, userName});
const cacheVal = cache.get(hash);
if (null !== cacheVal) {
this.stats.cache.author.requests++;
let miss = false;
const cacheVal = await this.cache.wrap(hash, async () => {
miss = true;
return await getAuthorActivities(user, options);
}, {ttl: this.authorTTL});
if (!miss) {
this.logger.debug(`Cache Hit: ${userName} (${options.type || 'overview'})`);
return cacheVal as Array<Submission | Comment>;
} else {
this.stats.cache.author.miss++;
}
return cacheVal as Array<Submission | Comment>;
}
const items = await getAuthorActivities(user, options);
if (useCache) {
cache.put(hash, items, this.authorTTL);
}
return Promise.resolve(items);
return await getAuthorActivities(user, options);
}
async getAuthorComments(user: RedditUser, options: AuthorActivitiesOptions): Promise<Comment[]> {
@@ -143,14 +199,13 @@ export class SubredditResources {
return val;
}
const useCache = this.enabled && this.wikiTTL > 0;
// try to get cached value first
let hash = `${subreddit.display_name}-${cacheKey}`;
if (useCache) {
const cachedContent = cache.get(hash);
if (this.cache !== undefined && this.wikiTTL > 0) {
const cachedContent = await this.cache.get(hash);
if (cachedContent !== null) {
this.logger.debug(`Cache Hit: ${cacheKey}`);
return cachedContent;
return cachedContent as string;
}
}
@@ -185,37 +240,34 @@ export class SubredditResources {
}
}
if (useCache) {
cache.put(hash, wikiContent, this.wikiTTL);
if (this.cache !== undefined && this.wikiTTL > 0) {
this.cache.set(hash, wikiContent, this.wikiTTL);
}
return wikiContent;
}
async testAuthorCriteria(item: (Comment | Submission), authorOpts: AuthorCriteria, include = true) {
const useCache = this.enabled && this.authorTTL > 0;
let hash;
if (useCache) {
if (this.cache !== undefined && this.authorTTL > 0) {
const hashObj = {itemId: item.id, ...authorOpts, include};
hash = `authorCrit-${objectHash.sha1(hashObj)}`;
const cachedAuthorTest = cache.get(hash);
if (null !== cachedAuthorTest) {
const hash = `authorCrit-${objectHash.sha1(hashObj)}`;
let miss = false;
const cachedAuthorTest = await this.cache.wrap(hash, async () => {
miss = true;
return await testAuthorCriteria(item, authorOpts, include, this.userNotes);
}, {ttl: this.authorTTL});
if (!miss) {
this.logger.debug(`Cache Hit: Author Check on ${item.id}`);
return cachedAuthorTest;
}
return cachedAuthorTest;
}
const result = await testAuthorCriteria(item, authorOpts, include, this.userNotes);
if (useCache) {
cache.put(hash, result, this.authorTTL);
}
return result;
return await testAuthorCriteria(item, authorOpts, include, this.userNotes);
}
async generateFooter(item: Submission | Comment, actionFooter?: false | string)
{
async generateFooter(item: Submission | Comment, actionFooter?: false | string) {
let footer = actionFooter !== undefined ? actionFooter : this.footer;
if(footer === false) {
if (footer === false) {
return '';
}
const subName = await item.subreddit.display_name;
@@ -227,11 +279,54 @@ export class SubredditResources {
}
}
export const createCacheManager = (options: CacheOptions) => {
const {store, max, ttl = 60, host = 'localhost', port, auth_pass, db} = options;
switch (store) {
case 'none':
return undefined;
case 'redis':
return cacheManager.caching({
store: redisStore,
host,
port,
auth_pass,
db,
ttl
});
case 'memory':
default:
return cacheManager.caching({store: 'memory', max, ttl});
}
}
class SubredditResourcesManager {
resources: Map<string, SubredditResources> = new Map();
authorTTL: number = 10000;
enabled: boolean = true;
modStreams: Map<string, SPoll<Snoowrap.Submission | Snoowrap.Comment>> = new Map();
defaultCache?: Cache;
ttlDefaults!: StrongSubredditCacheConfig;
setDefaultsFromConfig(config: OperatorConfig) {
const {
caching: {
authorTTL,
userNotesTTL,
wikiTTL,
provider,
},
} = config;
this.setDefaultCache(provider);
this.setTTLDefaults({authorTTL, userNotesTTL, wikiTTL});
}
setDefaultCache(options: CacheOptions) {
this.defaultCache = createCacheManager(options);
}
setTTLDefaults(def: StrongSubredditCacheConfig) {
this.ttlDefaults = def;
}
get(subName: string): SubredditResources | undefined {
if (this.resources.has(subName)) {
@@ -241,7 +336,7 @@ class SubredditResourcesManager {
}
set(subName: string, initOptions: SubredditResourceOptions): SubredditResources {
const resource = new SubredditResources(subName, initOptions);
const resource = new SubredditResources(subName, {...this.ttlDefaults, ...initOptions, cache: this.defaultCache});
this.resources.set(subName, resource);
return resource;
}

View File

@@ -5,7 +5,7 @@ export const clientId = new commander.Option('-i, --clientId <id>', 'Client ID f
export const clientSecret = new commander.Option('-e, --clientSecret <secret>', 'Client Secret for your Reddit application (default: process.env.CLIENT_SECRET)');
export const redirectURI = new commander.Option('-u, --redirectUri <uri>', 'Redirect URI for your Reddit application (default: process.env.REDIRECT_URI)');
export const redirectUri = new commander.Option('-u, --redirectUri <uri>', 'Redirect URI for your Reddit application (default: process.env.REDIRECT_URI)');
export const sessionSecret = new commander.Option('-t, --sessionSecret <secret>', 'Secret use to encrypt session id/data (default: process.env.SESSION_SECRET)');
@@ -67,7 +67,7 @@ export const getUniversalWebOptions = (): commander.Option[] => {
clientSecret,
createAccessTokenOption(),
createRefreshTokenOption(),
redirectURI,
redirectUri,
sessionSecret,
subreddits,
logDir,